blob: 0b762f3b58968117a4bc4796f6d55c0f15c91cd5 [file] [log] [blame]
/*
* 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<BluetoothDevice> mSelectedDeviceList = new ArrayList<>();
final List<BluetoothGatt> mConnectingGattList = new ArrayList<>();
final Map<CachedBluetoothDevice, BluetoothDevicePreference> mDevicePreferenceMap =
new HashMap<>();
private List<ScanFilter> 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.getDevice() + ", 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.getDevice());
}
}
void removeDevice(CachedBluetoothDevice cachedDevice) {
if (DEBUG) {
Log.d(TAG, "removeDevice: " + cachedDevice.getDevice());
}
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<ScanResult> 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.getDevice());
}
return;
}
if (cachedDevice.getHearingAidInfo() == null) {
if (DEBUG) {
Log.d(TAG, "Set hearing aid info on device: " + cachedDevice.getDevice());
}
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<ParcelUuid> 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.getDevice());
}
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.getDevice());
}
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.getDevice());
}
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.getDevice());
}
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();
}
}