1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.car.bluetooth; 18 19 import android.annotation.Nullable; 20 import android.app.ActivityManager; 21 import android.bluetooth.BluetoothAdapter; 22 import android.bluetooth.BluetoothDevice; 23 import android.bluetooth.BluetoothManager; 24 import android.bluetooth.BluetoothProfile; 25 import android.bluetooth.BluetoothStatusCodes; 26 import android.car.builtin.util.Slogf; 27 import android.content.BroadcastReceiver; 28 import android.content.Context; 29 import android.content.Intent; 30 import android.content.IntentFilter; 31 import android.os.Handler; 32 import android.os.ParcelUuid; 33 import android.os.UserHandle; 34 import android.util.Log; 35 import android.util.SparseArray; 36 37 import com.android.car.CarLog; 38 import com.android.car.CarServiceUtils; 39 import com.android.internal.annotations.VisibleForTesting; 40 41 import java.util.HashMap; 42 import java.util.List; 43 import java.util.Map; 44 import java.util.Objects; 45 46 /** 47 * BluetoothConnectionRetryManager: manages retry attempts for failed connections. 48 * <p> 49 * {@link FirstConnectionTracker} tracks the first auto-connect immediately following bonding, 50 * distinguished from other connection attempts. It automatically retries failed "first connects" 51 * every {@link sRetryFirstConnectTimeoutMs} milliseconds for a maximum of {@link 52 * MAX_RETRY_ATTEMPTS} attempts. It stops tracking a device if all expected profiles successfully 53 * connect for the first time, or if the device unbonds, or if the Bluetooth stack is torn down. 54 */ 55 public final class BluetoothConnectionRetryManager { 56 private static final String TAG = CarLog.tagFor(BluetoothConnectionRetryManager.class); 57 private static final boolean DBG = Slogf.isLoggable(TAG, Log.DEBUG); 58 59 private static final int MAX_RETRY_ATTEMPTS = 3; 60 // NOTE: the value is not "final" - it is modified in the unit tests 61 @VisibleForTesting 62 static int sRetryFirstConnectTimeoutMs = 8000; 63 64 private final Context mContext; 65 @Nullable 66 private Context mUserContext; 67 private BluetoothAdapter mBluetoothAdapter; 68 private final BluetoothBroadcastReceiver mBluetoothBroadcastReceiver; 69 private final Handler mHandler = new Handler( 70 CarServiceUtils.getHandlerThread(CarBluetoothService.THREAD_NAME).getLooper()); 71 72 private static final int[] MANAGED_PROFILES = BluetoothUtils.getManagedProfilesIds(); 73 74 private final FirstConnectionTracker mFirstConnectionTracker; 75 76 /** 77 * A BroadcastReceiver for the device we are managing. 78 */ 79 private class BluetoothBroadcastReceiver extends BroadcastReceiver { 80 @Override onReceive(Context context, Intent intent)81 public void onReceive(Context context, Intent intent) { 82 String action = intent.getAction(); 83 BluetoothDevice device = null; 84 85 if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) { 86 device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); 87 int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, 88 BluetoothDevice.ERROR); 89 90 mFirstConnectionTracker.handleDeviceBondStateChange(device, bondState); 91 } else if (BluetoothUtils.isAProfileAction(action)) { 92 device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); 93 int profile = BluetoothUtils.getProfileFromConnectionAction(action); 94 int toState = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, 95 BluetoothProfile.STATE_DISCONNECTED); 96 int fromState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, 97 BluetoothProfile.STATE_DISCONNECTED); 98 99 mFirstConnectionTracker.handleProfileConnectionStateChange(device, profile, 100 toState, fromState); 101 } 102 } 103 } 104 105 /** 106 * A helper class to track the first auto-connect immediately following bonding, distinguished 107 * from other connection attempts. 108 * <p> 109 * This is done by inferring whether a device failed a connection attempt, and retrying the 110 * connection if the device hasn't connected before since bonding. This does not persist across 111 * Bluetooth sessions, e.g., if a device bonds and Bluetooth is restarted before the device 112 * successfully connects on all supported profiles, {@code FirstConnectionTracker} will 113 * *no longer* track the device as one that yet to connect. 114 */ 115 private final class FirstConnectionTracker { 116 /** 117 * A simple counter to track the number of retry attempts a profile has made on a device. 118 * The counter instance also serves as a token to associate enqueued retry attempts to the 119 * (device, profile). 120 * <p> 121 * It does not get decremented or reset to {@code 0}. Connection attempts not initated by 122 * {@code FirstConnectionTracker} do not count, e.g., manual connections by the user, or 123 * possibly other connection retry attempts that are not first-connection-after-pairing. 124 */ 125 private final class RetryTokenAndCounter { 126 private int mRetryAttempts = 0; getCount()127 int getCount() { 128 return mRetryAttempts; 129 } increment()130 int increment() { 131 return ++mRetryAttempts; 132 } 133 } 134 135 /** 136 * Tracks devices that have bonded but whose profiles have not successfully connected 137 * at least once during the current Bluetooth session. Tracked profiles are the only 138 * ones that may attempt retries if they fail to connect. Note: does not persist across 139 * restarts of Bluetooth. 140 * <p> 141 * Key: A remote device's BD_ADDR. A BD_ADDR is present if and only if it is being 142 * tracked. A device starts getting tracked if it bonds during the current Bluetooth 143 * session. A device is untracked when (1) all supported profiles on the device 144 * successfully connect for the first time; or (2) the device unbonds beforehand. 145 * <p> 146 * Value: A {@link SparseArray} of {@link RetryTokenAndCounter}, a token for each supported 147 * profile. A token is present in the array if and only if its profile is being tracked. A 148 * profile starts getting tracked when the device starts getting tracked (i.e., the device 149 * bonds). A profile is untracked when (1) the profile successfully connects for the first 150 * time; or (2) the device is untracked beforehand (e.g., the device unbonds). 151 */ 152 private Map<String, SparseArray<RetryTokenAndCounter>> mBondedYetToConnect = 153 new HashMap<>(); 154 155 // Only purpose is to help {@link isRetryPosted}. 156 private static final int RETRY_MSG_WHAT = 0; 157 handleDeviceBondStateChange(BluetoothDevice device, int state)158 void handleDeviceBondStateChange(BluetoothDevice device, int state) { 159 if (DBG) { 160 Slogf.d(TAG, "Bond state has changed [device: %s, state: %s]", device, 161 BluetoothUtils.getBondStateName(state)); 162 } 163 if ((state == BluetoothDevice.BOND_BONDED) && !isDeviceBeingTracked(device)) { 164 // an untracked device has bonded 165 trackDevice(device); 166 } else if ((state == BluetoothDevice.BOND_NONE) && isDeviceBeingTracked(device)) { 167 // a tracked device has unbonded 168 untrackDevice(device); 169 } 170 } 171 handleProfileConnectionStateChange(BluetoothDevice device, int profile, int toState, int fromState)172 void handleProfileConnectionStateChange(BluetoothDevice device, int profile, int toState, 173 int fromState) { 174 // We are interested in connection state for *tracked* (device, profile)'s only 175 if (!isProfileBeingTracked(device, profile)) { 176 return; 177 } 178 if (toState == BluetoothProfile.STATE_CONNECTED) { 179 // tracked (device, profile) has successfully connected 180 if (DBG) { 181 Slogf.d(TAG, "%s has connected for the first time on %s.", device, 182 BluetoothUtils.getProfileName(profile)); 183 } 184 untrackProfile(device, profile); 185 } else if ((fromState == BluetoothProfile.STATE_CONNECTING) 186 && ((toState == BluetoothProfile.STATE_DISCONNECTING) || (toState 187 == BluetoothProfile.STATE_DISCONNECTED))) { 188 // Proxy for detecting a failed connection, until we get callbacks with 189 // status codes. Caveats: 190 // * False positives, e.g., a disconnect attempt during connecting. 191 // * PAN doesn't seem to follow these state transitions. 192 if (DBG) { 193 Slogf.d(TAG, "%s has failed to connect on %s.", device, 194 BluetoothUtils.getProfileName(profile)); 195 } 196 RetryTokenAndCounter counter = mBondedYetToConnect.get(device.getAddress()) 197 .get(profile); 198 if ((counter.getCount() < MAX_RETRY_ATTEMPTS) 199 && !isRetryPosted(device, profile)) { 200 // Retry connection attempt. 201 // Do not post a retry if there is already an outstanding retry posted. 202 // This ensures retries are posted at least {@link 203 // sRetryFirstConnectTimeoutMs} apart. 204 int countForLogs = counter.increment(); 205 mHandler.postDelayed(() -> { 206 if (DBG) { 207 Slogf.d(TAG, "[%s, %s] retry attempt (%s/%s)", device, 208 BluetoothUtils.getProfileName(profile), 209 countForLogs, MAX_RETRY_ATTEMPTS); 210 } 211 connect(device); 212 }, /* token */ counter, sRetryFirstConnectTimeoutMs); 213 // Only purpose is to help {@link isRetryPosted}. 214 mHandler.sendMessage(mHandler.obtainMessage(RETRY_MSG_WHAT, 215 /* token */ counter)); 216 } 217 } 218 } 219 220 /** 221 * Adds {@code device} to {@link mBondedYetToConnect}. 222 * <p> 223 * Assumes device exists (i.e., {@code device != null}) and is not already tracked (i.e., 224 * {@code mBondedYetToConnect.containsKey(device.getAddress()) == false}). 225 */ trackDevice(BluetoothDevice device)226 private void trackDevice(BluetoothDevice device) { 227 if (DBG) { 228 Slogf.d(TAG, "Tracking %s, supported profiles:", device); 229 // additional debug messages continued in for-loop below 230 } 231 List<ParcelUuid> ourUuids = mBluetoothAdapter.getUuidsList(); 232 SparseArray<RetryTokenAndCounter> profileCounters = 233 new SparseArray<RetryTokenAndCounter>(MANAGED_PROFILES.length); 234 for (int i = 0; i < MANAGED_PROFILES.length; i++) { 235 int profileId = MANAGED_PROFILES[i]; 236 if (BluetoothUtils.isProfileSupported(ourUuids, device, profileId)) { 237 if (DBG) { 238 // debug messaging continued from above 239 Slogf.d(TAG, " %s", BluetoothUtils.getProfileName(profileId)); 240 } 241 profileCounters.put(profileId, new RetryTokenAndCounter()); 242 } 243 } 244 mBondedYetToConnect.put(device.getAddress(), profileCounters); 245 } 246 247 /** 248 * Removes {@code device} from {@link mBondedYetToConnect}. Also removes any pending retry 249 * attempts. 250 * <p> 251 * Assumes device exists (i.e., {@code device != null}) and is being tracked 252 * (i.e., {@code mBondedYetToConnect.containsKey(device.getAddress()) == true}). 253 */ untrackDevice(BluetoothDevice device)254 private void untrackDevice(BluetoothDevice device) { 255 SparseArray<RetryTokenAndCounter> profileTokens = 256 mBondedYetToConnect.get(device.getAddress()); 257 for (int i = 0; i < profileTokens.size(); i++) { 258 mHandler.removeCallbacksAndMessages(profileTokens.valueAt(i)); 259 } 260 mBondedYetToConnect.remove(device.getAddress()); 261 } 262 untrackProfile(BluetoothDevice device, int profile)263 private void untrackProfile(BluetoothDevice device, int profile) { 264 RetryTokenAndCounter token = mBondedYetToConnect.get(device.getAddress()).get(profile); 265 if (token == null) { 266 Slogf.w(TAG, "Untracking profile, no token found for %s on device: %s", 267 BluetoothUtils.getProfileName(profile), device); 268 return; 269 } 270 mHandler.removeCallbacksAndMessages(token); 271 mBondedYetToConnect.get(device.getAddress()).delete(profile); 272 if (mBondedYetToConnect.get(device.getAddress()).size() == 0) { 273 untrackDevice(device); 274 } 275 } 276 277 /** 278 * Returns {@code true} if {@code device} is being tracked, and {@code false} otherwise. 279 * <p> 280 * Assumes {@code device != null}. 281 */ isDeviceBeingTracked(BluetoothDevice device)282 private boolean isDeviceBeingTracked(BluetoothDevice device) { 283 return mBondedYetToConnect.containsKey(device.getAddress()); 284 } 285 isProfileBeingTracked(BluetoothDevice device, int profile)286 private boolean isProfileBeingTracked(BluetoothDevice device, int profile) { 287 SparseArray<RetryTokenAndCounter> profileTokens = 288 mBondedYetToConnect.get(device.getAddress()); 289 if (profileTokens == null) { 290 return false; 291 } 292 return profileTokens.contains(profile); 293 } 294 295 /** 296 * Determine if retry attempts have been posted. 297 */ isRetryPosted(BluetoothDevice device, int profile)298 boolean isRetryPosted(BluetoothDevice device, int profile) { 299 if (!isProfileBeingTracked(device, profile)) { 300 // An untracked (device, profile) should have had any pending callbacks and 301 // messages removed 302 if (DBG) { 303 Slogf.d(TAG, "%s is no longer being tracked on device %s by the time" 304 + " isRetryPosted was called.", 305 BluetoothUtils.getProfileName(profile), device); 306 } 307 return false; 308 } 309 RetryTokenAndCounter token = mBondedYetToConnect.get(device.getAddress()).get(profile); 310 return mHandler.hasMessages(RETRY_MSG_WHAT, token); 311 } 312 } 313 314 /** 315 * For unit testing purposes, to aid in verifying retry attempts were posted. 316 */ 317 @VisibleForTesting isRetryPosted(BluetoothDevice device, int profile)318 boolean isRetryPosted(BluetoothDevice device, int profile) { 319 return mFirstConnectionTracker.isRetryPosted(device, profile); 320 } 321 322 /** 323 * For unit testing purposes. 324 */ 325 @VisibleForTesting getMaxRetriesFirstConnection()326 int getMaxRetriesFirstConnection() { 327 return MAX_RETRY_ATTEMPTS; 328 } 329 330 /** 331 * Creates an instance of {@link BluetoothConnectionRetryManager} that will manage 332 * connection retries. 333 * 334 * @param context - {@link Context} of calling code. 335 * @return A new instance of a {@link BluetoothConnectionRetryManager}, or {@code null} 336 * on error. 337 */ create(Context context)338 public static BluetoothConnectionRetryManager create(Context context) { 339 try { 340 return new BluetoothConnectionRetryManager(context); 341 } catch (NullPointerException e) { 342 return null; 343 } 344 } 345 346 /** 347 * Creates an instance of {@link BluetoothConnectionRetryManager} that will manage 348 * connection retries. 349 * 350 * @param context - {@link Context} of calling code. 351 * @return A new instance of a {@link BluetoothConnectionRetryManager}, or {@code null} 352 * on error. 353 */ BluetoothConnectionRetryManager(Context context)354 private BluetoothConnectionRetryManager(Context context) { 355 mContext = Objects.requireNonNull(context); 356 BluetoothManager bluetoothManager = 357 Objects.requireNonNull(mContext.getSystemService(BluetoothManager.class)); 358 mBluetoothAdapter = Objects.requireNonNull(bluetoothManager.getAdapter()); 359 mBluetoothBroadcastReceiver = new BluetoothBroadcastReceiver(); 360 mFirstConnectionTracker = new FirstConnectionTracker(); 361 } 362 363 /** 364 * Begin managing connection retries. 365 */ init()366 public void init() { 367 if (DBG) { 368 Slogf.d(TAG, "Starting connection retry management, managed profiles:"); 369 for (int i = 0; i < MANAGED_PROFILES.length; i++) { 370 Slogf.d(TAG, " %s", 371 BluetoothUtils.getProfileName(MANAGED_PROFILES[i])); 372 } 373 } 374 375 IntentFilter filter = new IntentFilter(); 376 filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); 377 // TODO (201800664): Profile State Change actions are hidden. This is a work around for now 378 filter.addAction(BluetoothUtils.A2DP_SINK_CONNECTION_STATE_CHANGED); 379 filter.addAction(BluetoothUtils.A2DP_SOURCE_CONNECTION_STATE_CHANGED); 380 filter.addAction(BluetoothUtils.HFP_CLIENT_CONNECTION_STATE_CHANGED); 381 filter.addAction(BluetoothUtils.MAP_CLIENT_CONNECTION_STATE_CHANGED); 382 filter.addAction(BluetoothUtils.PAN_CONNECTION_STATE_CHANGED); 383 filter.addAction(BluetoothUtils.PBAP_CLIENT_CONNECTION_STATE_CHANGED); 384 385 UserHandle currentUser = UserHandle.of(ActivityManager.getCurrentUser()); 386 mUserContext = mContext.createContextAsUser(currentUser, /* flags= */ 0); 387 388 mUserContext.registerReceiver(mBluetoothBroadcastReceiver, filter); 389 } 390 391 /** 392 * Stop managing connection retries. Clean up local resources. 393 */ release()394 public void release() { 395 if (DBG) { 396 Slogf.d(TAG, "Stopping connection retry management"); 397 } 398 399 if (mUserContext != null) { 400 if (mBluetoothBroadcastReceiver != null) { 401 mUserContext.unregisterReceiver(mBluetoothBroadcastReceiver); 402 } else { 403 Slogf.wtf(TAG, "mBluetoothBroadcastReceiver null during release()"); 404 } 405 mUserContext = null; 406 } 407 } 408 409 /** 410 * Connect a device. 411 * 412 * @param device - The device to connect 413 * @return 414 */ connect(BluetoothDevice device)415 private int connect(BluetoothDevice device) { 416 if (DBG) { 417 Slogf.d(TAG, "Connecting %s", device); 418 } 419 if (device == null) { 420 return BluetoothStatusCodes.ERROR_UNKNOWN; 421 } 422 return device.connect(); 423 } 424 } 425