Add GattConnectionManager.
Test: unit test will be added later per b/202524672.
Bug: 200231384
Change-Id: I4d97f5f2e393c7e0a8c89fd286a70a89c9b95065
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
new file mode 100644
index 0000000..d5e2256
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/FastPairDualConnection.java
@@ -0,0 +1,136 @@
+/*
+ * 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 android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.proto.NearbyEventCodes.NearbyEvent.EventCode;
+
+import com.google.common.base.Ascii;
+
+/**
+ * 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):
+ *
+ * <pre>
+ * {@code
+ * Connect GATT
+ * A) Success -> Handshake
+ * B) Failure (3s timeout) -> Retry 2x -> end
+ *
+ * Handshake
+ * A) Generate a shared secret with the headset (either using anti-spoofing key or account key)
+ * 1) Account key is used directly as the key
+ * 2) Anti-spoofing key is used by combining out private key with the headset's public and
+ * sending our public to the headset to combine with their private to generate a shared
+ * key. Sending our public key to headset takes ~3s.
+ * B) Write an encrypted packet to the headset containing their BLE address for verification
+ * that both sides have the same key (headset decodes this packet and checks it against their
+ * own address) (~250ms).
+ * C) Receive a response from the headset containing their public address (~250ms).
+ *
+ * Discovery (for devices < Oreo)
+ * A) Success -> Create Bond
+ * B) Failure (10s timeout) -> Sleep 1s, Retry 3x -> end
+ *
+ * Connect to device
+ * A) If already bonded
+ * 1) Attempt directly connecting to supported profiles (A2DP, etc)
+ * a) Success -> Write Account Key
+ * b) Failure (15s timeout, usually fails within a ~2s) -> Remove bond (~1s) -> Create bond
+ * B) If not already bonded
+ * 1) Create bond
+ * a) Success -> Connect profile
+ * b) Failure (15s timeout) -> Retry 2x -> end
+ * 2) Connect profile
+ * a) Success -> Write account key
+ * b) Failure -> Retry -> end
+ *
+ * Write account key
+ * A) Callback that pairing succeeded
+ * B) Disconnect GATT
+ * C) Reconnect GATT for secure connection
+ * D) Write account key (~3s)
+ * }
+ * </pre>
+ *
+ * The performance profiling result by {@link TimingLogger}:
+ *
+ * <pre>
+ * FastPairDualConnection [Exclusive time] / [Total time] ([Timestamp])
+ * Connect GATT #1 3054ms (0)
+ * Handshake 32ms / 740ms (3054)
+ * Generate key via ECDH 10ms (3054)
+ * Add salt 1ms (3067)
+ * Encrypt request 3ms (3068)
+ * Write data to GATT 692ms (3097)
+ * Wait response from GATT 0ms (3789)
+ * Decrypt response 2ms (3789)
+ * Get BR/EDR handover information via SDP 1ms (3795)
+ * Pair device #1 6ms / 4887ms (3805)
+ * Create bond 3965ms / 4881ms (3809)
+ * Exchange passkey 587ms / 915ms (7124)
+ * Encrypt passkey 6ms (7694)
+ * Send passkey to remote 290ms (7700)
+ * Wait for remote passkey 0ms (7993)
+ * Decrypt passkey 18ms (7994)
+ * Confirm the pairing: true 14ms (8025)
+ * Close BondedReceiver 1ms (8688)
+ * Connect: A2DP 19ms / 370ms (8701)
+ * Wait connection 348ms / 349ms (8720)
+ * Close ConnectedReceiver 1ms (9068)
+ * Close profile: A2DP 2ms (9069)
+ * Write account key 2ms / 789ms (9163)
+ * Encrypt key 0ms (9164)
+ * Write key via GATT #1 777ms / 783ms (9164)
+ * Close GATT 6ms (9941)
+ * Start CloudSyncing 2ms (9947)
+ * Broadcast Validator 2ms (9949)
+ * FastPairDualConnection end, 9952ms
+ * </pre>
+ */
+public abstract class FastPairDualConnection extends FastPairConnection {
+
+ static void logRetrySuccessEvent(
+ EventCode eventCode,
+ @Nullable Exception recoverFromException,
+ EventLoggerWrapper eventLogger) {
+ if (recoverFromException == null) {
+ return;
+ }
+ eventLogger.setCurrentEvent(eventCode);
+ eventLogger.logCurrentEventFailed(recoverFromException);
+ }
+
+ static void checkFastPairSignal(
+ FastPairSignalChecker fastPairSignalChecker,
+ String currentAddress,
+ Exception originalException)
+ throws SignalLostException, SignalRotatedException {
+ String newAddress = fastPairSignalChecker.getValidAddressForModelId(currentAddress);
+ if (TextUtils.isEmpty(newAddress)) {
+ throw new SignalLostException("Signal lost", originalException);
+ } else if (!Ascii.equalsIgnoreCase(currentAddress, newAddress)) {
+ throw new SignalRotatedException("Address rotated", newAddress, originalException);
+ }
+ }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManager.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManager.java
new file mode 100644
index 0000000..88936c0
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/GattConnectionManager.java
@@ -0,0 +1,270 @@
+/*
+ * 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 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.FastPairDualConnection.logRetrySuccessEvent;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.util.Consumer;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.BluetoothTimeoutException;
+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.BluetoothGattHelper;
+import com.android.server.nearby.common.bluetooth.gatt.BluetoothGattHelper.ConnectionOptions;
+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.proto.FastPairEnums.FastPairEvent.ErrorCode;
+import com.android.server.nearby.proto.NearbyEventCodes.NearbyEvent.EventCode;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Manager for working with Gatt connections.
+ *
+ * <p>This helper class allows for opening and closing GATT connections to a provided address.
+ * Optionally, it can also support automatically reopening a connection in the case that it has been
+ * closed when it's next needed through {@link Preferences#getAutomaticallyReconnectGattWhenNeeded}.
+ */
+// TODO(b/202524672): Add class unit test.
+final class GattConnectionManager {
+
+ private static final String TAG = GattConnectionManager.class.getSimpleName();
+
+ private final Context mContext;
+ private final Preferences mPreferences;
+ private final EventLoggerWrapper mEventLogger;
+ private final BluetoothAdapter mBluetoothAdapter;
+ private final ToggleBluetoothTask mToggleBluetooth;
+ private final String mAddress;
+ private final TimingLogger mTimingLogger;
+ private final boolean mSetMtu;
+ @Nullable
+ private final FastPairConnection.FastPairSignalChecker mFastPairSignalChecker;
+ @Nullable
+ private BluetoothGattConnection mGattConnection;
+
+ GattConnectionManager(
+ Context context,
+ Preferences preferences,
+ EventLoggerWrapper eventLogger,
+ BluetoothAdapter bluetoothAdapter,
+ ToggleBluetoothTask toggleBluetooth,
+ String address,
+ TimingLogger timingLogger,
+ @Nullable FastPairConnection.FastPairSignalChecker fastPairSignalChecker,
+ boolean setMtu) {
+ this.mContext = context;
+ this.mPreferences = preferences;
+ this.mEventLogger = eventLogger;
+ this.mBluetoothAdapter = bluetoothAdapter;
+ this.mToggleBluetooth = toggleBluetooth;
+ this.mAddress = address;
+ this.mTimingLogger = timingLogger;
+ this.mFastPairSignalChecker = fastPairSignalChecker;
+ this.mSetMtu = setMtu;
+ }
+
+ /**
+ * Gets a gatt connection to address. If this connection does not exist, it creates one.
+ */
+ BluetoothGattConnection getConnection()
+ throws InterruptedException, ExecutionException, TimeoutException, BluetoothException {
+ if (mGattConnection == null) {
+ try {
+ mGattConnection =
+ connect(mAddress, /* checkSignalWhenFail= */ false,
+ /* rescueFromError= */ null);
+ } catch (SignalLostException | SignalRotatedException e) {
+ // Impossible to happen here because we didn't do signal check.
+ throw new ExecutionException("getConnection throws SignalLostException", e);
+ }
+ }
+ return mGattConnection;
+ }
+
+ BluetoothGattConnection getConnectionWithSignalLostCheck(
+ @Nullable Consumer<ErrorCode> rescueFromError)
+ throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+ SignalLostException, SignalRotatedException {
+ if (mGattConnection == null) {
+ mGattConnection = connect(mAddress, /* checkSignalWhenFail= */ true,
+ rescueFromError);
+ }
+ return mGattConnection;
+ }
+
+ /**
+ * Closes the gatt connection when it is open.
+ */
+ void closeConnection() throws BluetoothException {
+ if (mGattConnection != null) {
+ try (ScopedTiming scopedTiming = new ScopedTiming(mTimingLogger, "Close GATT")) {
+ mGattConnection.close();
+ mGattConnection = null;
+ }
+ }
+ }
+
+ private BluetoothGattConnection connect(
+ String address, boolean checkSignalWhenFail,
+ @Nullable Consumer<ErrorCode> rescueFromError)
+ throws InterruptedException, ExecutionException, TimeoutException, BluetoothException,
+ SignalLostException, SignalRotatedException {
+ int i = 1;
+ boolean isRecoverable = true;
+ long startElapsedRealtime = SystemClock.elapsedRealtime();
+ BluetoothException lastException = null;
+ mEventLogger.setCurrentEvent(EventCode.GATT_CONNECT);
+ while (isRecoverable) {
+ try (ScopedTiming scopedTiming =
+ new ScopedTiming(mTimingLogger, "Connect GATT #" + i)) {
+ Log.i(TAG, "Connecting to GATT server at " + maskBluetoothAddress(address));
+ BluetoothGattConnection connection =
+ new BluetoothGattHelper(mContext, mBluetoothAdapter)
+ .connect(
+ mBluetoothAdapter.getRemoteDevice(address),
+ getConnectionOptions(startElapsedRealtime));
+ connection.setOperationTimeout(
+ TimeUnit.SECONDS.toMillis(mPreferences.getGattOperationTimeoutSeconds()));
+ if (mPreferences.getAutomaticallyReconnectGattWhenNeeded()) {
+ connection.addCloseListener(
+ () -> {
+ Log.i(TAG, "Gatt connection with " + maskBluetoothAddress(address)
+ + " closed.");
+ mGattConnection = null;
+ });
+ }
+ mEventLogger.logCurrentEventSucceeded();
+ if (lastException != null) {
+ logRetrySuccessEvent(EventCode.RECOVER_BY_RETRY_GATT, lastException,
+ mEventLogger);
+ }
+ return connection;
+ } catch (BluetoothException e) {
+ lastException = e;
+
+ boolean ableToRetry;
+ if (mPreferences.getGattConnectRetryTimeoutMillis() > 0) {
+ ableToRetry =
+ (SystemClock.elapsedRealtime() - startElapsedRealtime)
+ < mPreferences.getGattConnectRetryTimeoutMillis();
+ Log.i(TAG, "Retry connecting GATT by timeout: " + ableToRetry);
+ } else {
+ ableToRetry = i < mPreferences.getNumAttempts();
+ }
+
+ if (mPreferences.getRetryGattConnectionAndSecretHandshake()) {
+ if (isNoRetryError(mPreferences, e)) {
+ ableToRetry = false;
+ }
+
+ if (ableToRetry) {
+ if (rescueFromError != null) {
+ rescueFromError.accept(
+ e instanceof BluetoothOperationTimeoutException
+ ? ErrorCode.SUCCESS_RETRY_GATT_TIMEOUT
+ : ErrorCode.SUCCESS_RETRY_GATT_ERROR);
+ }
+ if (mFastPairSignalChecker != null && checkSignalWhenFail) {
+ FastPairDualConnection
+ .checkFastPairSignal(mFastPairSignalChecker, address, e);
+ }
+ }
+ isRecoverable = ableToRetry;
+ if (ableToRetry && mPreferences.getPairingRetryDelayMs() > 0) {
+ SystemClock.sleep(mPreferences.getPairingRetryDelayMs());
+ }
+ } else {
+ isRecoverable =
+ ableToRetry
+ && (e instanceof BluetoothOperationTimeoutException
+ || e instanceof BluetoothTimeoutException
+ || (e instanceof BluetoothGattException
+ && ((BluetoothGattException) e).getGattErrorCode() == 133));
+ }
+ Log.w(TAG, "GATT connect attempt " + i + "of " + mPreferences.getNumAttempts()
+ + " failed, " + (isRecoverable ? "recovering" : "permanently"), e);
+ if (isRecoverable) {
+ // If we're going to retry, log failure here. If we throw, an upper level will
+ // log it.
+ mToggleBluetooth.toggleBluetooth();
+ i++;
+ mEventLogger.logCurrentEventFailed(e);
+ mEventLogger.setCurrentEvent(EventCode.GATT_CONNECT);
+ }
+ }
+ }
+ throw checkNotNull(lastException);
+ }
+
+ static boolean isNoRetryError(Preferences preferences, BluetoothException e) {
+ return e instanceof BluetoothGattException
+ && preferences
+ .getGattConnectionAndSecretHandshakeNoRetryGattError()
+ .contains(((BluetoothGattException) e).getGattErrorCode());
+ }
+
+ @VisibleForTesting
+ long getTimeoutMs(long spentTime) {
+ long timeoutInMs;
+ if (mPreferences.getRetryGattConnectionAndSecretHandshake()) {
+ timeoutInMs =
+ spentTime < mPreferences.getGattConnectShortTimeoutRetryMaxSpentTimeMs()
+ ? mPreferences.getGattConnectShortTimeoutMs()
+ : mPreferences.getGattConnectLongTimeoutMs();
+ } else {
+ timeoutInMs = TimeUnit.SECONDS.toMillis(mPreferences.getGattConnectionTimeoutSeconds());
+ }
+ return timeoutInMs;
+ }
+
+ private ConnectionOptions getConnectionOptions(long startElapsedRealtime) {
+ return createConnectionOptions(
+ mSetMtu,
+ getTimeoutMs(SystemClock.elapsedRealtime() - startElapsedRealtime));
+ }
+
+ public static ConnectionOptions createConnectionOptions(boolean setMtu, long timeoutInMs) {
+ ConnectionOptions.Builder builder = ConnectionOptions.builder();
+ if (setMtu) {
+ // There are 3 overhead bytes added to BLE packets.
+ builder.setMtu(
+ AES_BLOCK_LENGTH + EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH + 3);
+ }
+ builder.setConnectionTimeoutMillis(timeoutInMs);
+ return builder.build();
+ }
+
+ @VisibleForTesting
+ void setGattConnection(BluetoothGattConnection gattConnection) {
+ this.mGattConnection = gattConnection;
+ }
+}