1 /*
2  * Copyright (C) 2015 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.tv.settings.accessories;
18 
19 import android.app.Fragment;
20 import android.bluetooth.BluetoothAdapter;
21 import android.bluetooth.BluetoothDevice;
22 import android.bluetooth.BluetoothGatt;
23 import android.bluetooth.BluetoothGattCallback;
24 import android.bluetooth.BluetoothGattCharacteristic;
25 import android.bluetooth.BluetoothGattService;
26 import android.content.BroadcastReceiver;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.content.IntentFilter;
30 import android.os.Bundle;
31 import android.os.Handler;
32 import android.text.TextUtils;
33 import android.util.Log;
34 
35 import androidx.annotation.DrawableRes;
36 import androidx.annotation.Keep;
37 import androidx.annotation.NonNull;
38 import androidx.leanback.app.GuidedStepFragment;
39 import androidx.leanback.widget.GuidanceStylist;
40 import androidx.leanback.widget.GuidedAction;
41 import androidx.preference.Preference;
42 import androidx.preference.PreferenceScreen;
43 
44 import com.android.internal.logging.nano.MetricsProto;
45 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
46 import com.android.tv.settings.R;
47 import com.android.tv.settings.SettingsPreferenceFragment;
48 
49 import java.util.List;
50 import java.util.Objects;
51 import java.util.Set;
52 import java.util.UUID;
53 
54 /**
55  * The screen in TV settings that let's users rename or unpair a bluetooth device.
56  */
57 @Keep
58 public class BluetoothAccessoryFragment extends SettingsPreferenceFragment {
59 
60     private static final boolean DEBUG = false;
61     private static final String TAG = "BluetoothAccessoryFrag";
62 
63     private static final UUID GATT_BATTERY_SERVICE_UUID =
64             UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb");
65     private static final UUID GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID =
66             UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
67 
68     private static final String KEY_CHANGE_NAME = "changeName";
69     private static final String KEY_UNPAIR = "unpair";
70     private static final String KEY_BATTERY = "battery";
71 
72     private static final String SAVE_STATE_UNPAIRING = "BluetoothAccessoryActivity.unpairing";
73 
74     private static final int UNPAIR_TIMEOUT = 5000;
75 
76     private static final String ARG_DEVICE = "device";
77     private static final String ARG_ACCESSORY_ADDRESS = "accessory_address";
78     private static final String ARG_ACCESSORY_NAME = "accessory_name";
79     private static final String ARG_ACCESSORY_ICON_ID = "accessory_icon_res";
80 
81     private BluetoothDevice mDevice;
82     private BluetoothGatt mDeviceGatt;
83     private String mDeviceAddress;
84     private String mDeviceName;
85     private @DrawableRes int mDeviceImgId;
86     private boolean mUnpairing;
87     private Preference mChangeNamePref;
88     private Preference mUnpairPref;
89     private Preference mBatteryPref;
90 
91     private final Handler mHandler = new Handler();
92     private Runnable mBailoutRunnable = new Runnable() {
93         @Override
94         public void run() {
95             if (isResumed() && !getFragmentManager().popBackStackImmediate()) {
96                 getActivity().onBackPressed();
97             }
98         }
99     };
100 
101     // Broadcast Receiver for Bluetooth related events
102     private BroadcastReceiver mBroadcastReceiver;
103 
newInstance(String deviceAddress, String deviceName, int deviceImgId)104     public static BluetoothAccessoryFragment newInstance(String deviceAddress, String deviceName,
105             int deviceImgId) {
106         final Bundle b = new Bundle(3);
107         prepareArgs(b, deviceAddress, deviceName, deviceImgId);
108         final BluetoothAccessoryFragment f = new BluetoothAccessoryFragment();
109         f.setArguments(b);
110         return f;
111     }
112 
prepareArgs(Bundle b, String deviceAddress, String deviceName, int deviceImgId)113     public static void prepareArgs(Bundle b, String deviceAddress, String deviceName,
114             int deviceImgId) {
115         b.putString(ARG_ACCESSORY_ADDRESS, deviceAddress);
116         b.putString(ARG_ACCESSORY_NAME, deviceName);
117         b.putInt(ARG_ACCESSORY_ICON_ID, deviceImgId);
118     }
119 
120     @Override
onCreate(Bundle savedInstanceState)121     public void onCreate(Bundle savedInstanceState) {
122         Bundle bundle = getArguments();
123         if (bundle != null) {
124             mDeviceAddress = bundle.getString(ARG_ACCESSORY_ADDRESS);
125             mDeviceName = bundle.getString(ARG_ACCESSORY_NAME);
126             mDeviceImgId = bundle.getInt(ARG_ACCESSORY_ICON_ID);
127         } else {
128             mDeviceName = getString(R.string.accessory_options);
129             mDeviceImgId = R.drawable.ic_qs_bluetooth_not_connected;
130         }
131 
132 
133         mUnpairing = savedInstanceState != null
134                 && savedInstanceState.getBoolean(SAVE_STATE_UNPAIRING);
135 
136         BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
137         if (btAdapter != null) {
138             final Set<BluetoothDevice> bondedDevices = btAdapter.getBondedDevices();
139             if (bondedDevices != null) {
140                 for (BluetoothDevice device : bondedDevices) {
141                     if (mDeviceAddress.equals(device.getAddress())) {
142                         mDevice = device;
143                         break;
144                     }
145                 }
146             }
147         }
148 
149         if (mDevice == null) {
150             navigateBack();
151         }
152 
153         super.onCreate(savedInstanceState);
154     }
155 
156     @Override
onStart()157     public void onStart() {
158         super.onStart();
159         if (mDevice != null &&
160                 (mDevice.getType() == BluetoothDevice.DEVICE_TYPE_LE ||
161                         mDevice.getType() == BluetoothDevice.DEVICE_TYPE_DUAL)) {
162             // Only LE devices support GATT
163             mDeviceGatt = mDevice.connectGatt(getActivity(), true, new GattBatteryCallbacks());
164         }
165         // Set a broadcast receiver to let us know when the device has been removed
166         final IntentFilter adapterIntentFilter = new IntentFilter();
167         adapterIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
168         mBroadcastReceiver = new UnpairReceiver(this, mDevice);
169         getActivity().registerReceiver(mBroadcastReceiver, adapterIntentFilter);
170         if (mDevice != null && mDevice.getBondState() == BluetoothDevice.BOND_NONE) {
171             navigateBack();
172         }
173     }
174 
175     @Override
onPause()176     public void onPause() {
177         super.onPause();
178         mHandler.removeCallbacks(mBailoutRunnable);
179     }
180 
181     @Override
onSaveInstanceState(@onNull Bundle savedInstanceState)182     public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
183         super.onSaveInstanceState(savedInstanceState);
184         savedInstanceState.putBoolean(SAVE_STATE_UNPAIRING, mUnpairing);
185     }
186 
187     @Override
onStop()188     public void onStop() {
189         super.onStop();
190         if (mDeviceGatt != null) {
191             mDeviceGatt.close();
192         }
193         getActivity().unregisterReceiver(mBroadcastReceiver);
194     }
195 
196     @Override
onCreatePreferences(Bundle savedInstanceState, String rootKey)197     public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
198         setPreferencesFromResource(R.xml.bluetooth_accessory, null);
199         final PreferenceScreen screen = getPreferenceScreen();
200         screen.setTitle(mDeviceName);
201 
202         mChangeNamePref = findPreference(KEY_CHANGE_NAME);
203         ChangeNameFragment.prepareArgs(mChangeNamePref.getExtras(), mDeviceName, mDeviceImgId);
204 
205         mUnpairPref = findPreference(KEY_UNPAIR);
206         updatePrefsForUnpairing();
207         UnpairConfirmFragment.prepareArgs(
208                 mUnpairPref.getExtras(), mDevice, mDeviceName, mDeviceImgId);
209 
210         mBatteryPref = findPreference(KEY_BATTERY);
211         mBatteryPref.setVisible(false);
212     }
213 
setUnpairing(boolean unpairing)214     public void setUnpairing(boolean unpairing) {
215         mUnpairing = unpairing;
216         updatePrefsForUnpairing();
217     }
218 
updatePrefsForUnpairing()219     private void updatePrefsForUnpairing() {
220         if (mUnpairing) {
221             mUnpairPref.setTitle(R.string.accessory_unpairing);
222             mUnpairPref.setEnabled(false);
223             mChangeNamePref.setEnabled(false);
224         } else {
225             mUnpairPref.setTitle(R.string.accessory_unpair);
226             mUnpairPref.setEnabled(true);
227             mChangeNamePref.setEnabled(true);
228         }
229     }
230 
navigateBack()231     private void navigateBack() {
232         // need to post this to avoid recursing in the fragment manager.
233         mHandler.removeCallbacks(mBailoutRunnable);
234         mHandler.post(mBailoutRunnable);
235     }
236 
renameDevice(String deviceName)237     private void renameDevice(String deviceName) {
238         mDeviceName = deviceName;
239         if (mDevice != null) {
240             mDevice.setAlias(deviceName);
241             getPreferenceScreen().setTitle(deviceName);
242             setTitle(deviceName);
243             ChangeNameFragment.prepareArgs(mChangeNamePref.getExtras(), mDeviceName, mDeviceImgId);
244             UnpairConfirmFragment.prepareArgs(
245                     mUnpairPref.getExtras(), mDevice, mDeviceName, mDeviceImgId);
246         }
247     }
248 
249     private class GattBatteryCallbacks extends BluetoothGattCallback {
250         @Override
onConnectionStateChange(BluetoothGatt gatt, int status, int newState)251         public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
252             if (DEBUG) {
253                 Log.d(TAG, "Connection status:" + status + " state:" + newState);
254             }
255             if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothGatt.STATE_CONNECTED) {
256                 gatt.discoverServices();
257             }
258         }
259 
260         @Override
onServicesDiscovered(BluetoothGatt gatt, int status)261         public void onServicesDiscovered(BluetoothGatt gatt, int status) {
262             if (status != BluetoothGatt.GATT_SUCCESS) {
263                 if (DEBUG) {
264                     Log.e(TAG, "Service discovery failure on " + gatt);
265                 }
266                 return;
267             }
268 
269             final BluetoothGattService battService = gatt.getService(GATT_BATTERY_SERVICE_UUID);
270             if (battService == null) {
271                 if (DEBUG) {
272                     Log.d(TAG, "No battery service");
273                 }
274                 return;
275             }
276 
277             final BluetoothGattCharacteristic battLevel =
278                     battService.getCharacteristic(GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID);
279             if (battLevel == null) {
280                 if (DEBUG) {
281                     Log.d(TAG, "No battery level");
282                 }
283                 return;
284             }
285 
286             gatt.readCharacteristic(battLevel);
287         }
288 
289         @Override
onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)290         public void onCharacteristicRead(BluetoothGatt gatt,
291                 BluetoothGattCharacteristic characteristic, int status) {
292             if (status != BluetoothGatt.GATT_SUCCESS) {
293                 if (DEBUG) {
294                     Log.e(TAG, "Read characteristic failure on " + gatt + " " + characteristic);
295                 }
296                 return;
297             }
298 
299             if (GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID.equals(characteristic.getUuid())) {
300                 final int batteryLevel =
301                         characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0);
302                 mHandler.post(new Runnable() {
303                     @Override
304                     public void run() {
305                         if (mBatteryPref != null && !mUnpairing) {
306                             mBatteryPref.setTitle(getString(R.string.accessory_battery,
307                                     batteryLevel));
308                             mBatteryPref.setVisible(true);
309                         }
310                     }
311                 });
312             }
313         }
314     }
315 
316     /**
317      * Fragment for changing the name of a bluetooth accessory
318      */
319     @Keep
320     public static class ChangeNameFragment extends GuidedStepFragment {
321 
322         private final MetricsFeatureProvider mMetricsFeatureProvider = new MetricsFeatureProvider();
323 
prepareArgs(@onNull Bundle args, String deviceName, @DrawableRes int deviceImgId)324         public static void prepareArgs(@NonNull Bundle args, String deviceName,
325                 @DrawableRes int deviceImgId) {
326             args.putString(ARG_ACCESSORY_NAME, deviceName);
327             args.putInt(ARG_ACCESSORY_ICON_ID, deviceImgId);
328         }
329 
330         @Override
onStart()331         public void onStart() {
332             super.onStart();
333             mMetricsFeatureProvider.action(getContext(),
334                     MetricsProto.MetricsEvent.ACTION_BLUETOOTH_RENAME);
335         }
336 
337         @NonNull
338         @Override
onCreateGuidance(Bundle savedInstanceState)339         public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) {
340             return new GuidanceStylist.Guidance(
341                     getString(R.string.accessory_change_name_title),
342                     null,
343                     getArguments().getString(ARG_ACCESSORY_NAME),
344                     getContext().getDrawable(getArguments().getInt(ARG_ACCESSORY_ICON_ID,
345                             R.drawable.ic_qs_bluetooth_not_connected))
346             );
347         }
348 
349         @Override
onCreateActions(@onNull List<GuidedAction> actions, Bundle savedInstanceState)350         public void onCreateActions(@NonNull List<GuidedAction> actions,
351                 Bundle savedInstanceState) {
352             final Context context = getContext();
353             actions.add(new GuidedAction.Builder(context)
354                     .title(getArguments().getString(ARG_ACCESSORY_NAME))
355                     .editable(true)
356                     .build());
357         }
358 
359         @Override
onGuidedActionEditedAndProceed(GuidedAction action)360         public long onGuidedActionEditedAndProceed(GuidedAction action) {
361             if (!TextUtils.equals(action.getTitle(),
362                     getArguments().getString(ARG_ACCESSORY_NAME))
363                     && TextUtils.isGraphic(action.getTitle())) {
364                 final BluetoothAccessoryFragment fragment =
365                         (BluetoothAccessoryFragment) getTargetFragment();
366                 fragment.renameDevice(action.getTitle().toString());
367                 getFragmentManager().popBackStack();
368             }
369             return GuidedAction.ACTION_ID_NEXT;
370         }
371     }
372 
373     private static class UnpairReceiver extends BroadcastReceiver {
374 
375         private final Fragment mFragment;
376         private final BluetoothDevice mDevice;
377 
UnpairReceiver(Fragment fragment, BluetoothDevice device)378         public UnpairReceiver(Fragment fragment, BluetoothDevice device) {
379             mFragment = fragment;
380             mDevice = device;
381         }
382 
383         @Override
onReceive(Context context, Intent intent)384         public void onReceive(Context context, Intent intent) {
385             final BluetoothDevice device = intent
386                     .getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
387             final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
388                     BluetoothDevice.BOND_NONE);
389             if (bondState == BluetoothDevice.BOND_NONE && Objects.equals(mDevice, device)) {
390                 // Device was removed, bail out of the fragment
391                 if (mFragment instanceof BluetoothAccessoryFragment) {
392                     ((BluetoothAccessoryFragment) mFragment).navigateBack();
393                 } else if (mFragment instanceof UnpairConfirmFragment) {
394                     ((UnpairConfirmFragment) mFragment).navigateBack();
395                 } else {
396                     throw new IllegalStateException(
397                             "UnpairReceiver attached to wrong fragment class");
398                 }
399             }
400         }
401     }
402 
403     public static class UnpairConfirmFragment extends GuidedStepFragment {
404 
405         private BluetoothDevice mDevice;
406         private BroadcastReceiver mBroadcastReceiver;
407         private final Handler mHandler = new Handler();
408         private final MetricsFeatureProvider mMetricsFeatureProvider =
409                 new MetricsFeatureProvider();
410 
411         private Runnable mBailoutRunnable = new Runnable() {
412             @Override
413             public void run() {
414                 if (isResumed() && !getFragmentManager().popBackStackImmediate()) {
415                     getActivity().onBackPressed();
416                 }
417             }
418         };
419 
420         private final Runnable mTimeoutRunnable = new Runnable() {
421             @Override
422             public void run() {
423                 navigateBack();
424             }
425         };
426 
prepareArgs(@onNull Bundle args, BluetoothDevice device, String deviceName, @DrawableRes int deviceImgId)427         public static void prepareArgs(@NonNull Bundle args, BluetoothDevice device,
428                 String deviceName, @DrawableRes int deviceImgId) {
429             args.putParcelable(ARG_DEVICE, device);
430             args.putString(ARG_ACCESSORY_NAME, deviceName);
431             args.putInt(ARG_ACCESSORY_ICON_ID, deviceImgId);
432         }
433 
434         @Override
onCreate(Bundle savedInstanceState)435         public void onCreate(Bundle savedInstanceState) {
436             mDevice = getArguments().getParcelable(ARG_DEVICE);
437             super.onCreate(savedInstanceState);
438         }
439 
440         @Override
onStart()441         public void onStart() {
442             super.onStart();
443             if (mDevice.getBondState() == BluetoothDevice.BOND_NONE) {
444                 navigateBack();
445             }
446             final IntentFilter adapterIntentFilter = new IntentFilter();
447             adapterIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
448             mBroadcastReceiver = new UnpairReceiver(this, mDevice);
449             getActivity().registerReceiver(mBroadcastReceiver, adapterIntentFilter);
450             mMetricsFeatureProvider.action(getContext(),
451                     MetricsProto.MetricsEvent.DIALOG_BLUETOOTH_PAIRED_DEVICE_FORGET);
452         }
453 
454         @Override
onStop()455         public void onStop() {
456             super.onStop();
457             getActivity().unregisterReceiver(mBroadcastReceiver);
458         }
459 
460         @Override
onDestroy()461         public void onDestroy() {
462             super.onDestroy();
463             mHandler.removeCallbacks(mTimeoutRunnable);
464             mHandler.removeCallbacks(mBailoutRunnable);
465         }
466 
467         @NonNull
468         @Override
onCreateGuidance(Bundle savedInstanceState)469         public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) {
470             return new GuidanceStylist.Guidance(
471                     getString(R.string.accessory_unpair),
472                     null,
473                     getArguments().getString(ARG_ACCESSORY_NAME),
474                     getContext().getDrawable(getArguments().getInt(ARG_ACCESSORY_ICON_ID,
475                             R.drawable.ic_qs_bluetooth_not_connected))
476             );
477         }
478 
479         @Override
onCreateActions(@onNull List<GuidedAction> actions, Bundle savedInstanceState)480         public void onCreateActions(@NonNull List<GuidedAction> actions,
481                 Bundle savedInstanceState) {
482             final Context context = getContext();
483             actions.add(new GuidedAction.Builder(context)
484                     .clickAction(GuidedAction.ACTION_ID_OK).build());
485             actions.add(new GuidedAction.Builder(context)
486                     .clickAction(GuidedAction.ACTION_ID_CANCEL).build());
487         }
488 
489         @Override
onGuidedActionClicked(GuidedAction action)490         public void onGuidedActionClicked(GuidedAction action) {
491             if (action.getId() == GuidedAction.ACTION_ID_OK) {
492                 unpairDevice();
493             } else if (action.getId() == GuidedAction.ACTION_ID_CANCEL) {
494                 getFragmentManager().popBackStack();
495             } else {
496                 super.onGuidedActionClicked(action);
497             }
498         }
499 
navigateBack()500         private void navigateBack() {
501             // need to post this to avoid recursing in the fragment manager.
502             mHandler.removeCallbacks(mBailoutRunnable);
503             mHandler.post(mBailoutRunnable);
504         }
505 
unpairDevice()506         private void unpairDevice() {
507             if (mDevice != null) {
508                 int state = mDevice.getBondState();
509 
510                 if (state == BluetoothDevice.BOND_BONDING) {
511                     mDevice.cancelBondProcess();
512                 }
513 
514                 if (state != BluetoothDevice.BOND_NONE) {
515                     ((BluetoothAccessoryFragment) getTargetFragment()).setUnpairing(true);
516                     // Set a timeout, just in case we don't receive the unpair notification we
517                     // use to finish the activity
518                     mHandler.postDelayed(mTimeoutRunnable, UNPAIR_TIMEOUT);
519                     final boolean successful = mDevice.removeBond();
520                     if (successful) {
521                         if (DEBUG) {
522                             Log.d(TAG, "Bluetooth device successfully unpaired.");
523                         }
524                     } else {
525                         Log.e(TAG, "Failed to unpair Bluetooth Device: " + mDevice.getName());
526                     }
527                 }
528             } else {
529                 Log.e(TAG, "Bluetooth device not found. Address = " + mDevice.getAddress());
530             }
531         }
532     }
533 
534     @Override
getMetricsCategory()535     public int getMetricsCategory() {
536         return MetricsProto.MetricsEvent.DIALOG_BLUETOOTH_PAIRED_DEVICE_PROFILE;
537     }
538 }
539