/* * 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.CachedBluetoothDevice; 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; /** * 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 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 final UserManager mUserManager; private Set 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; 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; @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(); } }; private class BluetoothDevicePreferenceCallback implements CachedBluetoothDevice.Callback { @Override public void onDeviceAttributesChanged() { onPreferenceAttributesChanged(); } } public BluetoothDevicePreference(Context context, CachedBluetoothDevice cachedDevice, boolean showDeviceWithoutNames, @SortType int type) { super(context, null); mResources = getContext().getResources(); mUserManager = (UserManager) context.getSystemService(Context.USER_SERVICE); 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; 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) { mCachedDevice.unregisterCallback(mCallback); unregisterMetadataChangedListener(); mIsCallbackRemoved = true; } if (mDisconnectDialog != null) { mDisconnectDialog.dismiss(); mDisconnectDialog = null; } } @Override public void onAttached() { super.onAttached(); if (mIsCallbackRemoved) { mCachedDevice.registerCallback(mCallback); registerMetadataChangedListener(); mIsCallbackRemoved = false; } onPreferenceAttributesChanged(); } @Override public void onDetached() { super.onDetached(); if (!mIsCallbackRemoved) { mCachedDevice.unregisterCallback(mCallback); unregisterMetadataChangedListener(); mIsCallbackRemoved = true; } } private void registerMetadataChangedListener() { if (mBluetoothAdapter == null) { Log.d(TAG, "No mBluetoothAdapter"); return; } if (mBluetoothDevices == null) { mBluetoothDevices = new HashSet<>(); } mBluetoothDevices.clear(); if (mCachedDevice.getDevice() != null) { mBluetoothDevices.add(mCachedDevice.getDevice()); } for (CachedBluetoothDevice cbd : mCachedDevice.getMemberDevice()) { mBluetoothDevices.add(cbd.getDevice()); } if (mBluetoothDevices.isEmpty()) { Log.d(TAG, "No BT device to register."); return; } Set 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(() -> { @Nullable String name = mCachedDevice.getName(); // Null check is done at the framework @Nullable String connectionSummary = getConnectionSummary(); @NonNull Pair 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(); 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); setIcon(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(); } }); }); } catch (RejectedExecutionException e) { Log.w(TAG, "Handler thread unavailable, skipping getConnectionSummary!"); } } @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); 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); } @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) { metricsFeatureProvider.action(context, SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR); if (!mCachedDevice.hasHumanReadableName()) { metricsFeatureProvider.action(context, SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR_DEVICES_WITHOUT_NAMES); } 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; if (mCachedDevice.getBondState() != BluetoothDevice.BOND_NONE) { summary = mCachedDevice.getConnectionSummary(); } return summary; } }