| /* |
| * Copyright (C) 2023 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.settings.bluetooth; |
| |
| import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; |
| |
| import android.app.settings.SettingsEnums; |
| import android.bluetooth.BluetoothAdapter; |
| import android.bluetooth.BluetoothDevice; |
| import android.content.Context; |
| import android.content.DialogInterface; |
| import android.content.res.Resources; |
| import android.graphics.drawable.Drawable; |
| import android.os.UserManager; |
| import android.text.Html; |
| import android.text.TextUtils; |
| import android.util.Log; |
| import android.util.Pair; |
| import android.util.TypedValue; |
| import android.view.View; |
| import android.widget.ImageView; |
| |
| import androidx.annotation.IntDef; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.appcompat.app.AlertDialog; |
| import androidx.preference.Preference; |
| import androidx.preference.PreferenceViewHolder; |
| |
| import com.android.settings.R; |
| import com.android.settings.overlay.FeatureFactory; |
| import com.android.settings.widget.GearPreference; |
| import com.android.settingslib.bluetooth.BluetoothUtils; |
| import com.android.settingslib.bluetooth.CachedBluetoothDevice; |
| import com.android.settingslib.bluetooth.LocalBluetoothManager; |
| import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; |
| import com.android.settingslib.utils.ThreadUtils; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.HashSet; |
| import java.util.Set; |
| import java.util.concurrent.RejectedExecutionException; |
| import java.util.concurrent.atomic.AtomicInteger; |
| import java.util.stream.Collectors; |
| |
| /** |
| * BluetoothDevicePreference is the preference type used to display each remote |
| * Bluetooth device in the Bluetooth Settings screen. |
| */ |
| public final class BluetoothDevicePreference extends GearPreference { |
| private static final String TAG = "BluetoothDevicePref"; |
| private static final long ALERT_DIALOG_BLOCKING_TIME_MILLS = 5000L; |
| private static int sDimAlpha = Integer.MIN_VALUE; |
| |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({SortType.TYPE_DEFAULT, |
| SortType.TYPE_FIFO, |
| SortType.TYPE_NO_SORT}) |
| public @interface SortType { |
| int TYPE_DEFAULT = 1; |
| int TYPE_FIFO = 2; |
| int TYPE_NO_SORT = 3; |
| } |
| |
| private final CachedBluetoothDevice mCachedDevice; |
| private Set<CachedBluetoothDevice> mCachedDeviceGroup; |
| |
| private final UserManager mUserManager; |
| private final LocalBluetoothManager mLocalBtManager; |
| |
| private Set<BluetoothDevice> mBluetoothDevices; |
| @VisibleForTesting |
| BluetoothAdapter mBluetoothAdapter; |
| private final boolean mShowDevicesWithoutNames; |
| @NonNull |
| private static final AtomicInteger sNextId = new AtomicInteger(); |
| private final int mId; |
| private final int mType; |
| |
| private AlertDialog mDisconnectDialog; |
| @Nullable private AlertDialog mBlockPairingDialog; |
| private String contentDescription = null; |
| private boolean mHideSecondTarget = false; |
| private boolean mIsCallbackRemoved = true; |
| @VisibleForTesting |
| boolean mNeedNotifyHierarchyChanged = false; |
| /* Talk-back descriptions for various BT icons */ |
| Resources mResources; |
| final BluetoothDevicePreferenceCallback mCallback; |
| |
| private long mLastBondingFailureTimeMillis = -1; |
| private long mLastConnectionFailureTimeMillis = -1; |
| private View mItemView; |
| |
| @VisibleForTesting |
| final BluetoothAdapter.OnMetadataChangedListener mMetadataListener = |
| new BluetoothAdapter.OnMetadataChangedListener() { |
| @Override |
| public void onMetadataChanged(BluetoothDevice device, int key, byte[] value) { |
| Log.d(TAG, String.format("Metadata updated in Device %s: %d = %s.", |
| device.getAnonymizedAddress(), |
| key, value == null ? null : new String(value))); |
| onPreferenceAttributesChanged(); |
| } |
| }; |
| |
| @NonNull |
| private final BluetoothFeatureProvider mBluetoothFeatureProvider = |
| FeatureFactory.getFeatureFactory().getBluetoothFeatureProvider(); |
| |
| private class BluetoothDevicePreferenceCallback implements CachedBluetoothDevice.Callback { |
| |
| @Override |
| public void onDeviceAttributesChanged() { |
| onPreferenceAttributesChanged(); |
| Set<CachedBluetoothDevice> newCachedDeviceGroup = new HashSet<>( |
| Utils.findAllCachedBluetoothDevicesByGroupId(mLocalBtManager, mCachedDevice)); |
| if (!mCachedDeviceGroup.equals(newCachedDeviceGroup)) { |
| for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { |
| cachedBluetoothDevice.unregisterCallback(this); |
| } |
| unregisterMetadataChangedListener(); |
| |
| mCachedDeviceGroup = newCachedDeviceGroup; |
| |
| for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { |
| cachedBluetoothDevice.registerCallback(getContext().getMainExecutor(), this); |
| } |
| registerMetadataChangedListener(); |
| } |
| } |
| } |
| |
| public BluetoothDevicePreference(Context context, CachedBluetoothDevice cachedDevice, |
| boolean showDeviceWithoutNames, @SortType int type) { |
| super(context, null); |
| mResources = getContext().getResources(); |
| mUserManager = (UserManager) context.getSystemService(Context.USER_SERVICE); |
| mLocalBtManager = Utils.getLocalBluetoothManager(context); |
| mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); |
| mShowDevicesWithoutNames = showDeviceWithoutNames; |
| |
| if (sDimAlpha == Integer.MIN_VALUE) { |
| TypedValue outValue = new TypedValue(); |
| context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true); |
| sDimAlpha = (int) (outValue.getFloat() * 255); |
| } |
| |
| mCachedDevice = cachedDevice; |
| mLastBondingFailureTimeMillis = mCachedDevice.getBondFailureTimeMillis(); |
| mLastConnectionFailureTimeMillis = mCachedDevice.getConnectionFailureTimeMillis(); |
| mCachedDeviceGroup = new HashSet<>( |
| Utils.findAllCachedBluetoothDevicesByGroupId(mLocalBtManager, mCachedDevice)); |
| mCallback = new BluetoothDevicePreferenceCallback(); |
| mId = sNextId.getAndIncrement(); |
| mType = type; |
| setVisible(false); |
| |
| onPreferenceAttributesChanged(); |
| } |
| |
| public void setNeedNotifyHierarchyChanged(boolean needNotifyHierarchyChanged) { |
| mNeedNotifyHierarchyChanged = needNotifyHierarchyChanged; |
| } |
| |
| @Override |
| protected boolean shouldHideSecondTarget() { |
| return mCachedDevice == null |
| || mCachedDevice.getBondState() != BluetoothDevice.BOND_BONDED |
| || mUserManager.hasUserRestriction(DISALLOW_CONFIG_BLUETOOTH) |
| || mHideSecondTarget; |
| } |
| |
| @Override |
| protected int getSecondTargetResId() { |
| return R.layout.preference_widget_gear; |
| } |
| |
| public CachedBluetoothDevice getCachedDevice() { |
| return mCachedDevice; |
| } |
| |
| @Override |
| protected void onPrepareForRemoval() { |
| super.onPrepareForRemoval(); |
| if (!mIsCallbackRemoved) { |
| for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { |
| cachedBluetoothDevice.unregisterCallback(mCallback); |
| } |
| unregisterMetadataChangedListener(); |
| mIsCallbackRemoved = true; |
| } |
| if (mDisconnectDialog != null) { |
| mDisconnectDialog.dismiss(); |
| mDisconnectDialog = null; |
| } |
| } |
| |
| @Override |
| public void onAttached() { |
| super.onAttached(); |
| if (mIsCallbackRemoved) { |
| for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { |
| cachedBluetoothDevice.registerCallback(getContext().getMainExecutor(), mCallback); |
| } |
| registerMetadataChangedListener(); |
| mIsCallbackRemoved = false; |
| } |
| onPreferenceAttributesChanged(); |
| } |
| |
| @Override |
| public void onDetached() { |
| super.onDetached(); |
| if (!mIsCallbackRemoved) { |
| for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) { |
| cachedBluetoothDevice.unregisterCallback(mCallback); |
| } |
| unregisterMetadataChangedListener(); |
| mIsCallbackRemoved = true; |
| } |
| } |
| |
| private void registerMetadataChangedListener() { |
| if (mBluetoothAdapter == null) { |
| Log.d(TAG, "No mBluetoothAdapter"); |
| return; |
| } |
| |
| mBluetoothDevices = mCachedDeviceGroup.stream() |
| .map(CachedBluetoothDevice::getDevice) |
| .collect(Collectors.toCollection(HashSet::new)); |
| |
| if (mBluetoothDevices.isEmpty()) { |
| Log.d(TAG, "No BT device to register."); |
| return; |
| } |
| Set<BluetoothDevice> errorDevices = new HashSet<>(); |
| mBluetoothDevices.forEach(bd -> { |
| try { |
| boolean isSuccess = mBluetoothAdapter.addOnMetadataChangedListener(bd, |
| getContext().getMainExecutor(), mMetadataListener); |
| if (!isSuccess) { |
| Log.e(TAG, bd.getAnonymizedAddress() + ": add into Listener failed"); |
| errorDevices.add(bd); |
| } |
| } catch (NullPointerException e) { |
| errorDevices.add(bd); |
| Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); |
| } catch (IllegalArgumentException e) { |
| errorDevices.add(bd); |
| Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); |
| } |
| }); |
| for (BluetoothDevice errorDevice : errorDevices) { |
| mBluetoothDevices.remove(errorDevice); |
| Log.d(TAG, "mBluetoothDevices remove " + errorDevice.getAnonymizedAddress()); |
| } |
| } |
| |
| private void unregisterMetadataChangedListener() { |
| if (mBluetoothAdapter == null) { |
| Log.d(TAG, "No mBluetoothAdapter"); |
| return; |
| } |
| if (mBluetoothDevices == null || mBluetoothDevices.isEmpty()) { |
| Log.d(TAG, "No BT device to unregister."); |
| return; |
| } |
| mBluetoothDevices.forEach(bd -> { |
| try { |
| mBluetoothAdapter.removeOnMetadataChangedListener(bd, mMetadataListener); |
| } catch (NullPointerException e) { |
| Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); |
| } catch (IllegalArgumentException e) { |
| Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString()); |
| } |
| }); |
| mBluetoothDevices.clear(); |
| } |
| |
| public CachedBluetoothDevice getBluetoothDevice() { |
| return mCachedDevice; |
| } |
| |
| public void hideSecondTarget(boolean hideSecondTarget) { |
| mHideSecondTarget = hideSecondTarget; |
| } |
| |
| @SuppressWarnings("FutureReturnValueIgnored") |
| void onPreferenceAttributesChanged() { |
| try { |
| ThreadUtils.postOnBackgroundThread(() -> { |
| if (mCachedDevice.getDevice() != null) { |
| Log.d(TAG, "onPreferenceAttributesChanged, start updating for device " |
| + mCachedDevice.getDevice().getAnonymizedAddress()); |
| } |
| @Nullable String name = mCachedDevice.getName(); |
| // Null check is done at the framework |
| @Nullable String connectionSummary = getConnectionSummary(); |
| @NonNull Pair<Drawable, String> pair = mCachedDevice.getDrawableWithDescription(); |
| boolean isBusy = mCachedDevice.isBusy(); |
| // Device is only visible in the UI if it has a valid name besides MAC address or |
| // when user allows showing devices without user-friendly name in developer settings |
| boolean isVisible = |
| mShowDevicesWithoutNames || mCachedDevice.hasHumanReadableName(); |
| boolean showFailureIcon = |
| BluetoothUtils.isBluetoothDiagnosisAvailable(getContext()) |
| && (BluetoothUtils.showPairingFailure(mCachedDevice) |
| || BluetoothUtils.showConnectionFailure(mCachedDevice)); |
| |
| ThreadUtils.postOnMainThread(() -> { |
| /* |
| * The preference framework takes care of making sure the value has |
| * changed before proceeding. It will also call notifyChanged() if |
| * any preference info has changed from the previous value. |
| */ |
| setTitle(name); |
| setSummary(connectionSummary); |
| // TODO: Move the logic into CachedBluetoothDevice when SystemUI is supported. |
| setIcon(showFailureIcon |
| ? getContext().getDrawable( |
| com.android.settingslib.R.drawable.bluetooth_warning_icon) |
| : pair.first); |
| contentDescription = pair.second; |
| // Used to gray out the item |
| setEnabled(!isBusy); |
| setVisible(isVisible); |
| |
| // This could affect ordering, so notify that |
| if (mNeedNotifyHierarchyChanged) { |
| notifyHierarchyChanged(); |
| } |
| }); |
| Log.d(TAG, "onPreferenceAttributesChanged, complete updating for device " + name); |
| }); |
| } catch (RejectedExecutionException e) { |
| Log.w(TAG, "Handler thread unavailable, skipping getConnectionSummary!"); |
| } |
| |
| getContext().getMainExecutor().execute(() -> { |
| // Show the alert dialog if pairing or connection fails. |
| // Run this on main thread in order to make sure the dialog only appear once |
| if (BluetoothUtils.isBluetoothDiagnosisAvailable(getContext())) { |
| if (mCachedDevice.getBondFailureTimeMillis() != mLastBondingFailureTimeMillis |
| && BluetoothUtils.showPairingFailure(mCachedDevice) |
| && mItemView != null |
| && mItemView.isShown()) { |
| ThreadUtils.postOnBackgroundThread( |
| () -> mBluetoothFeatureProvider.buildBluetoothDiagnosisAlertDialog( |
| getContext(), |
| BluetoothDiagnosisEntryPoint.ENTRY_POINT_CAN_NOT_PAIR, |
| mCachedDevice, |
| result -> { |
| if (result != null) { |
| Log.d( |
| TAG, |
| "Showing bonding failure alert dialog for " |
| + mCachedDevice.getAddress() |
| + " at " |
| + mCachedDevice.getBondFailureTimeMillis()); |
| getContext().getMainExecutor().execute(result::show); |
| } |
| })); |
| } else if (mCachedDevice.getConnectionFailureTimeMillis() |
| > mLastConnectionFailureTimeMillis + ALERT_DIALOG_BLOCKING_TIME_MILLS |
| && BluetoothUtils.showConnectionFailure(mCachedDevice) |
| && mItemView != null |
| && mItemView.isShown()) { |
| ThreadUtils.postOnBackgroundThread( |
| () -> mBluetoothFeatureProvider.buildBluetoothDiagnosisAlertDialog( |
| getContext(), |
| BluetoothDiagnosisEntryPoint.ENTRY_POINT_CAN_NOT_CONNECT, |
| mCachedDevice, |
| result -> { |
| if (result != null) { |
| Log.d( |
| TAG, |
| "Showing connection failure alert dialog for " |
| + mCachedDevice.getAddress() |
| + " at " |
| + mCachedDevice |
| .getConnectionFailureTimeMillis() |
| ); |
| getContext().getMainExecutor().execute(result::show); |
| } |
| })); |
| } |
| mLastBondingFailureTimeMillis = mCachedDevice.getBondFailureTimeMillis(); |
| mLastConnectionFailureTimeMillis = |
| mCachedDevice.getConnectionFailureTimeMillis(); |
| } |
| }); |
| } |
| |
| @Override |
| public void onBindViewHolder(PreferenceViewHolder view) { |
| // Disable this view if the bluetooth enable/disable preference view is off |
| if (null != findPreferenceInHierarchy("bt_checkbox")) { |
| setDependency("bt_checkbox"); |
| } |
| |
| if (mCachedDevice.getBondState() == BluetoothDevice.BOND_BONDED) { |
| ImageView deviceDetails = (ImageView) view.findViewById(R.id.settings_button); |
| deviceDetails.setContentDescription( |
| getContext().getResources().getString( |
| R.string.device_detail_icon_content_description, getTitle())); |
| |
| if (deviceDetails != null) { |
| deviceDetails.setOnClickListener(this); |
| } |
| } |
| final ImageView imageView = (ImageView) view.findViewById(android.R.id.icon); |
| if (imageView != null) { |
| imageView.setContentDescription(contentDescription); |
| // Set property to prevent Talkback from reading out. |
| imageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); |
| imageView.setElevation( |
| getContext().getResources().getDimension(R.dimen.bt_icon_elevation)); |
| } |
| super.onBindViewHolder(view); |
| mItemView = view.itemView; |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if ((o == null) || !(o instanceof BluetoothDevicePreference)) { |
| return false; |
| } |
| return mCachedDevice.equals( |
| ((BluetoothDevicePreference) o).mCachedDevice); |
| } |
| |
| @Override |
| public int hashCode() { |
| return mCachedDevice.hashCode(); |
| } |
| |
| @Override |
| public int compareTo(Preference another) { |
| if (!(another instanceof BluetoothDevicePreference)) { |
| // Rely on default sort |
| return super.compareTo(another); |
| } |
| |
| switch (mType) { |
| case SortType.TYPE_DEFAULT: |
| return mCachedDevice |
| .compareTo(((BluetoothDevicePreference) another).mCachedDevice); |
| case SortType.TYPE_FIFO: |
| return mId > ((BluetoothDevicePreference) another).mId ? 1 : -1; |
| default: |
| return super.compareTo(another); |
| } |
| } |
| |
| /** |
| * Performs different actions according to the device connected and bonded state after |
| * clicking on the preference. |
| */ |
| public void onClicked() { |
| Context context = getContext(); |
| int bondState = mCachedDevice.getBondState(); |
| |
| final MetricsFeatureProvider metricsFeatureProvider = |
| FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); |
| |
| if (mCachedDevice.isConnected()) { |
| metricsFeatureProvider.action(context, |
| SettingsEnums.ACTION_SETTINGS_BLUETOOTH_DISCONNECT); |
| askDisconnect(); |
| } else if (bondState == BluetoothDevice.BOND_BONDED) { |
| metricsFeatureProvider.action(context, |
| SettingsEnums.ACTION_SETTINGS_BLUETOOTH_CONNECT); |
| mCachedDevice.connect(); |
| } else if (bondState == BluetoothDevice.BOND_NONE) { |
| var unused = ThreadUtils.postOnBackgroundThread(() -> { |
| if (BluetoothUtils.isBroadcasting(mLocalBtManager)) { |
| metricsFeatureProvider.action(context, |
| SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR_IN_AUDIO_SHARING); |
| } |
| if (Utils.shouldBlockPairingInAudioSharing(mLocalBtManager)) { |
| context.getMainExecutor().execute(() -> |
| mBlockPairingDialog = |
| Utils.showBlockPairingDialog(context, mBlockPairingDialog, |
| mLocalBtManager)); |
| metricsFeatureProvider.action(context, |
| SettingsEnums |
| .ACTION_SETTINGS_BLUETOOTH_PAIR_BLOCKED_IN_AUDIO_SHARING); |
| return; |
| } |
| metricsFeatureProvider.action(context, |
| SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR); |
| if (!mCachedDevice.hasHumanReadableName()) { |
| metricsFeatureProvider.action(context, |
| SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR_DEVICES_WITHOUT_NAMES); |
| } |
| context.getMainExecutor().execute(() -> pair()); |
| }); |
| } |
| } |
| |
| // Show disconnect confirmation dialog for a device. |
| private void askDisconnect() { |
| Context context = getContext(); |
| String name = mCachedDevice.getName(); |
| if (TextUtils.isEmpty(name)) { |
| name = context.getString(R.string.bluetooth_device); |
| } |
| String message = context.getString(R.string.bluetooth_disconnect_all_profiles, name); |
| String title = context.getString(R.string.bluetooth_disconnect_title); |
| |
| DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener() { |
| public void onClick(DialogInterface dialog, int which) { |
| mCachedDevice.disconnect(); |
| } |
| }; |
| |
| mDisconnectDialog = Utils.showDisconnectDialog(context, |
| mDisconnectDialog, disconnectListener, title, Html.fromHtml(message)); |
| } |
| |
| private void pair() { |
| if (!mCachedDevice.startPairing()) { |
| Utils.showError(getContext(), mCachedDevice.getName(), |
| com.android.settingslib.R.string.bluetooth_pairing_error_message); |
| } |
| } |
| |
| private String getConnectionSummary() { |
| String summary = null; |
| // TODO: Move the logic into CachedBluetoothDevice when SystemUI is supported. |
| if (BluetoothUtils.isBluetoothDiagnosisAvailable(getContext()) && !mCachedDevice.isBusy()) { |
| if (BluetoothUtils.showPairingFailure(mCachedDevice)) { |
| return getContext() |
| .getString(com.android.settingslib.R.string.bluetooth_pairing_failure); |
| } else if (BluetoothUtils.showConnectionFailure(mCachedDevice)) { |
| return getContext() |
| .getString(com.android.settingslib.R.string.bluetooth_connection_failure); |
| } |
| } |
| |
| if (mCachedDevice.getBondState() != BluetoothDevice.BOND_NONE) { |
| summary = mCachedDevice.getConnectionSummary(); |
| } |
| return summary; |
| } |
| } |