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