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