Merge "Add Fast Pair provider simulator app to test_support." into tm-dev
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/Android.bp b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/Android.bp
new file mode 100644
index 0000000..f3eed51
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/Android.bp
@@ -0,0 +1,45 @@
+// 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_app {
+    name: "NearbyFastPairProviderSimulatorApp",
+    sdk_version: "test_current",
+    static_libs: ["NearbyFastPairProviderSimulatorLib"],
+    optimize: {
+        enabled: true,
+        shrink: true,
+        proguard_flags_files: ["proguard.flags"],
+    },
+}
+
+android_library {
+    name: "NearbyFastPairProviderSimulatorLib",
+    sdk_version: "test_current",
+        srcs: [
+            "src/**/*.java",
+            "src/**/*.kt",
+        ],
+        static_libs: [
+            "NearbyFastPairProviderLib",
+            "NearbyFastPairProviderLiteProtos",
+            "NearbyFastPairProviderSimulatorLiteProtos",
+            "androidx.annotation_annotation",
+            "error_prone_annotations",
+            "fast-pair-lite-protos",
+        ],
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/AndroidManifest.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/AndroidManifest.xml
new file mode 100644
index 0000000..8880b11
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/AndroidManifest.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.fastpair.provider.simulator.app" >
+
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+
+    <application
+        android:allowBackup="true"
+        android:label="@string/app_name" >
+        <activity
+            android:name=".MainActivity"
+            android:windowSoftInputMode="stateHidden"
+            android:screenOrientation="portrait"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT"/>
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proguard.flags b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proguard.flags
new file mode 100644
index 0000000..28680b3
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proguard.flags
@@ -0,0 +1,19 @@
+# Keep simulator reflection callback.
+-keep class android.nearby.fastpair.provider.** {
+     *;
+}
+
+# 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/test_support/fastpair_provider/simulator_app/proto/Android.bp b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/Android.bp
new file mode 100644
index 0000000..e964800
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/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: "NearbyFastPairProviderSimulatorLiteProtos",
+    proto: {
+        type: "lite",
+        canonical_path_from_root: false,
+    },
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+    srcs: ["*.proto"],
+}
+
+
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/simulator_stream_protocol.proto b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/simulator_stream_protocol.proto
new file mode 100644
index 0000000..9b17fda
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/proto/simulator_stream_protocol.proto
@@ -0,0 +1,110 @@
+syntax = "proto2";
+
+package android.nearby.fastpair.provider.simulator;
+
+option java_package = "android.nearby.fastpair.provider.simulator";
+option java_outer_classname = "SimulatorStreamProtocol";
+
+// Used by remote devices to control simulator behaviors.
+message Command {
+  // Type of this command.
+  required Code code = 1;
+
+  // Required for SHOW_BATTERY.
+  optional BatteryInfo battery_info = 2;
+
+  enum Code {
+    // Request for simulator's acknowledge message.
+    POLLING = 0;
+
+    // Reset and clear bluetooth state.
+    RESET = 1;
+
+    // Present battery information in the advertisement.
+    SHOW_BATTERY = 2;
+
+    // Remove battery information in the advertisement.
+    HIDE_BATTERY = 3;
+
+    // Request for BR/EDR address.
+    REQUEST_BLUETOOTH_ADDRESS_PUBLIC = 4;
+
+    // Request for BLE address.
+    REQUEST_BLUETOOTH_ADDRESS_BLE = 5;
+
+    // Request for account key.
+    REQUEST_ACCOUNT_KEY = 6;
+  }
+
+  // Battery information for true wireless headsets.
+  // https://devsite.googleplex.com/nearby/fast-pair/early-access/spec#BatteryNotification
+  message BatteryInfo {
+    // Show or hide the battery UI notification.
+    optional bool suppress_notification = 1;
+    repeated BatteryValue battery_values = 2;
+
+    // Advertised battery level data.
+    message BatteryValue {
+      // The charging flag.
+      required bool charging = 1;
+
+      // Battery level from 0 to 100.
+      required uint32 level = 2;
+    }
+  }
+}
+
+// Notify the remote devices when states are changed or response the command on
+// the simulator.
+message Event {
+  // Type of this event.
+  required Code code = 1;
+
+  // Required for BLUETOOTH_STATE_BOND.
+  optional int32 bond_state = 2;
+
+  // Required for BLUETOOTH_STATE_CONNECTION.
+  optional int32 connection_state = 3;
+
+  // Required for BLUETOOTH_STATE_SCAN_MODE.
+  optional int32 scan_mode = 4;
+
+  // Required for BLUETOOTH_ADDRESS_PUBLIC.
+  optional string public_address = 5;
+
+  // Required for BLUETOOTH_ADDRESS_BLE.
+  optional string ble_address = 6;
+
+  // Required for BLUETOOTH_ALIAS_NAME.
+  optional string alias_name = 7;
+
+  // Required for REQUEST_ACCOUNT_KEY.
+  optional bytes account_key = 8;
+
+  enum Code {
+    // Response the polling.
+    ACKNOWLEDGE = 0;
+
+    // Notify the event android.bluetooth.device.action.BOND_STATE_CHANGED
+    BLUETOOTH_STATE_BOND = 1;
+
+    // Notify the event
+    // android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED
+    BLUETOOTH_STATE_CONNECTION = 2;
+
+    // Notify the event android.bluetooth.adapter.action.SCAN_MODE_CHANGED
+    BLUETOOTH_STATE_SCAN_MODE = 3;
+
+    // Notify the current BR/EDR address
+    BLUETOOTH_ADDRESS_PUBLIC = 4;
+
+    // Notify the current BLE address
+    BLUETOOTH_ADDRESS_BLE = 5;
+
+    // Notify the event android.bluetooth.device.action.ALIAS_CHANGED
+    BLUETOOTH_ALIAS_NAME = 6;
+
+    // Response the REQUEST_ACCOUNT_KEY.
+    ACCOUNT_KEY = 7;
+  }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/activity_main.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/activity_main.xml
new file mode 100644
index 0000000..b7e85eb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/activity_main.xml
@@ -0,0 +1,190 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:layout_margin="16dp"
+    android:keepScreenOn="true"
+    tools:context=".MainActivity">
+
+    <TextView
+        android:id="@+id/bluetooth_address_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <TextView
+        android:id="@+id/device_name_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="8dp"
+        android:orientation="horizontal">
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textSize="14dp"
+                android:textStyle="bold"
+                android:text="Model ID:"/>
+            <Spinner
+                android:id="@+id/model_id_spinner"
+                android:textSize="14dp"
+                android:textStyle="bold"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1" />
+        <TextView
+            android:id="@+id/tx_power_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold" />
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/anti_spoofing_private_key_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+        <TextView
+            android:id="@+id/is_advertising_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+        <TextView
+            android:id="@+id/scan_mode_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/remote_device_text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textSize="14dp"
+        android:textStyle="bold"
+        android:padding="8dp"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+        <TextView
+            android:id="@+id/is_paired_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+        <TextView
+            android:id="@+id/is_connected_text_view"
+            android:layout_width="0dp"
+            android:layout_weight="1"
+            android:layout_height="wrap_content"
+            android:textSize="14dp"
+            android:textStyle="bold"
+            android:padding="8dp"/>
+    </LinearLayout>
+
+    <Button
+        android:id="@+id/reset_button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="Reset"
+        android:onClick="onResetButtonClicked"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:layout_marginBottom="8dp"
+        android:orientation="horizontal"
+        android:layout_gravity="center_vertical">
+
+      <Spinner
+          android:id="@+id/event_stream_spinner"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"/>
+
+      <Button
+          android:id="@+id/send_event_message_button"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          android:text="Send Event Message"
+          android:onClick="onSendEventStreamMessageButtonClicked"/>
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="8dp"
+        android:orientation="horizontal"
+        android:layout_gravity="center_vertical">
+        <Switch
+            android:id="@+id/fail_switch"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Force Fail" />
+        <Switch
+            android:id="@+id/app_launch_switch"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Trigger app launch"
+            android:paddingLeft="8dp"/>
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/adv_options"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:layout_marginBottom="8dp"
+        android:orientation="horizontal"
+        android:layout_gravity="center_vertical">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginRight="8dp"
+            android:textColor="@android:color/black"
+            android:text="adv options"/>
+
+        <Spinner
+            android:id="@+id/adv_option_spinner"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"/>
+
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/text_view"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:gravity="bottom"
+        android:scrollbars="vertical"/>
+</LinearLayout>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/user_input_dialog.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/user_input_dialog.xml
new file mode 100644
index 0000000..980b057
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/layout/user_input_dialog.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="16dp">
+
+  <EditText
+      android:id="@+id/userInputDialog"
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:hint="@string/firmware_input_hint"
+      android:inputType="text" />
+
+</LinearLayout>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/menu/menu.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/menu/menu.xml
new file mode 100644
index 0000000..f225522
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/menu/menu.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:context=".MainActivity">
+
+  <item
+      android:id="@+id/sign_out_menu_item"
+      android:title="Sign out"/>
+  <item
+      android:id="@+id/reset_account_keys_menu_item"
+      android:title="Reset Account Keys"/>
+  <item
+      android:id="@+id/reset_device_name_menu_item"
+      android:title="Reset Device Name"/>
+  <item
+    android:id="@+id/set_firmware_version"
+    android:title="Set Firmware Version"/>
+  <item
+      android:id="@+id/set_simulator_capability"
+      android:title="Set Simulator Capability"/>
+  <item
+    android:id="@+id/use_new_gatt_characteristics_id"
+    android:checkable="true"
+    android:checked="false"
+    android:title="Use new GATT characteristics id"/>
+</menu>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/dimens.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/dimens.xml
new file mode 100644
index 0000000..47c8224
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/dimens.xml
@@ -0,0 +1,5 @@
+<resources>
+    <!-- Default screen margins, per the Android Design guidelines. -->
+    <dimen name="activity_horizontal_margin">16dp</dimen>
+    <dimen name="activity_vertical_margin">16dp</dimen>
+</resources>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/strings.xml b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/strings.xml
new file mode 100644
index 0000000..5123038
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/res/values/strings.xml
@@ -0,0 +1,31 @@
+<resources>
+    <string name="app_name">Fast Pair Provider Simulator</string>
+    <string-array name="adv_options">
+        <item>0: No battery info</item>
+        <item>1: Show L(⬆) + R(⬆) + C(⬆)</item>
+        <item>2: Show L + R + C(unknown)</item>
+        <item>3: Show L(low 10) + R(low 9) + C(low 25)</item>
+        <item>4: Suppress battery w/o level changes</item>
+        <item>5: Suppress L(low 10) + R(11) + C</item>
+        <item>6: Suppress L(low ⬆) + R(low ⬆) + C(low 10)</item>
+        <item>7: Suppress L(low ⬆) + R(low ⬆) + C(low ⬆)</item>
+        <item>8: Show subsequent pairing notification</item>
+        <item>9: Suppress subsequent pairing notification</item>
+    </string-array>
+    <string-array name="event_stream_options">
+        <item>OHD event</item>
+        <item>Log event</item>
+        <item>Battery event</item>
+    </string-array>
+    <string name="firmware_dialog_title">Firmware version number</string>
+    <string name="firmware_input_hint">Type in version number</string>
+    <string name="passkey_dialog_title">Passkey needed</string>
+    <string name="passkey_input_hint">Type in passkey</string>
+    <!-- Passkey confirmation dialog title. [CHAR_LIMIT=NONE]-->
+    <string name="confirm_passkey">Confirm passkey</string>
+    <string name="model_id_progress_title">Get models from server</string>
+
+    <!-- Fast Pair Simulator: pair one device only. -->
+    <string name="fast_pair_simulator" translatable="false">Fast Pair Simulator</string>
+
+</resources>
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/AppLogger.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/AppLogger.java
new file mode 100644
index 0000000..befc64b
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/AppLogger.java
@@ -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.fastpair.provider.simulator.app;
+
+import android.util.Log;
+
+import com.google.errorprone.annotations.FormatMethod;
+
+/** Sends log to logcat with TAG. */
+public class AppLogger {
+    private static final String TAG = "FastPairSimulator";
+
+    @FormatMethod
+    public static void log(String message, Object... objects) {
+        Log.i(TAG, String.format(message, objects));
+    }
+
+    @FormatMethod
+    public static void warning(String message, Object... objects) {
+        Log.w(TAG, String.format(message, objects));
+    }
+
+    @FormatMethod
+    public static void error(String message, Object... objects) {
+        Log.e(TAG, String.format(message, objects));
+    }
+
+    private AppLogger() {
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/BluetoothController.kt b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/BluetoothController.kt
new file mode 100644
index 0000000..ed04eae
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/BluetoothController.kt
@@ -0,0 +1,286 @@
+/*
+ * 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.fastpair.provider.simulator.app
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.nearby.fastpair.provider.FastPairSimulator
+import android.nearby.fastpair.provider.simulator.app.AppLogger.*
+import android.nearby.fastpair.provider.simulator.testing.Reflect
+import android.nearby.fastpair.provider.simulator.testing.ReflectionException
+import android.os.SystemClock
+import android.provider.Settings
+
+/** Controls the local Bluetooth adapter for Fast Pair testing. */
+class BluetoothController(
+    private val context: Context,
+    private val listener: EventListener,
+) : BroadcastReceiver() {
+    private val bluetoothAdapter: BluetoothAdapter =
+        (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter!!
+    private var remoteDevice: BluetoothDevice? = null
+    private var remoteDeviceConnectionState: Int = BluetoothAdapter.STATE_DISCONNECTED
+    private var a2dpSinkProxy: BluetoothProfile? = null
+
+    /** Turns on the local Bluetooth adapter */
+    fun enableBluetooth() {
+        if (!bluetoothAdapter.isEnabled) {
+            bluetoothAdapter.enable()
+            waitForBluetoothState(BluetoothAdapter.STATE_ON)
+        }
+    }
+
+    /**
+     * Sets the Input/Output capability of the device for both classic Bluetooth and BLE operations.
+     * Note: In order to let changes take effect, this method will make sure the Bluetooth stack is
+     * restarted by blocking calling thread.
+     *
+     * @param ioCapabilityClassic One of {@link #IO_CAPABILITY_IO}, {@link #IO_CAPABILITY_NONE},
+     * ```
+     *     {@link #IO_CAPABILITY_KBDISP} or more in {@link BluetoothAdapter}.
+     * @param ioCapabilityBLE
+     * ```
+     * One of {@link #IO_CAPABILITY_IO}, {@link #IO_CAPABILITY_NONE}, {@link
+     * ```
+     *     #IO_CAPABILITY_KBDISP} or more in {@link BluetoothAdapter}.
+     * ```
+     */
+    fun setIoCapability(ioCapabilityClassic: Int, ioCapabilityBLE: Int) {
+        try {
+            Reflect.on(bluetoothAdapter)
+                .withMethod("setIoCapability", Int::class.javaPrimitiveType)[
+                    ioCapabilityClassic]
+        } catch (e: ReflectionException) {
+            warning("Error setIoCapability to %s: %s", ioCapabilityClassic, e)
+        }
+        try {
+            Reflect.on(bluetoothAdapter)
+                .withMethod("setLeIoCapability", Int::class.javaPrimitiveType)[
+                    ioCapabilityBLE]
+        } catch (e: ReflectionException) {
+            warning("Error setLeIoCapability to %s: %s", ioCapabilityBLE, e)
+        }
+
+        // Toggling airplane mode on/off to restart Bluetooth stack and reset the BLE.
+        // Since it also increases reliability, we will do so even if ReflectionException is caught.
+        try {
+            Settings.Global.putInt(
+                context.contentResolver,
+                Settings.Global.AIRPLANE_MODE_ON,
+                TURN_AIRPLANE_MODE_ON
+            )
+        } catch (expectedOnNonCustomAndroid: SecurityException) {
+            warning("Requires custom Android to toggle airplane mode")
+            // Fall back to turn off Bluetooth.
+            bluetoothAdapter.disable()
+        }
+        waitForBluetoothState(BluetoothAdapter.STATE_OFF)
+        try {
+            Settings.Global.putInt(
+                context.contentResolver,
+                Settings.Global.AIRPLANE_MODE_ON,
+                TURN_AIRPLANE_MODE_OFF
+            )
+        } catch (expectedOnNonCustomAndroid: SecurityException) {
+            error("SecurityException while toggled airplane mode.")
+        } finally {
+            // Double confirm that Bluetooth is turned on.
+            bluetoothAdapter.enable()
+        }
+        waitForBluetoothState(BluetoothAdapter.STATE_ON)
+    }
+
+    /** Registers this Bluetooth state change receiver. */
+    fun registerBluetoothStateReceiver() {
+        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 unregisterBluetoothStateReceiver() {
+        context.unregisterReceiver(this)
+    }
+
+    /** Clears current remote device. */
+    fun clearRemoteDevice() {
+        remoteDevice = null
+    }
+
+    /** Gets current remote device. */
+    fun getRemoteDevice(): BluetoothDevice? = remoteDevice
+
+    /** Gets current remote device as string. */
+    fun getRemoteDeviceAsString(): String = remoteDevice?.remoteDeviceToString() ?: "none"
+
+    /** Connects the Bluetooth A2DP sink profile service. */
+    fun connectA2DPSinkProfile() {
+        // 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
+                        listener.onA2DPSinkProfileConnected()
+                    }
+                }
+
+                override fun onServiceDisconnected(profile: Int) {}
+            },
+            BLUETOOTH_PROFILE_A2DP_SINK
+        )
+    }
+
+    /** Get the current Bluetooth scan mode of the local Bluetooth adapter. */
+    fun getScanMode(): Int = bluetoothAdapter.scanMode
+
+    /** Return true if the remote device is connected to the local adapter. */
+    fun isConnected(): Boolean = remoteDeviceConnectionState == BluetoothAdapter.STATE_CONNECTED
+
+    /** Return true if the remote device is bonded (paired) to the local adapter. */
+    fun isPaired(): Boolean = bluetoothAdapter.bondedDevices.contains(remoteDevice)
+
+    /** Gets the A2DP sink profile proxy. */
+    fun getA2DPSinkProfileProxy(): BluetoothProfile? = a2dpSinkProxy
+
+    /**
+     * Callback method for receiving Intent broadcast of Bluetooth state.
+     *
+     * See [BroadcastReceiver#onReceive].
+     *
+     * @param context the Context in which the receiver is running.
+     * @param intent the Intent being received.
+     */
+    override fun onReceive(context: Context, intent: Intent) {
+        log("BluetoothController received intent, action=%s", intent.action)
+
+        when (intent.action) {
+            BluetoothDevice.ACTION_BOND_STATE_CHANGED -> {
+                // After a device starts bonding, we only pay attention to intents about that device.
+                val device =
+                    intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
+                val bondState =
+                    intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
+                remoteDevice =
+                    when (bondState) {
+                        BluetoothDevice.BOND_BONDING, BluetoothDevice.BOND_BONDED -> device
+                        BluetoothDevice.BOND_NONE -> null
+                        else -> remoteDevice
+                    }
+                log(
+                    "ACTION_BOND_STATE_CHANGED, the bound state of the remote device (%s) change to %s.",
+                    remoteDevice?.remoteDeviceToString(),
+                    bondState.bondStateToString()
+                )
+                listener.onBondStateChanged(bondState)
+            }
+            BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED -> {
+                remoteDeviceConnectionState =
+                    intent.getIntExtra(
+                        BluetoothAdapter.EXTRA_CONNECTION_STATE,
+                        BluetoothAdapter.STATE_DISCONNECTED
+                    )
+                log(
+                    "ACTION_CONNECTION_STATE_CHANGED, the new connectionState: %s",
+                    remoteDeviceConnectionState
+                )
+                listener.onConnectionStateChanged(remoteDeviceConnectionState)
+            }
+            BluetoothAdapter.ACTION_SCAN_MODE_CHANGED -> {
+                val scanMode =
+                    intent.getIntExtra(
+                        BluetoothAdapter.EXTRA_SCAN_MODE,
+                        BluetoothAdapter.SCAN_MODE_NONE
+                    )
+                log(
+                    "ACTION_SCAN_MODE_CHANGED, the new scanMode: %s",
+                    FastPairSimulator.scanModeToString(scanMode)
+                )
+                listener.onScanModeChange(scanMode)
+            }
+            else -> {}
+        }
+    }
+
+    private fun waitForBluetoothState(state: Int) {
+        while (bluetoothAdapter.state != state) {
+            SystemClock.sleep(1000)
+        }
+    }
+
+    private fun BluetoothDevice.remoteDeviceToString(): String = "${this.name}-${this.address}"
+
+    private fun Int.bondStateToString(): String =
+        when (this) {
+            BluetoothDevice.BOND_NONE -> "BOND_NONE"
+            BluetoothDevice.BOND_BONDING -> "BOND_BONDING"
+            BluetoothDevice.BOND_BONDED -> "BOND_BONDED"
+            else -> "BOND_ERROR"
+        }
+
+    /** Interface for listening the events from Bluetooth controller. */
+    interface EventListener {
+        /** The callback for the first onServiceConnected of A2DP sink profile. */
+        fun onA2DPSinkProfileConnected()
+
+        /**
+         * Reports the current bond state of the remote device.
+         *
+         * @param bondState the bond state of the remote device.
+         */
+        fun onBondStateChanged(bondState: Int)
+
+        /**
+         * Reports the current connection state of the remote device.
+         *
+         * @param connectionState the bond state of the remote device.
+         */
+        fun onConnectionStateChanged(connectionState: Int)
+
+        /**
+         * Reports the current scan mode of the local Adapter.
+         *
+         * @param mode the current scan mode of the local Adapter.
+         */
+        fun onScanModeChange(mode: Int)
+    }
+
+    companion object {
+        /** Hidden SystemApi field in [BluetoothProfile] interface. */
+        private const val BLUETOOTH_PROFILE_A2DP_SINK = 11
+
+        private const val TURN_AIRPLANE_MODE_OFF = 0
+        private const val TURN_AIRPLANE_MODE_ON = 1
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/FutureCallbackWrapper.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/FutureCallbackWrapper.java
new file mode 100644
index 0000000..4db8560
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/FutureCallbackWrapper.java
@@ -0,0 +1,69 @@
+/*
+ * 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.fastpair.provider.simulator.app;
+
+import android.util.Log;
+
+import com.google.common.util.concurrent.FutureCallback;
+
+/** Wrapper for {@link FutureCallback} to prevent the memory linkage. */
+public abstract class FutureCallbackWrapper<T> implements FutureCallback<T> {
+    private static final String TAG = FutureCallback.class.getSimpleName();
+
+    public static FutureCallbackWrapper<Void> createRegisterCallback(MainActivity activity) {
+        String id = activity.mRemoteDeviceId;
+        return new FutureCallbackWrapper<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.d(TAG, String.format("%s was registered", id));
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, String.format("Failed to register %s", id), t);
+            }
+        };
+    }
+
+    public static FutureCallbackWrapper<Void> createDefaultIOCallback(MainActivity activity) {
+        String id = activity.mRemoteDeviceId;
+        return new FutureCallbackWrapper<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, String.format("IO stream error on %s", id), t);
+            }
+        };
+    }
+
+    public static FutureCallbackWrapper<Void> createDestroyCallback() {
+        return new FutureCallbackWrapper<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.d(TAG, "remote devices manager is destroyed");
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, "Failed to destroy remote devices manager", t);
+            }
+        };
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/MainActivity.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/MainActivity.java
new file mode 100644
index 0000000..9252173
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/MainActivity.java
@@ -0,0 +1,1043 @@
+/*
+ * 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.fastpair.provider.simulator.app;
+
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_STATE_BOND;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_STATE_CONNECTION;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_STATE_SCAN_MODE;
+import static android.nearby.fastpair.provider.simulator.app.AppLogger.log;
+import static android.nearby.fastpair.provider.simulator.app.AppLogger.warning;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.io.BaseEncoding.base64;
+
+import android.Manifest.permission;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.AdvertiseSettings;
+import android.content.DialogInterface;
+import android.content.SharedPreferences;
+import android.graphics.Color;
+import android.nearby.fastpair.provider.EventStreamProtocol.EventGroup;
+import android.nearby.fastpair.provider.FastPairSimulator;
+import android.nearby.fastpair.provider.FastPairSimulator.BatteryValue;
+import android.nearby.fastpair.provider.FastPairSimulator.KeyInputCallback;
+import android.nearby.fastpair.provider.FastPairSimulator.PasskeyEventCallback;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event;
+import android.nearby.fastpair.provider.simulator.testing.RemoteDevice;
+import android.nearby.fastpair.provider.simulator.testing.RemoteDevicesManager;
+import android.nearby.fastpair.provider.simulator.testing.StreamIOHandlerFactory;
+import android.nearby.fastpair.provider.utils.Logger;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.method.ScrollingMovementMethod;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.Switch;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.util.Consumer;
+
+import com.google.common.base.Ascii;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.protobuf.ByteString;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.Executors;
+
+import service.proto.Rpcs.AntiSpoofingKeyPair;
+import service.proto.Rpcs.Device;
+import service.proto.Rpcs.DeviceType;
+
+/**
+ * Simulates a Fast Pair device (e.g. a headset).
+ *
+ * <p>See README in this directory, and {http://go/fast-pair-spec}.
+ */
+@SuppressLint("SetTextI18n")
+public class MainActivity extends Activity {
+    /** Device has a display and the ability to input Yes/No. */
+    private static final int IO_CAPABILITY_IO = 1;
+
+    /** Device only has a keyboard for entry but no display. */
+    private static final int IO_CAPABILITY_IN = 2;
+
+    /** Device has no Input or Output capability. */
+    private static final int IO_CAPABILITY_NONE = 3;
+
+    /** Device has a display and a full keyboard. */
+    private static final int IO_CAPABILITY_KBDISP = 4;
+
+    private static final String SHARED_PREFS_NAME =
+            "android.nearby.fastpair.provider.simulator.app";
+    private static final String EXTRA_MODEL_ID = "MODEL_ID";
+    private static final String EXTRA_BLUETOOTH_ADDRESS = "BLUETOOTH_ADDRESS";
+    private static final String EXTRA_TX_POWER_LEVEL = "TX_POWER_LEVEL";
+    private static final String EXTRA_FIRMWARE_VERSION = "FIRMWARE_VERSION";
+    private static final String EXTRA_SUPPORT_DYNAMIC_SIZE = "SUPPORT_DYNAMIC_SIZE";
+    private static final String EXTRA_USE_RANDOM_SALT_FOR_ACCOUNT_KEY_ROTATION =
+            "USE_RANDOM_SALT_FOR_ACCOUNT_KEY_ROTATION";
+    private static final String EXTRA_REMOTE_DEVICE_ID = "REMOTE_DEVICE_ID";
+    private static final String EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID =
+            "USE_NEW_GATT_CHARACTERISTICS_ID";
+    public static final String EXTRA_REMOVE_ALL_DEVICES_DURING_PAIRING =
+            "REMOVE_ALL_DEVICES_DURING_PAIRING";
+    private static final String KEY_ACCOUNT_NAME = "ACCOUNT_NAME";
+    private static final String[] PERMISSIONS =
+            new String[]{permission.BLUETOOTH, permission.BLUETOOTH_ADMIN, permission.GET_ACCOUNTS};
+    private static final int LIGHT_GREEN = 0xFFC8FFC8;
+    private static final String ANTI_SPOOFING_KEY_LABEL = "Anti-spoofing key";
+
+    private static final ImmutableMap<String, String> ANTI_SPOOFING_PRIVATE_KEY_MAP =
+            new ImmutableMap.Builder<String, String>()
+                    .put("361A2E", "/1rMqyJRGeOK6vkTNgM70xrytxdKg14mNQkITeusK20=")
+                    .put("00000D", "03/MAmUPTGNsN+2iA/1xASXoPplDh3Ha5/lk2JgEBx4=")
+                    .put("00000C", "Cbj9eCJrTdDgSYxLkqtfADQi86vIaMvxJsQ298sZYWE=")
+                    // BLE only devices
+                    .put("49426D", "I5QFOJW0WWFgKKZiwGchuseXsq/p9RN/aYtNsGEVGT0=")
+                    .put("01E5CE", "FbHt8STpHJDd4zFQFjimh4Zt7IU94U28MOEIXgUEeCw=")
+                    .put("8D13B9", "mv++LcJB1n0mbLNGWlXCv/8Gb6aldctrJC4/Ma/Q3Rg=")
+                    .put("9AB0F6", "9eKQNwJUr5vCg0c8rtOXkJcWTAsBmmvEKSgXIqAd50Q=")
+                    // Android Auto
+                    .put("8E083D", "hGQeREDKM/H1834zWMmTIe0Ap4Zl5igThgE62OtdcKA=")
+                    .buildOrThrow();
+
+    private static final Uri REMOTE_DEVICE_INPUT_STREAM_URI =
+            Uri.fromFile(new File("/data/local/nearby/tmp/read.pipe"));
+
+    private static final Uri REMOTE_DEVICE_OUTPUT_STREAM_URI =
+            Uri.fromFile(new File("/data/local/nearby/tmp/write.pipe"));
+
+    private static final String MODEL_ID_DEFAULT = "00000C";
+
+    private static final String MODEL_ID_APP_LAUNCH = "60EB56";
+
+    private static final int MODEL_ID_LENGTH = 6;
+
+    private BluetoothController mBluetoothController;
+    private final BluetoothController.EventListener mEventListener =
+            new BluetoothController.EventListener() {
+
+                @Override
+                public void onBondStateChanged(int bondState) {
+                    sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(BLUETOOTH_STATE_BOND).setBondState(
+                                    bondState));
+                    updateStatusView();
+                }
+
+                @Override
+                public void onConnectionStateChanged(int connectionState) {
+                    sendEventToRemoteDevice(
+                            Event.newBuilder()
+                                    .setCode(BLUETOOTH_STATE_CONNECTION)
+                                    .setConnectionState(connectionState));
+                    updateStatusView();
+                }
+
+                @Override
+                public void onScanModeChange(int mode) {
+                    sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(BLUETOOTH_STATE_SCAN_MODE).setScanMode(
+                                    mode));
+                    updateStatusView();
+                }
+
+                @Override
+                public void onA2DPSinkProfileConnected() {
+                    reset();
+                }
+            };
+
+    @Nullable
+    private FastPairSimulator mFastPairSimulator;
+    @Nullable
+    private AlertDialog mInputPasskeyDialog;
+    private Switch mFailSwitch;
+    private Switch mAppLaunchSwitch;
+    private Spinner mAdvOptionSpinner;
+    private Spinner mEventStreamSpinner;
+    private EventGroup mEventGroup;
+    private SharedPreferences mSharedPreferences;
+    private Spinner mModelIdSpinner;
+    private final RemoteDevicesManager mRemoteDevicesManager = new RemoteDevicesManager();
+    @Nullable
+    private RemoteDeviceListener mInputStreamListener;
+    @Nullable
+    String mRemoteDeviceId;
+    private final Map<String, Device> mModelsMap = new LinkedHashMap<>();
+    private boolean mRemoveAllDevicesDuringPairing = true;
+
+    void sendEventToRemoteDevice(Event.Builder eventBuilder) {
+        if (mRemoteDeviceId == null) {
+            return;
+        }
+
+        log("Send data to output stream: %s", eventBuilder.getCode().getNumber());
+        mRemoteDevicesManager.writeDataToRemoteDevice(
+                mRemoteDeviceId,
+                eventBuilder.build().toByteString(),
+                FutureCallbackWrapper.createDefaultIOCallback(this));
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        setContentView(R.layout.activity_main);
+
+        mSharedPreferences = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+
+        mRemoveAllDevicesDuringPairing =
+                getIntent().getBooleanExtra(EXTRA_REMOVE_ALL_DEVICES_DURING_PAIRING, true);
+
+        mFailSwitch = findViewById(R.id.fail_switch);
+        mFailSwitch.setOnCheckedChangeListener((CompoundButton buttonView, boolean isChecked) -> {
+            if (mFastPairSimulator != null) {
+                mFastPairSimulator.setShouldFailPairing(isChecked);
+            }
+        });
+
+        mAppLaunchSwitch = findViewById(R.id.app_launch_switch);
+        mAppLaunchSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> reset());
+
+        mAdvOptionSpinner = findViewById(R.id.adv_option_spinner);
+        mEventStreamSpinner = findViewById(R.id.event_stream_spinner);
+        ArrayAdapter<CharSequence> advOptionAdapter =
+                ArrayAdapter.createFromResource(
+                        this, R.array.adv_options, android.R.layout.simple_spinner_item);
+        ArrayAdapter<CharSequence> eventStreamAdapter =
+                ArrayAdapter.createFromResource(
+                        this, R.array.event_stream_options, android.R.layout.simple_spinner_item);
+        advOptionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mAdvOptionSpinner.setAdapter(advOptionAdapter);
+        mEventStreamSpinner.setAdapter(eventStreamAdapter);
+        mAdvOptionSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> adapterView, View view, int position,
+                    long id) {
+                startAdvertisingBatteryInformationBasedOnOption(position);
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> adapterView) {
+            }
+        });
+        mEventStreamSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position,
+                    long id) {
+                switch (EventGroup.forNumber(position + 1)) {
+                    case BLUETOOTH:
+                        mEventGroup = EventGroup.BLUETOOTH;
+                        break;
+                    case LOGGING:
+                        mEventGroup = EventGroup.LOGGING;
+                        break;
+                    case DEVICE:
+                        mEventGroup = EventGroup.DEVICE;
+                        break;
+                    default:
+                        // fall through
+                }
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> parent) {
+            }
+        });
+        setupModelIdSpinner();
+        setupRemoteDevices();
+        if (checkPermissions(PERMISSIONS)) {
+            mBluetoothController = new BluetoothController(this, mEventListener);
+            mBluetoothController.registerBluetoothStateReceiver();
+            mBluetoothController.enableBluetooth();
+            mBluetoothController.connectA2DPSinkProfile();
+
+            if (mSharedPreferences.getString(KEY_ACCOUNT_NAME, "").isEmpty()) {
+                putFixedModelLocal();
+                resetModelIdSpinner();
+                reset();
+            }
+        } else {
+            requestPermissions(PERMISSIONS, 0 /* requestCode */);
+        }
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.menu, menu);
+        menu.findItem(R.id.use_new_gatt_characteristics_id).setChecked(
+                getFromIntentOrPrefs(
+                        EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID, /* defaultValue= */ false));
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == R.id.sign_out_menu_item) {
+            recreate();
+            return true;
+        } else if (item.getItemId() == R.id.reset_account_keys_menu_item) {
+            resetAccountKeys();
+            return true;
+        } else if (item.getItemId() == R.id.reset_device_name_menu_item) {
+            resetDeviceName();
+            return true;
+        } else if (item.getItemId() == R.id.set_firmware_version) {
+            setFirmware();
+            return true;
+        } else if (item.getItemId() == R.id.set_simulator_capability) {
+            setSimulatorCapability();
+            return true;
+        } else if (item.getItemId() == R.id.use_new_gatt_characteristics_id) {
+            if (!item.isChecked()) {
+                item.setChecked(true);
+                mSharedPreferences.edit()
+                        .putBoolean(EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID, true).apply();
+            } else {
+                item.setChecked(false);
+                mSharedPreferences.edit()
+                        .putBoolean(EXTRA_USE_NEW_GATT_CHARACTERISTICS_ID, false).apply();
+            }
+            reset();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void setFirmware() {
+        View firmwareInputView =
+                LayoutInflater.from(getApplicationContext()).inflate(R.layout.user_input_dialog,
+                        null);
+        EditText userInputDialogEditText = firmwareInputView.findViewById(R.id.userInputDialog);
+        new AlertDialog.Builder(MainActivity.this)
+                .setView(firmwareInputView)
+                .setCancelable(false)
+                .setPositiveButton(android.R.string.ok, (dialogBox, id) -> {
+                    String input = userInputDialogEditText.getText().toString();
+                    mSharedPreferences.edit().putString(EXTRA_FIRMWARE_VERSION,
+                            input).apply();
+                    reset();
+                })
+                .setNegativeButton(android.R.string.cancel, null)
+                .setTitle(R.string.firmware_dialog_title)
+                .show();
+    }
+
+    private void setSimulatorCapability() {
+        String[] capabilityKeys = new String[]{EXTRA_SUPPORT_DYNAMIC_SIZE};
+        String[] capabilityNames = new String[]{"Dynamic Buffer Size"};
+        // Default values.
+        boolean[] capabilitySelected = new boolean[]{false};
+        // Get from preferences if exist.
+        for (int i = 0; i < capabilityKeys.length; i++) {
+            capabilitySelected[i] =
+                    mSharedPreferences.getBoolean(capabilityKeys[i], capabilitySelected[i]);
+        }
+
+        new AlertDialog.Builder(MainActivity.this)
+                .setMultiChoiceItems(
+                        capabilityNames,
+                        capabilitySelected,
+                        (dialog, which, isChecked) -> capabilitySelected[which] = isChecked)
+                .setCancelable(false)
+                .setPositiveButton(
+                        android.R.string.ok,
+                        (dialogBox, id) -> {
+                            for (int i = 0; i < capabilityKeys.length; i++) {
+                                mSharedPreferences
+                                        .edit()
+                                        .putBoolean(capabilityKeys[i], capabilitySelected[i])
+                                        .apply();
+                            }
+                            setCapabilityToSimulator();
+                        })
+                .setNegativeButton(android.R.string.cancel, null)
+                .setTitle("Simulator Capability")
+                .show();
+    }
+
+    private void setCapabilityToSimulator() {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.setDynamicBufferSize(
+                    getFromIntentOrPrefs(EXTRA_SUPPORT_DYNAMIC_SIZE, false));
+        }
+    }
+
+    private static String getModelIdString(long id) {
+        String result = Ascii.toUpperCase(Long.toHexString(id));
+        while (result.length() < MODEL_ID_LENGTH) {
+            result = "0" + result;
+        }
+        return result;
+    }
+
+    private void putFixedModelLocal() {
+        mModelsMap.put(
+                "00000C",
+                Device.newBuilder()
+                        .setId(12)
+                        .setAntiSpoofingKeyPair(AntiSpoofingKeyPair.newBuilder().build())
+                        .setDeviceType(DeviceType.HEADPHONES)
+                        .build());
+    }
+
+    private void setupModelIdSpinner() {
+        mModelIdSpinner = findViewById(R.id.model_id_spinner);
+
+        ArrayAdapter<String> modelIdAdapter =
+                new ArrayAdapter<>(this, android.R.layout.simple_spinner_item);
+        modelIdAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+        mModelIdSpinner.setAdapter(modelIdAdapter);
+        resetModelIdSpinner();
+        mModelIdSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
+            @Override
+            public void onItemSelected(AdapterView<?> parent, View view, int position,
+                    long id) {
+                setModelId(mModelsMap.keySet().toArray(new String[0])[position]);
+            }
+
+            @Override
+            public void onNothingSelected(AdapterView<?> adapterView) {
+            }
+        });
+    }
+
+    private void setupRemoteDevices() {
+        if (Strings.isNullOrEmpty(getIntent().getStringExtra(EXTRA_REMOTE_DEVICE_ID))) {
+            log("Can't get remote device id");
+            return;
+        }
+        mRemoteDeviceId = getIntent().getStringExtra(EXTRA_REMOTE_DEVICE_ID);
+        mInputStreamListener = new RemoteDeviceListener(this);
+
+        try {
+            mRemoteDevicesManager.registerRemoteDevice(
+                    mRemoteDeviceId,
+                    new RemoteDevice(
+                            mRemoteDeviceId,
+                            StreamIOHandlerFactory.createStreamIOHandler(
+                                    StreamIOHandlerFactory.Type.LOCAL_FILE,
+                                    REMOTE_DEVICE_INPUT_STREAM_URI,
+                                    REMOTE_DEVICE_OUTPUT_STREAM_URI),
+                            mInputStreamListener));
+        } catch (IOException e) {
+            warning("Failed to create stream IO handler: %s", e);
+        }
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    @UiThread
+    private void resetModelIdSpinner() {
+        ArrayAdapter adapter = (ArrayAdapter) mModelIdSpinner.getAdapter();
+        if (adapter == null) {
+            return;
+        }
+
+        adapter.clear();
+        if (!mModelsMap.isEmpty()) {
+            for (String modelId : mModelsMap.keySet()) {
+                adapter.add(modelId + "-" + mModelsMap.get(modelId).getName());
+            }
+            mModelIdSpinner.setEnabled(true);
+            int newPos = getPositionFromModelId(getModelId());
+            if (newPos < 0) {
+                String newModelId = mModelsMap.keySet().iterator().next();
+                Toast.makeText(this,
+                        "Can't find Model ID " + getModelId() + " from console, reset it to "
+                                + newModelId, Toast.LENGTH_SHORT).show();
+                setModelId(newModelId);
+                newPos = 0;
+            }
+            mModelIdSpinner.setSelection(newPos, /* animate= */ false);
+        } else {
+            mModelIdSpinner.setEnabled(false);
+        }
+    }
+
+    private String getModelId() {
+        return getFromIntentOrPrefs(EXTRA_MODEL_ID, MODEL_ID_DEFAULT).toUpperCase(Locale.US);
+    }
+
+    private boolean setModelId(String modelId) {
+        String validModelId = getValidModelId(modelId);
+        if (TextUtils.isEmpty(validModelId)) {
+            log("Can't do setModelId because inputted modelId is invalid!");
+            return false;
+        }
+
+        if (getModelId().equals(validModelId)) {
+            return false;
+        }
+        mSharedPreferences.edit().putString(EXTRA_MODEL_ID, validModelId).apply();
+        reset();
+        return true;
+    }
+
+    @Nullable
+    private static String getValidModelId(String modelId) {
+        if (TextUtils.isEmpty(modelId) || modelId.length() < MODEL_ID_LENGTH) {
+            return null;
+        }
+
+        return modelId.substring(0, MODEL_ID_LENGTH).toUpperCase(Locale.US);
+    }
+
+    private int getPositionFromModelId(String modelId) {
+        int i = 0;
+        for (String id : mModelsMap.keySet()) {
+            if (id.equals(modelId)) {
+                return i;
+            }
+            i++;
+        }
+        return -1;
+    }
+
+    private void resetAccountKeys() {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.resetAccountKeys();
+            mFastPairSimulator.startAdvertising();
+        }
+    }
+
+    private void resetDeviceName() {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.resetDeviceName();
+        }
+    }
+
+    /** Called via activity_main.xml */
+    public void onResetButtonClicked(View view) {
+        reset();
+    }
+
+    /** Called via activity_main.xml */
+    public void onSendEventStreamMessageButtonClicked(View view) {
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.sendEventStreamMessageToRfcommDevices(mEventGroup);
+        }
+    }
+
+    void reset() {
+        Button resetButton = findViewById(R.id.reset_button);
+        if (mModelsMap.isEmpty() || !resetButton.isEnabled()) {
+            return;
+        }
+        resetButton.setText("Resetting...");
+        resetButton.setEnabled(false);
+        mModelIdSpinner.setEnabled(false);
+        mAppLaunchSwitch.setEnabled(false);
+
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.stopAdvertising();
+
+            if (mBluetoothController.getRemoteDevice() != null) {
+                if (mRemoveAllDevicesDuringPairing) {
+                    mFastPairSimulator.removeBond(mBluetoothController.getRemoteDevice());
+                }
+                mBluetoothController.clearRemoteDevice();
+            }
+            // To be safe, also unpair from all phones (this covers the case where you kill +
+            // relaunch the
+            // simulator while paired).
+            if (mRemoveAllDevicesDuringPairing) {
+                mFastPairSimulator.disconnectAllBondedDevices();
+            }
+            // Sometimes a device will still be connected even though it's not bonded. :( Clear
+            // that too.
+            BluetoothProfile profileProxy = mBluetoothController.getA2DPSinkProfileProxy();
+            for (BluetoothDevice device : profileProxy.getConnectedDevices()) {
+                mFastPairSimulator.disconnect(profileProxy, device);
+            }
+        }
+        updateStatusView();
+
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.destroy();
+        }
+        TextView textView = (TextView) findViewById(R.id.text_view);
+        textView.setText("");
+        textView.setMovementMethod(new ScrollingMovementMethod());
+
+        String modelId = getModelId();
+
+        String txPower = getFromIntentOrPrefs(EXTRA_TX_POWER_LEVEL, "HIGH");
+        updateStringStatusView(R.id.tx_power_text_view, "TxPower", txPower);
+
+        String bluetoothAddress = getFromIntentOrPrefs(EXTRA_BLUETOOTH_ADDRESS, "");
+
+        String firmwareVersion = getFromIntentOrPrefs(EXTRA_FIRMWARE_VERSION, "1.1");
+        try {
+            Preconditions.checkArgument(base16().decode(bluetoothAddress).length == 6);
+        } catch (IllegalArgumentException e) {
+            log("Invalid BLUETOOTH_ADDRESS extra (%s), using default.", bluetoothAddress);
+            bluetoothAddress = null;
+        }
+        final String finalBluetoothAddress = bluetoothAddress;
+
+        updateStringStatusView(
+                R.id.anti_spoofing_private_key_text_view, ANTI_SPOOFING_KEY_LABEL, "Loading...");
+
+        boolean useRandomSaltForAccountKeyRotation =
+                getFromIntentOrPrefs(EXTRA_USE_RANDOM_SALT_FOR_ACCOUNT_KEY_ROTATION, false);
+
+        Executors.newSingleThreadExecutor().execute(() -> {
+            // Fetch the anti-spoofing key corresponding to this model ID (if it
+            // exists).
+            // The account must have Project Viewer permission for the project
+            // that owns
+            // the model ID (normally discoverer-test or discoverer-devices).
+            byte[] antiSpoofingKey = getAntiSpoofingKey(modelId);
+            String antiSpoofingKeyString;
+            Device device = mModelsMap.get(modelId);
+            if (antiSpoofingKey != null) {
+                antiSpoofingKeyString = base64().encode(antiSpoofingKey);
+            } else {
+                if (mSharedPreferences.getString(KEY_ACCOUNT_NAME, "").isEmpty()) {
+                    antiSpoofingKeyString = "Can't fetch, no account";
+                } else {
+                    if (device == null) {
+                        antiSpoofingKeyString = String.format(Locale.US,
+                                "Can't find model %s from console", modelId);
+                    } else if (!device.hasAntiSpoofingKeyPair()) {
+                        antiSpoofingKeyString = String.format(Locale.US,
+                                "Can't find AntiSpoofingKeyPair for model %s", modelId);
+                    } else if (device.getAntiSpoofingKeyPair().getPrivateKey().isEmpty()) {
+                        antiSpoofingKeyString = String.format(Locale.US,
+                                "Can't find privateKey for model %s", modelId);
+                    } else {
+                        antiSpoofingKeyString = "Unknown error";
+                    }
+                }
+            }
+
+            int desiredIoCapability = getIoCapabilityFromModelId(modelId);
+
+            mBluetoothController.setIoCapability(
+                    /*ioCapabilityClassic=*/ desiredIoCapability,
+                    /*ioCapabilityBLE=*/ desiredIoCapability);
+
+            runOnUiThread(() -> {
+                updateStringStatusView(
+                        R.id.anti_spoofing_private_key_text_view,
+                        ANTI_SPOOFING_KEY_LABEL,
+                        antiSpoofingKeyString);
+                FastPairSimulator.Options option = FastPairSimulator.Options.builder(modelId)
+                        .setAdvertisingModelId(
+                                mAppLaunchSwitch.isChecked() ? MODEL_ID_APP_LAUNCH : modelId)
+                        .setBluetoothAddress(finalBluetoothAddress)
+                        .setTxPowerLevel(toTxPowerLevel(txPower))
+                        .setCallback(this::updateStatusView)
+                        .setAntiSpoofingPrivateKey(antiSpoofingKey)
+                        .setUseRandomSaltForAccountKeyRotation(useRandomSaltForAccountKeyRotation)
+                        .setDataOnlyConnection(device != null && device.getDataOnlyConnection())
+                        .setIsMemoryTest(mInputStreamListener != null)
+                        .setShowsPasskeyConfirmation(
+                                device.getDeviceType().equals(DeviceType.ANDROID_AUTO))
+                        .setRemoveAllDevicesDuringPairing(mRemoveAllDevicesDuringPairing)
+                        .build();
+                Logger textViewLogger = new Logger(FastPairSimulator.TAG) {
+
+                    @FormatMethod
+                    public void log(@Nullable Throwable exception, String message,
+                            Object... objects) {
+                        super.log(exception, message, objects);
+
+                        String exceptionMessage = (exception == null) ? ""
+                                : " - " + exception.getMessage();
+                        final String finalMessage =
+                                String.format(message, objects) + exceptionMessage;
+
+                        textView.post(() -> {
+                            String newText =
+                                    textView.getText() + "\n\n" + finalMessage;
+                            textView.setText(newText);
+                        });
+                    }
+                };
+                mFastPairSimulator =
+                        new FastPairSimulator(this, option, textViewLogger);
+                mFastPairSimulator.setFirmwareVersion(firmwareVersion);
+                mFailSwitch.setChecked(
+                        mFastPairSimulator.getShouldFailPairing());
+                mAdvOptionSpinner.setSelection(0);
+                setCapabilityToSimulator();
+
+                updateStringStatusView(R.id.bluetooth_address_text_view,
+                        "Bluetooth address",
+                        mFastPairSimulator.getBluetoothAddress());
+
+                updateStringStatusView(R.id.device_name_text_view,
+                        "Device name",
+                        mFastPairSimulator.getDeviceName());
+
+                resetButton.setText("Reset");
+                resetButton.setEnabled(true);
+                mModelIdSpinner.setEnabled(true);
+                mAppLaunchSwitch.setEnabled(true);
+                mFastPairSimulator.setDeviceNameCallback(deviceName ->
+                        updateStringStatusView(
+                                R.id.device_name_text_view,
+                                "Device name", deviceName));
+
+                if (desiredIoCapability == IO_CAPABILITY_IN
+                        || device.getDeviceType().equals(DeviceType.ANDROID_AUTO)) {
+                    mFastPairSimulator.setPasskeyEventCallback(mPasskeyEventCallback);
+                }
+                if (mInputStreamListener != null) {
+                    mInputStreamListener.setFastPairSimulator(mFastPairSimulator);
+                }
+            });
+        });
+    }
+
+    private int getIoCapabilityFromModelId(String modelId) {
+        Device device = mModelsMap.get(modelId);
+        if (device == null) {
+            return IO_CAPABILITY_NONE;
+        } else {
+            if (getAntiSpoofingKey(modelId) == null) {
+                return IO_CAPABILITY_NONE;
+            } else {
+                switch (device.getDeviceType()) {
+                    case INPUT_DEVICE:
+                        return IO_CAPABILITY_IN;
+
+                    case DEVICE_TYPE_UNSPECIFIED:
+                        return IO_CAPABILITY_NONE;
+
+                    // Treats wearable to IO_CAPABILITY_KBDISP for simulator because there seems
+                    // no suitable
+                    // type.
+                    case WEARABLE:
+                        return IO_CAPABILITY_KBDISP;
+
+                    default:
+                        return IO_CAPABILITY_IO;
+                }
+            }
+        }
+    }
+
+    @Nullable
+    ByteString getAccontKey() {
+        if (mFastPairSimulator == null) {
+            return null;
+        }
+        return mFastPairSimulator.getAccountKey();
+    }
+
+    @Nullable
+    private byte[] getAntiSpoofingKey(String modelId) {
+        Device device = mModelsMap.get(modelId);
+        if (device != null
+                && device.hasAntiSpoofingKeyPair()
+                && !device.getAntiSpoofingKeyPair().getPrivateKey().isEmpty()) {
+            return base64().decode(device.getAntiSpoofingKeyPair().getPrivateKey().toStringUtf8());
+        } else if (ANTI_SPOOFING_PRIVATE_KEY_MAP.containsKey(modelId)) {
+            return base64().decode(ANTI_SPOOFING_PRIVATE_KEY_MAP.get(modelId));
+        } else {
+            return null;
+        }
+    }
+
+    private final PasskeyEventCallback mPasskeyEventCallback = new PasskeyEventCallback() {
+        @Override
+        public void onPasskeyRequested(KeyInputCallback keyInputCallback) {
+            showInputPasskeyDialog(keyInputCallback);
+        }
+
+        @Override
+        public void onPasskeyConfirmation(int passkey, Consumer<Boolean> isConfirmed) {
+            showConfirmPasskeyDialog(passkey, isConfirmed);
+        }
+
+        @Override
+        public void onRemotePasskeyReceived(int passkey) {
+            if (mInputPasskeyDialog == null) {
+                return;
+            }
+
+            EditText userInputDialogEditText = mInputPasskeyDialog.findViewById(
+                    R.id.userInputDialog);
+            if (userInputDialogEditText == null) {
+                return;
+            }
+
+            userInputDialogEditText.setText(String.format("%d", passkey));
+        }
+    };
+
+    private void showInputPasskeyDialog(KeyInputCallback keyInputCallback) {
+        if (mInputPasskeyDialog == null) {
+            View userInputView =
+                    LayoutInflater.from(getApplicationContext()).inflate(R.layout.user_input_dialog,
+                            null);
+            EditText userInputDialogEditText = userInputView.findViewById(R.id.userInputDialog);
+            userInputDialogEditText.setHint(R.string.passkey_input_hint);
+            userInputDialogEditText.setInputType(InputType.TYPE_CLASS_NUMBER);
+            mInputPasskeyDialog = new AlertDialog.Builder(MainActivity.this)
+                    .setView(userInputView)
+                    .setCancelable(false)
+                    .setPositiveButton(
+                            android.R.string.ok,
+                            (DialogInterface dialogBox, int id) -> {
+                                String input = userInputDialogEditText.getText().toString();
+                                keyInputCallback.onKeyInput(Integer.parseInt(input));
+                            })
+                    .setNegativeButton(android.R.string.cancel, /* listener= */ null)
+                    .setTitle(R.string.passkey_dialog_title)
+                    .create();
+        }
+        if (!mInputPasskeyDialog.isShowing()) {
+            mInputPasskeyDialog.show();
+        }
+    }
+
+    private void showConfirmPasskeyDialog(int passkey, Consumer<Boolean> isConfirmed) {
+        runOnUiThread(() -> new AlertDialog.Builder(MainActivity.this)
+                .setCancelable(false)
+                .setTitle(R.string.confirm_passkey)
+                .setMessage(String.valueOf(passkey))
+                .setPositiveButton(android.R.string.ok,
+                        (d, w) -> isConfirmed.accept(true))
+                .setNegativeButton(android.R.string.cancel,
+                        (d, w) -> isConfirmed.accept(false))
+                .create()
+                .show());
+    }
+
+    @UiThread
+    private void updateStringStatusView(int id, String name, String value) {
+        ((TextView) findViewById(id)).setText(name + ": " + value);
+    }
+
+    @UiThread
+    private void updateStatusView() {
+        TextView remoteDeviceTextView = (TextView) findViewById(R.id.remote_device_text_view);
+        remoteDeviceTextView.setBackgroundColor(
+                mBluetoothController.getRemoteDevice() != null ? LIGHT_GREEN : Color.LTGRAY);
+        String remoteDeviceString = mBluetoothController.getRemoteDeviceAsString();
+        remoteDeviceTextView.setText("Remote device: " + remoteDeviceString);
+
+        updateBooleanStatusView(
+                R.id.is_advertising_text_view,
+                "BLE advertising",
+                mFastPairSimulator != null && mFastPairSimulator.isAdvertising());
+
+        updateStringStatusView(
+                R.id.scan_mode_text_view,
+                "Mode",
+                FastPairSimulator.scanModeToString(mBluetoothController.getScanMode()));
+
+        boolean isPaired = mBluetoothController.isPaired();
+        updateBooleanStatusView(R.id.is_paired_text_view, "Paired", isPaired);
+
+        updateBooleanStatusView(
+                R.id.is_connected_text_view, "Connected", mBluetoothController.isConnected());
+    }
+
+    @UiThread
+    private void updateBooleanStatusView(int id, String name, boolean value) {
+        TextView view = (TextView) findViewById(id);
+        view.setBackgroundColor(value ? LIGHT_GREEN : Color.LTGRAY);
+        view.setText(name + ": " + (value ? "Yes" : "No"));
+    }
+
+    private String getFromIntentOrPrefs(String key, String defaultValue) {
+        Bundle extras = getIntent().getExtras();
+        extras = extras != null ? extras : new Bundle();
+        SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+        String value = extras.getString(key, prefs.getString(key, defaultValue));
+        if (value == null) {
+            prefs.edit().remove(key).apply();
+        } else {
+            prefs.edit().putString(key, value).apply();
+        }
+        return value;
+    }
+
+    private boolean getFromIntentOrPrefs(String key, boolean defaultValue) {
+        Bundle extras = getIntent().getExtras();
+        extras = extras != null ? extras : new Bundle();
+        SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
+        boolean value = extras.getBoolean(key, prefs.getBoolean(key, defaultValue));
+        prefs.edit().putBoolean(key, value).apply();
+        return value;
+    }
+
+    private static int toTxPowerLevel(String txPowerLevelString) {
+        switch (txPowerLevelString.toUpperCase()) {
+            case "3":
+            case "HIGH":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_HIGH;
+            case "2":
+            case "MEDIUM":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM;
+            case "1":
+            case "LOW":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_LOW;
+            case "0":
+            case "ULTRA_LOW":
+                return AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW;
+            default:
+                throw new IllegalArgumentException(
+                        "Unexpected TxPower="
+                                + txPowerLevelString
+                                + ", please provide HIGH, MEDIUM, LOW, or ULTRA_LOW.");
+        }
+    }
+
+    private boolean checkPermissions(String[] permissions) {
+        for (String permission : permissions) {
+            if (checkSelfPermission(permission) != PERMISSION_GRANTED) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    protected void onDestroy() {
+        mRemoteDevicesManager.destroy();
+
+        if (mFastPairSimulator != null) {
+            mFastPairSimulator.destroy();
+            mBluetoothController.unregisterBluetoothStateReceiver();
+        }
+
+        // Recover the IO capability.
+        mBluetoothController.setIoCapability(
+                /*ioCapabilityClassic=*/ IO_CAPABILITY_IO, /*ioCapabilityBLE=*/
+                IO_CAPABILITY_KBDISP);
+
+        super.onDestroy();
+    }
+
+    @Override
+    public void onRequestPermissionsResult(
+            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        // Relaunch this activity.
+        recreate();
+    }
+
+    void startAdvertisingBatteryInformationBasedOnOption(int option) {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        // Option 0 is "No battery info", it means simulator will not pack battery information when
+        // advertising. For the others with battery info, since we are simulating the Presto's
+        // behavior,
+        // there will always be three battery values.
+        switch (option) {
+            case 0:
+                // Option "0: No battery info"
+                mFastPairSimulator.clearBatteryValues();
+                break;
+            case 1:
+                // Option "1: Show L(⬆) + R(⬆) + C(⬆)"
+                mFastPairSimulator.setSuppressBatteryNotification(false);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(true, 60),
+                        new BatteryValue(true, 61),
+                        new BatteryValue(true, 62));
+                break;
+            case 2:
+                // Option "2: Show L + R + C(unknown)"
+                mFastPairSimulator.setSuppressBatteryNotification(false);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(false, 70),
+                        new BatteryValue(false, 71),
+                        new BatteryValue(false, -1));
+                break;
+            case 3:
+                // Option "3: Show L(low 10) + R(low 9) + C(low 25)"
+                mFastPairSimulator.setSuppressBatteryNotification(false);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(false, 10),
+                        new BatteryValue(false, 9),
+                        new BatteryValue(false, 25));
+                break;
+            case 4:
+                // Option "4: Suppress battery w/o level changes"
+                // Just change the suppress bit and keep the battery values the same as before.
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                break;
+            case 5:
+                // Option "5: Suppress L(low 10) + R(11) + C"
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(false, 10),
+                        new BatteryValue(false, 11),
+                        new BatteryValue(false, 82));
+                break;
+            case 6:
+                // Option "6: Suppress L(low ⬆) + R(low ⬆) + C(low 10)"
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(true, 10),
+                        new BatteryValue(true, 9),
+                        new BatteryValue(false, 10));
+                break;
+            case 7:
+                // Option "7: Suppress L(low ⬆) + R(low ⬆) + C(low ⬆)"
+                mFastPairSimulator.setSuppressBatteryNotification(true);
+                mFastPairSimulator.setBatteryValues(new BatteryValue(true, 10),
+                        new BatteryValue(true, 9),
+                        new BatteryValue(true, 25));
+                break;
+            case 8:
+                // Option "8: Show subsequent pairing notification"
+                mFastPairSimulator.setSuppressSubsequentPairingNotification(false);
+                break;
+            case 9:
+                // Option "9: Suppress subsequent pairing notification"
+                mFastPairSimulator.setSuppressSubsequentPairingNotification(true);
+                break;
+            default:
+                // Unknown option, do nothing.
+                return;
+        }
+
+        mFastPairSimulator.startAdvertising();
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/RemoteDeviceListener.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/RemoteDeviceListener.java
new file mode 100644
index 0000000..fac8cb5
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/app/RemoteDeviceListener.java
@@ -0,0 +1,164 @@
+/*
+ * 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.fastpair.provider.simulator.app;
+
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.ACCOUNT_KEY;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.ACKNOWLEDGE;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_ADDRESS_BLE;
+import static android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event.Code.BLUETOOTH_ADDRESS_PUBLIC;
+
+import android.nearby.fastpair.provider.FastPairSimulator;
+import android.nearby.fastpair.provider.FastPairSimulator.BatteryValue;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Command;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Command.BatteryInfo;
+import android.nearby.fastpair.provider.simulator.SimulatorStreamProtocol.Event;
+import android.nearby.fastpair.provider.simulator.testing.InputStreamListener;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.ByteString;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+/** Listener for input stream of the remote device. */
+public class RemoteDeviceListener implements InputStreamListener {
+    private static final String TAG = RemoteDeviceListener.class.getSimpleName();
+
+    private final MainActivity mMainActivity;
+    @Nullable
+    private FastPairSimulator mFastPairSimulator;
+
+    public RemoteDeviceListener(MainActivity mainActivity) {
+        this.mMainActivity = mainActivity;
+    }
+
+    @Override
+    public void onInputData(ByteString byteString) {
+        Command command;
+        try {
+            command = Command.parseFrom(byteString);
+        } catch (InvalidProtocolBufferException e) {
+            Log.w(TAG, String.format("%s input data is not a Command",
+                    mMainActivity.mRemoteDeviceId), e);
+            return;
+        }
+
+        mMainActivity.runOnUiThread(() -> {
+            Log.d(TAG, String.format("%s new command %s",
+                    mMainActivity.mRemoteDeviceId, command.getCode()));
+            switch (command.getCode()) {
+                case POLLING:
+                    mMainActivity.sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(ACKNOWLEDGE));
+                    break;
+                case RESET:
+                    mMainActivity.reset();
+                    break;
+                case SHOW_BATTERY:
+                    onShowBattery(command.getBatteryInfo());
+                    break;
+                case HIDE_BATTERY:
+                    onHideBattery();
+                    break;
+                case REQUEST_BLUETOOTH_ADDRESS_BLE:
+                    onRequestBleAddress();
+                    break;
+                case REQUEST_BLUETOOTH_ADDRESS_PUBLIC:
+                    onRequestPublicAddress();
+                    break;
+                case REQUEST_ACCOUNT_KEY:
+                    ByteString accountKey = mMainActivity.getAccontKey();
+                    if (accountKey == null) {
+                        break;
+                    }
+                    mMainActivity.sendEventToRemoteDevice(
+                            Event.newBuilder().setCode(ACCOUNT_KEY)
+                                    .setAccountKey(accountKey));
+                    break;
+            }
+        });
+    }
+
+    @Override
+    public void onClose() {
+        Log.d(TAG, String.format("%s input stream is closed", mMainActivity.mRemoteDeviceId));
+    }
+
+    void setFastPairSimulator(FastPairSimulator fastPairSimulator) {
+        this.mFastPairSimulator = fastPairSimulator;
+    }
+
+    private void onShowBattery(@Nullable BatteryInfo batteryInfo) {
+        if (mFastPairSimulator == null || batteryInfo == null) {
+            Log.w(TAG, "skip showing battery");
+            return;
+        }
+
+        if (batteryInfo.getBatteryValuesCount() != 3) {
+            Log.w(TAG, String.format("skip showing battery: count is not valid %d",
+                    batteryInfo.getBatteryValuesCount()));
+            return;
+        }
+
+        Log.d(TAG, String.format("Show battery %s", batteryInfo));
+
+        if (batteryInfo.hasSuppressNotification()) {
+            mFastPairSimulator.setSuppressBatteryNotification(
+                    batteryInfo.getSuppressNotification());
+        }
+        mFastPairSimulator.setBatteryValues(
+                convertFrom(batteryInfo.getBatteryValues(0)),
+                convertFrom(batteryInfo.getBatteryValues(1)),
+                convertFrom(batteryInfo.getBatteryValues(2)));
+        mFastPairSimulator.startAdvertising();
+    }
+
+    private void onHideBattery() {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        mFastPairSimulator.clearBatteryValues();
+        mFastPairSimulator.startAdvertising();
+    }
+
+    private void onRequestBleAddress() {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        mMainActivity.sendEventToRemoteDevice(
+                Event.newBuilder()
+                        .setCode(BLUETOOTH_ADDRESS_BLE)
+                        .setBleAddress(mFastPairSimulator.getBleAddress()));
+    }
+
+    private void onRequestPublicAddress() {
+        if (mFastPairSimulator == null) {
+            return;
+        }
+
+        mMainActivity.sendEventToRemoteDevice(
+                Event.newBuilder()
+                        .setCode(BLUETOOTH_ADDRESS_PUBLIC)
+                        .setPublicAddress(mFastPairSimulator.getBluetoothAddress()));
+    }
+
+    private static BatteryValue convertFrom(BatteryInfo.BatteryValue batteryValue) {
+        return new BatteryValue(batteryValue.getCharging(), batteryValue.getLevel());
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/InputStreamListener.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/InputStreamListener.java
new file mode 100644
index 0000000..b29225a
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/InputStreamListener.java
@@ -0,0 +1,29 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import com.google.protobuf.ByteString;
+
+/** Listener for input stream. */
+public interface InputStreamListener {
+
+    /** Called when new data {@code byteString} is read from the input stream. */
+    void onInputData(ByteString byteString);
+
+    /** Called when the input stream is closed. */
+    void onClose();
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/LocalFileStreamIOHandler.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/LocalFileStreamIOHandler.java
new file mode 100644
index 0000000..cf8b022
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/LocalFileStreamIOHandler.java
@@ -0,0 +1,119 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.io.BaseEncoding.base16;
+
+import android.net.Uri;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.ByteString;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+
+/**
+ * Opens the {@code inputUri} and {@code outputUri} as local files and provides reading/writing
+ * data operations.
+ *
+ * To support bluetooth testing on real devices, the named pipes are created as local files and the
+ * pipe data are transferred via usb cable, then (1) the peripheral device writes {@code Event} to
+ * the output stream and reads {@code Command} from the input stream (2) the central devices write
+ * {@code Command} to the output stream and read {@code Event} from the input stream.
+ *
+ * The {@code Event} and {@code Command} are special protocols which are defined at
+ * simulator_stream_protocol.proto.
+ */
+public class LocalFileStreamIOHandler implements StreamIOHandler {
+
+    private static final int MAX_IO_DATA_LENGTH_BYTE = 65535;
+
+    private final String mInputPath;
+    private final String mOutputPath;
+
+    LocalFileStreamIOHandler(Uri inputUri, Uri outputUri) throws IOException {
+        if (!isFileExists(inputUri.getPath())) {
+            throw new FileNotFoundException("Input path is not exists.");
+        }
+        if (!isFileExists(outputUri.getPath())) {
+            throw new FileNotFoundException("Output path is not exists.");
+        }
+
+        this.mInputPath = inputUri.getPath();
+        this.mOutputPath = outputUri.getPath();
+    }
+
+    /**
+     * Reads a {@code ByteString} from the input stream. The input stream must be opened before
+     * calling this method.
+     */
+    @Override
+    public ByteString read() throws IOException {
+        try (InputStreamReader inputStream = new InputStreamReader(
+                new FileInputStream(mInputPath))) {
+            int size = inputStream.read();
+            if (size == 0) {
+                throw new IOException(String.format("Missing data size %d", size));
+            }
+
+            if (size > MAX_IO_DATA_LENGTH_BYTE) {
+                throw new IOException("Exceed the maximum data length when reading.");
+            }
+
+            char[] data = new char[size];
+            int count = inputStream.read(data);
+            if (count != size) {
+                throw new IOException(
+                        String.format("Expected size was %s but got %s", size, count));
+            }
+
+            return ByteString.copyFrom(base16().decode(new String(data)));
+        }
+    }
+
+    /**
+     * Writes a {@code output} into the output stream. The output stream must be opened before
+     * calling this method.
+     */
+    @Override
+    public void write(ByteString output) throws IOException {
+        checkArgument(output.size() > 0, "Output data is empty.");
+
+        if (output.size() > MAX_IO_DATA_LENGTH_BYTE) {
+            throw new IOException("Exceed the maximum data length when writing.");
+        }
+
+        try (OutputStreamWriter outputStream =
+                     new OutputStreamWriter(new FileOutputStream(mOutputPath))) {
+            String base16Output = base16().encode(output.toByteArray());
+            outputStream.write(base16Output.length());
+            outputStream.write(base16Output);
+        }
+    }
+
+    private static boolean isFileExists(@Nullable String path) {
+        return path != null && new File(path).exists();
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/Reflect.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/Reflect.java
new file mode 100644
index 0000000..16fbc71
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/Reflect.java
@@ -0,0 +1,103 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Utilities for calling methods using reflection. The main benefit of using this helper is to avoid
+ * complications around exception handling when calling methods reflectively. It's not safe to use
+ * Java 8's multicatch on such exceptions, because the java compiler converts multicatch into
+ * ReflectiveOperationException in some instances, which doesn't work on older sdk versions.
+ * Instead, use these utilities and catch ReflectionException.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * try {
+ *   Reflect.on(btAdapter)
+ *       .withMethod("setScanMode", int.class)
+ *       .invoke(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
+ * } catch (ReflectionException e) { }
+ * }</pre>
+ */
+public final class Reflect {
+    private final Object mTargetObject;
+
+    private Reflect(Object targetObject) {
+        this.mTargetObject = targetObject;
+    }
+
+    /** Creates an instance of this helper to invoke methods on the given target object. */
+    public static Reflect on(Object targetObject) {
+        return new Reflect(targetObject);
+    }
+
+    /** Finds a method with the given name and parameter types. */
+    public ReflectionMethod withMethod(String methodName, Class<?>... paramTypes)
+            throws ReflectionException {
+        try {
+            return new ReflectionMethod(mTargetObject.getClass().getMethod(methodName, paramTypes));
+        } catch (NoSuchMethodException e) {
+            throw new ReflectionException(e);
+        }
+    }
+
+    /** Represents an invokable method found reflectively. */
+    public final class ReflectionMethod {
+        private final Method mMethod;
+
+        private ReflectionMethod(Method method) {
+            this.mMethod = method;
+        }
+
+        /**
+         * Invokes this instance method with the given parameters. The called method does not return
+         * a value.
+         */
+        public void invoke(Object... parameters) throws ReflectionException {
+            try {
+                mMethod.invoke(mTargetObject, parameters);
+            } catch (IllegalAccessException e) {
+                throw new ReflectionException(e);
+            } catch (InvocationTargetException e) {
+                throw new ReflectionException(e);
+            }
+        }
+
+        /**
+         * Invokes this instance method with the given parameters. The called method returns a non
+         * null
+         * value.
+         */
+        public Object get(Object... parameters) throws ReflectionException {
+            Object value;
+            try {
+                value = mMethod.invoke(mTargetObject, parameters);
+            } catch (IllegalAccessException e) {
+                throw new ReflectionException(e);
+            } catch (InvocationTargetException e) {
+                throw new ReflectionException(e);
+            }
+            if (value == null) {
+                throw new ReflectionException(new NullPointerException());
+            }
+            return value;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/ReflectionException.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/ReflectionException.java
new file mode 100644
index 0000000..2a65f39
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/ReflectionException.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 android.nearby.fastpair.provider.simulator.testing;
+
+/**
+ * An exception thrown during a reflection operation. Like ReflectiveOperationException, except
+ * compatible on older API versions.
+ */
+public final class ReflectionException extends Exception {
+    public ReflectionException(Throwable cause) {
+        super(cause.getMessage(), cause);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevice.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevice.java
new file mode 100644
index 0000000..11ec9cb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevice.java
@@ -0,0 +1,46 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+/** Represents a remote device and provides a {@link StreamIOHandler} to communicate with it. */
+public class RemoteDevice {
+    private final String mId;
+    private final StreamIOHandler mStreamIOHandler;
+    private final InputStreamListener mInputStreamListener;
+
+    public RemoteDevice(
+            String id, StreamIOHandler streamIOHandler, InputStreamListener inputStreamListener) {
+        this.mId = id;
+        this.mStreamIOHandler = streamIOHandler;
+        this.mInputStreamListener = inputStreamListener;
+    }
+
+    /** The id used by this device. */
+    public String getId() {
+        return mId;
+    }
+
+    /** The handler processes input and output data channels. */
+    public StreamIOHandler getStreamIOHandler() {
+        return mStreamIOHandler;
+    }
+
+    /** Listener for the input stream. */
+    public InputStreamListener getInputStreamListener() {
+        return mInputStreamListener;
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevicesManager.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevicesManager.java
new file mode 100644
index 0000000..02260c2
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/RemoteDevicesManager.java
@@ -0,0 +1,140 @@
+/*
+ * 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.fastpair.provider.simulator.testing;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import android.util.Log;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.protobuf.ByteString;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+
+/**
+ * Manages the IO streams with remote devices.
+ *
+ * <p>The caller must invoke {@link #registerRemoteDevice} before starting to communicate with the
+ * remote device, and invoke {@link #unregisterRemoteDevice} after finishing tasks. If this instance
+ * is not used anymore, the caller need to invoke {@link #destroy} to release all resources.
+ *
+ * <p>All of the methods are thread-safe.
+ */
+public class RemoteDevicesManager {
+    private static final String TAG = "RemoteDevicesManager";
+
+    private final Map<String, RemoteDevice> mRemoteDeviceMap = new HashMap<>();
+    private final ListeningExecutorService mBackgroundExecutor =
+            MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
+    private final ListeningExecutorService mListenInputStreamExecutors =
+            MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+    private final Map<String, ListenableFuture<Void>> mListeningTaskMap = new HashMap<>();
+
+    /**
+     * Opens input and output data streams for {@code remoteDevice} in the background and notifies
+     * the
+     * open result via {@code callback}, and assigns a dedicated executor to listen the input data
+     * stream if data streams are opened successfully. The dedicated executor will invoke the
+     * {@code
+     * remoteDevice.inputStreamListener().onInputData()} directly if the new data exists in the
+     * input
+     * stream and invoke the {@code remoteDevice.inputStreamListener().onClose()} if the input
+     * stream
+     * is closed.
+     */
+    public synchronized void registerRemoteDevice(String id, RemoteDevice remoteDevice) {
+        checkState(mRemoteDeviceMap.put(id, remoteDevice) == null,
+                "The %s is already registered", id);
+        startListeningInputStreamTask(remoteDevice);
+    }
+
+    /**
+     * Closes the data streams for specific remote device {@code id} in the background and notifies
+     * the result via {@code callback}.
+     */
+    public synchronized void unregisterRemoteDevice(String id) {
+        RemoteDevice remoteDevice = mRemoteDeviceMap.remove(id);
+        checkState(remoteDevice != null, "The %s is not registered", id);
+        if (mListeningTaskMap.containsKey(id)) {
+            mListeningTaskMap.remove(id).cancel(/* mayInterruptIfRunning= */ true);
+        }
+    }
+
+    /** Closes all data streams of registered remote devices and stop all background tasks. */
+    public synchronized void destroy() {
+        mRemoteDeviceMap.clear();
+        mListeningTaskMap.clear();
+        mListenInputStreamExecutors.shutdownNow();
+    }
+
+    /**
+     * Writes {@code data} into the output data stream of specific remote device {@code id} in the
+     * background and notifies the result via {@code callback}.
+     */
+    public synchronized void writeDataToRemoteDevice(
+            String id, ByteString data, FutureCallback<Void> callback) {
+        RemoteDevice remoteDevice = mRemoteDeviceMap.get(id);
+        checkState(remoteDevice != null, "The %s is not registered", id);
+
+        runInBackground(() -> {
+            remoteDevice.getStreamIOHandler().write(data);
+            return null;
+        }, callback);
+    }
+
+    private void runInBackground(Callable<Void> callable, FutureCallback<Void> callback) {
+        Futures.addCallback(
+                mBackgroundExecutor.submit(callable), callback, MoreExecutors.directExecutor());
+    }
+
+    private void startListeningInputStreamTask(RemoteDevice remoteDevice) {
+        ListenableFuture<Void> listenFuture = mListenInputStreamExecutors.submit(() -> {
+            Log.i(TAG, "Start listening " + remoteDevice.getId());
+            while (true) {
+                ByteString data;
+                try {
+                    data = remoteDevice.getStreamIOHandler().read();
+                } catch (IOException | IllegalStateException e) {
+                    break;
+                }
+                remoteDevice.getInputStreamListener().onInputData(data);
+            }
+        }, /* result= */ null);
+        Futures.addCallback(listenFuture, new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.i(TAG, "Stop listening " + remoteDevice.getId());
+                remoteDevice.getInputStreamListener().onClose();
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.w(TAG, "Stop listening " + remoteDevice.getId() + ", cause: " + t);
+                remoteDevice.getInputStreamListener().onClose();
+            }
+        }, MoreExecutors.directExecutor());
+        mListeningTaskMap.put(remoteDevice.getId(), listenFuture);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandler.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandler.java
new file mode 100644
index 0000000..d5fdb9e
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandler.java
@@ -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.fastpair.provider.simulator.testing;
+
+import com.google.protobuf.ByteString;
+
+import java.io.IOException;
+
+/**
+ * Opens input and output data channels, then provides read and write operations to the data
+ * channels.
+ */
+public interface StreamIOHandler {
+    /**
+     * Reads stream data from the input channel.
+     *
+     * @return a protocol buffer contains the input message
+     * @throws IOException errors occur when reading the input stream
+     */
+    ByteString read() throws IOException;
+
+    /**
+     * Writes stream data to the output channel.
+     *
+     * @param output a protocol buffer contains the output message
+     * @throws IOException errors occur when writing the output message to output stream
+     */
+    void write(ByteString output) throws IOException;
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandlerFactory.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandlerFactory.java
new file mode 100644
index 0000000..24cfe56
--- /dev/null
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/simulator_app/src/android/nearby/fastpair/provider/simulator/testing/StreamIOHandlerFactory.java
@@ -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.fastpair.provider.simulator.testing;
+
+import android.net.Uri;
+
+import java.io.IOException;
+
+/** A simple factory creating {@link StreamIOHandler} according to {@link Type}. */
+public class StreamIOHandlerFactory {
+
+    /** Types for creating {@link StreamIOHandler}. */
+    public enum Type {
+
+        /**
+         * A {@link StreamIOHandler} accepts local file uris and provides reading/writing file
+         * operations.
+         */
+        LOCAL_FILE
+    }
+
+    /** Creates an instance of {@link StreamIOHandler}. */
+    public static StreamIOHandler createStreamIOHandler(Type type, Uri input, Uri output)
+            throws IOException {
+        if (type.equals(Type.LOCAL_FILE)) {
+            return new LocalFileStreamIOHandler(input, output);
+        }
+        throw new IllegalArgumentException(String.format("Can't support %s", type));
+    }
+}
diff --git a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java
index 4329c38..931f2e0 100644
--- a/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java
+++ b/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java
@@ -160,8 +160,8 @@
  * @see {http://go/fast-pair-2-spec}
  */
 public class FastPairSimulator {
-    private static final String TAG = "FastPairSimulator";
-    private final Logger mLogger = new Logger(TAG);
+    public static final String TAG = "FastPairSimulator";
+    private final Logger mLogger;
 
     /**
      * Headphones. Generated by
@@ -978,8 +978,13 @@
     }
 
     public FastPairSimulator(Context context, Options options) {
+        this(context, options, new Logger(TAG));
+    }
+
+    public FastPairSimulator(Context context, Options options, Logger logger) {
         this.mContext = context;
         this.mOptions = options;
+        this.mLogger = logger;
 
         this.mBatteryValues = new ArrayList<>();
 
@@ -1608,7 +1613,7 @@
                                     && mHandshakeRequest.getType()
                                     == HandshakeRequest.Type.KEY_BASED_PAIRING_REQUEST
                                     && !mHandshakeRequest.requestRetroactivePair()) {
-                                mExecutor.execute(() -> disconnect());
+                                mExecutor.execute(() -> disconnectAllBondedDevices());
                             }
 
                             if (mHandshakeRequest.getType()
@@ -2299,8 +2304,8 @@
         return createField(lengthAndType, filterBytes);
     }
 
-    /** Disconnects all connected devices. */
-    private void disconnect() {
+    /** Disconnects all bonded devices. */
+    public void disconnectAllBondedDevices() {
         for (BluetoothDevice device : mBluetoothAdapter.getBondedDevices()) {
             if (device.getBluetoothClass().getMajorDeviceClass() == Major.PHONE) {
                 removeBond(device);