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