Add FastPairDualConnection.java
Test: skip test due to missing testing framework. Will be added later per http://b/202524672.
Bug: 200231384
Change-Id: I9e456e38056215453f37e31011cbe4aeedac4d57
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
index acf64b9..9692517 100644
--- 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
@@ -70,7 +70,6 @@
/**
* 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();
@@ -78,21 +77,25 @@
/**
* Hidden, see {@link BluetoothDevice}.
*/
+ // TODO(b/202549655): remove Hidden usage.
private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
/**
* Hidden, see {@link BluetoothDevice}.
*/
+ // TODO(b/202549655): remove Hidden usage.
private static final int ACCESS_REJECTED = 2;
/**
* Hidden, see {@link BluetoothDevice}.
*/
+ // TODO(b/202549655): remove Hidden usage.
private static final int PAIRING_VARIANT_CONSENT = 3;
/**
* Hidden, see {@link BluetoothDevice}.
*/
+ // TODO(b/202549655): remove Hidden usage.
public static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
private static final int DISCOVERY_STATE_CHANGE_TIMEOUT_MS = 3000;
@@ -146,6 +149,7 @@
this.mPasskeyConfirmationHandler = passkeyConfirmationHandler;
this.mTimingLogger = timingLogger;
+ // TODO(b/203455314): follow up with the following comments.
// 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
@@ -227,9 +231,6 @@
// 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.
}
/**
@@ -471,21 +472,6 @@
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;
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java
index fa93639..d7c4258 100644
--- a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairConnection.java
@@ -29,7 +29,6 @@
import com.google.auto.value.AutoValue;
import java.security.GeneralSecurityException;
-import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
@@ -110,7 +109,7 @@
@Nullable
public abstract SharedSecret pair()
throws BluetoothException, InterruptedException, TimeoutException, ExecutionException,
- PairingException;
+ PairingException, ReflectionException;
/**
* Pairs with Provider. Synchronous: Blocks until paired and connected. Throws on any error.
@@ -124,12 +123,13 @@
@Nullable
public abstract SharedSecret pair(@Nullable byte[] key)
throws BluetoothException, InterruptedException, TimeoutException, ExecutionException,
- PairingException, GeneralSecurityException;
+ PairingException, GeneralSecurityException, ReflectionException;
/** Unpairs with Provider. Synchronous: Blocks until unpaired. Throws on any error. */
@WorkerThread
public abstract void unpair(BluetoothDevice device)
- throws InterruptedException, TimeoutException, ExecutionException, PairingException;
+ throws InterruptedException, TimeoutException, ExecutionException, PairingException,
+ ReflectionException;
/** Gets the public address of the Provider. */
@Nullable
@@ -180,11 +180,6 @@
return mPasskeyIsGotten;
}
- /** Check if connected device is provisioned by spot or not. */
- public abstract boolean shouldCallSpotProvision(byte[] accountKey)
- throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
- NoSuchAlgorithmException;
-
/** Interface to get latest address of ModelId. */
public interface FastPairSignalChecker {
/** Gets address of ModelId. */
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 c3b3711..b3be779 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
@@ -16,13 +16,85 @@
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 com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.get16BitUuid;
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothUuids.to128BitUuid;
+import static com.android.server.nearby.common.bluetooth.fastpair.Bytes.toBytes;
+import static com.android.server.nearby.common.bluetooth.fastpair.Bytes.toShorts;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static com.google.common.base.Verify.verifyNotNull;
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.ParcelUuid;
+import android.os.SystemClock;
import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.annotation.WorkerThread;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+import com.android.server.nearby.common.bluetooth.fastpair.BluetoothAudioPairer.KeyBasedPairingInfo;
+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.AdditionalDataCharacteristic.AdditionalDataType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.FirmwareVersionCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.NameCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.ActionOverBle;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.HandshakeException;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.HandshakeMessage;
+import com.android.server.nearby.common.bluetooth.fastpair.HandshakeHandler.KeyBasedPairingRequest;
+import com.android.server.nearby.common.bluetooth.fastpair.Ltv.ParseException;
+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.common.bluetooth.testability.android.bluetooth.BluetoothAdapter;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.BluetoothOperationTimeoutException;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.BrEdrHandoverErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ConnectErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.CreateBondErrorCode;
+import com.android.server.nearby.intdefs.FastPairEventIntDefs.ErrorCode;
import com.android.server.nearby.intdefs.NearbyEventIntDefs.EventCode;
import com.google.common.base.Ascii;
+import com.google.common.base.Preconditions;
+import com.google.common.primitives.Shorts;
+
+import java.nio.ByteOrder;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
/**
* Supports Fast Pair pairing with certain Bluetooth headphones, Auto, etc.
@@ -108,7 +180,571 @@
* FastPairDualConnection end, 9952ms
* </pre>
*/
-public abstract class FastPairDualConnection extends FastPairConnection {
+// TODO(b/203441105): break down FastPairDualConnection into smaller classes.
+public class FastPairDualConnection extends FastPairConnection {
+
+ private static final String TAG = FastPairDualConnection.class.getSimpleName();
+
+ @VisibleForTesting
+ static final int GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST = 10000;
+ @VisibleForTesting
+ static final int GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED = 20000;
+ @VisibleForTesting
+ static final int GATT_ERROR_CODE_USER_RETRY = 30000;
+ @VisibleForTesting
+ static final int GATT_ERROR_CODE_PAIR_WITH_SAME_MODEL_ID_COUNT = 40000;
+ @VisibleForTesting
+ static final int GATT_ERROR_CODE_TIMEOUT = 1000;
+
+ @Nullable
+ private static String sInitialConnectionFirmwareVersion;
+ private static final byte[] REQUESTED_SERVICES_LTV =
+ new Ltv(
+ TransportDiscoveryService.SERVICE_UUIDS_16_BIT_LIST_TYPE,
+ toBytes(
+ ByteOrder.LITTLE_ENDIAN,
+ Constants.A2DP_SINK_SERVICE_UUID,
+ Constants.HANDS_FREE_SERVICE_UUID,
+ Constants.HEADSET_SERVICE_UUID))
+ .getBytes();
+ private static final byte[] TDS_CONTROL_POINT_REQUEST =
+ concat(
+ new byte[]{
+ TransportDiscoveryService.ControlPointCharacteristic
+ .ACTIVATE_TRANSPORT_OP_CODE,
+ TransportDiscoveryService.BLUETOOTH_SIG_ORGANIZATION_ID
+ },
+ REQUESTED_SERVICES_LTV);
+
+ // TODO(b/201673262): remove Java enum usage.
+ private enum ResultCode {
+ UNKNOWN((byte) 0xFF),
+ 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 mByteValue;
+
+ ResultCode(byte byteValue) {
+ this.mByteValue = byteValue;
+ }
+
+ private static ResultCode fromTdsControlPointIndication(byte[] response) {
+ return response == null || response.length < 2 ? UNKNOWN : from(response[1]);
+ }
+
+ private static ResultCode from(byte byteValue) {
+ for (ResultCode resultCode : ResultCode.values()) {
+ if (resultCode.mByteValue == byteValue) {
+ return resultCode;
+ }
+ }
+ return UNKNOWN;
+ }
+ }
+
+ private static class BrEdrHandoverInformation {
+
+ private final byte[] mBluetoothAddress;
+ private final short[] mProfiles;
+
+ private BrEdrHandoverInformation(byte[] bluetoothAddress, short[] profiles) {
+ this.mBluetoothAddress = bluetoothAddress;
+
+ // For now, since we only connect to one profile, prefer A2DP Sink over headset/HFP.
+ // TODO(b/37167120): Connect to more than one profile.
+ Set<Short> profileSet = new HashSet<>(Shorts.asList(profiles));
+ if (profileSet.contains(Constants.A2DP_SINK_SERVICE_UUID)) {
+ profileSet.remove(Constants.HEADSET_SERVICE_UUID);
+ profileSet.remove(Constants.HANDS_FREE_SERVICE_UUID);
+ }
+ this.mProfiles = Shorts.toArray(profileSet);
+ }
+
+ @Override
+ public String toString() {
+ return "BrEdrHandoverInformation{"
+ + maskBluetoothAddress(BluetoothAddress.encode(mBluetoothAddress))
+ + ", profiles="
+ + (mProfiles.length > 0 ? Shorts.join(",", mProfiles) : "(none)")
+ + "}";
+ }
+ }
+
+ private final Context mContext;
+ private final Preferences mPreferences;
+ private final EventLoggerWrapper mEventLogger;
+ private final BluetoothAdapter mBluetoothAdapter =
+ checkNotNull(BluetoothAdapter.getDefaultAdapter());
+ private String mBleAddress;
+
+ private final TimingLogger mTimingLogger;
+ private GattConnectionManager mGattConnectionManager;
+ private boolean mProviderInitiatesBonding;
+ private @Nullable
+ byte[] mPairingSecret;
+ private @Nullable
+ byte[] mPairingKey;
+ @Nullable
+ private String mPublicAddress;
+ @VisibleForTesting
+ @Nullable
+ FastPairHistoryFinder mPairedHistoryFinder;
+ @Nullable
+ private String mProviderDeviceName = null;
+ private boolean mNeedUpdateProviderName = false;
+ @Nullable
+ DeviceNameReceiver mDeviceNameReceiver;
+ @Nullable
+ private HandshakeHandler mHandshakeHandlerForTest;
+ @Nullable
+ private Runnable mBeforeDirectlyConnectProfileFromCacheForTest;
+
+ public FastPairDualConnection(
+ Context context,
+ String bleAddress,
+ Preferences preferences,
+ @Nullable EventLogger eventLogger) {
+ this(context, bleAddress, preferences, eventLogger,
+ new TimingLogger("FastPairDualConnection", preferences));
+ }
+
+ @VisibleForTesting
+ FastPairDualConnection(
+ Context context,
+ String bleAddress,
+ Preferences preferences,
+ @Nullable EventLogger eventLogger,
+ TimingLogger timingLogger) {
+ this.mContext = context;
+ this.mPreferences = preferences;
+ this.mEventLogger = new EventLoggerWrapper(eventLogger);
+ this.mBleAddress = bleAddress;
+ this.mTimingLogger = timingLogger;
+ }
+
+ /**
+ * Unpairs with headphones. Synchronous: Blocks until unpaired. Throws on any error.
+ */
+ @WorkerThread
+ public void unpair(BluetoothDevice device)
+ throws ReflectionException, InterruptedException, ExecutionException, TimeoutException,
+ PairingException {
+ if (mPreferences.getExtraLoggingInformation() != null) {
+ mEventLogger
+ .bind(mContext, device.getAddress(), mPreferences.getExtraLoggingInformation());
+ }
+ new BluetoothAudioPairer(
+ mContext,
+ device,
+ mPreferences,
+ mEventLogger,
+ /* keyBasedPairingInfo= */ null,
+ /* passkeyConfirmationHandler= */ null,
+ mTimingLogger)
+ .unpair();
+ if (mEventLogger.isBound()) {
+ mEventLogger.unbind(mContext);
+ }
+ }
+
+ /**
+ * Sets the fast pair history for identifying the provider which has paired (without being
+ * forgotten) with the primary account on the device, i.e. the history is not limited on this
+ * phone, can be on other phones with the same account. If they have already paired, Fast Pair
+ * should not generate new account key and default personalized name for it after initial pair.
+ */
+ @WorkerThread
+ public void setFastPairHistory(List<FastPairHistoryItem> fastPairHistoryItem) {
+ Log.i(TAG, "Paired history has been set.");
+ this.mPairedHistoryFinder = new FastPairHistoryFinder(fastPairHistoryItem);
+ }
+
+ /**
+ * Update the provider device name when we take provider default name and account based name
+ * into consideration.
+ */
+ public void setProviderDeviceName(String deviceName) {
+ Log.i(TAG, "Update provider device name = " + deviceName);
+ mProviderDeviceName = deviceName;
+ mNeedUpdateProviderName = true;
+ }
+
+ /**
+ * Gets the device name from the Provider (via GATT notify).
+ */
+ @Nullable
+ public String getProviderDeviceName() {
+ if (mDeviceNameReceiver == null) {
+ Log.i(TAG, "getProviderDeviceName failed, deviceNameReceiver == null.");
+ return null;
+ }
+ if (mPairingSecret == null) {
+ Log.i(TAG, "getProviderDeviceName failed, pairingSecret == null.");
+ return null;
+ }
+ @Nullable String deviceName = mDeviceNameReceiver.getParsedResult(mPairingSecret);
+ Log.i(TAG, "getProviderDeviceName = " + deviceName);
+
+ return deviceName;
+ }
+
+ /**
+ * Get the existing account key of the provider, this API can be called after handshake.
+ *
+ * @return the existing account key if the provider has paired with the account before.
+ * Otherwise, return null, i.e. it is a real initial pairing.
+ */
+ @WorkerThread
+ @Nullable
+ public byte[] getExistingAccountKey() {
+ return mPairedHistoryFinder == null ? null : mPairedHistoryFinder.getExistingAccountKey();
+ }
+
+ /**
+ * Pairs with headphones. Synchronous: Blocks until paired and connected. Throws on any error.
+ *
+ * @return the secret key for the user's account, if written.
+ */
+ @WorkerThread
+ @Nullable
+ public SharedSecret pair()
+ throws BluetoothException, InterruptedException, ReflectionException, TimeoutException,
+ ExecutionException, PairingException {
+ try {
+ return pair(/*key=*/ null);
+ } catch (GeneralSecurityException e) {
+ throw new RuntimeException("Should never happen, no security key!", e);
+ }
+ }
+
+ /**
+ * Pairs with headphones. Synchronous: Blocks until paired and connected. Throws on any error.
+ *
+ * @param key can be in two different formats. If it is 16 bytes long, then it is an AES account
+ * key. Otherwise, it's a public key generated by {@link EllipticCurveDiffieHellmanExchange}.
+ * See go/fast-pair-2-spec for how each of these keys are used.
+ * @return the secret key for the user's account, if written
+ */
+ @WorkerThread
+ @Nullable
+ public SharedSecret pair(@Nullable byte[] key)
+ throws BluetoothException, InterruptedException, ReflectionException, TimeoutException,
+ ExecutionException, PairingException, GeneralSecurityException {
+ mPairingKey = key;
+ if (key != null) {
+ Log.i(TAG, "Starting to pair " + maskBluetoothAddress(mBleAddress) + ": key["
+ + key.length + "], " + mPreferences);
+ } else {
+ Log.i(TAG, "Pairing " + maskBluetoothAddress(mBleAddress) + ": " + mPreferences);
+ }
+ if (mPreferences.getExtraLoggingInformation() != null) {
+ this.mEventLogger.bind(
+ mContext, mBleAddress, mPreferences.getExtraLoggingInformation());
+ }
+ // Provider never initiates if key is null (Fast Pair 1.0).
+ if (key != null && mPreferences.getProviderInitiatesBondingIfSupported()) {
+ // Provider can't initiate if we can't get our own public address, so check.
+ this.mEventLogger.setCurrentEvent(EventCode.GET_LOCAL_PUBLIC_ADDRESS);
+ if (BluetoothAddress.getPublicAddress(mContext) != null) {
+ this.mEventLogger.logCurrentEventSucceeded();
+ mProviderInitiatesBonding = true;
+ } else {
+ this.mEventLogger
+ .logCurrentEventFailed(new IllegalStateException("null bluetooth_address"));
+ Log.e(TAG,
+ "Want provider to initiate bonding, but cannot access Bluetooth public "
+ + "address. Falling back to initiating bonding ourselves.");
+ }
+ }
+
+ // User might be pairing with a bonded device. In this case, we just connect profile
+ // directly and finish pairing.
+ if (directConnectProfileWithCachedAddress()) {
+ callbackOnPaired();
+ mTimingLogger.dump();
+ if (mEventLogger.isBound()) {
+ mEventLogger.unbind(mContext);
+ }
+ return null;
+ }
+
+ // Lazily initialize a new connection manager for each pairing request.
+ initGattConnectionManager();
+ boolean isSecretHandshakeCompleted = true;
+ try {
+ if (key != null && key.length > 0) {
+ // GATT_CONNECTION_AND_SECRET_HANDSHAKE start.
+ mEventLogger.setCurrentEvent(EventCode.GATT_CONNECTION_AND_SECRET_HANDSHAKE);
+ isSecretHandshakeCompleted = false;
+ Exception lastException = null;
+ boolean lastExceptionFromHandshake = false;
+ long startTime = SystemClock.elapsedRealtime();
+ // We communicate over this connection twice for Key-based Pairing: once before
+ // bonding begins, and once during (to transfer the passkey). Empirically, keeping
+ // it alive throughout is far more reliable than disconnecting and reconnecting for
+ // each step. The while loop is for retry of GATT connection and handshake only.
+ do {
+ boolean isHandshaking = false;
+ try (BluetoothGattConnection connection =
+ mGattConnectionManager
+ .getConnectionWithSignalLostCheck(mRescueFromError)) {
+ mEventLogger.setCurrentEvent(EventCode.SECRET_HANDSHAKE);
+ if (lastException != null && !lastExceptionFromHandshake) {
+ logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_GATT, lastException,
+ mEventLogger);
+ lastException = null;
+ }
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Handshake")) {
+ isHandshaking = true;
+ handshakeForKeyBasedPairing(key);
+ // After handshake, Fast Pair has the public address of the provider, so
+ // we can check if it has paired with the account.
+ if (mPublicAddress != null && mPairedHistoryFinder != null) {
+ if (mPairedHistoryFinder.isInPairedHistory(mPublicAddress)) {
+ Log.i(TAG, "The provider is found in paired history.");
+ } else {
+ Log.i(TAG, "The provider is not found in paired history.");
+ }
+ }
+ }
+ isHandshaking = false;
+ // SECRET_HANDSHAKE end.
+ mEventLogger.logCurrentEventSucceeded();
+ isSecretHandshakeCompleted = true;
+ if (mPrepareCreateBondCallback != null) {
+ mPrepareCreateBondCallback.run();
+ }
+ if (lastException != null && lastExceptionFromHandshake) {
+ logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_HANDSHAKE_RECONNECT,
+ lastException, mEventLogger);
+ }
+ logManualRetryCounts(/* success= */ true);
+ // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+ mEventLogger.logCurrentEventSucceeded();
+ return pair(mPreferences.getEnableBrEdrHandover());
+ } catch (SignalLostException e) {
+ long spentTime = SystemClock.elapsedRealtime() - startTime;
+ if (spentTime > mPreferences.getAddressRotateRetryMaxSpentTimeMs()) {
+ Log.w(TAG, "Signal lost but already spend too much time " + spentTime
+ + "ms");
+ throw e;
+ }
+
+ logCurrentEventFailedBySignalLost(e);
+ lastException = (Exception) e.getCause();
+ lastExceptionFromHandshake = isHandshaking;
+ if (mRescueFromError != null && isHandshaking) {
+ mRescueFromError.accept(ErrorCode.SUCCESS_SECRET_HANDSHAKE_RECONNECT);
+ }
+ Log.i(TAG, "Signal lost, retry");
+ // In case we meet some GATT error which is not recoverable and fail very
+ // quick.
+ SystemClock.sleep(mPreferences.getPairingRetryDelayMs());
+ } catch (SignalRotatedException e) {
+ long spentTime = SystemClock.elapsedRealtime() - startTime;
+ if (spentTime > mPreferences.getAddressRotateRetryMaxSpentTimeMs()) {
+ Log.w(TAG, "Address rotated but already spend too much time "
+ + spentTime + "ms");
+ throw e;
+ }
+
+ logCurrentEventFailedBySignalRotated(e);
+ setBleAddress(e.getNewAddress());
+ lastException = (Exception) e.getCause();
+ lastExceptionFromHandshake = isHandshaking;
+ if (mRescueFromError != null) {
+ mRescueFromError.accept(ErrorCode.SUCCESS_ADDRESS_ROTATE);
+ }
+ Log.i(TAG, "Address rotated, retry");
+ } catch (HandshakeException e) {
+ long spentTime = SystemClock.elapsedRealtime() - startTime;
+ if (spentTime > mPreferences
+ .getSecretHandshakeRetryGattConnectionMaxSpentTimeMs()) {
+ Log.w(TAG, "Secret handshake failed but already spend too much time "
+ + spentTime + "ms");
+ throw e.getOriginalException();
+ }
+ if (mEventLogger.isCurrentEvent()) {
+ mEventLogger.logCurrentEventFailed(e.getOriginalException());
+ }
+ initGattConnectionManager();
+ lastException = e.getOriginalException();
+ lastExceptionFromHandshake = true;
+ if (mRescueFromError != null) {
+ mRescueFromError.accept(ErrorCode.SUCCESS_SECRET_HANDSHAKE_RECONNECT);
+ }
+ Log.i(TAG, "Handshake failed, retry GATT connection");
+ }
+ } while (mPreferences.getRetryGattConnectionAndSecretHandshake());
+ }
+ if (mPrepareCreateBondCallback != null) {
+ mPrepareCreateBondCallback.run();
+ }
+ return pair(mPreferences.getEnableBrEdrHandover());
+ } catch (SignalLostException e) {
+ logCurrentEventFailedBySignalLost(e);
+ // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+ if (!isSecretHandshakeCompleted) {
+ logManualRetryCounts(/* success= */ false);
+ logCurrentEventFailedBySignalLost(e);
+ }
+ throw e;
+ } catch (SignalRotatedException e) {
+ logCurrentEventFailedBySignalRotated(e);
+ // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+ if (!isSecretHandshakeCompleted) {
+ logManualRetryCounts(/* success= */ false);
+ logCurrentEventFailedBySignalRotated(e);
+ }
+ throw e;
+ } catch (BluetoothException
+ | InterruptedException
+ | ReflectionException
+ | TimeoutException
+ | ExecutionException
+ | PairingException
+ | GeneralSecurityException e) {
+ if (mEventLogger.isCurrentEvent()) {
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ // GATT_CONNECTION_AND_SECRET_HANDSHAKE end.
+ if (!isSecretHandshakeCompleted) {
+ logManualRetryCounts(/* success= */ false);
+ if (mEventLogger.isCurrentEvent()) {
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ }
+ throw e;
+ } finally {
+ mTimingLogger.dump();
+ if (mEventLogger.isBound()) {
+ mEventLogger.unbind(mContext);
+ }
+ }
+ }
+
+ private boolean directConnectProfileWithCachedAddress() throws ReflectionException {
+ if (TextUtils.isEmpty(mPreferences.getCachedDeviceAddress())
+ || !mPreferences.getDirectConnectProfileIfModelIdInCache()
+ || mPreferences.getSkipConnectingProfiles()) {
+ return false;
+ }
+ Log.i(TAG, "Try to direct connect profile with cached address "
+ + maskBluetoothAddress(mPreferences.getCachedDeviceAddress()));
+ mEventLogger.setCurrentEvent(EventCode.DIRECTLY_CONNECT_PROFILE_WITH_CACHED_ADDRESS);
+ BluetoothDevice device =
+ mBluetoothAdapter.getRemoteDevice(mPreferences.getCachedDeviceAddress()).unwrap();
+ AtomicBoolean interruptConnection = new AtomicBoolean(false);
+ BroadcastReceiver receiver =
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null
+ || !BluetoothDevice.ACTION_PAIRING_REQUEST
+ .equals(intent.getAction())) {
+ return;
+ }
+ BluetoothDevice pairingDevice = intent
+ .getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (pairingDevice == null || !device.getAddress()
+ .equals(pairingDevice.getAddress())) {
+ return;
+ }
+ abortBroadcast();
+ // Should be the clear link key case, make it fail directly to go back to
+ // initial pairing process.
+ pairingDevice.setPairingConfirmation(/* confirm= */ false);
+ Log.w(TAG, "Get pairing request broadcast for device "
+ + maskBluetoothAddress(device.getAddress())
+ + " while try to direct connect profile with cached address, reject"
+ + " and to go back to initial pairing process");
+ interruptConnection.set(true);
+ }
+ };
+ mContext.registerReceiver(receiver,
+ new IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST));
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger,
+ "Connect to profile with cached address directly")) {
+ if (mBeforeDirectlyConnectProfileFromCacheForTest != null) {
+ mBeforeDirectlyConnectProfileFromCacheForTest.run();
+ }
+ attemptConnectProfiles(
+ new BluetoothAudioPairer(
+ mContext,
+ device,
+ mPreferences,
+ mEventLogger,
+ /* keyBasedPairingInfo= */ null,
+ /* passkeyConfirmationHandler= */ null,
+ mTimingLogger),
+ device,
+ getSupportedProfiles(device),
+ /* numConnectionAttempts= */ 1,
+ /* enablePairingBehavior= */ false,
+ interruptConnection);
+ Log.i(TAG,
+ "Directly connected to " + maskBluetoothAddress(device)
+ + "with cached address.");
+ mEventLogger.logCurrentEventSucceeded();
+ mEventLogger.setDevice(device);
+ logPairWithPossibleCachedAddress(device.getAddress());
+ return true;
+ } catch (PairingException e) {
+ if (interruptConnection.get()) {
+ Log.w(TAG, "Fail to connected to " + maskBluetoothAddress(device)
+ + " with cached address due to link key is cleared.", e);
+ mEventLogger.logCurrentEventFailed(
+ new ConnectException(ConnectErrorCode.LINK_KEY_CLEARED,
+ "Link key is cleared"));
+ } else {
+ Log.w(TAG, "Fail to connected to " + maskBluetoothAddress(device)
+ + " with cached address.", e);
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ return false;
+ } finally {
+ mContext.unregisterReceiver(receiver);
+ }
+ }
+
+ @VisibleForTesting
+ void setBeforeDirectlyConnectProfileFromCacheForTest(Runnable runnable) {
+ this.mBeforeDirectlyConnectProfileFromCacheForTest = runnable;
+ }
+
+ /**
+ * Logs for user retry, check go/fastpairquality21q3 for more details.
+ */
+ private void logManualRetryCounts(boolean success) {
+ if (!mPreferences.getLogUserManualRetry()) {
+ return;
+ }
+
+ // We don't want to be the final event on analytics.
+ if (!mEventLogger.isCurrentEvent()) {
+ return;
+ }
+
+ mEventLogger.setCurrentEvent(EventCode.GATT_HANDSHAKE_MANUAL_RETRY_ATTEMPTS);
+ if (mPreferences.getPairFailureCounts() <= 0 && success) {
+ mEventLogger.logCurrentEventSucceeded();
+ } else {
+ int errorCode = mPreferences.getPairFailureCounts();
+ if (errorCode > 99) {
+ errorCode = 99;
+ }
+ errorCode += success ? 0 : 100;
+ // To not conflict with current error codes.
+ errorCode += GATT_ERROR_CODE_USER_RETRY;
+ mEventLogger.logCurrentEventFailed(
+ new BluetoothGattException("Error for manual retry", errorCode));
+ }
+ }
static void logRetrySuccessEvent(
@EventCode int eventCode,
@@ -121,6 +757,1436 @@
eventLogger.logCurrentEventFailed(recoverFromException);
}
+ private void initGattConnectionManager() {
+ mGattConnectionManager =
+ new GattConnectionManager(
+ mContext,
+ mPreferences,
+ mEventLogger,
+ mBluetoothAdapter,
+ this::toggleBluetooth,
+ mBleAddress,
+ mTimingLogger,
+ mFastPairSignalChecker,
+ isPairingWithAntiSpoofingPublicKey());
+ }
+
+ private void logCurrentEventFailedBySignalRotated(SignalRotatedException e) {
+ if (!mEventLogger.isCurrentEvent()) {
+ return;
+ }
+
+ Log.w(TAG, "BLE Address for pairing device might rotated!");
+ mEventLogger.logCurrentEventFailed(
+ new BluetoothGattException(
+ "BLE Address for pairing device might rotated",
+ appendMoreErrorCode(GATT_ERROR_CODE_FAST_PAIR_ADDRESS_ROTATED,
+ e.getCause()),
+ e));
+ }
+
+ private void logCurrentEventFailedBySignalLost(SignalLostException e) {
+ if (!mEventLogger.isCurrentEvent()) {
+ return;
+ }
+
+ Log.w(TAG, "BLE signal for pairing device might lost!");
+ mEventLogger.logCurrentEventFailed(
+ new BluetoothGattException(
+ "BLE signal for pairing device might lost",
+ appendMoreErrorCode(GATT_ERROR_CODE_FAST_PAIR_SIGNAL_LOST, e.getCause()),
+ e));
+ }
+
+ @VisibleForTesting
+ static int appendMoreErrorCode(int masterErrorCode, @Nullable Throwable cause) {
+ if (cause instanceof BluetoothGattException) {
+ return masterErrorCode + ((BluetoothGattException) cause).getGattErrorCode();
+ } else if (cause instanceof TimeoutException
+ || cause instanceof BluetoothTimeoutException
+ || cause instanceof BluetoothOperationTimeoutException) {
+ return masterErrorCode + GATT_ERROR_CODE_TIMEOUT;
+ } else {
+ return masterErrorCode;
+ }
+ }
+
+ private void setBleAddress(String newAddress) {
+ if (TextUtils.isEmpty(newAddress) || Ascii.equalsIgnoreCase(newAddress, mBleAddress)) {
+ return;
+ }
+
+ mBleAddress = newAddress;
+
+ // Recreates a GattConnectionManager with the new address for establishing a new GATT
+ // connection later.
+ initGattConnectionManager();
+
+ mEventLogger.setDevice(mBluetoothAdapter.getRemoteDevice(mBleAddress).unwrap());
+ }
+
+ /**
+ * Gets the public address of the headset used in the connection. Before the handshake, this
+ * could be null.
+ */
+ @Nullable
+ public String getPublicAddress() {
+ return mPublicAddress;
+ }
+
+ /**
+ * Pairs with a Bluetooth device. In general, this process goes through the following steps:
+ *
+ * <ol>
+ * <li>Get BrEdr handover information if requested
+ * <li>Discover the device (on Android N and lower to work around a bug)
+ * <li>Connect to the device
+ * <ul>
+ * <li>Attempt a direct connection to a supported profile if we're already bonded
+ * <li>Create a new bond with the not bonded device and then connect to a supported
+ * profile
+ * </ul>
+ * <li>Write the account secret
+ * </ol>
+ *
+ * <p>Blocks until paired. May take 10+ seconds, so run on a background thread.
+ */
+ @Nullable
+ private SharedSecret pair(boolean enableBrEdrHandover)
+ throws BluetoothException, InterruptedException, ReflectionException, TimeoutException,
+ ExecutionException, PairingException, GeneralSecurityException {
+ BrEdrHandoverInformation brEdrHandoverInformation = null;
+ if (enableBrEdrHandover) {
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Get BR/EDR handover information via GATT")) {
+ brEdrHandoverInformation =
+ getBrEdrHandoverInformation(mGattConnectionManager.getConnection());
+ } catch (BluetoothException | TdsException e) {
+ Log.w(TAG,
+ "Couldn't get BR/EDR Handover info via TDS. Trying direct connect.", e);
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ }
+
+ if (brEdrHandoverInformation == null) {
+ // Pair directly to the BLE address. Works if the BLE and Bluetooth Classic addresses
+ // are the same, or if we can do BLE cross-key transport.
+ brEdrHandoverInformation =
+ new BrEdrHandoverInformation(
+ BluetoothAddress
+ .decode(mPublicAddress != null ? mPublicAddress : mBleAddress),
+ attemptGetBluetoothClassicProfiles(
+ mBluetoothAdapter.getRemoteDevice(mBleAddress).unwrap(),
+ mPreferences.getNumSdpAttempts()));
+ }
+ BluetoothDevice device =
+ mBluetoothAdapter.getRemoteDevice(brEdrHandoverInformation.mBluetoothAddress)
+ .unwrap();
+ callbackOnGetAddress(device.getAddress());
+ mEventLogger.setDevice(device);
+
+ Log.i(TAG, "Pairing with " + brEdrHandoverInformation);
+ KeyBasedPairingInfo keyBasedPairingInfo =
+ mPairingSecret == null
+ ? null
+ : new KeyBasedPairingInfo(
+ mPairingSecret, mGattConnectionManager, mProviderInitiatesBonding);
+ BluetoothAudioPairer pairer =
+ new BluetoothAudioPairer(
+ mContext,
+ device,
+ mPreferences,
+ mEventLogger,
+ keyBasedPairingInfo,
+ mPasskeyConfirmationHandler,
+ mTimingLogger);
+
+ logPairWithPossibleCachedAddress(device.getAddress());
+ logPairWithModelIdInCacheAndDiscoveryFailForCachedAddress(device);
+
+ // In the case where we are already bonded, we should first just try connecting to supported
+ // profiles. If successful, then this will be much faster than recreating the bond like we
+ // normally do and we can finish early. It is also more reliable than tearing down the bond
+ // and recreating it.
+ try {
+ attemptDirectConnectionIfBonded(device, pairer);
+ callbackOnPaired();
+ return maybeWriteAccountKey(device);
+ } catch (PairingException e) {
+ Log.i(TAG, "Failed to directly connect to supported profiles: " + e.getMessage());
+ // Catches exception when we fail to connect support profile. And makes the flow to go
+ // through step to write account key when device is bonded.
+ if (mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()
+ && device.getBondState() == BluetoothDevice.BOND_BONDED) {
+ if (mPreferences.getSkipConnectingProfiles()
+ && !mPreferences.getCheckBondStateWhenSkipConnectingProfiles()) {
+ Log.i(TAG, "For notCheckBondStateWhenSkipConnectingProfiles case should do "
+ + "re-bond");
+ } else {
+ Log.i(TAG, "Fail to connect profile when device is bonded, still call back on"
+ + "pair callback to show ui");
+ callbackOnPaired();
+ return maybeWriteAccountKey(device);
+ }
+ }
+ }
+
+ if (mPreferences.getMoreEventLogForQuality()) {
+ switch (device.getBondState()) {
+ case BOND_BONDED:
+ mEventLogger.setCurrentEvent(EventCode.BEFORE_CREATE_BOND_BONDED);
+ break;
+ case BOND_BONDING:
+ mEventLogger.setCurrentEvent(EventCode.BEFORE_CREATE_BOND_BONDING);
+ break;
+ case BOND_NONE:
+ default:
+ mEventLogger.setCurrentEvent(EventCode.BEFORE_CREATE_BOND);
+ }
+ }
+
+ for (int i = 1; i <= mPreferences.getNumCreateBondAttempts(); i++) {
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Pair device #" + i)) {
+ pairer.pair();
+ if (mPreferences.getMoreEventLogForQuality()) {
+ // For EventCode.BEFORE_CREATE_BOND
+ mEventLogger.logCurrentEventSucceeded();
+ }
+ break;
+ } catch (Exception e) {
+ mEventLogger.logCurrentEventFailed(e);
+ if (mPasskeyIsGotten) {
+ Log.w(TAG,
+ "createBond() failed because of " + e.getMessage()
+ + " after getting the passkey. Skip retry.");
+ if (mPreferences.getMoreEventLogForQuality()) {
+ // For EventCode.BEFORE_CREATE_BOND
+ mEventLogger.logCurrentEventFailed(
+ new CreateBondException(
+ CreateBondErrorCode.FAILED_BUT_ALREADY_RECEIVE_PASS_KEY,
+ 0,
+ "Already get the passkey"));
+ }
+ break;
+ }
+ Log.e(TAG,
+ "removeBond() or createBond() failed, attempt " + i + " of " + mPreferences
+ .getNumCreateBondAttempts() + ". Bond state "
+ + device.getBondState(), e);
+ if (i < mPreferences.getNumCreateBondAttempts()) {
+ toggleBluetooth();
+
+ // We've seen 3 createBond() failures within 100ms (!). And then success again
+ // later (even without turning on/off bluetooth). So create some minimum break
+ // time.
+ Log.i(TAG, "Sleeping 1 sec after createBond() failure.");
+ SystemClock.sleep(1000);
+ } else if (mPreferences.getMoreEventLogForQuality()) {
+ // For EventCode.BEFORE_CREATE_BOND
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ }
+ }
+ boolean deviceCreateBondFailWithNullSecret = false;
+ if (!pairer.isPaired()) {
+ if (mPairingSecret != null) {
+ // Bonding could fail for a few different reasons here. It could be an error, an
+ // attacker may have tried to bond, or the device may not be up to spec.
+ throw new PairingException("createBond() failed, exiting connection process.");
+ } else if (mPreferences.getSkipConnectingProfiles()) {
+ throw new PairingException(
+ "createBond() failed and skipping connecting to a profile.");
+ } else {
+ // When bond creation has failed, connecting a profile will still work most of the
+ // time for Fast Pair 1.0 devices (ie, pairing secret is null), so continue on with
+ // the spec anyways and attempt to connect supported profiles.
+ Log.w(TAG, "createBond() failed, will try connecting profiles anyway.");
+ deviceCreateBondFailWithNullSecret = true;
+ }
+ } else if (mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()) {
+ Log.i(TAG, "new flow to call on paired callback for ui when pairing step is finished");
+ callbackOnPaired();
+ }
+
+ if (!mPreferences.getSkipConnectingProfiles()) {
+ if (mPreferences.getWaitForUuidsAfterBonding()
+ && brEdrHandoverInformation.mProfiles.length == 0) {
+ short[] supportedProfiles = getCachedUuids(device);
+ if (supportedProfiles.length == 0
+ && mPreferences.getNumSdpAttemptsAfterBonded() > 0) {
+ Log.i(TAG, "Found no supported profiles in UUID cache, manually trigger SDP.");
+ attemptGetBluetoothClassicProfiles(device,
+ mPreferences.getNumSdpAttemptsAfterBonded());
+ }
+ brEdrHandoverInformation =
+ new BrEdrHandoverInformation(
+ brEdrHandoverInformation.mBluetoothAddress, supportedProfiles);
+ }
+ short[] profiles = brEdrHandoverInformation.mProfiles;
+ if (profiles.length == 0) {
+ profiles = Constants.getSupportedProfiles();
+ Log.w(TAG,
+ "Attempting to connect constants profiles, " + Arrays.toString(profiles));
+ } else {
+ Log.i(TAG, "Attempting to connect device profiles, " + Arrays.toString(profiles));
+ }
+
+ try {
+ attemptConnectProfiles(
+ pairer,
+ device,
+ profiles,
+ mPreferences.getNumConnectAttempts(),
+ /* enablePairingBehavior= */ false);
+ } catch (PairingException e) {
+ // For new pair flow to show ui, we already show success ui when finishing the
+ // createBond step. So we should catch the exception from connecting profile to
+ // avoid showing fail ui for user.
+ if (mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()
+ && !deviceCreateBondFailWithNullSecret) {
+ Log.i(TAG, "Fail to connect profile when device is bonded");
+ } else {
+ throw e;
+ }
+ }
+ }
+ if (!mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()) {
+ Log.i(TAG, "original flow to call on paired callback for ui");
+ callbackOnPaired();
+ } else if (deviceCreateBondFailWithNullSecret) {
+ // This paired callback is called for device which create bond fail with null secret
+ // such as FastPair 1.0 device when directly connecting to any supported profile.
+ Log.i(TAG, "call on paired callback for ui for device with null secret without bonded "
+ + "state");
+ callbackOnPaired();
+ }
+ if (mPreferences.getEnableFirmwareVersionCharacteristic()
+ && validateBluetoothGattCharacteristic(
+ mGattConnectionManager.getConnection(), FirmwareVersionCharacteristic.ID)) {
+ try {
+ sInitialConnectionFirmwareVersion = readFirmwareVersion();
+ } catch (BluetoothException e) {
+ Log.i(TAG, "Fast Pair: head phone does not support firmware read", e);
+ }
+ }
+
+ // Catch exception when writing account key or name fail to avoid showing pairing failure
+ // notice for user. Because device is already paired successfully based on paring step.
+ SharedSecret secret = null;
+ try {
+ secret = maybeWriteAccountKey(device);
+ } catch (InterruptedException
+ | ExecutionException
+ | TimeoutException
+ | NoSuchAlgorithmException
+ | BluetoothException e) {
+ Log.w(TAG, "Fast Pair: Got exception when writing account key or name to provider", e);
+ }
+
+ return secret;
+ }
+
+ private void logPairWithPossibleCachedAddress(String brEdrAddressForBonding) {
+ if (TextUtils.isEmpty(mPreferences.getPossibleCachedDeviceAddress())
+ || !mPreferences.getLogPairWithCachedModelId()) {
+ return;
+ }
+ mEventLogger.setCurrentEvent(EventCode.PAIR_WITH_CACHED_MODEL_ID);
+ if (Ascii.equalsIgnoreCase(
+ mPreferences.getPossibleCachedDeviceAddress(), brEdrAddressForBonding)) {
+ mEventLogger.logCurrentEventSucceeded();
+ Log.i(TAG, "Repair with possible cached device "
+ + maskBluetoothAddress(mPreferences.getPossibleCachedDeviceAddress()));
+ } else {
+ mEventLogger.logCurrentEventFailed(
+ new PairingException("Pairing with 2nd device with same model ID"));
+ Log.i(TAG, "Pair with a new device " + maskBluetoothAddress(brEdrAddressForBonding)
+ + " with model ID in cache "
+ + maskBluetoothAddress(mPreferences.getPossibleCachedDeviceAddress()));
+ }
+ }
+
+ /**
+ * Logs two type of events. First, why cachedAddress mechanism doesn't work if it's repair with
+ * bonded device case. Second, if it's not the case, log how many devices with the same model Id
+ * is already paired.
+ */
+ private void logPairWithModelIdInCacheAndDiscoveryFailForCachedAddress(BluetoothDevice device) {
+ if (!mPreferences.getLogPairWithCachedModelId()) {
+ return;
+ }
+
+ if (device.getBondState() == BOND_BONDED) {
+ if (mPreferences.getSameModelIdPairedDeviceCount() <= 0) {
+ Log.i(TAG, "Device is bonded but we don't have this model Id in cache.");
+ } else if (TextUtils.isEmpty(mPreferences.getCachedDeviceAddress())
+ && mPreferences.getDirectConnectProfileIfModelIdInCache()
+ && !mPreferences.getSkipConnectingProfiles()) {
+ // Pair with bonded device case. Log why the cached address is not found.
+ mEventLogger.setCurrentEvent(
+ EventCode.DIRECTLY_CONNECT_PROFILE_WITH_CACHED_ADDRESS);
+ mEventLogger.logCurrentEventFailed(
+ mPreferences.getIsDeviceFinishCheckAddressFromCache()
+ ? new ConnectException(ConnectErrorCode.FAIL_TO_DISCOVERY,
+ "Failed to discovery")
+ : new ConnectException(
+ ConnectErrorCode.DISCOVERY_NOT_FINISHED,
+ "Discovery not finished"));
+ Log.i(TAG, "Failed to get cached address due to "
+ + (mPreferences.getIsDeviceFinishCheckAddressFromCache()
+ ? "Failed to discovery"
+ : "Discovery not finished"));
+ }
+ } else if (device.getBondState() == BOND_NONE) {
+ // Pair with new device case, log how many devices with the same model id is in FastPair
+ // cache already.
+ mEventLogger.setCurrentEvent(EventCode.PAIR_WITH_NEW_MODEL);
+ if (mPreferences.getSameModelIdPairedDeviceCount() <= 0) {
+ mEventLogger.logCurrentEventSucceeded();
+ } else {
+ mEventLogger.logCurrentEventFailed(
+ new BluetoothGattException(
+ "Already have this model ID in cache",
+ GATT_ERROR_CODE_PAIR_WITH_SAME_MODEL_ID_COUNT
+ + mPreferences.getSameModelIdPairedDeviceCount()));
+ }
+ Log.i(TAG, "This device already has " + mPreferences.getSameModelIdPairedDeviceCount()
+ + " peripheral with the same model Id");
+ }
+ }
+
+ /**
+ * Attempts to directly connect to any supported profile if we're already bonded, this will save
+ * time over tearing down the bond and recreating it.
+ */
+ private void attemptDirectConnectionIfBonded(BluetoothDevice device,
+ BluetoothAudioPairer pairer)
+ throws PairingException {
+ if (mPreferences.getSkipConnectingProfiles()) {
+ if (mPreferences.getCheckBondStateWhenSkipConnectingProfiles()
+ && device.getBondState() == BluetoothDevice.BOND_BONDED) {
+ Log.i(TAG, "Skipping connecting to profiles by preferences.");
+ return;
+ }
+ throw new PairingException(
+ "Skipping connecting to profiles, no direct connection possible.");
+ } else if (!mPreferences.getAttemptDirectConnectionWhenPreviouslyBonded()
+ || device.getBondState() != BluetoothDevice.BOND_BONDED) {
+ throw new PairingException(
+ "Not previously bonded skipping direct connection, %s", device.getBondState());
+ }
+ short[] supportedProfiles = getSupportedProfiles(device);
+ mEventLogger.setCurrentEvent(EventCode.DIRECTLY_CONNECTED_TO_PROFILE);
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Connect to profile directly")) {
+ attemptConnectProfiles(
+ pairer,
+ device,
+ supportedProfiles,
+ mPreferences.getEnablePairFlowShowUiWithoutProfileConnection()
+ ? mPreferences.getNumConnectAttempts()
+ : 1,
+ mPreferences.getEnablePairingWhileDirectlyConnecting());
+ Log.i(TAG, "Directly connected to " + maskBluetoothAddress(device));
+ mEventLogger.logCurrentEventSucceeded();
+ } catch (PairingException e) {
+ mEventLogger.logCurrentEventFailed(e);
+ // Rethrow e so that the exception bubbles up and we continue the normal pairing
+ // process.
+ throw e;
+ }
+ }
+
+ private void attemptConnectProfiles(
+ BluetoothAudioPairer pairer,
+ BluetoothDevice device,
+ short[] profiles,
+ int numConnectionAttempts,
+ boolean enablePairingBehavior)
+ throws PairingException {
+ attemptConnectProfiles(
+ pairer,
+ device,
+ profiles,
+ numConnectionAttempts,
+ enablePairingBehavior,
+ new AtomicBoolean(false));
+ }
+
+ private void attemptConnectProfiles(
+ BluetoothAudioPairer pairer,
+ BluetoothDevice device,
+ short[] profiles,
+ int numConnectionAttempts,
+ boolean enablePairingBehavior,
+ AtomicBoolean interruptConnection)
+ throws PairingException {
+ if (mPreferences.getMoreEventLogForQuality()) {
+ mEventLogger.setCurrentEvent(EventCode.BEFORE_CONNECT_PROFILE);
+ }
+ Exception lastException = null;
+ for (short profile : profiles) {
+ if (interruptConnection.get()) {
+ Log.w(TAG, "attemptConnectProfiles interrupted");
+ break;
+ }
+ if (!mPreferences.isSupportedProfile(profile)) {
+ Log.w(TAG, "Ignoring unsupported profile=" + profile);
+ continue;
+ }
+ for (int i = 1; i <= numConnectionAttempts; i++) {
+ if (interruptConnection.get()) {
+ Log.w(TAG, "attemptConnectProfiles interrupted");
+ break;
+ }
+ mEventLogger.setCurrentEvent(EventCode.CONNECT_PROFILE);
+ mEventLogger.setCurrentProfile(profile);
+ try {
+ pairer.connect(profile, enablePairingBehavior);
+ mEventLogger.logCurrentEventSucceeded();
+ if (mPreferences.getMoreEventLogForQuality()) {
+ // For EventCode.BEFORE_CONNECT_PROFILE
+ mEventLogger.logCurrentEventSucceeded();
+ }
+ // If successful, we're done.
+ // TODO(b/37167120): Connect to more than one profile.
+ return;
+ } catch (InterruptedException
+ | ReflectionException
+ | TimeoutException
+ | ExecutionException
+ | ConnectException e) {
+ Log.w(TAG,
+ "Error connecting to profile=" + profile
+ + " for device=" + maskBluetoothAddress(device)
+ + " (attempt " + i + " of " + mPreferences
+ .getNumConnectAttempts(), e);
+ mEventLogger.logCurrentEventFailed(e);
+ lastException = e;
+ }
+ }
+ }
+ if (mPreferences.getMoreEventLogForQuality()) {
+ // For EventCode.BEFORE_CONNECT_PROFILE
+ if (lastException != null) {
+ mEventLogger.logCurrentEventFailed(lastException);
+ } else {
+ mEventLogger.logCurrentEventSucceeded();
+ }
+ }
+ throw new PairingException(
+ "Unable to connect to any profiles in: %s", Arrays.toString(profiles));
+ }
+
+ /**
+ * Creates cloud syncing intent which saves the Fast Pair device to the account.
+ *
+ * @param accountKey account key which is written into the Fast Pair device
+ * @return cloud syncing intent
+ */
+ public Intent createCloudSyncingIntent(byte[] accountKey) {
+ Intent intent = new Intent(BroadcastConstants.ACTION_FAST_PAIR_DEVICE_ADDED);
+ intent.setClassName(BroadcastConstants.PACKAGE_NAME, BroadcastConstants.SERVICE_NAME);
+ intent.putExtra(BroadcastConstants.EXTRA_ADDRESS, mBleAddress);
+ if (mPublicAddress != null) {
+ intent.putExtra(BroadcastConstants.EXTRA_PUBLIC_ADDRESS, mPublicAddress);
+ }
+ intent.putExtra(BroadcastConstants.EXTRA_ACCOUNT_KEY, accountKey);
+ intent.putExtra(
+ BroadcastConstants.EXTRA_RETROACTIVE_PAIR, mPreferences.getIsRetroactivePairing());
+
+ return intent;
+ }
+
+ /**
+ * Checks whether or not an account key should be written to the device and writes it if so.
+ * This is called after handle notifying the pairedCallback that we've finished pairing, because
+ * at this point the headset is ready to use.
+ */
+ @Nullable
+ private SharedSecret maybeWriteAccountKey(BluetoothDevice device)
+ throws InterruptedException, ExecutionException, TimeoutException,
+ NoSuchAlgorithmException,
+ BluetoothException {
+ if (!shouldWriteAccountKey()) {
+ // For FastPair 2.0, here should be a subsequent pairing case.
+ return null;
+ }
+
+ // Check if it should be a subsequent pairing but go through initial pairing. If there is an
+ // existed paired history found, use the same account key instead of creating a new one.
+ byte[] accountKey =
+ mPairedHistoryFinder == null ? null : mPairedHistoryFinder.getExistingAccountKey();
+ if (accountKey == null) {
+ // It is a real initial pairing, generate a new account key for the headset.
+ try (ScopedTiming scopedTiming1 =
+ new ScopedTiming(mTimingLogger, "Write account key")) {
+ accountKey = doWriteAccountKey(createAccountKey(), device.getAddress());
+ if (accountKey == null) {
+ // Without writing account key back to provider, close the connection.
+ mGattConnectionManager.closeConnection();
+ return null;
+ }
+ if (!mPreferences.getIsRetroactivePairing()) {
+ try (ScopedTiming scopedTiming2 = new ScopedTiming(mTimingLogger,
+ "Start CloudSyncing")) {
+ mContext.startService(createCloudSyncingIntent(accountKey));
+ } catch (SecurityException e) {
+ Log.w(TAG, "Error adding device.", e);
+ }
+ }
+ }
+ } else if (shouldWriteAccountKeyForExistingCase(accountKey)) {
+ // There is an existing account key, but go through initial pairing, and still write the
+ // existing account key.
+ doWriteAccountKey(accountKey, device.getAddress());
+ }
+
+ // When finish writing account key in initial pairing, write new device name back to
+ // provider.
+ UUID characteristicUuid = NameCharacteristic.getId(mGattConnectionManager.getConnection());
+ if (mPreferences.getEnableNamingCharacteristic()
+ && mNeedUpdateProviderName
+ && validateBluetoothGattCharacteristic(
+ mGattConnectionManager.getConnection(), characteristicUuid)) {
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "WriteNameToProvider")) {
+ writeNameToProvider(this.mProviderDeviceName, device.getAddress());
+ }
+ }
+
+ // When finish writing account key and name back to provider, close the connection.
+ mGattConnectionManager.closeConnection();
+ return SharedSecret.create(accountKey, device.getAddress());
+ }
+
+ private boolean shouldWriteAccountKey() {
+ return isWritingAccountKeyEnabled() && isPairingWithAntiSpoofingPublicKey();
+ }
+
+ private boolean isWritingAccountKeyEnabled() {
+ return mPreferences.getNumWriteAccountKeyAttempts() > 0;
+ }
+
+ private boolean isPairingWithAntiSpoofingPublicKey() {
+ return isPairingWithAntiSpoofingPublicKey(mPairingKey);
+ }
+
+ private boolean isPairingWithAntiSpoofingPublicKey(@Nullable byte[] key) {
+ return key != null && key.length == EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH;
+ }
+
+ /**
+ * Creates and writes an account key to the provided mac address.
+ */
+ @Nullable
+ private byte[] doWriteAccountKey(byte[] accountKey, String macAddress)
+ throws InterruptedException, ExecutionException, TimeoutException, BluetoothException {
+ byte[] localPairingSecret = mPairingSecret;
+ if (localPairingSecret == null) {
+ Log.w(TAG, "Pairing secret was null, account key couldn't be encrypted or written.");
+ return null;
+ }
+ if (!mPreferences.getSkipDisconnectingGattBeforeWritingAccountKey()) {
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Close GATT and sleep")) {
+ // Make a new connection instead of reusing gattConnection, because this is
+ // post-pairing and we need an encrypted connection.
+ mGattConnectionManager.closeConnection();
+ // Sleep before re-connecting to gatt, for writing account key, could increase
+ // stability.
+ Thread.sleep(mPreferences.getWriteAccountKeySleepMillis());
+ }
+ }
+
+ byte[] encryptedKey;
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Encrypt key")) {
+ encryptedKey = AesEcbSingleBlockEncryption.encrypt(localPairingSecret, accountKey);
+ } catch (GeneralSecurityException e) {
+ Log.w("Failed to encrypt key.", e);
+ return null;
+ }
+
+ for (int i = 1; i <= mPreferences.getNumWriteAccountKeyAttempts(); i++) {
+ mEventLogger.setCurrentEvent(EventCode.WRITE_ACCOUNT_KEY);
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "Write key via GATT #" + i)) {
+ writeAccountKey(encryptedKey, macAddress);
+ mEventLogger.logCurrentEventSucceeded();
+ return accountKey;
+ } catch (BluetoothException e) {
+ Log.w("Error writing account key attempt " + i + " of " + mPreferences
+ .getNumWriteAccountKeyAttempts(), e);
+ mEventLogger.logCurrentEventFailed(e);
+ // Retry with a while for stability.
+ Thread.sleep(mPreferences.getWriteAccountKeySleepMillis());
+ }
+ }
+ return null;
+ }
+
+ private byte[] createAccountKey() throws NoSuchAlgorithmException {
+ return AccountKeyGenerator.createAccountKey();
+ }
+
+ @VisibleForTesting
+ boolean shouldWriteAccountKeyForExistingCase(byte[] existingAccountKey) {
+ if (!mPreferences.getKeepSameAccountKeyWrite()) {
+ Log.i(TAG,
+ "The provider has already paired with the account, skip writing account key.");
+ return false;
+ }
+ if (existingAccountKey[0] != AccountKeyCharacteristic.TYPE) {
+ Log.i(TAG,
+ "The provider has already paired with the account, but accountKey[0] != 0x04."
+ + " Forget the device from the account and re-try");
+
+ return false;
+ }
+ Log.i(TAG, "The provider has already paired with the account, still write the same account "
+ + "key.");
+ return true;
+ }
+
+ /**
+ * Performs a key-based pairing request handshake to authenticate and get the remote device's
+ * public address.
+ *
+ * @param key is described in {@link #pair(byte[])}
+ */
+ @VisibleForTesting
+ SharedSecret handshakeForKeyBasedPairing(byte[] key)
+ throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+ GeneralSecurityException, PairingException {
+ // We may also initialize gattConnectionManager of prepareForHandshake() that will be used
+ // in registerNotificationForNamePacket(), so we need to call it here.
+ HandshakeHandler handshakeHandler = prepareForHandshake();
+ KeyBasedPairingRequest.Builder keyBasedPairingRequestBuilder =
+ new KeyBasedPairingRequest.Builder()
+ .setVerificationData(BluetoothAddress.decode(mBleAddress));
+ if (mProviderInitiatesBonding) {
+ keyBasedPairingRequestBuilder
+ .addFlag(KeyBasedPairingRequestFlag.PROVIDER_INITIATES_BONDING);
+ }
+ // Seeker only request provider device name in initial pairing.
+ if (mPreferences.getEnableNamingCharacteristic() && isPairingWithAntiSpoofingPublicKey(
+ key)) {
+ keyBasedPairingRequestBuilder.addFlag(KeyBasedPairingRequestFlag.REQUEST_DEVICE_NAME);
+ // Register listener to receive name characteristic response from provider.
+ registerNotificationForNamePacket();
+ }
+ if (mPreferences.getIsRetroactivePairing()) {
+ keyBasedPairingRequestBuilder
+ .addFlag(KeyBasedPairingRequestFlag.REQUEST_RETROACTIVE_PAIR);
+ keyBasedPairingRequestBuilder.setSeekerPublicAddress(
+ Preconditions.checkNotNull(BluetoothAddress.getPublicAddress(mContext)));
+ }
+
+ return performHandshakeWithRetryAndSignalLostCheck(
+ handshakeHandler, key, keyBasedPairingRequestBuilder.build(), /* withRetry= */
+ true);
+ }
+
+ /**
+ * Performs an action-over-BLE request handshake for authentication, i.e. to identify the shared
+ * secret. The given key should be the account key.
+ */
+ private SharedSecret handshakeForActionOverBle(byte[] key,
+ AdditionalDataType additionalDataType)
+ throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+ GeneralSecurityException, PairingException {
+ HandshakeHandler handshakeHandler = prepareForHandshake();
+ return performHandshakeWithRetryAndSignalLostCheck(
+ handshakeHandler,
+ key,
+ new ActionOverBle.Builder()
+ .setVerificationData(BluetoothAddress.decode(mBleAddress))
+ .setAdditionalDataType(additionalDataType)
+ .build(),
+ /* withRetry= */ false);
+ }
+
+ private HandshakeHandler prepareForHandshake() {
+ if (mGattConnectionManager == null) {
+ mGattConnectionManager =
+ new GattConnectionManager(
+ mContext,
+ mPreferences,
+ mEventLogger,
+ mBluetoothAdapter,
+ this::toggleBluetooth,
+ mBleAddress,
+ mTimingLogger,
+ mFastPairSignalChecker,
+ isPairingWithAntiSpoofingPublicKey());
+ }
+ if (mHandshakeHandlerForTest != null) {
+ Log.w(TAG, "Use handshakeHandlerForTest!");
+ return verifyNotNull(mHandshakeHandlerForTest);
+ }
+ return new HandshakeHandler(
+ mGattConnectionManager, mBleAddress, mPreferences, mEventLogger,
+ mFastPairSignalChecker);
+ }
+
+ @VisibleForTesting
+ void setHandshakeHandlerForTest(@Nullable HandshakeHandler handshakeHandlerForTest) {
+ this.mHandshakeHandlerForTest = handshakeHandlerForTest;
+ }
+
+ private SharedSecret performHandshakeWithRetryAndSignalLostCheck(
+ HandshakeHandler handshakeHandler,
+ byte[] key,
+ HandshakeMessage handshakeMessage,
+ boolean withRetry)
+ throws GeneralSecurityException, ExecutionException, BluetoothException,
+ InterruptedException, TimeoutException, PairingException {
+ SharedSecret handshakeResult =
+ withRetry
+ ? handshakeHandler.doHandshakeWithRetryAndSignalLostCheck(
+ key, handshakeMessage, mRescueFromError)
+ : handshakeHandler.doHandshake(key, handshakeMessage);
+ // TODO: Try to remove these two global variables, publicAddress and pairingSecret.
+ mPublicAddress = handshakeResult.getAddress();
+ mPairingSecret = handshakeResult.getKey();
+ return handshakeResult;
+ }
+
+ private void toggleBluetooth()
+ throws InterruptedException, ExecutionException, TimeoutException {
+ if (!mPreferences.getToggleBluetoothOnFailure()) {
+ return;
+ }
+
+ Log.i(TAG, "Turning Bluetooth off.");
+ mEventLogger.setCurrentEvent(EventCode.DISABLE_BLUETOOTH);
+ mBluetoothAdapter.unwrap().disable();
+ disableBle(mBluetoothAdapter.unwrap());
+ try {
+ waitForBluetoothState(android.bluetooth.BluetoothAdapter.STATE_OFF);
+ mEventLogger.logCurrentEventSucceeded();
+ } catch (TimeoutException e) {
+ mEventLogger.logCurrentEventFailed(e);
+ // Soldier on despite failing to turn off Bluetooth. We can't control whether other
+ // clients (even inside GCore) kept it enabled in BLE-only mode.
+ Log.w(TAG, "Bluetooth still on. BluetoothAdapter state="
+ + getBleState(mBluetoothAdapter.unwrap()), e);
+ }
+
+ // Note: Intentionally don't re-enable BLE-only mode, because we don't know which app
+ // enabled it. The client app should listen to Bluetooth events and enable as necessary
+ // (because the user can toggle at any time; e.g. via Airplane mode).
+ Log.i(TAG, "Turning Bluetooth on.");
+ mEventLogger.setCurrentEvent(EventCode.ENABLE_BLUETOOTH);
+ mBluetoothAdapter.unwrap().enable();
+ waitForBluetoothState(android.bluetooth.BluetoothAdapter.STATE_ON);
+ mEventLogger.logCurrentEventSucceeded();
+ }
+
+ private void waitForBluetoothState(int state)
+ throws TimeoutException, ExecutionException, InterruptedException {
+ waitForBluetoothStateUsingPolling(state);
+ }
+
+ /**
+ * Update device name to provider.
+ *
+ * <pre>
+ * A) Connect GATT
+ * B) Handshake with provider to get the pairing secret and public address
+ * C) Write new device name into provider through name characteristic in GATT
+ * D) Disconnect GATT
+ * </pre>
+ *
+ * Synchronous: Blocks until until the name has finished being written. Throws on any error.
+ *
+ * @param key is a 16-byte account key. See go/fast-pair-2-spec for how these keys are used.
+ * @return true if the task is done, i.e. name is written successfully or it is skipped because
+ * of unsupported Name characteristic, false if some error happens and may need to re-try.
+ */
+ @WorkerThread
+ public boolean updateProviderName(@Nullable byte[] key, @Nullable String deviceName)
+ throws BluetoothException, InterruptedException, TimeoutException, ExecutionException,
+ PairingException, GeneralSecurityException {
+ if (!mPreferences.getEnableNamingCharacteristic()) {
+ Log.i(TAG, "Disable NamingCharacteristic feature, ignoring.");
+ return false;
+ }
+ if (isNullOrEmpty(deviceName)) {
+ Log.i(TAG, "Provider name is null or empty, ignoring.");
+ return false;
+ }
+ if (key == null || key.length != AES_BLOCK_LENGTH) {
+ Log.i(TAG, "key is null or key length is not account key size.");
+ return false;
+ }
+
+ Log.i(TAG, "Start to update device name for provider.");
+ boolean result = false;
+ if (mPreferences.getExtraLoggingInformation() != null) {
+ this.mEventLogger.bind(
+ mContext, mBleAddress, mPreferences.getExtraLoggingInformation());
+ }
+
+ // Lazily initialize a new connection manager for each renaming request.
+ mGattConnectionManager =
+ new GattConnectionManager(
+ mContext,
+ mPreferences,
+ mEventLogger,
+ mBluetoothAdapter,
+ this::toggleBluetooth,
+ mBleAddress,
+ mTimingLogger,
+ mFastPairSignalChecker,
+ /* setMtu= */ true);
+
+ try (BluetoothGattConnection connection = mGattConnectionManager.getConnection()) {
+ UUID characteristicUuid = NameCharacteristic.getId(connection);
+ if (!validateBluetoothGattCharacteristic(connection, characteristicUuid)) {
+ Log.i(TAG, "Can't find name characteristic, skip to write name with retry times.");
+ mGattConnectionManager.closeConnection();
+ // Returns true because the task is done with no name characteristic in device.
+ return true;
+ }
+ mEventLogger.setCurrentEvent(EventCode.SECRET_HANDSHAKE);
+ // Handshake to get pairing secret for name characteristic decryption and encryption.
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Handshake")) {
+ handshakeForActionOverBle(key, AdditionalDataType.PERSONALIZED_NAME);
+ }
+ mEventLogger.logCurrentEventSucceeded();
+ // After handshake to get secret, write the name back to provider.
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger,
+ "WriteNameToProvider")) {
+ result = writeNameToProvider(deviceName, mPublicAddress);
+ }
+ } catch (BluetoothException
+ | InterruptedException
+ | TimeoutException
+ | ExecutionException
+ | PairingException
+ | GeneralSecurityException e) {
+ if (mEventLogger.isCurrentEvent()) {
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ throw e;
+ } finally {
+ mTimingLogger.dump();
+ }
+ mGattConnectionManager.closeConnection();
+ return result;
+ }
+
+ private void waitForBluetoothStateUsingPolling(int state) throws TimeoutException {
+ // There's a bug where we (pretty often!) never get the broadcast for STATE_ON or STATE_OFF.
+ // So poll instead.
+ long start = SystemClock.elapsedRealtime();
+ long timeoutMillis = mPreferences.getBluetoothToggleTimeoutSeconds() * 1000L;
+ while (SystemClock.elapsedRealtime() - start < timeoutMillis) {
+ if (state == getBleState(mBluetoothAdapter.unwrap())) {
+ break;
+ }
+ SystemClock.sleep(mPreferences.getBluetoothStatePollingMillis());
+ }
+
+ if (state != getBleState(mBluetoothAdapter.unwrap())) {
+ throw new TimeoutException(
+ String.format(
+ Locale.getDefault(),
+ "Timed out waiting for state %d, current state is %d",
+ state,
+ getBleState(mBluetoothAdapter.unwrap())));
+ }
+ }
+
+ private BrEdrHandoverInformation getBrEdrHandoverInformation(BluetoothGattConnection connection)
+ throws BluetoothException, TdsException, InterruptedException, ExecutionException,
+ TimeoutException {
+ Log.i(TAG, "Connecting GATT server to BLE address=" + maskBluetoothAddress(mBleAddress));
+ Log.i(TAG, "Telling device to become discoverable");
+ mEventLogger.setCurrentEvent(EventCode.BR_EDR_HANDOVER_WRITE_CONTROL_POINT_REQUEST);
+ ChangeObserver changeObserver =
+ connection.enableNotification(
+ TransportDiscoveryService.ID,
+ TransportDiscoveryService.ControlPointCharacteristic.ID);
+ connection.writeCharacteristic(
+ TransportDiscoveryService.ID,
+ TransportDiscoveryService.ControlPointCharacteristic.ID,
+ TDS_CONTROL_POINT_REQUEST);
+
+ byte[] response =
+ changeObserver.waitForUpdate(
+ TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+ ResultCode resultCode = ResultCode.fromTdsControlPointIndication(response);
+ if (resultCode != ResultCode.SUCCESS) {
+ throw new TdsException(
+ BrEdrHandoverErrorCode.CONTROL_POINT_RESULT_CODE_NOT_SUCCESS,
+ "TDS Control Point result code (%s) was not success in response %s",
+ resultCode,
+ base16().lowerCase().encode(response));
+ }
+ mEventLogger.logCurrentEventSucceeded();
+ return new BrEdrHandoverInformation(
+ getAddressFromBrEdrConnection(connection),
+ getProfilesFromBrEdrConnection(connection));
+ }
+
+ private byte[] getAddressFromBrEdrConnection(BluetoothGattConnection connection)
+ throws BluetoothException, TdsException {
+ Log.i(TAG, "Getting Bluetooth MAC");
+ mEventLogger.setCurrentEvent(EventCode.BR_EDR_HANDOVER_READ_BLUETOOTH_MAC);
+ byte[] brHandoverData =
+ connection.readCharacteristic(
+ TransportDiscoveryService.ID,
+ to128BitUuid(mPreferences.getBrHandoverDataCharacteristicId()));
+ if (brHandoverData == null || brHandoverData.length < 7) {
+ throw new TdsException(
+ BrEdrHandoverErrorCode.BLUETOOTH_MAC_INVALID,
+ "Bluetooth MAC not contained in BR handover data: %s",
+ brHandoverData != null ? base16().lowerCase().encode(brHandoverData)
+ : "(none)");
+ }
+ byte[] bluetoothAddress =
+ new Bytes.Value(Arrays.copyOfRange(brHandoverData, 1, 7), ByteOrder.LITTLE_ENDIAN)
+ .getBytes(ByteOrder.BIG_ENDIAN);
+ mEventLogger.logCurrentEventSucceeded();
+ return bluetoothAddress;
+ }
+
+ private short[] getProfilesFromBrEdrConnection(BluetoothGattConnection connection) {
+ mEventLogger.setCurrentEvent(EventCode.BR_EDR_HANDOVER_READ_TRANSPORT_BLOCK);
+ try {
+ byte[] transportBlock =
+ connection.readDescriptor(
+ TransportDiscoveryService.ID,
+ to128BitUuid(mPreferences.getBluetoothSigDataCharacteristicId()),
+ to128BitUuid(mPreferences.getBrTransportBlockDataDescriptorId()));
+ Log.i(TAG, "Got transport block: " + base16().lowerCase().encode(transportBlock));
+ short[] profiles = getSupportedProfiles(transportBlock);
+ mEventLogger.logCurrentEventSucceeded();
+ return profiles;
+ } catch (BluetoothException | TdsException | ParseException e) {
+ Log.w(TAG, "Failed to get supported profiles from transport block.", e);
+ mEventLogger.logCurrentEventFailed(e);
+ }
+ return new short[0];
+ }
+
+ @VisibleForTesting
+ boolean writeNameToProvider(@Nullable String deviceName, @Nullable String address)
+ throws InterruptedException, TimeoutException, ExecutionException {
+ if (deviceName == null || address == null) {
+ Log.i(TAG, "writeNameToProvider fail because provider name or address is null.");
+ return false;
+ }
+ if (mPairingSecret == null) {
+ Log.i(TAG, "writeNameToProvider fail because no pairingSecret.");
+ return false;
+ }
+ byte[] encryptedDeviceNamePacket;
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Encode device name")) {
+ encryptedDeviceNamePacket =
+ NamingEncoder.encodeNamingPacket(mPairingSecret, deviceName);
+ } catch (GeneralSecurityException e) {
+ Log.w(TAG, "Failed to encrypt device name.", e);
+ return false;
+ }
+
+ for (int i = 1; i <= mPreferences.getNumWriteAccountKeyAttempts(); i++) {
+ mEventLogger.setCurrentEvent(EventCode.WRITE_DEVICE_NAME);
+ try {
+ writeDeviceName(encryptedDeviceNamePacket, address);
+ mEventLogger.logCurrentEventSucceeded();
+ return true;
+ } catch (BluetoothException e) {
+ Log.w(TAG, "Error writing name attempt " + i + " of "
+ + mPreferences.getNumWriteAccountKeyAttempts());
+ mEventLogger.logCurrentEventFailed(e);
+ // Reuses the existing preference because the same usage.
+ Thread.sleep(mPreferences.getWriteAccountKeySleepMillis());
+ }
+ }
+ return false;
+ }
+
+ private void writeAccountKey(byte[] encryptedAccountKey, String address)
+ throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+ Log.i(TAG, "Writing account key to address=" + maskBluetoothAddress(address));
+ BluetoothGattConnection connection = mGattConnectionManager.getConnection();
+ connection.setOperationTimeout(
+ TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+ UUID characteristicUuid = AccountKeyCharacteristic.getId(connection);
+ connection.writeCharacteristic(FastPairService.ID, characteristicUuid, encryptedAccountKey);
+ Log.i(TAG,
+ "Finished writing encrypted account key=" + base16().encode(encryptedAccountKey));
+ }
+
+ private void writeDeviceName(byte[] naming, String address)
+ throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+ Log.i(TAG, "Writing new device name to address=" + maskBluetoothAddress(address));
+ BluetoothGattConnection connection = mGattConnectionManager.getConnection();
+ connection.setOperationTimeout(
+ TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+ UUID characteristicUuid = NameCharacteristic.getId(connection);
+ connection.writeCharacteristic(FastPairService.ID, characteristicUuid, naming);
+ Log.i(TAG, "Finished writing new device name=" + base16().encode(naming));
+ }
+
+ /**
+ * Reads firmware version after write account key to provider since simulator is more stable to
+ * read firmware version in initial gatt connection. This function will also read firmware when
+ * detect bloomfilter. Need to verify this after real device come out. TODO(b/130592473)
+ */
+ @Nullable
+ public String readFirmwareVersion()
+ throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+ if (!TextUtils.isEmpty(sInitialConnectionFirmwareVersion)) {
+ String result = sInitialConnectionFirmwareVersion;
+ sInitialConnectionFirmwareVersion = null;
+ return result;
+ }
+ if (mGattConnectionManager == null) {
+ mGattConnectionManager =
+ new GattConnectionManager(
+ mContext,
+ mPreferences,
+ mEventLogger,
+ mBluetoothAdapter,
+ this::toggleBluetooth,
+ mBleAddress,
+ mTimingLogger,
+ mFastPairSignalChecker,
+ /* setMtu= */ true);
+ mGattConnectionManager.closeConnection();
+ }
+ BluetoothGattConnection connection = mGattConnectionManager.getConnection();
+ connection.setOperationTimeout(
+ TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+ try {
+ String firmwareVersion =
+ new String(
+ connection.readCharacteristic(
+ FastPairService.ID,
+ to128BitUuid(
+ mPreferences.getFirmwareVersionCharacteristicId())));
+ Log.i(TAG, "FastPair: Got the firmware info version number = " + firmwareVersion);
+ mGattConnectionManager.closeConnection();
+ return firmwareVersion;
+ } catch (BluetoothException e) {
+ Log.i(TAG, "FastPair: can't read firmware characteristic.", e);
+ mGattConnectionManager.closeConnection();
+ return null;
+ }
+ }
+
+ @VisibleForTesting
+ @Nullable
+ String getInitialConnectionFirmware() {
+ return sInitialConnectionFirmwareVersion;
+ }
+
+ private void registerNotificationForNamePacket()
+ throws BluetoothException, InterruptedException, ExecutionException, TimeoutException {
+ Log.i(TAG,
+ "register for the device name response from address=" + maskBluetoothAddress(
+ mBleAddress));
+
+ BluetoothGattConnection gattConnection = mGattConnectionManager.getConnection();
+ gattConnection.setOperationTimeout(
+ TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+ try {
+ mDeviceNameReceiver = new DeviceNameReceiver(gattConnection);
+ } catch (BluetoothException e) {
+ Log.i(TAG, "Can't register for device name response, no naming characteristic.");
+ return;
+ }
+ }
+
+ private short[] getSupportedProfiles(BluetoothDevice device) {
+ short[] supportedProfiles = getCachedUuids(device);
+ if (supportedProfiles.length == 0 && mPreferences.getNumSdpAttemptsAfterBonded() > 0) {
+ supportedProfiles =
+ attemptGetBluetoothClassicProfiles(device,
+ mPreferences.getNumSdpAttemptsAfterBonded());
+ }
+ if (supportedProfiles.length == 0) {
+ supportedProfiles = Constants.getSupportedProfiles();
+ Log.w(TAG, "Attempting to connect constants profiles, "
+ + Arrays.toString(supportedProfiles));
+ } else {
+ Log.i(TAG,
+ "Attempting to connect device profiles, " + Arrays.toString(supportedProfiles));
+ }
+ return supportedProfiles;
+ }
+
+ private static short[] getSupportedProfiles(byte[] transportBlock)
+ throws TdsException, ParseException {
+ if (transportBlock == null || transportBlock.length < 4) {
+ throw new TdsException(
+ BrEdrHandoverErrorCode.TRANSPORT_BLOCK_INVALID,
+ "Transport Block null or too short: %s",
+ base16().lowerCase().encode(transportBlock));
+ }
+ int transportDataLength = transportBlock[2];
+ if (transportBlock.length < 3 + transportDataLength) {
+ throw new TdsException(
+ BrEdrHandoverErrorCode.TRANSPORT_BLOCK_INVALID,
+ "Transport Block has wrong length byte: %s",
+ base16().lowerCase().encode(transportBlock));
+ }
+ byte[] transportData = Arrays.copyOfRange(transportBlock, 3, 3 + transportDataLength);
+ for (Ltv ltv : Ltv.parse(transportData)) {
+ int uuidLength = uuidLength(ltv.mType);
+ // We currently only support a single list of 2-byte UUIDs.
+ // TODO(b/37539535): Support multiple lists, and longer (32-bit, 128-bit) IDs?
+ if (uuidLength == 2) {
+ return toShorts(ByteOrder.LITTLE_ENDIAN, ltv.mValue);
+ }
+ }
+ return new short[0];
+ }
+
+ /**
+ * Returns 0 if the type is not one of the UUID list types; otherwise returns length in bytes.
+ */
+ private static int uuidLength(byte dataType) {
+ switch (dataType) {
+ case TransportDiscoveryService.SERVICE_UUIDS_16_BIT_LIST_TYPE:
+ return 2;
+ case TransportDiscoveryService.SERVICE_UUIDS_32_BIT_LIST_TYPE:
+ return 4;
+ case TransportDiscoveryService.SERVICE_UUIDS_128_BIT_LIST_TYPE:
+ return 16;
+ default:
+ return 0;
+ }
+ }
+
+ private short[] attemptGetBluetoothClassicProfiles(BluetoothDevice device, int numSdpAttempts) {
+ // The docs say that if fetchUuidsWithSdp() has an error or "takes a long time", we get an
+ // intent containing only the stuff in the cache (i.e. nothing). Retry a few times.
+ short[] supportedProfiles = null;
+ for (int i = 1; i <= numSdpAttempts; i++) {
+ mEventLogger.setCurrentEvent(EventCode.GET_PROFILES_VIA_SDP);
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger,
+ "Get BR/EDR handover information via SDP #" + i)) {
+ supportedProfiles = getSupportedProfilesViaBluetoothClassic(device);
+ } catch (ExecutionException | InterruptedException | TimeoutException e) {
+ // Ignores and retries if needed.
+ }
+ if (supportedProfiles != null && supportedProfiles.length != 0) {
+ mEventLogger.logCurrentEventSucceeded();
+ break;
+ } else {
+ mEventLogger.logCurrentEventFailed(new TimeoutException());
+ Log.w(TAG, "SDP returned no UUIDs from " + maskBluetoothAddress(device.getAddress())
+ + ", assuming timeout (attempt " + i + " of " + numSdpAttempts + ").");
+ }
+ }
+ return (supportedProfiles == null) ? new short[0] : supportedProfiles;
+ }
+
+ private short[] getSupportedProfilesViaBluetoothClassic(BluetoothDevice device)
+ throws ExecutionException, InterruptedException, TimeoutException {
+ Log.i(TAG, "Getting supported profiles via SDP (Bluetooth Classic) for "
+ + maskBluetoothAddress(device.getAddress()));
+ try (DeviceIntentReceiver supportedProfilesReceiver =
+ DeviceIntentReceiver.oneShotReceiver(
+ mContext, mPreferences, device, BluetoothDevice.ACTION_UUID)) {
+ device.fetchUuidsWithSdp();
+ supportedProfilesReceiver.await(mPreferences.getSdpTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+ return getCachedUuids(device);
+ }
+
+ private static short[] getCachedUuids(BluetoothDevice device) {
+ ParcelUuid[] parcelUuids = device.getUuids();
+ Log.i(TAG, "Got supported UUIDs: " + Arrays.toString(parcelUuids));
+ if (parcelUuids == null) {
+ // The OS can return null.
+ parcelUuids = new ParcelUuid[0];
+ }
+
+ List<Short> shortUuids = new ArrayList<>(parcelUuids.length);
+ for (ParcelUuid parcelUuid : parcelUuids) {
+ UUID uuid = parcelUuid.getUuid();
+ if (BluetoothUuids.is16BitUuid(uuid)) {
+ shortUuids.add(get16BitUuid(uuid));
+ }
+ }
+ return Shorts.toArray(shortUuids);
+ }
+
+ private void callbackOnPaired() {
+ if (mPairedCallback != null) {
+ mPairedCallback.onPaired(mPublicAddress != null ? mPublicAddress : mBleAddress);
+ }
+ }
+
+ private void callbackOnGetAddress(String address) {
+ if (mOnGetBluetoothAddressCallback != null) {
+ mOnGetBluetoothAddressCallback.onGetBluetoothAddress(address);
+ }
+ }
+
+ private boolean validateBluetoothGattCharacteristic(
+ BluetoothGattConnection connection, UUID characteristicUUID) {
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Get service characteristic list")) {
+ List<BluetoothGattCharacteristic> serviceCharacteristicList =
+ connection.getService(FastPairService.ID).getCharacteristics();
+ for (BluetoothGattCharacteristic characteristic : serviceCharacteristicList) {
+ if (characteristicUUID.equals(characteristic.getUuid())) {
+ Log.i(TAG, "characteristic is exists, uuid = " + characteristicUUID);
+ return true;
+ }
+ }
+ } catch (BluetoothException e) {
+ Log.w(TAG, "Can't get service characteristic list.", e);
+ }
+ Log.i(TAG, "can't find characteristic, uuid = " + characteristicUUID);
+ return false;
+ }
+
+ // This method is only for testing to make test method block until get name response or time
+ // out.
+ /**
+ * Set name response countdown latch.
+ */
+ public void setNameResponseCountDownLatch(CountDownLatch countDownLatch) {
+ if (mDeviceNameReceiver != null) {
+ mDeviceNameReceiver.setCountDown(countDownLatch);
+ Log.v(TAG, "set up nameResponseCountDown");
+ }
+ }
+
+ private static int getBleState(android.bluetooth.BluetoothAdapter bluetoothAdapter) {
+ // Can't use the public isLeEnabled() API, because it returns false for
+ // STATE_BLE_TURNING_(ON|OFF). So if we assume false == STATE_OFF, that can be
+ // very wrong.
+ return getLeState(bluetoothAdapter);
+ }
+
+ private static int getLeState(android.bluetooth.BluetoothAdapter adapter) {
+ try {
+ return (Integer) Reflect.on(adapter).withMethod("getLeState").get();
+ } catch (ReflectionException e) {
+ Log.i(TAG, "Can't call getLeState", e);
+ }
+ return adapter.getState();
+ }
+
+ private static void disableBle(android.bluetooth.BluetoothAdapter adapter) {
+ try {
+ Reflect.on(adapter).withMethod("disableBLE").invoke();
+ } catch (ReflectionException e) {
+ Log.i(TAG, "Can't call disableBLE", e);
+ }
+ }
+
+ /**
+ * Handle the searching of Fast Pair history. Since there is only one public address using
+ * during Fast Pair connection, {@link #isInPairedHistory(String)} only needs to be called once,
+ * then the result is kept, and call {@link #getExistingAccountKey()} to get the result.
+ */
+ @VisibleForTesting
+ static final class FastPairHistoryFinder {
+
+ private @Nullable
+ byte[] mExistingAccountKey;
+ @Nullable
+ private final List<FastPairHistoryItem> mHistoryItems;
+
+ FastPairHistoryFinder(List<FastPairHistoryItem> historyItems) {
+ this.mHistoryItems = historyItems;
+ }
+
+ @WorkerThread
+ @VisibleForTesting
+ boolean isInPairedHistory(String publicAddress) {
+ if (mHistoryItems == null || mHistoryItems.isEmpty()) {
+ return false;
+ }
+ for (FastPairHistoryItem item : mHistoryItems) {
+ if (item.isMatched(BluetoothAddress.decode(publicAddress))) {
+ mExistingAccountKey = item.accountKey().toByteArray();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // This function should be called after isInPairedHistory(). Or it will just return null.
+ @WorkerThread
+ @VisibleForTesting
+ @Nullable
+ byte[] getExistingAccountKey() {
+ return mExistingAccountKey;
+ }
+ }
+
+ private static final class DeviceNameReceiver {
+
+ @GuardedBy("this")
+ private @Nullable
+ byte[] mEncryptedResponse;
+
+ @GuardedBy("this")
+ @Nullable
+ private String mDecryptedDeviceName;
+
+ @Nullable
+ private CountDownLatch mResponseCountDown;
+
+ DeviceNameReceiver(BluetoothGattConnection gattConnection) throws BluetoothException {
+ UUID characteristicUuid = NameCharacteristic.getId(gattConnection);
+ ChangeObserver observer =
+ gattConnection.enableNotification(FastPairService.ID, characteristicUuid);
+ observer.setListener(
+ (byte[] value) -> {
+ synchronized (DeviceNameReceiver.this) {
+ Log.i(TAG, "DeviceNameReceiver: device name response size = "
+ + value.length);
+ // We don't decrypt it here because we may not finish handshaking and
+ // the pairing
+ // secret is not available.
+ mEncryptedResponse = value;
+ }
+ // For testing to know we get the device name from provider.
+ if (mResponseCountDown != null) {
+ mResponseCountDown.countDown();
+ Log.v(TAG, "Finish nameResponseCountDown.");
+ }
+ });
+ }
+
+ void setCountDown(CountDownLatch countDownLatch) {
+ this.mResponseCountDown = countDownLatch;
+ }
+
+ synchronized @Nullable String getParsedResult(byte[] secret) {
+ if (mDecryptedDeviceName != null) {
+ return mDecryptedDeviceName;
+ }
+ if (mEncryptedResponse == null) {
+ Log.i(TAG, "DeviceNameReceiver: no device name sent from the Provider.");
+ return null;
+ }
+ try {
+ mDecryptedDeviceName = NamingEncoder.decodeNamingPacket(secret, mEncryptedResponse);
+ Log.i(TAG, "DeviceNameReceiver: decrypted provider's name from naming response, "
+ + "name = " + mDecryptedDeviceName);
+ } catch (GeneralSecurityException e) {
+ Log.w(TAG, "DeviceNameReceiver: fail to parse the NameCharacteristic from provider"
+ + ".", e);
+ return null;
+ }
+ return mDecryptedDeviceName;
+ }
+ }
+
static void checkFastPairSignal(
FastPairSignalChecker fastPairSignalChecker,
String currentAddress,
@@ -133,4 +2199,9 @@
throw new SignalRotatedException("Address rotated", newAddress, originalException);
}
}
+
+ @VisibleForTesting
+ public Preferences getPreferences() {
+ return mPreferences;
+ }
}