From c7a4d1bb74f76dfdead6827514e8d2cc0aa14024 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 11 Jan 2022 11:56:45 +0000 Subject: [PATCH] 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 --- nearby/README.md | 2 +- nearby/tests/multidevices/OWNERS | 4 + nearby/tests/multidevices/clients/Android.bp | 48 + .../multidevices/clients/AndroidManifest.xml | 69 + .../tests/multidevices/clients/proguard.flags | 24 + .../multidevices/clients/proto/Android.bp | 30 + .../src/fastpair/event_stream_protocol.proto | 85 + .../multidevices/common/SnippetEventHelper.kt | 43 + .../provider/BluetoothA2dpSinkService.kt | 112 + .../provider/BluetoothStateChangeReceiver.kt | 132 + .../FastPairProviderSimulatorSnippet.kt | 91 + .../fastpair/provider/ProviderStatusEvents.kt | 44 + .../fastpair/seeker/CompanionAppUtils.kt | 34 + .../fastpair/seeker/FastPairSeekerSnippet.kt | 80 + .../seeker/FastPairTestDataProvider.kt | 281 ++ .../seeker/FastPairTestDataProviderService.kt | 49 + .../fastpair/seeker/ScanCallbackEvents.kt | 43 + .../bluetooth/fastpair/testing/Crypto.java | 70 + .../fastpair/testing/E2eeCalculator.java | 185 ++ .../fastpair/testing/FastPairAdvertiser.java | 27 + .../fastpair/testing/FastPairSimulator.java | 2358 +++++++++++++++++ .../testing/FastPairSimulatorDatabase.java | 264 ++ .../fastpair/testing/HandshakeRequest.java | 150 ++ .../bluetooth/fastpair/testing/Logger.java | 50 + .../testing/OreoFastPairAdvertiser.java | 172 ++ .../fastpair/testing/RfcommServer.java | 412 +++ .../server/BluetoothGattServerConfig.java | 132 + .../server/BluetoothGattServerConnection.java | 439 +++ .../server/BluetoothGattServerHelper.java | 408 +++ .../gatt/server/BluetoothGattServlet.java | 54 + .../android/bluetooth/BluetoothManager.java | 70 + .../bluetooth/util/BluetoothGattUtils.java | 123 + .../multidevices/clients/tests/Android.bp | 39 + .../clients/tests/AndroidManifest.xml | 19 + .../clients/tests/robolectric.properties | 16 + .../nearby/multidevices/common/Mockotlin.kt | 82 + .../common/SnippetEventHelperTest.kt | 58 + .../BluetoothStateChangeReceiverTest.kt | 80 + .../fastpair/seeker/CompanionAppUtilsTest.kt | 79 + 39 files changed, 6457 insertions(+), 1 deletion(-) create mode 100644 nearby/tests/multidevices/OWNERS create mode 100644 nearby/tests/multidevices/clients/Android.bp create mode 100644 nearby/tests/multidevices/clients/AndroidManifest.xml create mode 100644 nearby/tests/multidevices/clients/proguard.flags create mode 100644 nearby/tests/multidevices/clients/proto/Android.bp create mode 100644 nearby/tests/multidevices/clients/proto/src/fastpair/event_stream_protocol.proto create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/common/SnippetEventHelper.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothA2dpSinkService.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiver.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/ProviderStatusEvents.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/CompanionAppUtils.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProvider.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProviderService.kt create mode 100644 nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ScanCallbackEvents.kt create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Crypto.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/E2eeCalculator.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairAdvertiser.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulator.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulatorDatabase.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/HandshakeRequest.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Logger.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/OreoFastPairAdvertiser.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/RfcommServer.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConfig.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConnection.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerHelper.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServlet.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothManager.java create mode 100644 nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java create mode 100644 nearby/tests/multidevices/clients/tests/Android.bp create mode 100644 nearby/tests/multidevices/clients/tests/AndroidManifest.xml create mode 100644 nearby/tests/multidevices/clients/tests/robolectric.properties create mode 100644 nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/Mockotlin.kt create mode 100644 nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/SnippetEventHelperTest.kt create mode 100644 nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiverTest.kt create mode 100644 nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/seeker/CompanionAppUtilsTest.kt diff --git a/nearby/README.md b/nearby/README.md index ff3b701303..6925dc4be9 100644 --- a/nearby/README.md +++ b/nearby/README.md @@ -19,7 +19,7 @@ This directory contains code for the AOSP Nearby mainline module. - Server side implementation for nearby module services. `tests` - - Unit tests for Nearby module (both Java and native code). + - Unit/Multi devices tests for Nearby module (both Java and native code). ## IDE setup diff --git a/nearby/tests/multidevices/OWNERS b/nearby/tests/multidevices/OWNERS new file mode 100644 index 0000000000..f4dbde2621 --- /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 0000000000..72b752b103 --- /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 0000000000..b6dc5e8085 --- /dev/null +++ b/nearby/tests/multidevices/clients/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nearby/tests/multidevices/clients/proguard.flags b/nearby/tests/multidevices/clients/proguard.flags new file mode 100644 index 0000000000..ec8f526d55 --- /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 0000000000..80e09b4069 --- /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 0000000000..69ed1ea8a1 --- /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 0000000000..c4816fbbf6 --- /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 0000000000..f65dfabf12 --- /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 0000000000..25cc6376d3 --- /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.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 0000000000..a2f50b4171 --- /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 0000000000..20c8e857d0 --- /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 0000000000..7ed4372264 --- /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 0000000000..add0bc3e6b --- /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() + 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 0000000000..5a30fcfbf4 --- /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 0000000000..5a1c832480 --- /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 0000000000..55a6b8f9f9 --- /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 0000000000..1543953a9d --- /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 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 0000000000..6f213e673f --- /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 t may be either {@code computeE2eeEid(eik, k, t)} or + * {@code computeE2eeEid(eik, k, t - (1 << k))}. + * + *

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 0000000000..33add27df0 --- /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 0000000000..acc13e3c53 --- /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). + * + *

Note: There are two deviations from the spec: + * + *

    + *
  • 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. + *
  • 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. + *
+ * + * @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. + * + *
    + *
  • BLE flags: 3 bytes + *
  • TxPower: 3 bytes + *
  • FastPair: max 25 bytes + *
      + *
    • FastPair service data: 4 bytes + *
    • Flags: 1 byte + *
    • Account key filter: max 14 bytes (1 byte: length + type, 13 bytes: max 8 account + * keys) + *
    • Salt: 2 bytes + *
    • Battery: 4 bytes + *
    + *
+ */ + 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 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 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 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 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 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 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 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 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 + * + *
    + *
  • 0-1: Role - 0b10 (Provider only) + *
  • 2: Transport Data Incomplete: 0 (false) + *
  • 3-4: Transport State (0b00: Off, 0b01: On, 0b10: Temporarily Unavailable) + *
  • 5-7: Reserved for future use + *
+ */ + 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 0000000000..4d2a9e893c --- /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 accountKeys = new HashSet<>(getAccountKeys()); + if (accountKeys.size() >= MAX_NUMBER_OF_ACCOUNT_KEYS) { + Set 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 accountKeys) { + if (sharedPreferences == null) { + return; + } + + Set 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 getAccountKeys() { + if (sharedPreferences == null) { + return new HashSet<>(); + } + + Set keys = sharedPreferences.getStringSet(KEY_ACCOUNT_KEYS, new HashSet<>()); + Set 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. Sass design doc + */ + 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 fastPairSeekerDevices = getFastPairSeekerDevices(); + if (fastPairSeekerDevices.size() > MAX_NUMBER_OF_ACCOUNT_KEYS) { + int removedCount = fastPairSeekerDevices.size() - MAX_NUMBER_OF_ACCOUNT_KEYS + 1; + Set 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 fastPairSeekerDeviceSet) { + if (sharedPreferences == null) { + return; + } + + Set 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 getFastPairSeekerDevices() { + if (sharedPreferences == null) { + return new HashSet<>(); + } + + Set fastPairSeekerDevices = new HashSet<>(); + Set 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 0000000000..5453a87116 --- /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 0000000000..37b065fada --- /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 0000000000..6913356756 --- /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 0000000000..7846226b06 --- /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. + * + *
{@code
+     *      +------------------------------------------------+
+     *      +-------------------------------+                |
+     *      v                               |                |
+     * +---------+    +----------+    +-----+-----+    +-----+-----+
+     * | STOPPED +--> | STARTING +--> | ACCEPTING +--> | CONNECTED |
+     * +---------+    +-----+----+    +-------+---+    +-----+-----+
+     *      ^               |             ^   v              |
+     *      +---------------+         +---+--------+         |
+     *                                | RESTARTING | <-------+
+     *                                +------------+
+     * }
+ * + * 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 0000000000..132e0261aa --- /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 mServiceConfigs = new HashMap(); + + @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 getBluetoothGattServices() { + List result = new ArrayList(); + for (Entry 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 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 getAdvertisedUuids() { + List result = new ArrayList(); + for (Entry 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 getServlets() { + Map result = + new HashMap(); + for (ServiceConfig serviceConfig : mServiceConfigs.values()) { + result.putAll(serviceConfig.getServlets()); + } + return result; + } + + /** Configuration of a GATT service. */ + public static class ServiceConfig { + private final Map mServlets = + new HashMap(); + 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 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 0000000000..df1a832ae7 --- /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 mContextValues = new HashMap(); + private final List mCloseListeners = new ArrayList(); + + 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> mQueuedCharacteristicWrites = + new HashMap>(); + + @VisibleForTesting + final Map mRegisteredNotifications = + new HashMap(); + + private final Map 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 bytePackets = mQueuedCharacteristicWrites.get(servlet); + if (bytePackets == null) { + bytePackets = new TreeMap(); + 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> queuedWrite : + mQueuedCharacteristicWrites.entrySet()) { + BluetoothGattServlet servlet = queuedWrite.getKey(); + SortedMap 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: + * + * offset data_size + * 0 10 + * 10 1 + * 11 5 + * + * + * then this method would call writeHandler.onWrite(0, byte[16]) + * + * 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: + * + * offset data_size + * 10 12 + * 30 5 + * 35 9 + * + * + * then this method would call writeHandler.onWrite(10, byte[12) and + * writeHandler.onWrite(30, byte[14]). + */ + private void assembleByteChunksAndHandle(BluetoothGattServlet servlet, + SortedMap 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(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(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 0000000000..3cec24a92d --- /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 + mConnections = new ConcurrentHashMap(); + + 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(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(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(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(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 0000000000..4c8499c0dc --- /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 0000000000..9f47426a07 --- /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 getConnectedDevices(int profile) { + List devices = mWrappedInstance.getConnectedDevices(profile); + List 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 0000000000..b8e1b08342 --- /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 0000000000..a29a298545 --- /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 0000000000..c8e17e85a4 --- /dev/null +++ b/nearby/tests/multidevices/clients/tests/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + diff --git a/nearby/tests/multidevices/clients/tests/robolectric.properties b/nearby/tests/multidevices/clients/tests/robolectric.properties new file mode 100644 index 0000000000..2ea03bbf11 --- /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 0000000000..8e70d9fb08 --- /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 any() = Mockito.any() as T + + /** + * Delegates to [Mockito.eq]. + * @return null as T + */ + fun eq(match: T) = Mockito.eq(match) as T + + /** + * Delegates to [Mockito.isA]. + * @return null as T + */ + fun isA(match: Class): T = Mockito.isA(match) as T + + /** Delegates to [Mockito.when ], uses the same API as the mockitokotlin2 library. */ + fun whenever(methodCall: T) = Mockito.`when`(methodCall)!! + + /** + * Delegates to [Mockito.any] and calls it with Class. + * @return Class + */ + inline fun anyClass(): Class { + Mockito.any(T::class.java) + return T::class.java + } + + /** + * Delegates to [Mockito.anyListOf] and calls it with Class. + * @return List + */ + fun anyListOf(): List = Mockito.anyList() + + /** + * Delegates to [Mockito.mock]. + * @return T + */ + inline fun 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 capture(argumentCaptor: ArgumentCaptor): 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 0000000000..1fbd352fa8 --- /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 0000000000..f23ccbb4c7 --- /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() + + @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 0000000000..94c095285b --- /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" + } +}