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