/* * Copyright (C) 2023 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.settings.accessibility; import static android.app.Activity.RESULT_OK; import static android.bluetooth.BluetoothGatt.GATT_SUCCESS; import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCallback; import android.bluetooth.BluetoothManager; import android.bluetooth.BluetoothProfile; import android.bluetooth.BluetoothUuid; import android.bluetooth.le.BluetoothLeScanner; import android.bluetooth.le.ScanCallback; import android.bluetooth.le.ScanFilter; import android.bluetooth.le.ScanRecord; import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanSettings; import android.content.Context; import android.os.Bundle; import android.os.ParcelUuid; import android.os.SystemProperties; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.Preference; import com.android.settings.R; import com.android.settings.bluetooth.BluetoothDevicePreference; import com.android.settings.bluetooth.BluetoothProgressCategory; import com.android.settings.bluetooth.Utils; import com.android.settings.dashboard.RestrictedDashboardFragment; import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.bluetooth.BluetoothCallback; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; import com.android.settingslib.bluetooth.HearingAidInfo; import com.android.settingslib.bluetooth.HearingAidStatsLogUtils; import com.android.settingslib.bluetooth.LocalBluetoothManager; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * This fragment shows all scanned hearing devices through BLE scanning. Users can * pair them in this page. */ public class HearingDevicePairingFragment extends RestrictedDashboardFragment implements BluetoothCallback { private static final boolean DEBUG = true; private static final String TAG = "HearingDevicePairingFragment"; private static final String BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY = "persist.bluetooth.showdeviceswithoutnames"; private static final String KEY_AVAILABLE_HEARING_DEVICES = "available_hearing_devices"; LocalBluetoothManager mLocalManager; @Nullable BluetoothAdapter mBluetoothAdapter; @Nullable CachedBluetoothDeviceManager mCachedDeviceManager; private boolean mShowDevicesWithoutNames; @Nullable private BluetoothProgressCategory mAvailableHearingDeviceGroup; @Nullable BluetoothDevice mSelectedDevice; final List mSelectedDeviceList = new ArrayList<>(); final List mConnectingGattList = new ArrayList<>(); final Map mDevicePreferenceMap = new HashMap<>(); private List mLeScanFilters; public HearingDevicePairingFragment() { super(DISALLOW_CONFIG_BLUETOOTH); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mLocalManager = Utils.getLocalBtManager(getActivity()); if (mLocalManager == null) { Log.e(TAG, "Bluetooth is not supported on this device"); return; } mBluetoothAdapter = getSystemService(BluetoothManager.class).getAdapter(); mCachedDeviceManager = mLocalManager.getCachedDeviceManager(); mShowDevicesWithoutNames = SystemProperties.getBoolean( BLUETOOTH_SHOW_DEVICES_WITHOUT_NAMES_PROPERTY, false); initPreferencesFromPreferenceScreen(); initHearingDeviceLeScanFilters(); } @Override public void onAttach(Context context) { super.onAttach(context); use(ViewAllBluetoothDevicesPreferenceController.class).init(this); } @Override public void onStart() { super.onStart(); if (mLocalManager == null || mBluetoothAdapter == null || isUiRestricted()) { return; } mLocalManager.setForegroundActivity(getActivity()); mLocalManager.getEventManager().registerCallback(this); if (mBluetoothAdapter.isEnabled()) { startScanning(); } else { // Turn on bluetooth if it is disabled mBluetoothAdapter.enable(); } } @Override public void onStop() { super.onStop(); if (mLocalManager == null || isUiRestricted()) { return; } stopScanning(); removeAllDevices(); for (BluetoothGatt gatt: mConnectingGattList) { gatt.disconnect(); } mConnectingGattList.clear(); mLocalManager.setForegroundActivity(null); mLocalManager.getEventManager().unregisterCallback(this); } @Override public boolean onPreferenceTreeClick(Preference preference) { if (preference instanceof BluetoothDevicePreference) { stopScanning(); BluetoothDevicePreference devicePreference = (BluetoothDevicePreference) preference; mSelectedDevice = devicePreference.getCachedDevice().getDevice(); if (mSelectedDevice != null) { mSelectedDeviceList.add(mSelectedDevice); } devicePreference.onClicked(); return true; } return super.onPreferenceTreeClick(preference); } @Override public void onDeviceDeleted(@NonNull CachedBluetoothDevice cachedDevice) { removeDevice(cachedDevice); } @Override public void onBluetoothStateChanged(int bluetoothState) { switch (bluetoothState) { case BluetoothAdapter.STATE_ON: startScanning(); showBluetoothTurnedOnToast(); break; case BluetoothAdapter.STATE_OFF: finish(); break; } } @Override public void onDeviceBondStateChanged(@NonNull CachedBluetoothDevice cachedDevice, int bondState) { if (DEBUG) { Log.d(TAG, "onDeviceBondStateChanged: " + cachedDevice + ", state = " + bondState); } if (bondState == BluetoothDevice.BOND_BONDED) { // If one device is connected(bonded), then close this fragment. setResult(RESULT_OK); finish(); return; } else if (bondState == BluetoothDevice.BOND_BONDING) { // Set the bond entry where binding process starts for logging hearing aid device info final int pageId = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider() .getAttribution(getActivity()); final int bondEntry = AccessibilityStatsLogUtils.convertToHearingAidInfoBondEntry( pageId); HearingAidStatsLogUtils.setBondEntryForDevice(bondEntry, cachedDevice); } if (mSelectedDevice != null) { BluetoothDevice device = cachedDevice.getDevice(); if (mSelectedDevice.equals(device) && bondState == BluetoothDevice.BOND_NONE) { // If current selected device failed to bond, restart scanning startScanning(); } } } @Override public void onProfileConnectionStateChanged(@NonNull CachedBluetoothDevice cachedDevice, int state, int bluetoothProfile) { // This callback is used to handle the case that bonded device is connected in pairing list. // 1. If user selected multiple bonded devices in pairing list, after connected // finish this page. // 2. If the bonded devices auto connected in paring list, after connected it will be // removed from paring list. if (cachedDevice.isConnected()) { final BluetoothDevice device = cachedDevice.getDevice(); if (device != null && mSelectedDeviceList.contains(device)) { setResult(RESULT_OK); finish(); } else { removeDevice(cachedDevice); } } } @Override public int getMetricsCategory() { return SettingsEnums.HEARING_AID_PAIRING; } @Override protected int getPreferenceScreenResId() { return R.xml.hearing_device_pairing_fragment; } @Override protected String getLogTag() { return TAG; } void addDevice(CachedBluetoothDevice cachedDevice) { if (mBluetoothAdapter == null) { return; } // Do not create new preference while the list shows one of the state messages if (mBluetoothAdapter.getState() != BluetoothAdapter.STATE_ON) { return; } if (mDevicePreferenceMap.get(cachedDevice) != null) { return; } String key = cachedDevice.getDevice().getAddress(); BluetoothDevicePreference preference = (BluetoothDevicePreference) getCachedPreference(key); if (preference == null) { preference = new BluetoothDevicePreference(getPrefContext(), cachedDevice, mShowDevicesWithoutNames, BluetoothDevicePreference.SortType.TYPE_FIFO); preference.setKey(key); preference.hideSecondTarget(true); } if (mAvailableHearingDeviceGroup != null) { mAvailableHearingDeviceGroup.addPreference(preference); } mDevicePreferenceMap.put(cachedDevice, preference); if (DEBUG) { Log.d(TAG, "Add device. device: " + cachedDevice); } } void removeDevice(CachedBluetoothDevice cachedDevice) { if (DEBUG) { Log.d(TAG, "removeDevice: " + cachedDevice); } BluetoothDevicePreference preference = mDevicePreferenceMap.remove(cachedDevice); if (mAvailableHearingDeviceGroup != null && preference != null) { mAvailableHearingDeviceGroup.removePreference(preference); } } void startScanning() { if (mCachedDeviceManager != null) { mCachedDeviceManager.clearNonBondedDevices(); } removeAllDevices(); startLeScanning(); } void stopScanning() { stopLeScanning(); } private final ScanCallback mLeScanCallback = new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { handleLeScanResult(result); } @Override public void onBatchScanResults(List results) { for (ScanResult result: results) { handleLeScanResult(result); } } @Override public void onScanFailed(int errorCode) { Log.w(TAG, "BLE Scan failed with error code " + errorCode); } }; void handleLeScanResult(ScanResult result) { if (mCachedDeviceManager == null) { return; } final BluetoothDevice device = result.getDevice(); CachedBluetoothDevice cachedDevice = mCachedDeviceManager.findDevice(device); if (cachedDevice == null) { cachedDevice = mCachedDeviceManager.addDevice(device); } else if (cachedDevice.getBondState() == BluetoothDevice.BOND_BONDED) { if (DEBUG) { Log.d(TAG, "Skip this device, already bonded: " + cachedDevice); } return; } if (cachedDevice.getHearingAidInfo() == null) { if (DEBUG) { Log.d(TAG, "Set hearing aid info on device: " + cachedDevice); } cachedDevice.setHearingAidInfo(new HearingAidInfo.Builder().build()); } // No need to handle the device if the device is already in the list or discovering services if (mDevicePreferenceMap.get(cachedDevice) == null && mConnectingGattList.stream().noneMatch( gatt -> gatt.getDevice().equals(device))) { if (isAndroidCompatibleHearingAid(result)) { addDevice(cachedDevice); } else { discoverServices(cachedDevice); } } } void startLeScanning() { if (mBluetoothAdapter == null) { return; } if (DEBUG) { Log.v(TAG, "startLeScanning"); } final BluetoothLeScanner leScanner = mBluetoothAdapter.getBluetoothLeScanner(); if (leScanner == null) { Log.w(TAG, "LE scanner not found, cannot start LE scanning"); } else { final ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setLegacy(false) .build(); leScanner.startScan(mLeScanFilters, settings, mLeScanCallback); if (mAvailableHearingDeviceGroup != null) { mAvailableHearingDeviceGroup.setProgress(true); } } } void stopLeScanning() { if (mBluetoothAdapter == null) { return; } if (DEBUG) { Log.v(TAG, "stopLeScanning"); } final BluetoothLeScanner leScanner = mBluetoothAdapter.getBluetoothLeScanner(); if (leScanner != null) { leScanner.stopScan(mLeScanCallback); if (mAvailableHearingDeviceGroup != null) { mAvailableHearingDeviceGroup.setProgress(false); } } } private void removeAllDevices() { mDevicePreferenceMap.clear(); if (mAvailableHearingDeviceGroup != null) { mAvailableHearingDeviceGroup.removeAll(); } } void initPreferencesFromPreferenceScreen() { mAvailableHearingDeviceGroup = findPreference(KEY_AVAILABLE_HEARING_DEVICES); } private void initHearingDeviceLeScanFilters() { mLeScanFilters = new ArrayList<>(); // Filters for ASHA hearing aids mLeScanFilters.add( new ScanFilter.Builder().setServiceUuid(BluetoothUuid.HEARING_AID).build()); mLeScanFilters.add(new ScanFilter.Builder() .setServiceData(BluetoothUuid.HEARING_AID, new byte[0]).build()); // Filters for LE audio hearing aids mLeScanFilters.add(new ScanFilter.Builder().setServiceUuid(BluetoothUuid.HAS).build()); mLeScanFilters.add(new ScanFilter.Builder() .setServiceData(BluetoothUuid.HAS, new byte[0]).build()); // Filters for MFi hearing aids mLeScanFilters.add(new ScanFilter.Builder().setServiceUuid(BluetoothUuid.MFI_HAS).build()); mLeScanFilters.add(new ScanFilter.Builder() .setServiceData(BluetoothUuid.MFI_HAS, new byte[0]).build()); } boolean isAndroidCompatibleHearingAid(ScanResult scanResult) { ScanRecord scanRecord = scanResult.getScanRecord(); if (scanRecord == null) { if (DEBUG) { Log.d(TAG, "Scan record is null, not compatible with Android. device: " + scanResult.getDevice()); } return false; } List uuids = scanRecord.getServiceUuids(); if (uuids != null) { if (uuids.contains(BluetoothUuid.HEARING_AID) || uuids.contains(BluetoothUuid.HAS)) { if (DEBUG) { Log.d(TAG, "Scan record uuid matched, compatible with Android. device: " + scanResult.getDevice()); } return true; } } if (scanRecord.getServiceData(BluetoothUuid.HEARING_AID) != null || scanRecord.getServiceData(BluetoothUuid.HAS) != null) { if (DEBUG) { Log.d(TAG, "Scan record service data matched, compatible with Android. device: " + scanResult.getDevice()); } return true; } if (DEBUG) { Log.d(TAG, "Scan record mismatched, not compatible with Android. device: " + scanResult.getDevice()); } return false; } void discoverServices(CachedBluetoothDevice cachedDevice) { if (DEBUG) { Log.d(TAG, "connectGattToCheckCompatibility, device: " + cachedDevice); } BluetoothGatt gatt = cachedDevice.getDevice().connectGatt(getContext(), false, new BluetoothGattCallback() { @Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { super.onConnectionStateChange(gatt, status, newState); if (DEBUG) { Log.d(TAG, "onConnectionStateChange, status: " + status + ", newState: " + newState + ", device: " + cachedDevice); } if (status == GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices(); } else { gatt.disconnect(); mConnectingGattList.remove(gatt); } } @Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { super.onServicesDiscovered(gatt, status); if (DEBUG) { Log.d(TAG, "onServicesDiscovered, status: " + status + ", device: " + cachedDevice); } if (status == GATT_SUCCESS) { if (gatt.getService(BluetoothUuid.HEARING_AID.getUuid()) != null || gatt.getService(BluetoothUuid.HAS.getUuid()) != null) { if (DEBUG) { Log.d(TAG, "compatible with Android, device: " + cachedDevice); } addDevice(cachedDevice); } } else { gatt.disconnect(); mConnectingGattList.remove(gatt); } } }); mConnectingGattList.add(gatt); } void showBluetoothTurnedOnToast() { Toast.makeText(getContext(), R.string.connected_device_bluetooth_turned_on_toast, Toast.LENGTH_SHORT).show(); } }