diff --git a/res/layout/le_audio_bt_entity_header.xml b/res/layout/le_audio_bt_entity_header.xml new file mode 100644 index 00000000000..6e2a1e85b07 --- /dev/null +++ b/res/layout/le_audio_bt_entity_header.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values/dimens.xml b/res/values/dimens.xml index c44a1cbfa46..8a59da2844b 100755 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -399,6 +399,13 @@ 27.5dp -4dp + + 5dp + 10dp + 6dp + 20dp + 1.5dp + 8dp 8dp diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml index b21d5c931e5..7ad193076df 100644 --- a/res/xml/bluetooth_device_details_fragment.xml +++ b/res/xml/bluetooth_device_details_fragment.xml @@ -34,6 +34,14 @@ settings:searchable="false" settings:controller="com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController"/> + + diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java b/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java index 9f5e78e0396..9c7aa58cf7c 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java @@ -16,6 +16,7 @@ package com.android.settings.bluetooth; +import android.bluetooth.BluetoothProfile; import android.content.Context; import android.graphics.drawable.Drawable; import android.text.TextUtils; @@ -53,7 +54,10 @@ public class BluetoothDetailsHeaderController extends BluetoothDetailsController @Override public boolean isAvailable() { - return !Utils.isAdvancedDetailsHeader(mCachedDevice.getDevice()); + boolean hasLeAudio = mCachedDevice.getConnectableProfiles() + .stream() + .anyMatch(profile -> profile.getProfileId() == BluetoothProfile.LE_AUDIO); + return !Utils.isAdvancedDetailsHeader(mCachedDevice.getDevice()) && !hasLeAudio; } @Override diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java index 653248263bd..6d443ee4044 100644 --- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java +++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java @@ -117,6 +117,7 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment return; } use(AdvancedBluetoothDetailsHeaderController.class).init(mCachedDevice); + use(LeAudioBluetoothDetailsHeaderController.class).init(mCachedDevice, mManager); final BluetoothFeatureProvider featureProvider = FeatureFactory.getFactory( context).getBluetoothFeatureProvider(context); diff --git a/src/com/android/settings/bluetooth/LeAudioBluetoothDetailsHeaderController.java b/src/com/android/settings/bluetooth/LeAudioBluetoothDetailsHeaderController.java new file mode 100644 index 00000000000..06cee852442 --- /dev/null +++ b/src/com/android/settings/bluetooth/LeAudioBluetoothDetailsHeaderController.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2022 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 android.bluetooth.BluetoothCsipSetCoordinator; +import android.bluetooth.BluetoothLeAudio; +import android.bluetooth.BluetoothProfile; +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.util.Pair; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.VisibleForTesting; +import androidx.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settings.core.BasePreferenceController; +import com.android.settings.fuelgauge.BatteryMeterView; +import com.android.settingslib.bluetooth.BluetoothUtils; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.LeAudioProfile; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; +import com.android.settingslib.core.lifecycle.LifecycleObserver; +import com.android.settingslib.core.lifecycle.events.OnDestroy; +import com.android.settingslib.core.lifecycle.events.OnStart; +import com.android.settingslib.core.lifecycle.events.OnStop; +import com.android.settingslib.widget.LayoutPreference; + +import java.util.ArrayList; +import java.util.List; + +/** + * This class adds a header with device name and status (connected/disconnected, etc.). + */ +public class LeAudioBluetoothDetailsHeaderController extends BasePreferenceController implements + LifecycleObserver, OnStart, OnStop, OnDestroy, CachedBluetoothDevice.Callback { + private static final String TAG = "LeAudioBtHeaderCtrl"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + @VisibleForTesting + static final int LEFT_DEVICE_ID = + BluetoothLeAudio.AUDIO_LOCATION_FRONT_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_BACK_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_FRONT_LEFT_OF_CENTER + | BluetoothLeAudio.AUDIO_LOCATION_SIDE_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_TOP_FRONT_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_TOP_BACK_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_TOP_SIDE_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_BOTTOM_FRONT_LEFT + | BluetoothLeAudio.AUDIO_LOCATION_FRONT_LEFT_WIDE + | BluetoothLeAudio.AUDIO_LOCATION_LEFT_SURROUND; + + @VisibleForTesting + static final int RIGHT_DEVICE_ID = + BluetoothLeAudio.AUDIO_LOCATION_FRONT_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_BACK_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_FRONT_RIGHT_OF_CENTER + | BluetoothLeAudio.AUDIO_LOCATION_SIDE_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_TOP_FRONT_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_TOP_BACK_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_TOP_SIDE_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_BOTTOM_FRONT_RIGHT + | BluetoothLeAudio.AUDIO_LOCATION_FRONT_RIGHT_WIDE + | BluetoothLeAudio.AUDIO_LOCATION_RIGHT_SURROUND; + + @VisibleForTesting + static final int INVALID_RESOURCE_ID = -1; + + @VisibleForTesting + LayoutPreference mLayoutPreference; + private CachedBluetoothDevice mCachedDevice; + @VisibleForTesting + Handler mHandler = new Handler(Looper.getMainLooper()); + @VisibleForTesting + boolean mIsRegisterCallback = false; + + private LocalBluetoothProfileManager mProfileManager; + + public LeAudioBluetoothDetailsHeaderController(Context context, String prefKey) { + super(context, prefKey); + } + + @Override + public int getAvailabilityStatus() { + if (mCachedDevice == null || mProfileManager == null) { + return CONDITIONALLY_UNAVAILABLE; + } + boolean hasLeAudio = mCachedDevice.getConnectableProfiles() + .stream() + .anyMatch(profile -> profile.getProfileId() == BluetoothProfile.LE_AUDIO); + + return !Utils.isAdvancedDetailsHeader(mCachedDevice.getDevice()) && hasLeAudio + ? AVAILABLE : CONDITIONALLY_UNAVAILABLE; + } + + @Override + public void displayPreference(PreferenceScreen screen) { + super.displayPreference(screen); + mLayoutPreference = screen.findPreference(getPreferenceKey()); + mLayoutPreference.setVisible(isAvailable()); + } + + @Override + public void onStart() { + if (!isAvailable()) { + return; + } + mIsRegisterCallback = true; + mCachedDevice.registerCallback(this); + refresh(); + } + + @Override + public void onStop() { + if (!mIsRegisterCallback) { + return; + } + mCachedDevice.unregisterCallback(this); + mIsRegisterCallback = false; + } + + @Override + public void onDestroy() { + } + + public void init(CachedBluetoothDevice cachedBluetoothDevice, + LocalBluetoothManager bluetoothManager) { + mCachedDevice = cachedBluetoothDevice; + mProfileManager = bluetoothManager.getProfileManager(); + } + + @VisibleForTesting + void refresh() { + if (mLayoutPreference == null || mCachedDevice == null) { + return; + } + final ImageView imageView = mLayoutPreference.findViewById(R.id.entity_header_icon); + if (imageView != null) { + final Pair pair = + BluetoothUtils.getBtRainbowDrawableWithDescription(mContext, mCachedDevice); + imageView.setImageDrawable(pair.first); + imageView.setContentDescription(pair.second); + } + + final TextView title = mLayoutPreference.findViewById(R.id.entity_header_title); + if (title != null) { + title.setText(mCachedDevice.getName()); + } + final TextView summary = mLayoutPreference.findViewById(R.id.entity_header_summary); + if (summary != null) { + summary.setText(mCachedDevice.getConnectionSummary(true /* shortSummary */)); + } + + if (!mCachedDevice.isConnected() || mCachedDevice.isBusy()) { + hideAllOfBatteryLayouts(); + return; + } + + updateBatteryLayout(); + } + + @VisibleForTesting + Drawable createBtBatteryIcon(Context context, int level) { + final BatteryMeterView.BatteryMeterDrawable drawable = + new BatteryMeterView.BatteryMeterDrawable(context, + context.getColor(R.color.meter_background_color), + context.getResources().getDimensionPixelSize( + R.dimen.advanced_bluetooth_battery_meter_width), + context.getResources().getDimensionPixelSize( + R.dimen.advanced_bluetooth_battery_meter_height)); + drawable.setBatteryLevel(level); + drawable.setColorFilter(new PorterDuffColorFilter( + com.android.settings.Utils.getColorAttrDefaultColor(context, + android.R.attr.colorControlNormal), + PorterDuff.Mode.SRC)); + return drawable; + } + + private int getBatteryTitleResource(int deviceId) { + if (deviceId == LEFT_DEVICE_ID) { + return R.id.bt_battery_left_title; + } + if (deviceId == RIGHT_DEVICE_ID) { + return R.id.bt_battery_right_title; + } + Log.d(TAG, "No resource id. The deviceId is " + deviceId); + return INVALID_RESOURCE_ID; + } + + private int getBatterySummaryResource(int deviceId) { + if (deviceId == LEFT_DEVICE_ID) { + return R.id.bt_battery_left_summary; + } + if (deviceId == RIGHT_DEVICE_ID) { + return R.id.bt_battery_right_summary; + } + Log.d(TAG, "No resource id. The deviceId is " + deviceId); + return INVALID_RESOURCE_ID; + } + + private void hideAllOfBatteryLayouts() { + // hide the case + updateBatteryLayout(R.id.bt_battery_case_title, R.id.bt_battery_case_summary, + BluetoothUtils.META_INT_ERROR); + // hide the left + updateBatteryLayout(R.id.bt_battery_left_title, R.id.bt_battery_left_summary, + BluetoothUtils.META_INT_ERROR); + // hide the right + updateBatteryLayout(R.id.bt_battery_right_title, R.id.bt_battery_right_summary, + BluetoothUtils.META_INT_ERROR); + } + + private List getAllOfLeAudioDevices() { + if (mCachedDevice == null) { + return null; + } + List leAudioDevices = new ArrayList<>(); + leAudioDevices.add(mCachedDevice); + if (mCachedDevice.getGroupId() != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) { + for (CachedBluetoothDevice member : mCachedDevice.getMemberDevice()) { + leAudioDevices.add(member); + } + } + return leAudioDevices; + } + + private void updateBatteryLayout() { + // Init the battery layouts. + hideAllOfBatteryLayouts(); + final List leAudioDevices = getAllOfLeAudioDevices(); + LeAudioProfile leAudioProfile = mProfileManager.getLeAudioProfile(); + if (leAudioDevices == null || leAudioDevices.isEmpty()) { + Log.e(TAG, "There is no LeAudioProfile."); + return; + } + + if (!leAudioProfile.isEnabled(mCachedDevice.getDevice())) { + Log.d(TAG, "Show the legacy battery style if the LeAudio is not enabled."); + final TextView summary = mLayoutPreference.findViewById(R.id.entity_header_summary); + if (summary != null) { + summary.setText(mCachedDevice.getConnectionSummary()); + } + return; + } + + for (CachedBluetoothDevice cachedDevice : leAudioDevices) { + int deviceId = leAudioProfile.getAudioLocation(cachedDevice.getDevice()); + Log.d(TAG, "LeAudioDevices:" + cachedDevice.getDevice().getAnonymizedAddress() + + ", deviceId:" + deviceId); + + if (deviceId == BluetoothLeAudio.AUDIO_LOCATION_INVALID) { + Log.d(TAG, "The device does not support the AUDIO_LOCATION."); + return; + } + boolean isLeft = (deviceId & LEFT_DEVICE_ID) != 0; + boolean isRight = (deviceId & LEFT_DEVICE_ID) != 0; + boolean isLeftRight = isLeft && isRight; + // The LE device updates the BatteryLayout + if (isLeftRight) { + Log.d(TAG, "The device id is left+right. Do nothing."); + } else if (isLeft) { + updateBatteryLayout(getBatteryTitleResource(LEFT_DEVICE_ID), + getBatterySummaryResource(LEFT_DEVICE_ID), cachedDevice.getBatteryLevel()); + } else if (isRight) { + updateBatteryLayout(getBatteryTitleResource(RIGHT_DEVICE_ID), + getBatterySummaryResource(RIGHT_DEVICE_ID), cachedDevice.getBatteryLevel()); + } else { + Log.d(TAG, "The device id is other Audio Location. Do nothing."); + } + } + } + + private void updateBatteryLayout(int titleResId, int summaryResId, int batteryLevel) { + final TextView batteryTitleView = mLayoutPreference.findViewById(titleResId); + final TextView batterySummaryView = mLayoutPreference.findViewById(summaryResId); + if (batteryTitleView == null || batterySummaryView == null) { + Log.e(TAG, "updateBatteryLayout: No TextView"); + return; + } + if (batteryLevel != BluetoothUtils.META_INT_ERROR) { + batteryTitleView.setVisibility(View.VISIBLE); + batterySummaryView.setVisibility(View.VISIBLE); + batterySummaryView.setText( + com.android.settings.Utils.formatPercentage(batteryLevel)); + batterySummaryView.setCompoundDrawablesRelativeWithIntrinsicBounds( + createBtBatteryIcon(mContext, batteryLevel), /* top */ null, + /* end */ null, /* bottom */ null); + } else { + Log.d(TAG, "updateBatteryLayout: Hide it if it doesn't have battery information."); + batteryTitleView.setVisibility(View.GONE); + batterySummaryView.setVisibility(View.GONE); + } + } + + @Override + public void onDeviceAttributesChanged() { + if (mCachedDevice != null) { + refresh(); + } + } +}