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