/* * 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.car.bluetooth; import android.annotation.Nullable; import android.app.ActivityManager; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothManager; import android.bluetooth.BluetoothProfile; import android.bluetooth.BluetoothStatusCodes; import android.car.builtin.util.Slogf; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Handler; import android.os.ParcelUuid; import android.os.UserHandle; import android.util.Log; import android.util.SparseArray; import com.android.car.CarLog; import com.android.car.CarServiceUtils; import com.android.internal.annotations.VisibleForTesting; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * BluetoothConnectionRetryManager: manages retry attempts for failed connections. *

* {@link FirstConnectionTracker} tracks the first auto-connect immediately following bonding, * distinguished from other connection attempts. It automatically retries failed "first connects" * every {@link sRetryFirstConnectTimeoutMs} milliseconds for a maximum of {@link * MAX_RETRY_ATTEMPTS} attempts. It stops tracking a device if all expected profiles successfully * connect for the first time, or if the device unbonds, or if the Bluetooth stack is torn down. */ public final class BluetoothConnectionRetryManager { private static final String TAG = CarLog.tagFor(BluetoothConnectionRetryManager.class); private static final boolean DBG = Slogf.isLoggable(TAG, Log.DEBUG); private static final int MAX_RETRY_ATTEMPTS = 3; // NOTE: the value is not "final" - it is modified in the unit tests @VisibleForTesting static int sRetryFirstConnectTimeoutMs = 8000; private final Context mContext; @Nullable private Context mUserContext; private BluetoothAdapter mBluetoothAdapter; private final BluetoothBroadcastReceiver mBluetoothBroadcastReceiver; private final Handler mHandler = new Handler( CarServiceUtils.getHandlerThread(CarBluetoothService.THREAD_NAME).getLooper()); private static final int[] MANAGED_PROFILES = BluetoothUtils.getManagedProfilesIds(); private final FirstConnectionTracker mFirstConnectionTracker; /** * A BroadcastReceiver for the device we are managing. */ private class BluetoothBroadcastReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); BluetoothDevice device = null; if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) { device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR); mFirstConnectionTracker.handleDeviceBondStateChange(device, bondState); } else if (BluetoothUtils.isAProfileAction(action)) { device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); int profile = BluetoothUtils.getProfileFromConnectionAction(action); int toState = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED); int fromState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED); mFirstConnectionTracker.handleProfileConnectionStateChange(device, profile, toState, fromState); } } } /** * A helper class to track the first auto-connect immediately following bonding, distinguished * from other connection attempts. *

* This is done by inferring whether a device failed a connection attempt, and retrying the * connection if the device hasn't connected before since bonding. This does not persist across * Bluetooth sessions, e.g., if a device bonds and Bluetooth is restarted before the device * successfully connects on all supported profiles, {@code FirstConnectionTracker} will * *no longer* track the device as one that yet to connect. */ private final class FirstConnectionTracker { /** * A simple counter to track the number of retry attempts a profile has made on a device. * The counter instance also serves as a token to associate enqueued retry attempts to the * (device, profile). *

* It does not get decremented or reset to {@code 0}. Connection attempts not initated by * {@code FirstConnectionTracker} do not count, e.g., manual connections by the user, or * possibly other connection retry attempts that are not first-connection-after-pairing. */ private final class RetryTokenAndCounter { private int mRetryAttempts = 0; int getCount() { return mRetryAttempts; } int increment() { return ++mRetryAttempts; } } /** * Tracks devices that have bonded but whose profiles have not successfully connected * at least once during the current Bluetooth session. Tracked profiles are the only * ones that may attempt retries if they fail to connect. Note: does not persist across * restarts of Bluetooth. *

* Key: A remote device's BD_ADDR. A BD_ADDR is present if and only if it is being * tracked. A device starts getting tracked if it bonds during the current Bluetooth * session. A device is untracked when (1) all supported profiles on the device * successfully connect for the first time; or (2) the device unbonds beforehand. *

* Value: A {@link SparseArray} of {@link RetryTokenAndCounter}, a token for each supported * profile. A token is present in the array if and only if its profile is being tracked. A * profile starts getting tracked when the device starts getting tracked (i.e., the device * bonds). A profile is untracked when (1) the profile successfully connects for the first * time; or (2) the device is untracked beforehand (e.g., the device unbonds). */ private Map> mBondedYetToConnect = new HashMap<>(); // Only purpose is to help {@link isRetryPosted}. private static final int RETRY_MSG_WHAT = 0; void handleDeviceBondStateChange(BluetoothDevice device, int state) { if (DBG) { Slogf.d(TAG, "Bond state has changed [device: %s, state: %s]", device, BluetoothUtils.getBondStateName(state)); } if ((state == BluetoothDevice.BOND_BONDED) && !isDeviceBeingTracked(device)) { // an untracked device has bonded trackDevice(device); } else if ((state == BluetoothDevice.BOND_NONE) && isDeviceBeingTracked(device)) { // a tracked device has unbonded untrackDevice(device); } } void handleProfileConnectionStateChange(BluetoothDevice device, int profile, int toState, int fromState) { // We are interested in connection state for *tracked* (device, profile)'s only if (!isProfileBeingTracked(device, profile)) { return; } if (toState == BluetoothProfile.STATE_CONNECTED) { // tracked (device, profile) has successfully connected if (DBG) { Slogf.d(TAG, "%s has connected for the first time on %s.", device, BluetoothUtils.getProfileName(profile)); } untrackProfile(device, profile); } else if ((fromState == BluetoothProfile.STATE_CONNECTING) && ((toState == BluetoothProfile.STATE_DISCONNECTING) || (toState == BluetoothProfile.STATE_DISCONNECTED))) { // Proxy for detecting a failed connection, until we get callbacks with // status codes. Caveats: // * False positives, e.g., a disconnect attempt during connecting. // * PAN doesn't seem to follow these state transitions. if (DBG) { Slogf.d(TAG, "%s has failed to connect on %s.", device, BluetoothUtils.getProfileName(profile)); } RetryTokenAndCounter counter = mBondedYetToConnect.get(device.getAddress()) .get(profile); if ((counter.getCount() < MAX_RETRY_ATTEMPTS) && !isRetryPosted(device, profile)) { // Retry connection attempt. // Do not post a retry if there is already an outstanding retry posted. // This ensures retries are posted at least {@link // sRetryFirstConnectTimeoutMs} apart. int countForLogs = counter.increment(); mHandler.postDelayed(() -> { if (DBG) { Slogf.d(TAG, "[%s, %s] retry attempt (%s/%s)", device, BluetoothUtils.getProfileName(profile), countForLogs, MAX_RETRY_ATTEMPTS); } connect(device); }, /* token */ counter, sRetryFirstConnectTimeoutMs); // Only purpose is to help {@link isRetryPosted}. mHandler.sendMessage(mHandler.obtainMessage(RETRY_MSG_WHAT, /* token */ counter)); } } } /** * Adds {@code device} to {@link mBondedYetToConnect}. *

* Assumes device exists (i.e., {@code device != null}) and is not already tracked (i.e., * {@code mBondedYetToConnect.containsKey(device.getAddress()) == false}). */ private void trackDevice(BluetoothDevice device) { if (DBG) { Slogf.d(TAG, "Tracking %s, supported profiles:", device); // additional debug messages continued in for-loop below } List ourUuids = mBluetoothAdapter.getUuidsList(); SparseArray profileCounters = new SparseArray(MANAGED_PROFILES.length); for (int i = 0; i < MANAGED_PROFILES.length; i++) { int profileId = MANAGED_PROFILES[i]; if (BluetoothUtils.isProfileSupported(ourUuids, device, profileId)) { if (DBG) { // debug messaging continued from above Slogf.d(TAG, " %s", BluetoothUtils.getProfileName(profileId)); } profileCounters.put(profileId, new RetryTokenAndCounter()); } } mBondedYetToConnect.put(device.getAddress(), profileCounters); } /** * Removes {@code device} from {@link mBondedYetToConnect}. Also removes any pending retry * attempts. *

* Assumes device exists (i.e., {@code device != null}) and is being tracked * (i.e., {@code mBondedYetToConnect.containsKey(device.getAddress()) == true}). */ private void untrackDevice(BluetoothDevice device) { SparseArray profileTokens = mBondedYetToConnect.get(device.getAddress()); for (int i = 0; i < profileTokens.size(); i++) { mHandler.removeCallbacksAndMessages(profileTokens.valueAt(i)); } mBondedYetToConnect.remove(device.getAddress()); } private void untrackProfile(BluetoothDevice device, int profile) { RetryTokenAndCounter token = mBondedYetToConnect.get(device.getAddress()).get(profile); if (token == null) { Slogf.w(TAG, "Untracking profile, no token found for %s on device: %s", BluetoothUtils.getProfileName(profile), device); return; } mHandler.removeCallbacksAndMessages(token); mBondedYetToConnect.get(device.getAddress()).delete(profile); if (mBondedYetToConnect.get(device.getAddress()).size() == 0) { untrackDevice(device); } } /** * Returns {@code true} if {@code device} is being tracked, and {@code false} otherwise. *

* Assumes {@code device != null}. */ private boolean isDeviceBeingTracked(BluetoothDevice device) { return mBondedYetToConnect.containsKey(device.getAddress()); } private boolean isProfileBeingTracked(BluetoothDevice device, int profile) { SparseArray profileTokens = mBondedYetToConnect.get(device.getAddress()); if (profileTokens == null) { return false; } return profileTokens.contains(profile); } /** * Determine if retry attempts have been posted. */ boolean isRetryPosted(BluetoothDevice device, int profile) { if (!isProfileBeingTracked(device, profile)) { // An untracked (device, profile) should have had any pending callbacks and // messages removed if (DBG) { Slogf.d(TAG, "%s is no longer being tracked on device %s by the time" + " isRetryPosted was called.", BluetoothUtils.getProfileName(profile), device); } return false; } RetryTokenAndCounter token = mBondedYetToConnect.get(device.getAddress()).get(profile); return mHandler.hasMessages(RETRY_MSG_WHAT, token); } } /** * For unit testing purposes, to aid in verifying retry attempts were posted. */ @VisibleForTesting boolean isRetryPosted(BluetoothDevice device, int profile) { return mFirstConnectionTracker.isRetryPosted(device, profile); } /** * For unit testing purposes. */ @VisibleForTesting int getMaxRetriesFirstConnection() { return MAX_RETRY_ATTEMPTS; } /** * Creates an instance of {@link BluetoothConnectionRetryManager} that will manage * connection retries. * * @param context - {@link Context} of calling code. * @return A new instance of a {@link BluetoothConnectionRetryManager}, or {@code null} * on error. */ public static BluetoothConnectionRetryManager create(Context context) { try { return new BluetoothConnectionRetryManager(context); } catch (NullPointerException e) { return null; } } /** * Creates an instance of {@link BluetoothConnectionRetryManager} that will manage * connection retries. * * @param context - {@link Context} of calling code. * @return A new instance of a {@link BluetoothConnectionRetryManager}, or {@code null} * on error. */ private BluetoothConnectionRetryManager(Context context) { mContext = Objects.requireNonNull(context); BluetoothManager bluetoothManager = Objects.requireNonNull(mContext.getSystemService(BluetoothManager.class)); mBluetoothAdapter = Objects.requireNonNull(bluetoothManager.getAdapter()); mBluetoothBroadcastReceiver = new BluetoothBroadcastReceiver(); mFirstConnectionTracker = new FirstConnectionTracker(); } /** * Begin managing connection retries. */ public void init() { if (DBG) { Slogf.d(TAG, "Starting connection retry management, managed profiles:"); for (int i = 0; i < MANAGED_PROFILES.length; i++) { Slogf.d(TAG, " %s", BluetoothUtils.getProfileName(MANAGED_PROFILES[i])); } } IntentFilter filter = new IntentFilter(); filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); // TODO (201800664): Profile State Change actions are hidden. This is a work around for now filter.addAction(BluetoothUtils.A2DP_SINK_CONNECTION_STATE_CHANGED); filter.addAction(BluetoothUtils.A2DP_SOURCE_CONNECTION_STATE_CHANGED); filter.addAction(BluetoothUtils.HFP_CLIENT_CONNECTION_STATE_CHANGED); filter.addAction(BluetoothUtils.MAP_CLIENT_CONNECTION_STATE_CHANGED); filter.addAction(BluetoothUtils.PAN_CONNECTION_STATE_CHANGED); filter.addAction(BluetoothUtils.PBAP_CLIENT_CONNECTION_STATE_CHANGED); UserHandle currentUser = UserHandle.of(ActivityManager.getCurrentUser()); mUserContext = mContext.createContextAsUser(currentUser, /* flags= */ 0); mUserContext.registerReceiver(mBluetoothBroadcastReceiver, filter); } /** * Stop managing connection retries. Clean up local resources. */ public void release() { if (DBG) { Slogf.d(TAG, "Stopping connection retry management"); } if (mUserContext != null) { if (mBluetoothBroadcastReceiver != null) { mUserContext.unregisterReceiver(mBluetoothBroadcastReceiver); } else { Slogf.wtf(TAG, "mBluetoothBroadcastReceiver null during release()"); } mUserContext = null; } } /** * Connect a device. * * @param device - The device to connect * @return */ private int connect(BluetoothDevice device) { if (DBG) { Slogf.d(TAG, "Connecting %s", device); } if (device == null) { return BluetoothStatusCodes.ERROR_UNKNOWN; } return device.connect(); } }