Add BluetoothAudioPairer.
Test: skip test due to missing testing framework. Will be added later per http://b/202524672.
Bug: 200231384
Change-Id: I71c19e6d614f00c476de42020243bfee1a1972a7
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairer.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairer.java
new file mode 100644
index 0000000..e05399c
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothAudioPairer.java
@@ -0,0 +1,792 @@
+/*
+ * 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.fastpair;
+
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDING;
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+import static android.bluetooth.BluetoothDevice.ERROR;
+import static android.bluetooth.BluetoothProfile.A2DP;
+import static android.bluetooth.BluetoothProfile.HEADSET;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+
+import static java.util.concurrent.Executors.newSingleThreadExecutor;
+
+import android.Manifest.permission;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+import androidx.core.content.ContextCompat;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.PasskeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.Profile;
+import com.android.server.nearby.common.bluetooth.fastpair.TimingLogger.ScopedTiming;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattConnection.ChangeObserver;
+import com.android.server.nearby.proto.FastPairEnums.FastPairEvent.ConnectErrorCode;
+import com.android.server.nearby.proto.FastPairEnums.FastPairEvent.CreateBondErrorCode;
+import com.android.server.nearby.proto.NearbyEventCodes.NearbyEvent.EventCode;
+
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Pairs to Bluetooth audio devices.
+ */
+// Try-with-resources and ReflectiveOperationException are only available on KitKat+.
+public class BluetoothAudioPairer {
+
+ private static final String TAG = BluetoothAudioPairer.class.getSimpleName();
+
+ /**
+ * Hidden, see {@link BluetoothDevice}.
+ */
+ private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
+
+ /**
+ * Hidden, see {@link BluetoothDevice}.
+ */
+ private static final int ACCESS_REJECTED = 2;
+
+ /**
+ * Hidden, see {@link BluetoothDevice}.
+ */
+ private static final int PAIRING_VARIANT_CONSENT = 3;
+
+ /**
+ * Hidden, see {@link BluetoothDevice}.
+ */
+ public static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
+
+ private static final int DISCOVERY_STATE_CHANGE_TIMEOUT_MS = 3000;
+
+ private final Context mContext;
+ private final Preferences mPreferences;
+ private final EventLoggerWrapper mEventLogger;
+ private final BluetoothDevice mDevice;
+ @Nullable
+ private final KeyBasedPairingInfo mKeyBasedPairingInfo;
+ @Nullable
+ private final PasskeyConfirmationHandler mPasskeyConfirmationHandler;
+ private final TimingLogger mTimingLogger;
+
+ static class KeyBasedPairingInfo {
+
+ private final byte[] mSecret;
+ private final GattConnectionManager mGattConnectionManager;
+ private final boolean mProviderInitiatesBonding;
+
+ /**
+ * @param secret The secret negotiated during the initial BLE handshake for Key-based
+ * Pairing. See {@link FastPairConnection#handshake}.
+ * @param gattConnectionManager A manager that knows how to get and create Gatt connections
+ * to the remote device.
+ */
+ KeyBasedPairingInfo(
+ byte[] secret,
+ GattConnectionManager gattConnectionManager,
+ boolean providerInitiatesBonding) {
+ this.mSecret = secret;
+ this.mGattConnectionManager = gattConnectionManager;
+ this.mProviderInitiatesBonding = providerInitiatesBonding;
+ }
+ }
+
+ public BluetoothAudioPairer(
+ Context context,
+ BluetoothDevice device,
+ Preferences preferences,
+ EventLoggerWrapper eventLogger,
+ @Nullable KeyBasedPairingInfo keyBasedPairingInfo,
+ @Nullable PasskeyConfirmationHandler passkeyConfirmationHandler,
+ TimingLogger timingLogger)
+ throws ReflectionException, PairingException {
+ this.mContext = context;
+ this.mDevice = device;
+ this.mPreferences = preferences;
+ this.mEventLogger = eventLogger;
+ this.mKeyBasedPairingInfo = keyBasedPairingInfo;
+ this.mPasskeyConfirmationHandler = passkeyConfirmationHandler;
+ this.mTimingLogger = timingLogger;
+
+ // The OS should give the user some UI to choose if they want to allow access, but there
+ // seems to be a bug where if we don't reject access, it's auto-granted in some cases
+ // (Plantronics headset gets contacts access when pairing with my Taimen via Bluetooth
+ // Settings, without me seeing any UI about it). b/64066631
+ //
+ // If that OS bug doesn't get fixed, we can flip these flags to force-reject the
+ // permissions.
+ if (preferences.getRejectPhonebookAccess()
+ && !(Boolean)
+ Reflect.on(device)
+ .withMethod("setPhonebookAccessPermission", int.class)
+ .get(ACCESS_REJECTED)) {
+ throw new PairingException("Failed to deny contacts (phonebook) access.");
+ }
+ if (preferences.getRejectMessageAccess()
+ && !(Boolean)
+ Reflect.on(device)
+ .withMethod("setMessageAccessPermission", int.class)
+ .get(ACCESS_REJECTED)) {
+ throw new PairingException("Failed to deny message access.");
+ }
+ if (preferences.getRejectSimAccess()
+ && !(Boolean)
+ Reflect.on(device)
+ .withMethod("setSimAccessPermission", int.class)
+ .get(ACCESS_REJECTED)) {
+ throw new PairingException("Failed to deny SIM access.");
+ }
+ }
+
+ boolean isPaired() {
+ return mDevice.getBondState() == BOND_BONDED;
+ }
+
+ /**
+ * Unpairs from the device. Throws an exception if any error occurs.
+ */
+ @WorkerThread
+ void unpair()
+ throws ReflectionException, InterruptedException, ExecutionException, TimeoutException,
+ PairingException {
+ // Unbond methods are public, but hidden.
+ String methodName;
+ switch (mDevice.getBondState()) {
+ case BOND_BONDED:
+ mEventLogger.setCurrentEvent(EventCode.REMOVE_BOND);
+ methodName = "removeBond";
+ break;
+ case BOND_BONDING:
+ mEventLogger.setCurrentEvent(EventCode.CANCEL_BOND);
+ methodName = "cancelBondProcess";
+ break;
+ case BOND_NONE:
+ default:
+ return;
+ }
+
+ try (UnbondedReceiver unbondedReceiver = new UnbondedReceiver();
+ ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Unpair: " + methodName)) {
+ Log.i(TAG, methodName + " with " + maskBluetoothAddress(mDevice));
+ // We'll only get a state change broadcast if we're actually unbonding (method returns
+ // true).
+ if ((Boolean) Reflect.on(mDevice).withMethod(methodName).get()) {
+ unbondedReceiver
+ .await(mPreferences.getRemoveBondTimeoutSeconds(), TimeUnit.SECONDS);
+ } else {
+ int bondState = mDevice.getBondState();
+ Log.w(TAG, methodName + " returned false, state=" + bondState);
+ // The OS may have beaten us in a race, unbonding before we called the method. So if
+ // we're (somehow) in the desired state then we're happy, if not then bail.
+ if (bondState != BOND_NONE) {
+ throw new PairingException("%s failed, returned false, state=%s", methodName,
+ bondState);
+ }
+ }
+ }
+
+ // This seems to improve the probability that createBond will succeed after removeBond.
+ SystemClock.sleep(mPreferences.getRemoveBondSleepMillis());
+ mEventLogger.logCurrentEventSucceeded();
+
+ // TODO(b/111075567): Unpairing disconnects us from the gatt connection we had previously
+ // opened. Find the right way to reconnect so that we can do the exchange later on.
+ }
+
+ /**
+ * Pairs with the device. Throws an exception if any error occurs.
+ */
+ @WorkerThread
+ void pair()
+ throws InterruptedException, ExecutionException, TimeoutException, PairingException,
+ ReflectionException {
+ // Unpair first, because if we have a bond, but the other device has forgotten its bond,
+ // it can send us a pairing request that we're not ready for (which can pop up a dialog).
+ // Or, if we're in the middle of a (too-long) bonding attempt, we want to cancel.
+ unpair();
+
+ mEventLogger.setCurrentEvent(EventCode.CREATE_BOND);
+ try (BondedReceiver bondedReceiver = new BondedReceiver();
+ ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Create bond")) {
+ // If the provider's initiating the bond, we do nothing but wait for broadcasts.
+ if (mKeyBasedPairingInfo == null || !mKeyBasedPairingInfo.mProviderInitiatesBonding) {
+ Log.i(TAG, "createBond with " + maskBluetoothAddress(mDevice) + ", type="
+ + mDevice.getType());
+ if (mPreferences.getSpecifyCreateBondTransportType()) {
+ Reflect.on(mDevice)
+ .withMethod("createBond", int.class)
+ .invoke(mPreferences.getCreateBondTransportType());
+ } else {
+ mDevice.createBond();
+ }
+ }
+
+ try {
+ bondedReceiver.await(mPreferences.getCreateBondTimeoutSeconds(), TimeUnit.SECONDS);
+ } catch (TimeoutException e) {
+ Log.w(TAG, "bondedReceiver time out after " + mPreferences
+ .getCreateBondTimeoutSeconds() + " seconds");
+ if (mPreferences.getIgnoreUuidTimeoutAfterBonded() && isPaired()) {
+ Log.w(TAG, "Created bond but never received UUIDs, attempting to continue.");
+ } else {
+ // Rethrow e to cause the pairing to fail and be retried if necessary.
+ throw e;
+ }
+ }
+ }
+ mEventLogger.logCurrentEventSucceeded();
+ }
+
+ /**
+ * Connects to the given profile. Throws an exception if any error occurs.
+ *
+ * <p>If remote device clears the link key, the BOND_BONDED state would transit to BOND_BONDING
+ * (and go through the pairing process again) when directly connecting the profile. By enabling
+ * enablePairingBehavior, we provide both pairing and connecting behaviors at the same time. See
+ * b/145699390 for more details.
+ */
+ // Suppression for possible null from ImmutableMap#get. See go/lsc-get-nullable
+ @SuppressWarnings("nullness:argument")
+ @WorkerThread
+ public void connect(short profileUuid, boolean enablePairingBehavior)
+ throws InterruptedException, ReflectionException, TimeoutException, ExecutionException,
+ ConnectException {
+ if (!mPreferences.isSupportedProfile(profileUuid)) {
+ throw new ConnectException(
+ ConnectErrorCode.UNSUPPORTED_PROFILE, "Unsupported profile=%s", profileUuid);
+ }
+ Profile profile = Constants.PROFILES.get(profileUuid);
+ Log.i(TAG,
+ "Connecting to profile=" + profile + " on device=" + maskBluetoothAddress(mDevice));
+ try (BondedReceiver bondedReceiver = enablePairingBehavior ? new BondedReceiver() : null;
+ ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Connect: " + profile)) {
+ connectByProfileProxy(profile);
+ }
+ }
+
+ private void connectByProfileProxy(Profile profile)
+ throws ReflectionException, InterruptedException, ExecutionException, TimeoutException,
+ ConnectException {
+ try (BluetoothProfileWrapper autoClosingProxy = new BluetoothProfileWrapper(profile);
+ ConnectedReceiver connectedReceiver = new ConnectedReceiver(profile)) {
+ BluetoothProfile proxy = autoClosingProxy.mProxy;
+
+ // Try to connect via reflection
+ Log.v(TAG, "Connect to proxy=" + proxy);
+
+ if (!(Boolean) Reflect.on(proxy).withMethod("connect", BluetoothDevice.class)
+ .get(mDevice)) {
+ // If we're already connecting, connect() may return false. :/
+ Log.w(TAG, "connect returned false, expected if connecting, state="
+ + proxy.getConnectionState(mDevice));
+ }
+
+ // If we're already connected, the OS may not send the connection state broadcast, so
+ // return immediately for that case.
+ if (proxy.getConnectionState(mDevice) == BluetoothProfile.STATE_CONNECTED) {
+ Log.v(TAG, "connectByProfileProxy: already connected to device="
+ + maskBluetoothAddress(mDevice));
+ return;
+ }
+
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Wait connection")) {
+ // Wait for connecting to succeed or fail (via event or timeout).
+ connectedReceiver
+ .await(mPreferences.getCreateBondTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ private class BluetoothProfileWrapper implements AutoCloseable {
+
+ // incompatible types in assignment.
+ @SuppressWarnings("nullness:assignment")
+ private final BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+
+ private final Profile mProfile;
+ private final BluetoothProfile mProxy;
+
+ /**
+ * Blocks until we get the proxy. Throws on error.
+ */
+ private BluetoothProfileWrapper(Profile profile)
+ throws InterruptedException, ExecutionException, TimeoutException,
+ ConnectException {
+ this.mProfile = profile;
+ mProxy = getProfileProxy(profile);
+ }
+
+ @Override
+ public void close() {
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Close profile: " + mProfile)) {
+ mBluetoothAdapter.closeProfileProxy(mProfile.type, mProxy);
+ }
+ }
+
+ private BluetoothProfile getProfileProxy(BluetoothProfileWrapper this, Profile profile)
+ throws InterruptedException, ExecutionException, TimeoutException,
+ ConnectException {
+ if (profile.type != A2DP && profile.type != HEADSET) {
+ throw new IllegalArgumentException("Unsupported profile type=" + profile.type);
+ }
+
+ SettableFuture<BluetoothProfile> proxyFuture = SettableFuture.create();
+ BluetoothProfile.ServiceListener listener =
+ new BluetoothProfile.ServiceListener() {
+ @UiThread
+ @Override
+ public void onServiceConnected(int profileType, BluetoothProfile proxy) {
+ proxyFuture.set(proxy);
+ }
+
+ @Override
+ public void onServiceDisconnected(int profileType) {
+ Log.v(TAG, "proxy disconnected for profile=" + profile);
+ }
+ };
+
+ if (!mBluetoothAdapter.getProfileProxy(mContext, listener, profile.type)) {
+ throw new ConnectException(
+ ConnectErrorCode.GET_PROFILE_PROXY_FAILED,
+ "getProfileProxy failed immediately");
+ }
+
+ return proxyFuture.get(mPreferences.getProxyTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+ }
+
+ private class UnbondedReceiver extends DeviceIntentReceiver {
+
+ private UnbondedReceiver() {
+ super(mContext, mPreferences, mDevice, BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+ }
+
+ @Override
+ protected void onReceiveDeviceIntent(Intent intent) throws Exception {
+ if (mDevice.getBondState() == BOND_NONE) {
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Close UnbondedReceiver")) {
+ close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Receiver that closes after bonding has completed.
+ */
+ private class BondedReceiver extends DeviceIntentReceiver {
+
+ private boolean mReceivedUuids = false;
+ private boolean mReceivedPasskey = false;
+
+ private BondedReceiver() {
+ super(
+ mContext,
+ mPreferences,
+ mDevice,
+ BluetoothDevice.ACTION_PAIRING_REQUEST,
+ BluetoothDevice.ACTION_BOND_STATE_CHANGED,
+ BluetoothDevice.ACTION_UUID);
+ }
+
+ // switching on a possibly-null value (intent.getAction())
+ // incompatible types in argument.
+ @SuppressWarnings({"nullness:switching.nullable", "nullness:argument"})
+ @Override
+ protected void onReceiveDeviceIntent(Intent intent)
+ throws PairingException, InterruptedException, ExecutionException, TimeoutException,
+ BluetoothException, GeneralSecurityException, ReflectionException {
+ switch (intent.getAction()) {
+ case BluetoothDevice.ACTION_PAIRING_REQUEST:
+ int variant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, ERROR);
+ int passkey = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, ERROR);
+ handlePairingRequest(variant, passkey);
+ break;
+ case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+ // Use the state in the intent, not device.getBondState(), to avoid a race where
+ // we log the wrong failure reason during a rapid transition.
+ int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, ERROR);
+ int reason = intent.getIntExtra(EXTRA_REASON, ERROR);
+ handleBondStateChanged(bondState, reason);
+ break;
+ case BluetoothDevice.ACTION_UUID:
+ // According to eisenbach@ and pavlin@, there's always a UUID broadcast when
+ // pairing (it can happen either before or after the transition to BONDED).
+ if (mPreferences.getWaitForUuidsAfterBonding()) {
+ Parcelable[] uuids = intent
+ .getParcelableArrayExtra(BluetoothDevice.EXTRA_UUID);
+ handleUuids(uuids);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void handlePairingRequest(int variant, int passkey) {
+ Log.i(TAG, "Pairing request, variant=" + variant + ", passkey=" + (passkey == ERROR
+ ? "(none)" : String.valueOf(passkey)));
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.setCurrentEvent(EventCode.HANDLE_PAIRING_REQUEST);
+ }
+ if (!hasPermission(permission.BLUETOOTH_PRIVILEGED)) {
+ // AGSA doesn't have this permission. Attempting to accept/deny a request will
+ // crash, so let the platform Bluetooth UX appear instead. We (almost) never get
+ // here because there's no pairing request when we initiate Just Works, which is
+ // what Apollo uses. (But there is if the remote device initiates JW. In that case
+ // the user gets a platform notification.)
+ Log.v(TAG, "No BLUETOOTH_PRIVILEGED permission, ignoring pairing request.");
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.logCurrentEventFailed(
+ new CreateBondException(
+ CreateBondErrorCode.NO_PERMISSION, 0,
+ "No BLUETOOTH_PRIVILEGED permission"));
+ }
+ return;
+ }
+
+ if (mPreferences.getSupportHidDevice() && variant == PAIRING_VARIANT_DISPLAY_PASSKEY) {
+ mReceivedPasskey = true;
+ extendAwaitSecond(
+ mPreferences.getHidCreateBondTimeoutSeconds()
+ - mPreferences.getCreateBondTimeoutSeconds());
+ triggerDiscoverStateChange();
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.logCurrentEventSucceeded();
+ }
+ return;
+
+ } else {
+ // Prevent Bluetooth Settings from getting the pairing request and showing its own
+ // UI.
+ abortBroadcast();
+
+ if (variant == PAIRING_VARIANT_CONSENT
+ && mKeyBasedPairingInfo == null // Fast Pair 1.0 device
+ && mPreferences.getAcceptConsentForFastPairOne()) {
+ // Previously, if Bluetooth decided to use the Just Works variant (e.g. Fast
+ // Pair 1.0), we don't get a pairing request broadcast at all.
+ // However, after CVE-2019-2225, Bluetooth will decide to ask consent from
+ // users. Details:
+ // https://source.android.com/security/bulletin/2019-12-01#system
+ // Since we've certified the Fast Pair 1.0 devices, and user taps to pair it
+ // (with the device's image), we could help user to accept the consent.
+ mDevice.setPairingConfirmation(true);
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.logCurrentEventSucceeded();
+ }
+ return;
+ } else if (variant != BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION) {
+ mDevice.setPairingConfirmation(false);
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.logCurrentEventFailed(
+ new CreateBondException(
+ CreateBondErrorCode.INCORRECT_VARIANT, 0,
+ "Incorrect variant for FastPair"));
+ }
+ return;
+ }
+ mReceivedPasskey = true;
+
+ if (mKeyBasedPairingInfo == null) {
+ if (mPreferences.getAcceptPasskey()) {
+ // Must be the simulator using FP 1.0 (no Key-based Pairing). Real
+ // headphones using FP 1.0 use Just Works instead (and maybe we should
+ // disable this flag for them).
+ mDevice.setPairingConfirmation(true);
+ }
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.logCurrentEventSucceeded();
+ }
+ return;
+ }
+ }
+
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.logCurrentEventSucceeded();
+ }
+
+ newSingleThreadExecutor()
+ .execute(
+ () -> {
+ try (ScopedTiming scopedTiming1 =
+ new ScopedTiming(mTimingLogger, "Exchange passkey")) {
+ mEventLogger.setCurrentEvent(EventCode.PASSKEY_EXCHANGE);
+
+ // We already check above, but the static analyzer's not
+ // convinced without this.
+ Preconditions.checkNotNull(mKeyBasedPairingInfo);
+ BluetoothGattConnection connection =
+ mKeyBasedPairingInfo.mGattConnectionManager
+ .getConnection();
+ UUID characteristicUuid =
+ PasskeyCharacteristic.getId(connection);
+ ChangeObserver remotePasskeyObserver =
+ connection.enableNotification(FastPairService.ID,
+ characteristicUuid);
+ Log.i(TAG, "Sending local passkey.");
+ byte[] encryptedData;
+ try (ScopedTiming scopedTiming2 =
+ new ScopedTiming(mTimingLogger, "Encrypt passkey")) {
+ encryptedData =
+ PasskeyCharacteristic.encrypt(
+ PasskeyCharacteristic.Type.SEEKER,
+ mKeyBasedPairingInfo.mSecret, passkey);
+ }
+ try (ScopedTiming scopedTiming3 =
+ new ScopedTiming(mTimingLogger,
+ "Send passkey to remote")) {
+ connection.writeCharacteristic(
+ FastPairService.ID, characteristicUuid,
+ encryptedData);
+ }
+ Log.i(TAG, "Waiting for remote passkey.");
+ byte[] encryptedRemotePasskey;
+ try (ScopedTiming scopedTiming4 =
+ new ScopedTiming(mTimingLogger,
+ "Wait for remote passkey")) {
+ encryptedRemotePasskey =
+ remotePasskeyObserver.waitForUpdate(
+ TimeUnit.SECONDS.toMillis(mPreferences
+ .getGattOperationTimeoutSeconds()));
+ }
+ int remotePasskey;
+ try (ScopedTiming scopedTiming5 =
+ new ScopedTiming(mTimingLogger, "Decrypt passkey")) {
+ remotePasskey =
+ PasskeyCharacteristic.decrypt(
+ PasskeyCharacteristic.Type.PROVIDER,
+ mKeyBasedPairingInfo.mSecret,
+ encryptedRemotePasskey);
+ }
+
+ // We log success if we made it through with no exceptions. If
+ // the passkey was wrong, pairing will fail and we'll log
+ // BOND_BROKEN with reason = AUTH_FAILED.
+ mEventLogger.logCurrentEventSucceeded();
+
+ boolean isPasskeyCorrect = passkey == remotePasskey;
+ if (isPasskeyCorrect) {
+ Log.i(TAG, "Passkey correct.");
+ } else {
+ Log.e(TAG, "Passkey incorrect, local= " + passkey
+ + ", remote=" + remotePasskey);
+ }
+
+ // Don't estimate the {@code ScopedTiming} because the passkey
+ // confirmation is done by UI.
+ if (isPasskeyCorrect
+ && mPreferences.getHandlePasskeyConfirmationByUi()
+ && mPasskeyConfirmationHandler != null) {
+ Log.i(TAG, "Callback the passkey to UI for confirmation.");
+ mPasskeyConfirmationHandler
+ .onPasskeyConfirmation(mDevice, passkey);
+ } else {
+ try (ScopedTiming scopedTiming6 =
+ new ScopedTiming(
+ mTimingLogger, "Confirm the pairing: "
+ + isPasskeyCorrect)) {
+ mDevice.setPairingConfirmation(isPasskeyCorrect);
+ }
+ }
+ } catch (BluetoothException
+ | GeneralSecurityException
+ | InterruptedException
+ | ExecutionException
+ | TimeoutException e) {
+ mEventLogger.logCurrentEventFailed(e);
+ closeWithError(e);
+ }
+ });
+ }
+
+ /**
+ * Workaround to let Settings popup a pairing dialog instead of notification. When pairing
+ * request intent passed to Settings, it'll check several conditions to decide that it
+ * should show a dialog or a notification. One of those conditions is to check if the device
+ * is in discovery mode recently, which can be fulfilled by calling {@link
+ * BluetoothAdapter#startDiscovery()}. This method aims to fulfill the condition, and block
+ * the pairing broadcast for at most
+ * {@link BluetoothAudioPairer#DISCOVERY_STATE_CHANGE_TIMEOUT_MS}
+ * to make sure that we fulfill the condition first and successful.
+ */
+ // dereference of possibly-null reference bluetoothAdapter
+ @SuppressWarnings("nullness:dereference.of.nullable")
+ private void triggerDiscoverStateChange() {
+ BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+
+ if (bluetoothAdapter.isDiscovering()) {
+ return;
+ }
+
+ HandlerThread backgroundThread = new HandlerThread("TriggerDiscoverStateChangeThread");
+ backgroundThread.start();
+
+ AtomicBoolean result = new AtomicBoolean(false);
+ SimpleBroadcastReceiver receiver =
+ new SimpleBroadcastReceiver(
+ mContext,
+ mPreferences,
+ new Handler(backgroundThread.getLooper()),
+ BluetoothAdapter.ACTION_DISCOVERY_STARTED,
+ BluetoothAdapter.ACTION_DISCOVERY_FINISHED) {
+
+ @Override
+ protected void onReceive(Intent intent) throws Exception {
+ result.set(true);
+ close();
+ }
+ };
+
+ Log.i(TAG, "triggerDiscoverStateChange call startDiscovery.");
+ // Uses startDiscovery to trigger Settings show pairing dialog instead of notification.
+ bluetoothAdapter.startDiscovery();
+ bluetoothAdapter.cancelDiscovery();
+ try {
+ receiver.await(DISCOVERY_STATE_CHANGE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ Log.w(TAG, "triggerDiscoverStateChange failed!");
+ }
+
+ backgroundThread.quitSafely();
+ try {
+ backgroundThread.join();
+ } catch (InterruptedException e) {
+ Log.i(TAG, "triggerDiscoverStateChange backgroundThread.join meet exception!", e);
+ }
+
+ if (result.get()) {
+ Log.i(TAG, "triggerDiscoverStateChange successful.");
+ }
+ }
+
+ private void handleBondStateChanged(int bondState, int reason)
+ throws PairingException, InterruptedException, ExecutionException,
+ ReflectionException, TimeoutException {
+ Log.i(TAG, "Bond state changed to " + bondState + ", reason=" + reason);
+ switch (bondState) {
+ case BOND_BONDED:
+ if (mKeyBasedPairingInfo != null && !mReceivedPasskey) {
+ // The device bonded with Just Works, although we did the Key-based Pairing
+ // GATT handshake and agreed on a pairing secret. It might be a Person In
+ // The Middle Attack!
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger,
+ "Close BondedReceiver: POSSIBLE_MITM")) {
+ closeWithError(
+ new CreateBondException(
+ CreateBondErrorCode.POSSIBLE_MITM,
+ reason,
+ "Unexpectedly bonded without a passkey. It might be a "
+ + "Person In The Middle Attack! Unbonding!"));
+ }
+ unpair();
+ } else if (!mPreferences.getWaitForUuidsAfterBonding()
+ || (mPreferences.getReceiveUuidsAndBondedEventBeforeClose()
+ && mReceivedUuids)) {
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Close BondedReceiver")) {
+ close();
+ }
+ }
+ break;
+ case BOND_NONE:
+ throw new CreateBondException(
+ CreateBondErrorCode.BOND_BROKEN, reason, "Bond broken, reason=%d",
+ reason);
+ case BOND_BONDING:
+ default:
+ break;
+ }
+ }
+
+ private void handleUuids(Parcelable[] uuids) {
+ Log.i(TAG, "Got UUIDs for " + maskBluetoothAddress(mDevice) + ": "
+ + Arrays.toString(uuids));
+ mReceivedUuids = true;
+ if (!mPreferences.getReceiveUuidsAndBondedEventBeforeClose() || isPaired()) {
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Close BondedReceiver")) {
+ close();
+ }
+ }
+ }
+ }
+
+ private class ConnectedReceiver extends DeviceIntentReceiver {
+
+ private ConnectedReceiver(Profile profile) throws ConnectException {
+ super(mContext, mPreferences, mDevice, profile.connectionStateAction);
+ }
+
+ @Override
+ public void onReceiveDeviceIntent(Intent intent) throws PairingException {
+ int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, ERROR);
+ Log.i(TAG, "Connection state changed to " + state);
+ switch (state) {
+ case BluetoothAdapter.STATE_CONNECTED:
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Close ConnectedReceiver")) {
+ close();
+ }
+ break;
+ case BluetoothAdapter.STATE_DISCONNECTED:
+ throw new ConnectException(ConnectErrorCode.DISCONNECTED, "Disconnected");
+ case BluetoothAdapter.STATE_CONNECTING:
+ case BluetoothAdapter.STATE_DISCONNECTING:
+ default:
+ break;
+ }
+ }
+ }
+
+ private boolean hasPermission(String permission) {
+ return ContextCompat.checkSelfPermission(mContext, permission) == PERMISSION_GRANTED;
+ }
+
+ public BluetoothDevice getDevice() {
+ return mDevice;
+ }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java
index d5e2256..13b1458 100644
--- a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java
@@ -27,9 +27,9 @@
/**
* Supports Fast Pair pairing with certain Bluetooth headphones, Auto, etc.
*
- * <p>Based on go/fast-pair-2-spec, the pairing is constructed by both BLE and BREDR connections.
- * Example state transitions for Fast Pair 2, ie a pairing key is included in the request (note:
- * timeouts and retries are governed by flags, may change):
+ * <p>Based on https://developers.google.com/nearby/fast-pair/spec, the pairing is constructed by
+ * both BLE and BREDR connections. Example state transitions for Fast Pair 2, ie a pairing key is
+ * included in the request (note: timeouts and retries are governed by flags, may change):
*
* <pre>
* {@code