1 /*
2  * Copyright (C) 2017 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.bluetooth;
18 
19 import static android.bluetooth.BluetoothDevice.BOND_NONE;
20 import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH;
21 
22 import android.app.Activity;
23 import android.app.settings.SettingsEnums;
24 import android.bluetooth.BluetoothAdapter;
25 import android.bluetooth.BluetoothDevice;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.content.res.TypedArray;
29 import android.hardware.input.InputManager;
30 import android.net.Uri;
31 import android.os.Bundle;
32 import android.os.UserManager;
33 import android.provider.DeviceConfig;
34 import android.text.TextUtils;
35 import android.util.FeatureFlagUtils;
36 import android.util.Log;
37 import android.view.InputDevice;
38 import android.view.LayoutInflater;
39 import android.view.Menu;
40 import android.view.MenuInflater;
41 import android.view.MenuItem;
42 import android.view.View;
43 import android.view.ViewGroup;
44 import android.view.ViewTreeObserver;
45 
46 import androidx.annotation.Nullable;
47 import androidx.annotation.VisibleForTesting;
48 
49 import com.android.settings.R;
50 import com.android.settings.connecteddevice.stylus.StylusDevicesController;
51 import com.android.settings.core.SettingsUIDeviceConfig;
52 import com.android.settings.dashboard.RestrictedDashboardFragment;
53 import com.android.settings.inputmethod.KeyboardSettingsPreferenceController;
54 import com.android.settings.overlay.FeatureFactory;
55 import com.android.settings.slices.SlicePreferenceController;
56 import com.android.settingslib.bluetooth.BluetoothCallback;
57 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
58 import com.android.settingslib.bluetooth.LocalBluetoothManager;
59 import com.android.settingslib.core.AbstractPreferenceController;
60 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
61 import com.android.settingslib.core.lifecycle.Lifecycle;
62 
63 import java.util.ArrayList;
64 import java.util.List;
65 
66 public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment {
67     public static final String KEY_DEVICE_ADDRESS = "device_address";
68     private static final String TAG = "BTDeviceDetailsFrg";
69     private static final int METADATA_FAST_PAIR_CUSTOMIZED_FIELDS = 25;
70 
71     @VisibleForTesting
72     static int EDIT_DEVICE_NAME_ITEM_ID = Menu.FIRST;
73 
74     /**
75      * An interface to let tests override the normal mechanism for looking up the
76      * CachedBluetoothDevice and LocalBluetoothManager, and substitute their own mocks instead.
77      * This is only needed in situations where you instantiate the fragment indirectly (eg via an
78      * intent) and can't use something like spying on an instance you construct directly via
79      * newInstance.
80      */
81     @VisibleForTesting
82     interface TestDataFactory {
getDevice(String deviceAddress)83         CachedBluetoothDevice getDevice(String deviceAddress);
84 
getManager(Context context)85         LocalBluetoothManager getManager(Context context);
86 
getUserManager()87         UserManager getUserManager();
88     }
89 
90     @VisibleForTesting
91     static TestDataFactory sTestDataFactory;
92 
93     @VisibleForTesting
94     String mDeviceAddress;
95     @VisibleForTesting
96     LocalBluetoothManager mManager;
97     @VisibleForTesting
98     CachedBluetoothDevice mCachedDevice;
99     BluetoothAdapter mBluetoothAdapter;
100 
101     @Nullable
102     InputDevice mInputDevice;
103 
104     private UserManager mUserManager;
105     int mExtraControlViewWidth = 0;
106     boolean mExtraControlUriLoaded = false;
107 
108     private final BluetoothCallback mBluetoothCallback =
109             new BluetoothCallback() {
110                 @Override
111                 public void onBluetoothStateChanged(int bluetoothState) {
112                     if (bluetoothState == BluetoothAdapter.STATE_OFF) {
113                         Log.i(TAG, "Bluetooth is off, exit activity.");
114                         Activity activity = getActivity();
115                         if (activity != null) {
116                             activity.finish();
117                         }
118                     }
119                 }
120             };
121 
122     private final BluetoothAdapter.OnMetadataChangedListener mExtraControlMetadataListener =
123             (device, key, value) -> {
124                 if (key == METADATA_FAST_PAIR_CUSTOMIZED_FIELDS
125                         && mExtraControlViewWidth > 0
126                         && !mExtraControlUriLoaded) {
127                     Log.i(TAG, "Update extra control UI because of metadata change.");
128                     updateExtraControlUri(mExtraControlViewWidth);
129                 }
130             };
131 
BluetoothDeviceDetailsFragment()132     public BluetoothDeviceDetailsFragment() {
133         super(DISALLOW_CONFIG_BLUETOOTH);
134     }
135 
136     @VisibleForTesting
getLocalBluetoothManager(Context context)137     LocalBluetoothManager getLocalBluetoothManager(Context context) {
138         if (sTestDataFactory != null) {
139             return sTestDataFactory.getManager(context);
140         }
141         return Utils.getLocalBtManager(context);
142     }
143 
144     @VisibleForTesting
145     @Nullable
getCachedDevice(String deviceAddress)146     CachedBluetoothDevice getCachedDevice(String deviceAddress) {
147         if (sTestDataFactory != null) {
148             return sTestDataFactory.getDevice(deviceAddress);
149         }
150         BluetoothDevice remoteDevice =
151                 mManager.getBluetoothAdapter().getRemoteDevice(deviceAddress);
152         if (remoteDevice == null) {
153             return null;
154         }
155         CachedBluetoothDevice cachedDevice =
156                 mManager.getCachedDeviceManager().findDevice(remoteDevice);
157         if (cachedDevice != null) {
158             return cachedDevice;
159         }
160         Log.i(TAG, "Add device to cached device manager: " + remoteDevice.getAnonymizedAddress());
161         return mManager.getCachedDeviceManager().addDevice(remoteDevice);
162     }
163 
164     @VisibleForTesting
getUserManager()165     UserManager getUserManager() {
166         if (sTestDataFactory != null) {
167             return sTestDataFactory.getUserManager();
168         }
169 
170         return getSystemService(UserManager.class);
171     }
172 
173     @Nullable
174     @VisibleForTesting
getInputDevice(Context context)175     InputDevice getInputDevice(Context context) {
176         InputManager im = context.getSystemService(InputManager.class);
177 
178         for (int deviceId : im.getInputDeviceIds()) {
179             String btAddress = im.getInputDeviceBluetoothAddress(deviceId);
180 
181             if (btAddress != null && btAddress.equals(mDeviceAddress)) {
182                 return im.getInputDevice(deviceId);
183             }
184         }
185         return null;
186     }
187 
newInstance(String deviceAddress)188     public static BluetoothDeviceDetailsFragment newInstance(String deviceAddress) {
189         Bundle args = new Bundle(1);
190         args.putString(KEY_DEVICE_ADDRESS, deviceAddress);
191         BluetoothDeviceDetailsFragment fragment = new BluetoothDeviceDetailsFragment();
192         fragment.setArguments(args);
193         return fragment;
194     }
195 
196     @Override
onAttach(Context context)197     public void onAttach(Context context) {
198         mDeviceAddress = getArguments().getString(KEY_DEVICE_ADDRESS);
199         mManager = getLocalBluetoothManager(context);
200         mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
201         mCachedDevice = getCachedDevice(mDeviceAddress);
202         mUserManager = getUserManager();
203 
204         if (FeatureFlagUtils.isEnabled(context,
205                 FeatureFlagUtils.SETTINGS_SHOW_STYLUS_PREFERENCES)) {
206             mInputDevice = getInputDevice(context);
207         }
208 
209         super.onAttach(context);
210         if (mCachedDevice == null) {
211             // Close this page if device is null with invalid device mac address
212             Log.w(TAG, "onAttach() CachedDevice is null!");
213             finish();
214             return;
215         }
216         use(AdvancedBluetoothDetailsHeaderController.class).init(mCachedDevice);
217         use(LeAudioBluetoothDetailsHeaderController.class).init(mCachedDevice, mManager);
218         use(KeyboardSettingsPreferenceController.class).init(mCachedDevice);
219 
220         final BluetoothFeatureProvider featureProvider =
221                 FeatureFactory.getFeatureFactory().getBluetoothFeatureProvider();
222         final boolean sliceEnabled = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SETTINGS_UI,
223                 SettingsUIDeviceConfig.BT_SLICE_SETTINGS_ENABLED, true);
224 
225         use(BlockingPrefWithSliceController.class).setSliceUri(sliceEnabled
226                 ? featureProvider.getBluetoothDeviceSettingsUri(mCachedDevice.getDevice())
227                 : null);
228 
229         mManager.getEventManager().registerCallback(mBluetoothCallback);
230         mBluetoothAdapter.addOnMetadataChangedListener(
231                 mCachedDevice.getDevice(),
232                 context.getMainExecutor(),
233                 mExtraControlMetadataListener);
234     }
235 
236     @Override
onDetach()237     public void onDetach() {
238         super.onDetach();
239         mManager.getEventManager().unregisterCallback(mBluetoothCallback);
240         mBluetoothAdapter.removeOnMetadataChangedListener(
241                 mCachedDevice.getDevice(), mExtraControlMetadataListener);
242     }
243 
updateExtraControlUri(int viewWidth)244     private void updateExtraControlUri(int viewWidth) {
245         BluetoothFeatureProvider featureProvider =
246                 FeatureFactory.getFeatureFactory().getBluetoothFeatureProvider();
247         boolean sliceEnabled = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SETTINGS_UI,
248                 SettingsUIDeviceConfig.BT_SLICE_SETTINGS_ENABLED, true);
249         Uri controlUri = null;
250         String uri = featureProvider.getBluetoothDeviceControlUri(mCachedDevice.getDevice());
251         if (!TextUtils.isEmpty(uri)) {
252             try {
253                 controlUri = Uri.parse(uri + viewWidth);
254             } catch (NullPointerException exception) {
255                 Log.d(TAG, "unable to parse uri");
256             }
257         }
258         mExtraControlUriLoaded |= controlUri != null;
259         final SlicePreferenceController slicePreferenceController = use(
260                 SlicePreferenceController.class);
261         slicePreferenceController.setSliceUri(sliceEnabled ? controlUri : null);
262         slicePreferenceController.onStart();
263         slicePreferenceController.displayPreference(getPreferenceScreen());
264 
265         // Temporarily fix the issue that the page will be automatically scrolled to a wrong
266         // position when entering the page. This will make sure the bluetooth header is shown on top
267         // of the page.
268         use(LeAudioBluetoothDetailsHeaderController.class).displayPreference(
269                 getPreferenceScreen());
270         use(AdvancedBluetoothDetailsHeaderController.class).displayPreference(
271                 getPreferenceScreen());
272         use(BluetoothDetailsHeaderController.class).displayPreference(
273                 getPreferenceScreen());
274     }
275 
276     private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener =
277             new ViewTreeObserver.OnGlobalLayoutListener() {
278                 @Override
279                 public void onGlobalLayout() {
280                     View view = getView();
281                     if (view == null) {
282                         return;
283                     }
284                     if (view.getWidth() <= 0) {
285                         return;
286                     }
287                     mExtraControlViewWidth = view.getWidth() - getPaddingSize();
288                     updateExtraControlUri(mExtraControlViewWidth);
289                     view.getViewTreeObserver().removeOnGlobalLayoutListener(
290                             mOnGlobalLayoutListener);
291                 }
292             };
293 
294     @Override
onCreate(Bundle savedInstanceState)295     public void onCreate(Bundle savedInstanceState) {
296         super.onCreate(savedInstanceState);
297         setTitleForInputDevice();
298     }
299 
300     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)301     public View onCreateView(LayoutInflater inflater, ViewGroup container,
302             Bundle savedInstanceState) {
303         View view = super.onCreateView(inflater, container, savedInstanceState);
304         if (view != null) {
305             view.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener);
306         }
307         return view;
308     }
309 
310     @Override
onResume()311     public void onResume() {
312         super.onResume();
313         finishFragmentIfNecessary();
314     }
315 
316     @VisibleForTesting
finishFragmentIfNecessary()317     void finishFragmentIfNecessary() {
318         if (mCachedDevice.getBondState() == BOND_NONE) {
319             finish();
320             return;
321         }
322     }
323 
324     @Override
getMetricsCategory()325     public int getMetricsCategory() {
326         return SettingsEnums.BLUETOOTH_DEVICE_DETAILS;
327     }
328 
329     @Override
getLogTag()330     protected String getLogTag() {
331         return TAG;
332     }
333 
334     @Override
getPreferenceScreenResId()335     protected int getPreferenceScreenResId() {
336         return R.xml.bluetooth_device_details_fragment;
337     }
338 
339     @Override
onCreateOptionsMenu(Menu menu, MenuInflater inflater)340     public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
341         if (!mUserManager.isGuestUser()) {
342             MenuItem item = menu.add(0, EDIT_DEVICE_NAME_ITEM_ID, 0,
343                     R.string.bluetooth_rename_button);
344             item.setIcon(com.android.internal.R.drawable.ic_mode_edit);
345             item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
346         }
347         super.onCreateOptionsMenu(menu, inflater);
348     }
349 
350     @Override
onOptionsItemSelected(MenuItem menuItem)351     public boolean onOptionsItemSelected(MenuItem menuItem) {
352         if (menuItem.getItemId() == EDIT_DEVICE_NAME_ITEM_ID) {
353             RemoteDeviceNameDialogFragment.newInstance(mCachedDevice).show(
354                     getFragmentManager(), RemoteDeviceNameDialogFragment.TAG);
355             return true;
356         }
357         return super.onOptionsItemSelected(menuItem);
358     }
359 
360     @Override
createPreferenceControllers(Context context)361     protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
362         ArrayList<AbstractPreferenceController> controllers = new ArrayList<>();
363 
364         if (mCachedDevice != null) {
365             Lifecycle lifecycle = getSettingsLifecycle();
366             controllers.add(new BluetoothDetailsHeaderController(context, this, mCachedDevice,
367                     lifecycle));
368             controllers.add(new BluetoothDetailsButtonsController(context, this, mCachedDevice,
369                     lifecycle));
370             controllers.add(new BluetoothDetailsCompanionAppsController(context, this,
371                     mCachedDevice, lifecycle));
372             controllers.add(new BluetoothDetailsAudioDeviceTypeController(context, this, mManager,
373                     mCachedDevice, lifecycle));
374             controllers.add(new BluetoothDetailsSpatialAudioController(context, this, mCachedDevice,
375                     lifecycle));
376             controllers.add(new BluetoothDetailsProfilesController(context, this, mManager,
377                     mCachedDevice, lifecycle));
378             controllers.add(new BluetoothDetailsMacAddressController(context, this, mCachedDevice,
379                     lifecycle));
380             controllers.add(new StylusDevicesController(context, mInputDevice, mCachedDevice,
381                     lifecycle));
382             controllers.add(new BluetoothDetailsRelatedToolsController(context, this, mCachedDevice,
383                     lifecycle));
384             controllers.add(new BluetoothDetailsPairOtherController(context, this, mCachedDevice,
385                     lifecycle));
386             controllers.add(new BluetoothDetailsDataSyncController(context, this, mCachedDevice,
387                     lifecycle));
388             controllers.add(new BluetoothDetailsExtraOptionsController(context, this, mCachedDevice,
389                     lifecycle));
390             BluetoothDetailsHearingDeviceController hearingDeviceController =
391                     new BluetoothDetailsHearingDeviceController(context, this, mManager,
392                             mCachedDevice, lifecycle);
393             controllers.add(hearingDeviceController);
394             hearingDeviceController.initSubControllers(isLaunchFromHearingDevicePage());
395             controllers.addAll(hearingDeviceController.getSubControllers());
396         }
397         return controllers;
398     }
399 
getPaddingSize()400     private int getPaddingSize() {
401         TypedArray resolvedAttributes =
402                 getContext().obtainStyledAttributes(
403                         new int[]{
404                                 android.R.attr.listPreferredItemPaddingStart,
405                                 android.R.attr.listPreferredItemPaddingEnd
406                         });
407         int width = resolvedAttributes.getDimensionPixelSize(0, 0)
408                 + resolvedAttributes.getDimensionPixelSize(1, 0);
409         resolvedAttributes.recycle();
410         return width;
411     }
412 
isLaunchFromHearingDevicePage()413     private boolean isLaunchFromHearingDevicePage() {
414         final Intent intent = getIntent();
415         if (intent == null) {
416             return false;
417         }
418 
419         return intent.getIntExtra(MetricsFeatureProvider.EXTRA_SOURCE_METRICS_CATEGORY,
420                 SettingsEnums.PAGE_UNKNOWN) == SettingsEnums.ACCESSIBILITY_HEARING_AID_SETTINGS;
421     }
422 
423     @VisibleForTesting
setTitleForInputDevice()424     void setTitleForInputDevice() {
425         if (StylusDevicesController.isDeviceStylus(mInputDevice, mCachedDevice)) {
426             // This will override the default R.string.device_details_title "Device Details"
427             // that will show on non-stylus bluetooth devices.
428             // That title is set via the manifest and also from BluetoothDeviceUpdater.
429             getActivity().setTitle(getContext().getString(R.string.stylus_device_details_title));
430         }
431     }
432 }
433