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