Add AccountKeyGenerator, BluetoothClassicPairer, DeviceIntentReceiver,
SimpleBroadcastReceiver.
DeviceIntentReceiver and SimpleBroadcastReceiver will be tested by usage class.
BluetoothClassPairer can't be tested without a test framework. Has added a TODO.
// TODO(b/202524672): Add class unit test.
Test: unit test
Bug: 200231384
Change-Id: I97343ea932dd7a7f3563a990dbfd638fdf5025dc
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGenerator.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGenerator.java
new file mode 100644
index 0000000..28a9c33
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGenerator.java
@@ -0,0 +1,45 @@
+/*
+ * 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.generateKey;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * This is to generate account key with fast-pair style.
+ */
+public final class AccountKeyGenerator {
+
+ // Generate a key where the first byte is always defined as the type, 0x04. This maintains 15
+ // bytes of entropy in the key while also allowing providers to verify that they have received
+ // a properly formatted key and decrypted it correctly, minimizing the risk of replay attacks.
+
+ /**
+ * Creates account key.
+ */
+ public static byte[] createAccountKey() throws NoSuchAlgorithmException {
+ byte[] accountKey = generateKey();
+ accountKey[0] = AccountKeyCharacteristic.TYPE;
+ return accountKey;
+ }
+
+ private AccountKeyGenerator() {
+ }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairer.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairer.java
new file mode 100644
index 0000000..6c467d3
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairer.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair;
+
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDING;
+import static android.bluetooth.BluetoothDevice.BOND_NONE;
+import static android.bluetooth.BluetoothDevice.ERROR;
+import static android.bluetooth.BluetoothDevice.EXTRA_DEVICE;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.annotation.SuppressLint;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.WorkerThread;
+
+import com.google.common.base.Strings;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Pairs to Bluetooth classic devices with passkey confirmation.
+ */
+// TODO(b/202524672): Add class unit test.
+public class BluetoothClassicPairer {
+
+ private static final String TAG = BluetoothClassicPairer.class.getSimpleName();
+ /**
+ * Hidden, see {@link BluetoothDevice}.
+ */
+ private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
+
+ private final Context mContext;
+ private final BluetoothDevice mDevice;
+ private final Preferences mPreferences;
+ private final PasskeyConfirmationHandler mPasskeyConfirmationHandler;
+
+ public BluetoothClassicPairer(
+ Context context,
+ BluetoothDevice device,
+ Preferences preferences,
+ PasskeyConfirmationHandler passkeyConfirmationHandler) {
+ this.mContext = context;
+ this.mDevice = device;
+ this.mPreferences = preferences;
+ this.mPasskeyConfirmationHandler = passkeyConfirmationHandler;
+ }
+
+ /**
+ * Pairs with the device. Throws a {@link PairingException} if any error occurs.
+ */
+ @WorkerThread
+ public void pair() throws PairingException {
+ Log.i(TAG, "BluetoothClassicPairer, createBond with " + maskBluetoothAddress(mDevice)
+ + ", type=" + mDevice.getType());
+ try (BondedReceiver bondedReceiver = new BondedReceiver()) {
+ if (mDevice.createBond()) {
+ bondedReceiver.await(mPreferences.getCreateBondTimeoutSeconds(), SECONDS);
+ } else {
+ throw new PairingException(
+ "BluetoothClassicPairer, createBond got immediate error");
+ }
+ } catch (TimeoutException | InterruptedException | ExecutionException e) {
+ throw new PairingException("BluetoothClassicPairer, createBond failed", e);
+ }
+ }
+
+ protected boolean isPaired() {
+ return mDevice.getBondState() == BOND_BONDED;
+ }
+
+ /**
+ * Receiver that closes after bonding has completed.
+ */
+ private class BondedReceiver extends DeviceIntentReceiver {
+
+ private BondedReceiver() {
+ super(
+ mContext,
+ mPreferences,
+ mDevice,
+ BluetoothDevice.ACTION_PAIRING_REQUEST,
+ BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+ }
+
+ /**
+ * Called with ACTION_PAIRING_REQUEST and ACTION_BOND_STATE_CHANGED about the interesting
+ * device (see {@link DeviceIntentReceiver}).
+ *
+ * <p>The ACTION_PAIRING_REQUEST intent provides the passkey which will be sent to the
+ * {@link PasskeyConfirmationHandler} for showing the UI, and the ACTION_BOND_STATE_CHANGED
+ * will provide the result of the bonding.
+ */
+ @Override
+ protected void onReceiveDeviceIntent(Intent intent) {
+ String intentAction = intent.getAction();
+ BluetoothDevice remoteDevice = intent.getParcelableExtra(EXTRA_DEVICE);
+ if (Strings.isNullOrEmpty(intentAction)
+ || remoteDevice == null
+ || !remoteDevice.getAddress().equals(mDevice.getAddress())) {
+ Log.w(TAG,
+ "BluetoothClassicPairer, receives " + intentAction
+ + " from unexpected device " + maskBluetoothAddress(remoteDevice));
+ return;
+ }
+ switch (intentAction) {
+ case BluetoothDevice.ACTION_PAIRING_REQUEST:
+ handlePairingRequest(
+ remoteDevice,
+ intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, ERROR),
+ intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, ERROR));
+ break;
+ case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+ handleBondStateChanged(
+ intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, ERROR),
+ intent.getIntExtra(EXTRA_REASON, ERROR));
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void handlePairingRequest(BluetoothDevice device, int variant, int passkey) {
+ Log.i(TAG,
+ "BluetoothClassicPairer, pairing request, " + device + ", " + variant + ", "
+ + passkey);
+ // Prevent Bluetooth Settings from getting the pairing request and showing its own UI.
+ abortBroadcast();
+ mPasskeyConfirmationHandler.onPasskeyConfirmation(device, passkey);
+ }
+
+ private void handleBondStateChanged(int bondState, int reason) {
+ Log.i(TAG,
+ "BluetoothClassicPairer, bond state changed to " + bondState + ", reason="
+ + reason);
+ switch (bondState) {
+ case BOND_BONDING:
+ // Don't close!
+ return;
+ case BOND_BONDED:
+ close();
+ return;
+ case BOND_NONE:
+ default:
+ closeWithError(
+ new PairingException(
+ "BluetoothClassicPairer, createBond failed, reason:" + reason));
+ }
+ }
+ }
+
+ // Applies UsesPermission annotation will create circular dependency.
+ @SuppressLint("MissingPermission")
+ static void setPairingConfirmation(BluetoothDevice device, boolean confirm) {
+ Log.i(TAG, "BluetoothClassicPairer: setPairingConfirmation " + maskBluetoothAddress(device)
+ + ", confirm: " + confirm);
+ device.setPairingConfirmation(confirm);
+ }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/DeviceIntentReceiver.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/DeviceIntentReceiver.java
new file mode 100644
index 0000000..5bcf10a
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/DeviceIntentReceiver.java
@@ -0,0 +1,75 @@
+/*
+ * 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.BluetoothAddress.maskBluetoothAddress;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+/**
+ * Like {@link SimpleBroadcastReceiver}, but for intents about a certain {@link BluetoothDevice}.
+ */
+abstract class DeviceIntentReceiver extends SimpleBroadcastReceiver {
+
+ private static final String TAG = DeviceIntentReceiver.class.getSimpleName();
+
+ private final BluetoothDevice mDevice;
+
+ static DeviceIntentReceiver oneShotReceiver(
+ Context context, Preferences preferences, BluetoothDevice device, String... actions) {
+ return new DeviceIntentReceiver(context, preferences, device, actions) {
+ @Override
+ protected void onReceiveDeviceIntent(Intent intent) throws Exception {
+ close();
+ }
+ };
+ }
+
+ /**
+ * @param context The context to use to register / unregister the receiver.
+ * @param device The interesting device. We ignore intents about other devices.
+ * @param actions The actions to include in our intent filter.
+ */
+ protected DeviceIntentReceiver(
+ Context context, Preferences preferences, BluetoothDevice device, String... actions) {
+ super(context, preferences, actions);
+ this.mDevice = device;
+ }
+
+ /**
+ * Called with intents about the interesting device (see {@link #DeviceIntentReceiver}). Any
+ * exception thrown by this method will be delivered via {@link #await}.
+ */
+ protected abstract void onReceiveDeviceIntent(Intent intent) throws Exception;
+
+ // incompatible types in argument.
+ @Override
+ protected void onReceive(Intent intent) throws Exception {
+ BluetoothDevice intentDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ if (mDevice == null || mDevice.equals(intentDevice)) {
+ onReceiveDeviceIntent(intent);
+ } else {
+ Log.v(TAG,
+ "Ignoring intent for device=" + maskBluetoothAddress(intentDevice)
+ + "(expected "
+ + maskBluetoothAddress(mDevice) + ")");
+ }
+ }
+}
diff --git a/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SimpleBroadcastReceiver.java b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SimpleBroadcastReceiver.java
new file mode 100644
index 0000000..7f525a7
--- /dev/null
+++ b/nearby/service/java/com/android/server/nearby/common/bluetooth/fastpair/SimpleBroadcastReceiver.java
@@ -0,0 +1,148 @@
+/*
+ * 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.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.util.Arrays;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Like {@link BroadcastReceiver}, but:
+ *
+ * <ul>
+ * <li>Simpler to create and register, with a list of actions.
+ * <li>Implements AutoCloseable. If used as a resource in try-with-resources (available on
+ * KitKat+), unregisters itself automatically.
+ * <li>Lets you block waiting for your state transition with {@link #await}.
+ * </ul>
+ */
+// AutoCloseable only available on KitKat+.
+@TargetApi(VERSION_CODES.KITKAT)
+public abstract class SimpleBroadcastReceiver extends BroadcastReceiver implements AutoCloseable {
+
+ private static final String TAG = SimpleBroadcastReceiver.class.getSimpleName();
+
+ /**
+ * Creates a one shot receiver.
+ */
+ public static SimpleBroadcastReceiver oneShotReceiver(
+ Context context, Preferences preferences, String... actions) {
+ return new SimpleBroadcastReceiver(context, preferences, actions) {
+ @Override
+ protected void onReceive(Intent intent) {
+ close();
+ }
+ };
+ }
+
+ private final Context mContext;
+ private final SettableFuture<Void> mIsClosedFuture = SettableFuture.create();
+ private long mAwaitExtendSecond;
+
+ // Nullness checker complains about 'this' being @UnderInitialization
+ @SuppressWarnings("nullness")
+ public SimpleBroadcastReceiver(
+ Context context, Preferences preferences, @Nullable Handler handler,
+ String... actions) {
+ Log.v(TAG, this + " listening for actions " + Arrays.toString(actions));
+ this.mContext = context;
+ IntentFilter intentFilter = new IntentFilter();
+ if (preferences.getIncreaseIntentFilterPriority()) {
+ intentFilter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY);
+ }
+ for (String action : actions) {
+ intentFilter.addAction(action);
+ }
+ context.registerReceiver(this, intentFilter, /* broadcastPermission= */ null, handler);
+ }
+
+ public SimpleBroadcastReceiver(Context context, Preferences preferences, String... actions) {
+ this(context, preferences, /* handler= */ null, actions);
+ }
+
+ /**
+ * Any exception thrown by this method will be delivered via {@link #await}.
+ */
+ protected abstract void onReceive(Intent intent) throws Exception;
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.v(TAG, "Got intent with action= " + intent.getAction());
+ try {
+ onReceive(intent);
+ } catch (Exception e) {
+ closeWithError(e);
+ }
+ }
+
+ @Override
+ public void close() {
+ closeWithError(null);
+ }
+
+ void closeWithError(@Nullable Exception e) {
+ try {
+ mContext.unregisterReceiver(this);
+ } catch (IllegalArgumentException ignored) {
+ // Ignore. Happens if you unregister twice.
+ }
+ if (e == null) {
+ mIsClosedFuture.set(null);
+ } else {
+ mIsClosedFuture.setException(e);
+ }
+ }
+
+ /**
+ * Extends the awaiting time.
+ */
+ public void extendAwaitSecond(int awaitExtendSecond) {
+ this.mAwaitExtendSecond = awaitExtendSecond;
+ }
+
+ /**
+ * Blocks until this receiver has closed (i.e. the state transition that this receiver is
+ * interested in has completed). Throws an exception on any error.
+ */
+ public void await(long timeout, TimeUnit timeUnit)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ Log.v(TAG, this + " waiting on future for " + timeout + " " + timeUnit);
+ try {
+ mIsClosedFuture.get(timeout, timeUnit);
+ } catch (TimeoutException e) {
+ if (mAwaitExtendSecond <= 0) {
+ throw e;
+ }
+ Log.i(TAG, "Extend timeout for " + mAwaitExtendSecond + " seconds");
+ mIsClosedFuture.get(mAwaitExtendSecond, TimeUnit.SECONDS);
+ }
+ }
+}
diff --git a/nearby/tests/src/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGeneratorTest.java b/nearby/tests/src/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGeneratorTest.java
new file mode 100644
index 0000000..5084e7e
--- /dev/null
+++ b/nearby/tests/src/com/android/server/nearby/common/bluetooth/fastpair/AccountKeyGeneratorTest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 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 com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Unit tests for {@link AccountKeyGenerator}.
+ */
+@Presubmit
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class AccountKeyGeneratorTest {
+ @Test
+ public void createAccountKey() throws NoSuchAlgorithmException {
+ byte[] accountKey = AccountKeyGenerator.createAccountKey();
+
+ assertThat(accountKey).hasLength(16);
+ assertThat(accountKey[0]).isEqualTo(AccountKeyCharacteristic.TYPE);
+ }
+}