1 /* 2 * Copyright (C) 2023 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.settings.accessibility; 18 19 import static android.app.Activity.RESULT_OK; 20 import static android.bluetooth.BluetoothGatt.GATT_SUCCESS; 21 import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; 22 23 import android.app.settings.SettingsEnums; 24 import android.bluetooth.BluetoothAdapter; 25 import android.bluetooth.BluetoothDevice; 26 import android.bluetooth.BluetoothGatt; 27 import android.bluetooth.BluetoothGattCallback; 28 import android.bluetooth.BluetoothManager; 29 import android.bluetooth.BluetoothProfile; 30 import android.bluetooth.BluetoothUuid; 31 import android.bluetooth.le.BluetoothLeScanner; 32 import android.bluetooth.le.ScanCallback; 33 import android.bluetooth.le.ScanFilter; 34 import android.bluetooth.le.ScanRecord; 35 import android.bluetooth.le.ScanResult; 36 import android.bluetooth.le.ScanSettings; 37 import android.content.Context; 38 import android.os.Bundle; 39 import android.os.ParcelUuid; 40 import android.os.SystemProperties; 41 import android.util.Log; 42 import android.widget.Toast; 43 44 import androidx.annotation.NonNull; 45 import androidx.annotation.Nullable; 46 import androidx.preference.Preference; 47 48 import com.android.settings.R; 49 import com.android.settings.bluetooth.BluetoothDevicePreference; 50 import com.android.settings.bluetooth.BluetoothProgressCategory; 51 import com.android.settings.bluetooth.Utils; 52 import com.android.settings.dashboard.RestrictedDashboardFragment; 53 import com.android.settings.overlay.FeatureFactory; 54 import com.android.settingslib.bluetooth.BluetoothCallback; 55 import com.android.settingslib.bluetooth.CachedBluetoothDevice; 56 import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; 57 import com.android.settingslib.bluetooth.HearingAidInfo; 58 import com.android.settingslib.bluetooth.HearingAidStatsLogUtils; 59 import com.android.settingslib.bluetooth.LocalBluetoothManager; 60 61 import java.util.ArrayList; 62 import java.util.HashMap; 63 import java.util.List; 64 import java.util.Map; 65 66 /** 67 * This fragment shows all scanned hearing devices through BLE scanning. Users can 68 * pair them in this page. 69 */ 70 public class HearingDevicePairingFragment extends RestrictedDashboardFragment implements 71 BluetoothCallback { 72 73 private static final boolean DEBUG = true; 74 private static final String TAG = "HearingDevicePairingFragment"; 75 private static final String BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY = 76 "persist.bluetooth.showdeviceswithoutnames"; 77 private static final String KEY_AVAILABLE_HEARING_DEVICES = "available_hearing_devices"; 78 // Flags data type from CSS 1.3 Flags 79 private static final int BT_DISCOVERABLE_MASK = 0x03; 80 81 LocalBluetoothManager mLocalManager; 82 @Nullable 83 BluetoothAdapter mBluetoothAdapter; 84 @Nullable 85 CachedBluetoothDeviceManager mCachedDeviceManager; 86 87 private boolean mShowDevicesWithoutNames; 88 @Nullable 89 private BluetoothProgressCategory mAvailableHearingDeviceGroup; 90 91 @Nullable 92 BluetoothDevice mSelectedDevice; 93 final List<BluetoothDevice> mSelectedDeviceList = new ArrayList<>(); 94 final List<BluetoothGatt> mConnectingGattList = new ArrayList<>(); 95 final Map<CachedBluetoothDevice, BluetoothDevicePreference> mDevicePreferenceMap = 96 new HashMap<>(); 97 98 private List<ScanFilter> mLeScanFilters; 99 HearingDevicePairingFragment()100 public HearingDevicePairingFragment() { 101 super(DISALLOW_CONFIG_BLUETOOTH); 102 } 103 104 @Override onCreate(Bundle savedInstanceState)105 public void onCreate(Bundle savedInstanceState) { 106 super.onCreate(savedInstanceState); 107 108 mLocalManager = Utils.getLocalBtManager(getActivity()); 109 if (mLocalManager == null) { 110 Log.e(TAG, "Bluetooth is not supported on this device"); 111 return; 112 } 113 mBluetoothAdapter = getSystemService(BluetoothManager.class).getAdapter(); 114 mCachedDeviceManager = mLocalManager.getCachedDeviceManager(); 115 mShowDevicesWithoutNames = SystemProperties.getBoolean( 116 BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY, false); 117 118 initPreferencesFromPreferenceScreen(); 119 initHearingDeviceLeScanFilters(); 120 } 121 122 @Override onAttach(Context context)123 public void onAttach(Context context) { 124 super.onAttach(context); 125 use(ViewAllBluetoothDevicesPreferenceController.class).init(this); 126 } 127 128 @Override onStart()129 public void onStart() { 130 super.onStart(); 131 if (mLocalManager == null || mBluetoothAdapter == null || isUiRestricted()) { 132 return; 133 } 134 mLocalManager.setForegroundActivity(getActivity()); 135 mLocalManager.getEventManager().registerCallback(this); 136 if (mBluetoothAdapter.isEnabled()) { 137 startScanning(); 138 } else { 139 // Turn on bluetooth if it is disabled 140 mBluetoothAdapter.enable(); 141 } 142 } 143 144 @Override onStop()145 public void onStop() { 146 super.onStop(); 147 if (mLocalManager == null || isUiRestricted()) { 148 return; 149 } 150 stopScanning(); 151 removeAllDevices(); 152 for (BluetoothGatt gatt: mConnectingGattList) { 153 gatt.disconnect(); 154 } 155 mConnectingGattList.clear(); 156 mLocalManager.setForegroundActivity(null); 157 mLocalManager.getEventManager().unregisterCallback(this); 158 } 159 160 @Override onPreferenceTreeClick(Preference preference)161 public boolean onPreferenceTreeClick(Preference preference) { 162 if (preference instanceof BluetoothDevicePreference) { 163 stopScanning(); 164 BluetoothDevicePreference devicePreference = (BluetoothDevicePreference) preference; 165 mSelectedDevice = devicePreference.getCachedDevice().getDevice(); 166 if (mSelectedDevice != null) { 167 mSelectedDeviceList.add(mSelectedDevice); 168 } 169 devicePreference.onClicked(); 170 return true; 171 } 172 return super.onPreferenceTreeClick(preference); 173 } 174 175 @Override onDeviceDeleted(@onNull CachedBluetoothDevice cachedDevice)176 public void onDeviceDeleted(@NonNull CachedBluetoothDevice cachedDevice) { 177 removeDevice(cachedDevice); 178 } 179 180 @Override onBluetoothStateChanged(int bluetoothState)181 public void onBluetoothStateChanged(int bluetoothState) { 182 switch (bluetoothState) { 183 case BluetoothAdapter.STATE_ON: 184 startScanning(); 185 showBluetoothTurnedOnToast(); 186 break; 187 case BluetoothAdapter.STATE_OFF: 188 finish(); 189 break; 190 } 191 } 192 193 @Override onDeviceBondStateChanged(@onNull CachedBluetoothDevice cachedDevice, int bondState)194 public void onDeviceBondStateChanged(@NonNull CachedBluetoothDevice cachedDevice, 195 int bondState) { 196 if (DEBUG) { 197 Log.d(TAG, "onDeviceBondStateChanged: " + cachedDevice + ", state = " 198 + bondState); 199 } 200 if (bondState == BluetoothDevice.BOND_BONDED) { 201 // If one device is connected(bonded), then close this fragment. 202 setResult(RESULT_OK); 203 finish(); 204 return; 205 } else if (bondState == BluetoothDevice.BOND_BONDING) { 206 // Set the bond entry where binding process starts for logging hearing aid device info 207 final int pageId = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider() 208 .getAttribution(getActivity()); 209 final int bondEntry = AccessibilityStatsLogUtils.convertToHearingAidInfoBondEntry( 210 pageId); 211 HearingAidStatsLogUtils.setBondEntryForDevice(bondEntry, cachedDevice); 212 } 213 if (mSelectedDevice != null) { 214 BluetoothDevice device = cachedDevice.getDevice(); 215 if (mSelectedDevice.equals(device) && bondState == BluetoothDevice.BOND_NONE) { 216 // If current selected device failed to bond, restart scanning 217 startScanning(); 218 } 219 } 220 } 221 222 @Override onProfileConnectionStateChanged(@onNull CachedBluetoothDevice cachedDevice, int state, int bluetoothProfile)223 public void onProfileConnectionStateChanged(@NonNull CachedBluetoothDevice cachedDevice, 224 int state, int bluetoothProfile) { 225 // This callback is used to handle the case that bonded device is connected in pairing list. 226 // 1. If user selected multiple bonded devices in pairing list, after connected 227 // finish this page. 228 // 2. If the bonded devices auto connected in paring list, after connected it will be 229 // removed from paring list. 230 if (cachedDevice.isConnected()) { 231 final BluetoothDevice device = cachedDevice.getDevice(); 232 if (device != null && mSelectedDeviceList.contains(device)) { 233 setResult(RESULT_OK); 234 finish(); 235 } else { 236 removeDevice(cachedDevice); 237 } 238 } 239 } 240 241 @Override getMetricsCategory()242 public int getMetricsCategory() { 243 return SettingsEnums.HEARING_AID_PAIRING; 244 } 245 246 @Override getPreferenceScreenResId()247 protected int getPreferenceScreenResId() { 248 return R.xml.hearing_device_pairing_fragment; 249 } 250 251 252 @Override getLogTag()253 protected String getLogTag() { 254 return TAG; 255 } 256 addDevice(CachedBluetoothDevice cachedDevice)257 void addDevice(CachedBluetoothDevice cachedDevice) { 258 if (mBluetoothAdapter == null) { 259 return; 260 } 261 // Do not create new preference while the list shows one of the state messages 262 if (mBluetoothAdapter.getState() != BluetoothAdapter.STATE_ON) { 263 return; 264 } 265 if (mDevicePreferenceMap.get(cachedDevice) != null) { 266 return; 267 } 268 String key = cachedDevice.getDevice().getAddress(); 269 BluetoothDevicePreference preference = (BluetoothDevicePreference) getCachedPreference(key); 270 if (preference == null) { 271 preference = new BluetoothDevicePreference(getPrefContext(), cachedDevice, 272 mShowDevicesWithoutNames, BluetoothDevicePreference.SortType.TYPE_FIFO); 273 preference.setKey(key); 274 preference.hideSecondTarget(true); 275 } 276 if (mAvailableHearingDeviceGroup != null) { 277 mAvailableHearingDeviceGroup.addPreference(preference); 278 } 279 mDevicePreferenceMap.put(cachedDevice, preference); 280 if (DEBUG) { 281 Log.d(TAG, "Add device. device: " + cachedDevice); 282 } 283 } 284 removeDevice(CachedBluetoothDevice cachedDevice)285 void removeDevice(CachedBluetoothDevice cachedDevice) { 286 if (DEBUG) { 287 Log.d(TAG, "removeDevice: " + cachedDevice); 288 } 289 BluetoothDevicePreference preference = mDevicePreferenceMap.remove(cachedDevice); 290 if (mAvailableHearingDeviceGroup != null && preference != null) { 291 mAvailableHearingDeviceGroup.removePreference(preference); 292 } 293 } 294 startScanning()295 void startScanning() { 296 if (mCachedDeviceManager != null) { 297 mCachedDeviceManager.clearNonBondedDevices(); 298 } 299 removeAllDevices(); 300 startLeScanning(); 301 } 302 stopScanning()303 void stopScanning() { 304 stopLeScanning(); 305 } 306 307 private final ScanCallback mLeScanCallback = new ScanCallback() { 308 @Override 309 public void onScanResult(int callbackType, ScanResult result) { 310 handleLeScanResult(result); 311 } 312 313 @Override 314 public void onBatchScanResults(List<ScanResult> results) { 315 for (ScanResult result: results) { 316 handleLeScanResult(result); 317 } 318 } 319 320 @Override 321 public void onScanFailed(int errorCode) { 322 Log.w(TAG, "BLE Scan failed with error code " + errorCode); 323 } 324 }; 325 handleLeScanResult(ScanResult result)326 void handleLeScanResult(ScanResult result) { 327 if (mCachedDeviceManager == null || !isDeviceDiscoverable(result)) { 328 return; 329 } 330 final BluetoothDevice device = result.getDevice(); 331 CachedBluetoothDevice cachedDevice = mCachedDeviceManager.findDevice(device); 332 if (cachedDevice == null) { 333 cachedDevice = mCachedDeviceManager.addDevice(device); 334 } else if (cachedDevice.getBondState() == BluetoothDevice.BOND_BONDED) { 335 if (DEBUG) { 336 Log.d(TAG, "Skip this device, already bonded: " + cachedDevice); 337 } 338 return; 339 } 340 if (cachedDevice.getHearingAidInfo() == null) { 341 if (DEBUG) { 342 Log.d(TAG, "Set hearing aid info on device: " + cachedDevice); 343 } 344 cachedDevice.setHearingAidInfo(new HearingAidInfo.Builder().build()); 345 } 346 // No need to handle the device if the device is already in the list or discovering services 347 if (mDevicePreferenceMap.get(cachedDevice) == null 348 && mConnectingGattList.stream().noneMatch( 349 gatt -> gatt.getDevice().equals(device))) { 350 if (isAndroidCompatibleHearingAid(result)) { 351 addDevice(cachedDevice); 352 } else { 353 discoverServices(cachedDevice); 354 } 355 } 356 } 357 startLeScanning()358 void startLeScanning() { 359 if (mBluetoothAdapter == null) { 360 return; 361 } 362 if (DEBUG) { 363 Log.v(TAG, "startLeScanning"); 364 } 365 final BluetoothLeScanner leScanner = mBluetoothAdapter.getBluetoothLeScanner(); 366 if (leScanner == null) { 367 Log.w(TAG, "LE scanner not found, cannot start LE scanning"); 368 } else { 369 final ScanSettings settings = new ScanSettings.Builder() 370 .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) 371 .setLegacy(false) 372 .build(); 373 leScanner.startScan(mLeScanFilters, settings, mLeScanCallback); 374 if (mAvailableHearingDeviceGroup != null) { 375 mAvailableHearingDeviceGroup.setProgress(true); 376 } 377 } 378 } 379 stopLeScanning()380 void stopLeScanning() { 381 if (mBluetoothAdapter == null) { 382 return; 383 } 384 if (DEBUG) { 385 Log.v(TAG, "stopLeScanning"); 386 } 387 final BluetoothLeScanner leScanner = mBluetoothAdapter.getBluetoothLeScanner(); 388 if (leScanner != null) { 389 leScanner.stopScan(mLeScanCallback); 390 if (mAvailableHearingDeviceGroup != null) { 391 mAvailableHearingDeviceGroup.setProgress(false); 392 } 393 } 394 } 395 removeAllDevices()396 private void removeAllDevices() { 397 mDevicePreferenceMap.clear(); 398 if (mAvailableHearingDeviceGroup != null) { 399 mAvailableHearingDeviceGroup.removeAll(); 400 } 401 } 402 initPreferencesFromPreferenceScreen()403 void initPreferencesFromPreferenceScreen() { 404 mAvailableHearingDeviceGroup = findPreference(KEY_AVAILABLE_HEARING_DEVICES); 405 } 406 initHearingDeviceLeScanFilters()407 private void initHearingDeviceLeScanFilters() { 408 mLeScanFilters = new ArrayList<>(); 409 // Filters for ASHA hearing aids 410 mLeScanFilters.add( 411 new ScanFilter.Builder().setServiceUuid(BluetoothUuid.HEARING_AID).build()); 412 mLeScanFilters.add(new ScanFilter.Builder() 413 .setServiceData(BluetoothUuid.HEARING_AID, new byte[0]).build()); 414 // Filters for LE audio hearing aids 415 mLeScanFilters.add(new ScanFilter.Builder().setServiceUuid(BluetoothUuid.HAS).build()); 416 mLeScanFilters.add(new ScanFilter.Builder() 417 .setServiceData(BluetoothUuid.HAS, new byte[0]).build()); 418 // Filters for MFi hearing aids 419 mLeScanFilters.add(new ScanFilter.Builder().setServiceUuid(BluetoothUuid.MFI_HAS).build()); 420 mLeScanFilters.add(new ScanFilter.Builder() 421 .setServiceData(BluetoothUuid.MFI_HAS, new byte[0]).build()); 422 } 423 isAndroidCompatibleHearingAid(ScanResult scanResult)424 boolean isAndroidCompatibleHearingAid(ScanResult scanResult) { 425 ScanRecord scanRecord = scanResult.getScanRecord(); 426 if (scanRecord == null) { 427 if (DEBUG) { 428 Log.d(TAG, "Scan record is null, not compatible with Android. device: " 429 + scanResult.getDevice()); 430 } 431 return false; 432 } 433 List<ParcelUuid> uuids = scanRecord.getServiceUuids(); 434 if (uuids != null) { 435 if (uuids.contains(BluetoothUuid.HEARING_AID) || uuids.contains(BluetoothUuid.HAS)) { 436 if (DEBUG) { 437 Log.d(TAG, "Scan record uuid matched, compatible with Android. device: " 438 + scanResult.getDevice()); 439 } 440 return true; 441 } 442 } 443 if (scanRecord.getServiceData(BluetoothUuid.HEARING_AID) != null 444 || scanRecord.getServiceData(BluetoothUuid.HAS) != null) { 445 if (DEBUG) { 446 Log.d(TAG, "Scan record service data matched, compatible with Android. device: " 447 + scanResult.getDevice()); 448 } 449 return true; 450 } 451 if (DEBUG) { 452 Log.d(TAG, "Scan record mismatched, not compatible with Android. device: " 453 + scanResult.getDevice()); 454 } 455 return false; 456 } 457 discoverServices(CachedBluetoothDevice cachedDevice)458 void discoverServices(CachedBluetoothDevice cachedDevice) { 459 if (DEBUG) { 460 Log.d(TAG, "connectGattToCheckCompatibility, device: " + cachedDevice); 461 } 462 BluetoothGatt gatt = cachedDevice.getDevice().connectGatt(getContext(), false, 463 new BluetoothGattCallback() { 464 @Override 465 public void onConnectionStateChange(BluetoothGatt gatt, int status, 466 int newState) { 467 super.onConnectionStateChange(gatt, status, newState); 468 if (DEBUG) { 469 Log.d(TAG, "onConnectionStateChange, status: " + status + ", newState: " 470 + newState + ", device: " + cachedDevice); 471 } 472 if (status == GATT_SUCCESS 473 && newState == BluetoothProfile.STATE_CONNECTED) { 474 gatt.discoverServices(); 475 } else { 476 gatt.disconnect(); 477 mConnectingGattList.remove(gatt); 478 } 479 } 480 481 @Override 482 public void onServicesDiscovered(BluetoothGatt gatt, int status) { 483 super.onServicesDiscovered(gatt, status); 484 if (DEBUG) { 485 Log.d(TAG, "onServicesDiscovered, status: " + status + ", device: " 486 + cachedDevice); 487 } 488 if (status == GATT_SUCCESS) { 489 if (gatt.getService(BluetoothUuid.HEARING_AID.getUuid()) != null 490 || gatt.getService(BluetoothUuid.HAS.getUuid()) != null) { 491 if (DEBUG) { 492 Log.d(TAG, "compatible with Android, device: " 493 + cachedDevice); 494 } 495 addDevice(cachedDevice); 496 } 497 } else { 498 gatt.disconnect(); 499 mConnectingGattList.remove(gatt); 500 } 501 } 502 }); 503 mConnectingGattList.add(gatt); 504 } 505 showBluetoothTurnedOnToast()506 void showBluetoothTurnedOnToast() { 507 Toast.makeText(getContext(), R.string.connected_device_bluetooth_turned_on_toast, 508 Toast.LENGTH_SHORT).show(); 509 } 510 isDeviceDiscoverable(ScanResult result)511 boolean isDeviceDiscoverable(ScanResult result) { 512 final ScanRecord scanRecord = result.getScanRecord(); 513 if (scanRecord == null) { 514 return false; 515 } 516 final int flags = scanRecord.getAdvertiseFlags(); 517 return (flags & BT_DISCOVERABLE_MASK) != 0; 518 } 519 } 520