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.isConnected() &&
159                 (mDevice.getType() == BluetoothDevice.DEVICE_TYPE_LE ||
160                         mDevice.getType() == BluetoothDevice.DEVICE_TYPE_DUAL)) {
161             // Only LE devices support GATT
162             mDeviceGatt = mDevice.connectGatt(getActivity(), true, new GattBatteryCallbacks());
163         }
164         // Set a broadcast receiver to let us know when the device has been removed
165         final IntentFilter adapterIntentFilter = new IntentFilter();
166         adapterIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
167         mBroadcastReceiver = new UnpairReceiver(this, mDevice);
168         getActivity().registerReceiver(mBroadcastReceiver, adapterIntentFilter);
169         if (mDevice != null && mDevice.getBondState() == BluetoothDevice.BOND_NONE) {
170             navigateBack();
171         }
172     }
173 
174     @Override
onPause()175     public void onPause() {
176         super.onPause();
177         mHandler.removeCallbacks(mBailoutRunnable);
178     }
179 
180     @Override
onSaveInstanceState(@onNull Bundle savedInstanceState)181     public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
182         super.onSaveInstanceState(savedInstanceState);
183         savedInstanceState.putBoolean(SAVE_STATE_UNPAIRING, mUnpairing);
184     }
185 
186     @Override
onStop()187     public void onStop() {
188         super.onStop();
189         if (mDeviceGatt != null) {
190             mDeviceGatt.close();
191         }
192         getActivity().unregisterReceiver(mBroadcastReceiver);
193     }
194 
195     @Override
onCreatePreferences(Bundle savedInstanceState, String rootKey)196     public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
197         setPreferencesFromResource(R.xml.bluetooth_accessory, null);
198         final PreferenceScreen screen = getPreferenceScreen();
199         screen.setTitle(mDeviceName);
200 
201         mChangeNamePref = findPreference(KEY_CHANGE_NAME);
202         ChangeNameFragment.prepareArgs(mChangeNamePref.getExtras(), mDeviceName, mDeviceImgId);
203 
204         mUnpairPref = findPreference(KEY_UNPAIR);
205         updatePrefsForUnpairing();
206         UnpairConfirmFragment.prepareArgs(
207                 mUnpairPref.getExtras(), mDevice, mDeviceName, mDeviceImgId);
208 
209         mBatteryPref = findPreference(KEY_BATTERY);
210         mBatteryPref.setVisible(false);
211     }
212 
setUnpairing(boolean unpairing)213     public void setUnpairing(boolean unpairing) {
214         mUnpairing = unpairing;
215         updatePrefsForUnpairing();
216     }
217 
updatePrefsForUnpairing()218     private void updatePrefsForUnpairing() {
219         if (mUnpairing) {
220             mUnpairPref.setTitle(R.string.accessory_unpairing);
221             mUnpairPref.setEnabled(false);
222             mChangeNamePref.setEnabled(false);
223         } else {
224             mUnpairPref.setTitle(R.string.accessory_unpair);
225             mUnpairPref.setEnabled(true);
226             mChangeNamePref.setEnabled(true);
227         }
228     }
229 
navigateBack()230     private void navigateBack() {
231         // need to post this to avoid recursing in the fragment manager.
232         mHandler.removeCallbacks(mBailoutRunnable);
233         mHandler.post(mBailoutRunnable);
234     }
235 
renameDevice(String deviceName)236     private void renameDevice(String deviceName) {
237         mDeviceName = deviceName;
238         if (mDevice != null) {
239             mDevice.setAlias(deviceName);
240             getPreferenceScreen().setTitle(deviceName);
241             setTitle(deviceName);
242             ChangeNameFragment.prepareArgs(mChangeNamePref.getExtras(), mDeviceName, mDeviceImgId);
243             UnpairConfirmFragment.prepareArgs(
244                     mUnpairPref.getExtras(), mDevice, mDeviceName, mDeviceImgId);
245         }
246     }
247 
248     private class GattBatteryCallbacks extends BluetoothGattCallback {
249         @Override
onConnectionStateChange(BluetoothGatt gatt, int status, int newState)250         public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
251             if (DEBUG) {
252                 Log.d(TAG, "Connection status:" + status + " state:" + newState);
253             }
254             if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothGatt.STATE_CONNECTED) {
255                 gatt.discoverServices();
256             }
257         }
258 
259         @Override
onServicesDiscovered(BluetoothGatt gatt, int status)260         public void onServicesDiscovered(BluetoothGatt gatt, int status) {
261             if (status != BluetoothGatt.GATT_SUCCESS) {
262                 if (DEBUG) {
263                     Log.e(TAG, "Service discovery failure on " + gatt);
264                 }
265                 return;
266             }
267 
268             final BluetoothGattService battService = gatt.getService(GATT_BATTERY_SERVICE_UUID);
269             if (battService == null) {
270                 if (DEBUG) {
271                     Log.d(TAG, "No battery service");
272                 }
273                 return;
274             }
275 
276             final BluetoothGattCharacteristic battLevel =
277                     battService.getCharacteristic(GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID);
278             if (battLevel == null) {
279                 if (DEBUG) {
280                     Log.d(TAG, "No battery level");
281                 }
282                 return;
283             }
284 
285             gatt.readCharacteristic(battLevel);
286         }
287 
288         @Override
onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status)289         public void onCharacteristicRead(BluetoothGatt gatt,
290                 BluetoothGattCharacteristic characteristic, int status) {
291             if (status != BluetoothGatt.GATT_SUCCESS) {
292                 if (DEBUG) {
293                     Log.e(TAG, "Read characteristic failure on " + gatt + " " + characteristic);
294                 }
295                 return;
296             }
297 
298             if (GATT_BATTERY_LEVEL_CHARACTERISTIC_UUID.equals(characteristic.getUuid())) {
299                 final int batteryLevel =
300                         characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0);
301                 mHandler.post(new Runnable() {
302                     @Override
303                     public void run() {
304                         if (mBatteryPref != null && !mUnpairing) {
305                             mBatteryPref.setTitle(getString(R.string.accessory_battery,
306                                     batteryLevel));
307                             mBatteryPref.setVisible(true);
308                         }
309                     }
310                 });
311             }
312         }
313     }
314 
315     /**
316      * Fragment for changing the name of a bluetooth accessory
317      */
318     @Keep
319     public static class ChangeNameFragment extends GuidedStepSupportFragment {
320 
prepareArgs(@onNull Bundle args, String deviceName, @DrawableRes int deviceImgId)321         public static void prepareArgs(@NonNull Bundle args, String deviceName,
322                 @DrawableRes int deviceImgId) {
323             args.putString(ARG_ACCESSORY_NAME, deviceName);
324             args.putInt(ARG_ACCESSORY_ICON_ID, deviceImgId);
325         }
326 
327         @Override
onStart()328         public void onStart() {
329             super.onStart();
330         }
331 
332         @NonNull
333         @Override
onCreateGuidance(Bundle savedInstanceState)334         public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) {
335             return new GuidanceStylist.Guidance(
336                     getString(R.string.accessory_change_name_title),
337                     null,
338                     getArguments().getString(ARG_ACCESSORY_NAME),
339                     getContext().getDrawable(getArguments().getInt(ARG_ACCESSORY_ICON_ID,
340                             R.drawable.ic_qs_bluetooth_not_connected))
341             );
342         }
343 
344         @Override
onCreateActions(@onNull List<GuidedAction> actions, Bundle savedInstanceState)345         public void onCreateActions(@NonNull List<GuidedAction> actions,
346                 Bundle savedInstanceState) {
347             final Context context = getContext();
348             actions.add(new GuidedAction.Builder(context)
349                     .title(getArguments().getString(ARG_ACCESSORY_NAME))
350                     .editable(true)
351                     .build());
352         }
353 
354         @Override
onGuidedActionEditedAndProceed(GuidedAction action)355         public long onGuidedActionEditedAndProceed(GuidedAction action) {
356             if (!TextUtils.equals(action.getTitle(),
357                     getArguments().getString(ARG_ACCESSORY_NAME))
358                     && TextUtils.isGraphic(action.getTitle())) {
359                 final BluetoothAccessoryFragment fragment =
360                         (BluetoothAccessoryFragment) getTargetFragment();
361                 fragment.renameDevice(action.getTitle().toString());
362                 getFragmentManager().popBackStack();
363             }
364             return GuidedAction.ACTION_ID_NEXT;
365         }
366     }
367 
368     private static class UnpairReceiver extends BroadcastReceiver {
369 
370         private final Fragment mFragment;
371         private final BluetoothDevice mDevice;
372 
UnpairReceiver(Fragment fragment, BluetoothDevice device)373         public UnpairReceiver(Fragment fragment, BluetoothDevice device) {
374             mFragment = fragment;
375             mDevice = device;
376         }
377 
378         @Override
onReceive(Context context, Intent intent)379         public void onReceive(Context context, Intent intent) {
380             final BluetoothDevice device = intent
381                     .getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
382             final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE,
383                     BluetoothDevice.BOND_NONE);
384             if (bondState == BluetoothDevice.BOND_NONE && Objects.equals(mDevice, device)) {
385                 // Device was removed, bail out of the fragment
386                 if (mFragment instanceof BluetoothAccessoryFragment) {
387                     ((BluetoothAccessoryFragment) mFragment).navigateBack();
388                 } else if (mFragment instanceof UnpairConfirmFragment) {
389                     ((UnpairConfirmFragment) mFragment).navigateBack();
390                 } else {
391                     throw new IllegalStateException(
392                             "UnpairReceiver attached to wrong fragment class");
393                 }
394             }
395         }
396     }
397 
398     public static class UnpairConfirmFragment extends GuidedStepSupportFragment {
399 
400         private BluetoothDevice mDevice;
401         private BroadcastReceiver mBroadcastReceiver;
402         private final Handler mHandler = new Handler();
403 
404         private Runnable mBailoutRunnable = new Runnable() {
405             @Override
406             public void run() {
407                 if (isResumed() && !getFragmentManager().popBackStackImmediate()) {
408                     getActivity().onBackPressed();
409                 }
410             }
411         };
412 
413         private final Runnable mTimeoutRunnable = new Runnable() {
414             @Override
415             public void run() {
416                 navigateBack();
417             }
418         };
419 
prepareArgs(@onNull Bundle args, BluetoothDevice device, String deviceName, @DrawableRes int deviceImgId)420         public static void prepareArgs(@NonNull Bundle args, BluetoothDevice device,
421                 String deviceName, @DrawableRes int deviceImgId) {
422             args.putParcelable(ARG_DEVICE, device);
423             args.putString(ARG_ACCESSORY_NAME, deviceName);
424             args.putInt(ARG_ACCESSORY_ICON_ID, deviceImgId);
425         }
426 
427         @Override
onCreate(Bundle savedInstanceState)428         public void onCreate(Bundle savedInstanceState) {
429             mDevice = getArguments().getParcelable(ARG_DEVICE);
430             super.onCreate(savedInstanceState);
431         }
432 
433         @Override
onStart()434         public void onStart() {
435             super.onStart();
436             if (mDevice.getBondState() == BluetoothDevice.BOND_NONE) {
437                 navigateBack();
438             }
439             final IntentFilter adapterIntentFilter = new IntentFilter();
440             adapterIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
441             mBroadcastReceiver = new UnpairReceiver(this, mDevice);
442             getActivity().registerReceiver(mBroadcastReceiver, adapterIntentFilter);
443         }
444 
445         @Override
onStop()446         public void onStop() {
447             super.onStop();
448             getActivity().unregisterReceiver(mBroadcastReceiver);
449         }
450 
451         @Override
onDestroy()452         public void onDestroy() {
453             super.onDestroy();
454             mHandler.removeCallbacks(mTimeoutRunnable);
455             mHandler.removeCallbacks(mBailoutRunnable);
456         }
457 
458         @NonNull
459         @Override
onCreateGuidance(Bundle savedInstanceState)460         public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) {
461             return new GuidanceStylist.Guidance(
462                     getString(R.string.accessory_unpair),
463                     null,
464                     getArguments().getString(ARG_ACCESSORY_NAME),
465                     getContext().getDrawable(getArguments().getInt(ARG_ACCESSORY_ICON_ID,
466                             R.drawable.ic_qs_bluetooth_not_connected))
467             );
468         }
469 
470         @Override
onCreateActions(@onNull List<GuidedAction> actions, Bundle savedInstanceState)471         public void onCreateActions(@NonNull List<GuidedAction> actions,
472                 Bundle savedInstanceState) {
473             final Context context = getContext();
474             actions.add(new GuidedAction.Builder(context)
475                     .clickAction(GuidedAction.ACTION_ID_OK).build());
476             actions.add(new GuidedAction.Builder(context)
477                     .clickAction(GuidedAction.ACTION_ID_CANCEL).build());
478         }
479 
480         @Override
onGuidedActionClicked(GuidedAction action)481         public void onGuidedActionClicked(GuidedAction action) {
482             if (action.getId() == GuidedAction.ACTION_ID_OK) {
483                 unpairDevice();
484             } else if (action.getId() == GuidedAction.ACTION_ID_CANCEL) {
485                 getFragmentManager().popBackStack();
486             } else {
487                 super.onGuidedActionClicked(action);
488             }
489         }
490 
navigateBack()491         private void navigateBack() {
492             // need to post this to avoid recursing in the fragment manager.
493             mHandler.removeCallbacks(mBailoutRunnable);
494             mHandler.post(mBailoutRunnable);
495         }
496 
unpairDevice()497         private void unpairDevice() {
498             if (mDevice != null) {
499                 int state = mDevice.getBondState();
500 
501                 if (state == BluetoothDevice.BOND_BONDING) {
502                     mDevice.cancelBondProcess();
503                 }
504 
505                 if (state != BluetoothDevice.BOND_NONE) {
506                     ((BluetoothAccessoryFragment) getTargetFragment()).setUnpairing(true);
507                     // Set a timeout, just in case we don't receive the unpair notification we
508                     // use to finish the activity
509                     mHandler.postDelayed(mTimeoutRunnable, UNPAIR_TIMEOUT);
510                     final boolean successful = mDevice.removeBond();
511                     if (successful) {
512                         if (DEBUG) {
513                             Log.d(TAG, "Bluetooth device successfully unpaired.");
514                         }
515                     } else {
516                         Log.e(TAG, "Failed to unpair Bluetooth Device: " + mDevice.getName());
517                     }
518                 }
519             } else {
520                 Log.e(TAG, "Bluetooth device not found. Address = " + mDevice.getAddress());
521             }
522         }
523     }
524 }
525