summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SettingsLib/res/values/strings.xml2
-rw-r--r--packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUi.java170
-rw-r--r--packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUiController.java527
-rw-r--r--packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java21
-rw-r--r--packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/AmbientVolumeUiControllerTest.java315
-rw-r--r--packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java15
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayoutTest.java221
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSliderTest.java91
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java70
-rw-r--r--packages/SystemUI/res/drawable/hearing_devices_spinner_background.xml17
-rw-r--r--packages/SystemUI/res/drawable/ic_hearing_device_expand.xml27
-rw-r--r--packages/SystemUI/res/layout/hearing_device_ambient_volume_layout.xml67
-rw-r--r--packages/SystemUI/res/layout/hearing_device_ambient_volume_slider.xml46
-rw-r--r--packages/SystemUI/res/layout/hearing_devices_tile_dialog.xml11
-rw-r--r--packages/SystemUI/res/values/strings.xml14
-rw-r--r--packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayout.java322
-rw-r--r--packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java170
-rw-r--r--packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegate.java82
18 files changed, 2132 insertions, 56 deletions
diff --git a/packages/SettingsLib/res/values/strings.xml b/packages/SettingsLib/res/values/strings.xml
index e1929b725a58..6cf9e83ef342 100644
--- a/packages/SettingsLib/res/values/strings.xml
+++ b/packages/SettingsLib/res/values/strings.xml
@@ -229,6 +229,8 @@
<string name="bluetooth_hearing_aid_right_active">Active (right only)</string>
<!-- Connected device settings. Message when the left-side and right-side hearing aids device are active. [CHAR LIMIT=NONE] -->
<string name="bluetooth_hearing_aid_left_and_right_active">Active (left and right)</string>
+ <!-- Connected device settings.: Message when changing remote ambient state failed. [CHAR LIMIT=NONE] -->
+ <string name="bluetooth_hearing_device_ambient_error">Couldn\u2019t update surroundings</string>
<!-- Connected devices settings. Message when Bluetooth is connected and active for media only, showing remote device status and battery level. [CHAR LIMIT=NONE] -->
<string name="bluetooth_active_media_only_battery_level">Active (media only). <xliff:g id="battery_level_as_percentage">%1$s</xliff:g> battery.</string>
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUi.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUi.java
new file mode 100644
index 000000000000..881a97bfadcd
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUi.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2024 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.settingslib.bluetooth;
+
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+
+import android.bluetooth.BluetoothDevice;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.List;
+import java.util.Map;
+
+/** Interface for the ambient volume UI. */
+public interface AmbientVolumeUi {
+
+ /** Interface definition for a callback to be invoked when event happens in AmbientVolumeUi. */
+ interface AmbientVolumeUiListener {
+ /** Called when the expand icon is clicked. */
+ void onExpandIconClick();
+
+ /** Called when the ambient volume icon is clicked. */
+ void onAmbientVolumeIconClick();
+
+ /** Called when the slider of the specified side is changed. */
+ void onSliderValueChange(int side, int value);
+ };
+
+ /** The rotation degree of the expand icon when the UI is in collapsed mode. */
+ float ROTATION_COLLAPSED = 0f;
+ /** The rotation degree of the expand icon when the UI is in expanded mode. */
+ float ROTATION_EXPANDED = 180f;
+
+ /**
+ * The default ambient volume level for hearing device ambient volume icon
+ *
+ * <p> This icon visually represents the current ambient volume. It displays separate
+ * levels for the left and right sides, each with 5 levels ranging from 0 to 4.
+ *
+ * <p> To represent the combined left/right levels with a single value, the following
+ * calculation is used:
+ * finalLevel = (leftLevel * 5) + rightLevel
+ * For example:
+ * <ul>
+ * <li>If left level is 2 and right level is 3, the final level will be 13 (2 * 5 + 3)</li>
+ * <li>If both left and right levels are 0, the final level will be 0</li>
+ * <li>If both left and right levels are 4, the final level will be 24</li>
+ * </ul>
+ */
+ int AMBIENT_VOLUME_LEVEL_DEFAULT = 24;
+ /**
+ * The minimum ambient volume level for hearing device ambient volume icon
+ *
+ * @see #AMBIENT_VOLUME_LEVEL_DEFAULT
+ */
+ int AMBIENT_VOLUME_LEVEL_MIN = 0;
+ /**
+ * The maximum ambient volume level for hearing device ambient volume icon
+ *
+ * @see #AMBIENT_VOLUME_LEVEL_DEFAULT
+ */
+ int AMBIENT_VOLUME_LEVEL_MAX = 24;
+
+ /**
+ * Ths side identifier for slider in collapsed mode which can unified control the ambient
+ * volume of all devices in the same set.
+ */
+ int SIDE_UNIFIED = 999;
+
+ /** All valid side of the sliders in the UI. */
+ List<Integer> VALID_SIDES = List.of(SIDE_UNIFIED, SIDE_LEFT, SIDE_RIGHT);
+
+ /** Sets if the UI is visible. */
+ void setVisible(boolean visible);
+
+ /**
+ * Sets if the UI is expandable between expanded and collapsed mode.
+ *
+ * <p> If the UI is not expandable, it implies the UI will always stay in collapsed mode
+ */
+ void setExpandable(boolean expandable);
+
+ /** @return if the UI is expandable. */
+ boolean isExpandable();
+
+ /** Sets if the UI is in expanded mode. */
+ void setExpanded(boolean expanded);
+
+ /** @return if the UI is in expanded mode. */
+ boolean isExpanded();
+
+ /**
+ * Sets if the UI is capable to mute the ambient of the remote device.
+ *
+ * <p> If the value is {@code false}, it implies the remote device ambient will always be
+ * unmute and can not be mute from the UI
+ */
+ void setMutable(boolean mutable);
+
+ /** @return if the UI is capable to mute the ambient of remote device. */
+ boolean isMutable();
+
+ /** Sets if the UI shows mute state. */
+ void setMuted(boolean muted);
+
+ /** @return if the UI shows mute state */
+ boolean isMuted();
+
+ /**
+ * Sets listener on the UI.
+ *
+ * @see AmbientVolumeUiListener
+ */
+ void setListener(@Nullable AmbientVolumeUiListener listener);
+
+ /**
+ * Sets up sliders in the UI.
+ *
+ * <p> For each side of device, the UI should hava a corresponding slider to control it's
+ * ambient volume.
+ * <p> For all devices in the same set, the UI should have a slider to control all devices'
+ * ambient volume at once.
+ * @param sideToDeviceMap the side and device mapping of all devices in the same set
+ */
+ void setupSliders(@NonNull Map<Integer, BluetoothDevice> sideToDeviceMap);
+
+ /**
+ * Sets if the slider is enabled.
+ *
+ * @param side the side of the slider
+ * @param enabled the enabled state
+ */
+ void setSliderEnabled(int side, boolean enabled);
+
+ /**
+ * Sets the slider value.
+ *
+ * @param side the side of the slider
+ * @param value the ambient value
+ */
+ void setSliderValue(int side, int value);
+
+ /**
+ * Sets the slider's minimum and maximum value.
+ *
+ * @param side the side of the slider
+ * @param min the minimum ambient value
+ * @param max the maximum ambient value
+ */
+ void setSliderRange(int side, int min, int max);
+
+ /** Updates the UI according to current state. */
+ void updateLayout();
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUiController.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUiController.java
new file mode 100644
index 000000000000..ce392b12516f
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/AmbientVolumeUiController.java
@@ -0,0 +1,527 @@
+/*
+ * Copyright (C) 2024 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.settingslib.bluetooth;
+
+import static android.bluetooth.AudioInputControl.MUTE_NOT_MUTED;
+import static android.bluetooth.AudioInputControl.MUTE_MUTED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+
+import static com.android.settingslib.bluetooth.AmbientVolumeUi.SIDE_UNIFIED;
+import static com.android.settingslib.bluetooth.AmbientVolumeUi.VALID_SIDES;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_INVALID;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+import static com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data.INVALID_VOLUME;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.util.ArraySet;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settingslib.R;
+import com.android.settingslib.utils.ThreadUtils;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+
+import java.util.Map;
+import java.util.Set;
+
+/** This class controls ambient volume UI with local and remote ambient data. */
+public class AmbientVolumeUiController implements
+ HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener,
+ AmbientVolumeController.AmbientVolumeControlCallback,
+ AmbientVolumeUi.AmbientVolumeUiListener, BluetoothCallback, CachedBluetoothDevice.Callback {
+
+ private static final boolean DEBUG = true;
+ private static final String TAG = "AmbientVolumeUiController";
+
+ private final Context mContext;
+ private final LocalBluetoothProfileManager mProfileManager;
+ private final BluetoothEventManager mEventManager;
+ private final AmbientVolumeUi mAmbientLayout;
+ private final AmbientVolumeController mVolumeController;
+ private final HearingDeviceLocalDataManager mLocalDataManager;
+
+ private final Set<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
+ private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
+ private CachedBluetoothDevice mCachedDevice;
+ private boolean mShowUiWhenLocalDataExist = true;
+
+ public AmbientVolumeUiController(@NonNull Context context,
+ @NonNull LocalBluetoothManager bluetoothManager,
+ @NonNull AmbientVolumeUi ambientLayout) {
+ mContext = context;
+ mProfileManager = bluetoothManager.getProfileManager();
+ mEventManager = bluetoothManager.getEventManager();
+ mAmbientLayout = ambientLayout;
+ mAmbientLayout.setListener(this);
+ mVolumeController = new AmbientVolumeController(mProfileManager, this);
+ mLocalDataManager = new HearingDeviceLocalDataManager(context);
+ mLocalDataManager.setOnDeviceLocalDataChangeListener(this,
+ ThreadUtils.getBackgroundExecutor());
+ }
+
+ @VisibleForTesting
+ public AmbientVolumeUiController(@NonNull Context context,
+ @NonNull LocalBluetoothManager bluetoothManager,
+ @NonNull AmbientVolumeUi ambientLayout,
+ @NonNull AmbientVolumeController volumeController,
+ @NonNull HearingDeviceLocalDataManager localDataManager) {
+ mContext = context;
+ mProfileManager = bluetoothManager.getProfileManager();
+ mEventManager = bluetoothManager.getEventManager();
+ mAmbientLayout = ambientLayout;
+ mVolumeController = volumeController;
+ mLocalDataManager = localDataManager;
+ }
+
+
+ @Override
+ public void onDeviceLocalDataChange(@NonNull String address,
+ @Nullable HearingDeviceLocalDataManager.Data data) {
+ if (data == null) {
+ // The local data is removed because the device is unpaired, do nothing
+ return;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "onDeviceLocalDataChange, address:" + address + ", data:" + data);
+ }
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ if (device.getAnonymizedAddress().equals(address)) {
+ postOnMainThread(() -> loadLocalDataToUi(device));
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onVolumeControlServiceConnected() {
+ mCachedDevices.forEach(device -> mVolumeController.registerCallback(
+ ThreadUtils.getBackgroundExecutor(), device.getDevice()));
+ }
+
+ @Override
+ public void onAmbientChanged(@NonNull BluetoothDevice device, int gainSettings) {
+ if (DEBUG) {
+ Log.d(TAG, "onAmbientChanged, value:" + gainSettings + ", device:" + device);
+ }
+ HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
+ final boolean expanded = mAmbientLayout.isExpanded();
+ final boolean isInitiatedFromUi = (expanded && data.ambient() == gainSettings)
+ || (!expanded && data.groupAmbient() == gainSettings);
+ if (isInitiatedFromUi) {
+ // The change is initiated from UI, no need to update UI
+ return;
+ }
+
+ // We have to check if we need to expand the controls by getting all remote
+ // device's ambient value, delay for a while to wait all remote devices update
+ // to the latest value to avoid unnecessary expand action.
+ postDelayedOnMainThread(this::refresh, 1200L);
+ }
+
+ @Override
+ public void onMuteChanged(@NonNull BluetoothDevice device, int mute) {
+ if (DEBUG) {
+ Log.d(TAG, "onMuteChanged, mute:" + mute + ", device:" + device);
+ }
+ final boolean muted = mAmbientLayout.isMuted();
+ boolean isInitiatedFromUi = (muted && mute == MUTE_MUTED)
+ || (!muted && mute == MUTE_NOT_MUTED);
+ if (isInitiatedFromUi) {
+ // The change is initiated from UI, no need to update UI
+ return;
+ }
+
+ // We have to check if we need to mute the devices by getting all remote
+ // device's mute state, delay for a while to wait all remote devices update
+ // to the latest value.
+ postDelayedOnMainThread(this::refresh, 1200L);
+ }
+
+ @Override
+ public void onCommandFailed(@NonNull BluetoothDevice device) {
+ Log.w(TAG, "onCommandFailed, device:" + device);
+ postOnMainThread(() -> {
+ showErrorToast(R.string.bluetooth_hearing_device_ambient_error);
+ refresh();
+ });
+ }
+
+ @Override
+ public void onExpandIconClick() {
+ mSideToDeviceMap.forEach((s, d) -> {
+ if (!mAmbientLayout.isMuted()) {
+ // Apply previous collapsed/expanded volume to remote device
+ HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(d);
+ int volume = mAmbientLayout.isExpanded()
+ ? data.ambient() : data.groupAmbient();
+ mVolumeController.setAmbient(d, volume);
+ }
+ // Update new value to local data
+ mLocalDataManager.updateAmbientControlExpanded(d,
+ mAmbientLayout.isExpanded());
+ });
+ mLocalDataManager.flush();
+ }
+
+ @Override
+ public void onAmbientVolumeIconClick() {
+ if (!mAmbientLayout.isMuted()) {
+ loadLocalDataToUi();
+ }
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ mVolumeController.setMuted(device, mAmbientLayout.isMuted());
+ }
+ }
+
+ @Override
+ public void onSliderValueChange(int side, int value) {
+ if (DEBUG) {
+ Log.d(TAG, "onSliderValueChange: side=" + side + ", value=" + value);
+ }
+ setVolumeIfValid(side, value);
+
+ Runnable setAmbientRunnable = () -> {
+ if (side == SIDE_UNIFIED) {
+ mSideToDeviceMap.forEach((s, d) -> mVolumeController.setAmbient(d, value));
+ } else {
+ final BluetoothDevice device = mSideToDeviceMap.get(side);
+ mVolumeController.setAmbient(device, value);
+ }
+ };
+
+ if (mAmbientLayout.isMuted()) {
+ // User drag on the volume slider when muted. Unmute the devices first.
+ mAmbientLayout.setMuted(false);
+
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ mVolumeController.setMuted(device, false);
+ }
+ // Restore the value before muted
+ loadLocalDataToUi();
+ // Delay set ambient on remote device since the immediately sequential command
+ // might get failed sometimes
+ postDelayedOnMainThread(setAmbientRunnable, 1000L);
+ } else {
+ setAmbientRunnable.run();
+ }
+ }
+
+ @Override
+ public void onProfileConnectionStateChanged(@NonNull CachedBluetoothDevice cachedDevice,
+ int state, int bluetoothProfile) {
+ if (bluetoothProfile == BluetoothProfile.VOLUME_CONTROL
+ && state == BluetoothProfile.STATE_CONNECTED
+ && mCachedDevices.contains(cachedDevice)) {
+ // After VCP connected, AICS may not ready yet and still return invalid value, delay
+ // a while to wait AICS ready as a workaround
+ postDelayedOnMainThread(this::refresh, 1000L);
+ }
+ }
+
+ @Override
+ public void onDeviceAttributesChanged() {
+ mCachedDevices.forEach(device -> {
+ device.unregisterCallback(this);
+ mVolumeController.unregisterCallback(device.getDevice());
+ });
+ postOnMainThread(()-> {
+ loadDevice(mCachedDevice);
+ ThreadUtils.postOnBackgroundThread(()-> {
+ mCachedDevices.forEach(device -> {
+ device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+ mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
+ device.getDevice());
+ });
+ });
+ });
+ }
+
+ /**
+ * Registers callbacks and listeners, this should be called when needs to start listening to
+ * events.
+ */
+ public void start() {
+ mEventManager.registerCallback(this);
+ mLocalDataManager.start();
+ mCachedDevices.forEach(device -> {
+ device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+ mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
+ device.getDevice());
+ });
+ }
+
+ /**
+ * Unregisters callbacks and listeners, this should be called when no longer needs to listen to
+ * events.
+ */
+ public void stop() {
+ mEventManager.unregisterCallback(this);
+ mLocalDataManager.stop();
+ mCachedDevices.forEach(device -> {
+ device.unregisterCallback(this);
+ mVolumeController.unregisterCallback(device.getDevice());
+ });
+ }
+
+ /**
+ * Loads all devices in the same set with {@code cachedDevice} and create corresponding sliders.
+ *
+ * <p>If the devices has valid ambient control points, the ambient volume UI will be visible.
+ * @param cachedDevice the remote device
+ */
+ public void loadDevice(CachedBluetoothDevice cachedDevice) {
+ if (DEBUG) {
+ Log.d(TAG, "loadDevice, device=" + cachedDevice);
+ }
+ mCachedDevice = cachedDevice;
+ mSideToDeviceMap.clear();
+ mCachedDevices.clear();
+ boolean deviceSupportVcp =
+ cachedDevice != null && cachedDevice.getProfiles().stream().anyMatch(
+ p -> p instanceof VolumeControlProfile);
+ if (!deviceSupportVcp) {
+ mAmbientLayout.setVisible(false);
+ return;
+ }
+
+ // load devices in the same set
+ if (VALID_SIDES.contains(cachedDevice.getDeviceSide())
+ && cachedDevice.getBondState() == BOND_BONDED) {
+ mSideToDeviceMap.put(cachedDevice.getDeviceSide(), cachedDevice.getDevice());
+ mCachedDevices.add(cachedDevice);
+ }
+ for (CachedBluetoothDevice memberDevice : cachedDevice.getMemberDevice()) {
+ if (VALID_SIDES.contains(memberDevice.getDeviceSide())
+ && memberDevice.getBondState() == BOND_BONDED) {
+ mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice());
+ mCachedDevices.add(memberDevice);
+ }
+ }
+
+ mAmbientLayout.setExpandable(mSideToDeviceMap.size() > 1);
+ mAmbientLayout.setupSliders(mSideToDeviceMap);
+ refresh();
+ }
+
+ /** Refreshes the ambient volume UI. */
+ public void refresh() {
+ if (isAmbientControlAvailable()) {
+ mAmbientLayout.setVisible(true);
+ loadRemoteDataToUi();
+ } else {
+ mAmbientLayout.setVisible(false);
+ }
+ }
+
+ /** Sets if the ambient volume UI should be visible when local ambient data exist. */
+ public void setShowUiWhenLocalDataExist(boolean shouldShow) {
+ mShowUiWhenLocalDataExist = shouldShow;
+ }
+
+ /** Updates the ambient sliders according to current state. */
+ private void updateSliderUi() {
+ boolean isAnySliderEnabled = false;
+ for (Map.Entry<Integer, BluetoothDevice> entry : mSideToDeviceMap.entrySet()) {
+ final int side = entry.getKey();
+ final BluetoothDevice device = entry.getValue();
+ final boolean enabled = isDeviceConnectedToVcp(device)
+ && mVolumeController.isAmbientControlAvailable(device);
+ isAnySliderEnabled |= enabled;
+ mAmbientLayout.setSliderEnabled(side, enabled);
+ }
+ mAmbientLayout.setSliderEnabled(SIDE_UNIFIED, isAnySliderEnabled);
+ mAmbientLayout.updateLayout();
+ }
+
+ /** Sets the ambient to the corresponding control slider. */
+ private void setVolumeIfValid(int side, int volume) {
+ if (volume == INVALID_VOLUME) {
+ return;
+ }
+ mAmbientLayout.setSliderValue(side, volume);
+ // Update new value to local data
+ if (side == SIDE_UNIFIED) {
+ mSideToDeviceMap.forEach((s, d) -> mLocalDataManager.updateGroupAmbient(d, volume));
+ } else {
+ mLocalDataManager.updateAmbient(mSideToDeviceMap.get(side), volume);
+ }
+ mLocalDataManager.flush();
+ }
+
+ private void loadLocalDataToUi() {
+ mSideToDeviceMap.forEach((s, d) -> loadLocalDataToUi(d));
+ }
+
+ private void loadLocalDataToUi(BluetoothDevice device) {
+ final HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
+ if (DEBUG) {
+ Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device);
+ }
+ if (isDeviceConnectedToVcp(device) && !mAmbientLayout.isMuted()) {
+ final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID);
+ setVolumeIfValid(side, data.ambient());
+ setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient());
+ }
+ setAmbientControlExpanded(data.ambientControlExpanded());
+ updateSliderUi();
+ }
+
+ private void loadRemoteDataToUi() {
+ BluetoothDevice leftDevice = mSideToDeviceMap.get(SIDE_LEFT);
+ AmbientVolumeController.RemoteAmbientState leftState =
+ mVolumeController.refreshAmbientState(leftDevice);
+ BluetoothDevice rightDevice = mSideToDeviceMap.get(SIDE_RIGHT);
+ AmbientVolumeController.RemoteAmbientState rightState =
+ mVolumeController.refreshAmbientState(rightDevice);
+ if (DEBUG) {
+ Log.d(TAG, "loadRemoteDataToUi, left=" + leftState + ", right=" + rightState);
+ }
+ mSideToDeviceMap.forEach((side, device) -> {
+ int ambientMax = mVolumeController.getAmbientMax(device);
+ int ambientMin = mVolumeController.getAmbientMin(device);
+ if (ambientMin != ambientMax) {
+ mAmbientLayout.setSliderRange(side, ambientMin, ambientMax);
+ mAmbientLayout.setSliderRange(SIDE_UNIFIED, ambientMin, ambientMax);
+ }
+ });
+
+ // Update ambient volume
+ final int leftAmbient = leftState != null ? leftState.gainSetting() : INVALID_VOLUME;
+ final int rightAmbient = rightState != null ? rightState.gainSetting() : INVALID_VOLUME;
+ if (mAmbientLayout.isExpanded()) {
+ setVolumeIfValid(SIDE_LEFT, leftAmbient);
+ setVolumeIfValid(SIDE_RIGHT, rightAmbient);
+ } else {
+ if (leftAmbient != rightAmbient && leftAmbient != INVALID_VOLUME
+ && rightAmbient != INVALID_VOLUME) {
+ setVolumeIfValid(SIDE_LEFT, leftAmbient);
+ setVolumeIfValid(SIDE_RIGHT, rightAmbient);
+ setAmbientControlExpanded(true);
+ } else {
+ int unifiedAmbient = leftAmbient != INVALID_VOLUME ? leftAmbient : rightAmbient;
+ setVolumeIfValid(SIDE_UNIFIED, unifiedAmbient);
+ }
+ }
+ // Initialize local data between side and group value
+ initLocalAmbientDataIfNeeded();
+
+ // Update mute state
+ boolean mutable = true;
+ boolean muted = true;
+ if (isDeviceConnectedToVcp(leftDevice) && leftState != null) {
+ mutable &= leftState.isMutable();
+ muted &= leftState.isMuted();
+ }
+ if (isDeviceConnectedToVcp(rightDevice) && rightState != null) {
+ mutable &= rightState.isMutable();
+ muted &= rightState.isMuted();
+ }
+ mAmbientLayout.setMutable(mutable);
+ mAmbientLayout.setMuted(muted);
+
+ // Ensure remote device mute state is synced
+ syncMuteStateIfNeeded(leftDevice, leftState, muted);
+ syncMuteStateIfNeeded(rightDevice, rightState, muted);
+
+ updateSliderUi();
+ }
+
+ private void setAmbientControlExpanded(boolean expanded) {
+ mAmbientLayout.setExpanded(expanded);
+ mSideToDeviceMap.forEach((s, d) -> {
+ // Update new value to local data
+ mLocalDataManager.updateAmbientControlExpanded(d, expanded);
+ });
+ mLocalDataManager.flush();
+ }
+
+ /** Checks if any device in the same set has valid ambient control points */
+ private boolean isAmbientControlAvailable() {
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ if (mShowUiWhenLocalDataExist) {
+ // Found local ambient data
+ if (mLocalDataManager.get(device).hasAmbientData()) {
+ return true;
+ }
+ }
+ // Found remote ambient control points
+ if (mVolumeController.isAmbientControlAvailable(device)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void initLocalAmbientDataIfNeeded() {
+ int smallerVolumeAmongGroup = Integer.MAX_VALUE;
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
+ if (data.ambient() != INVALID_VOLUME) {
+ smallerVolumeAmongGroup = Math.min(data.ambient(), smallerVolumeAmongGroup);
+ } else if (data.groupAmbient() != INVALID_VOLUME) {
+ // Initialize side ambient from group ambient value
+ mLocalDataManager.updateAmbient(device, data.groupAmbient());
+ }
+ }
+ if (smallerVolumeAmongGroup != Integer.MAX_VALUE) {
+ for (BluetoothDevice device : mSideToDeviceMap.values()) {
+ HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(device);
+ if (data.groupAmbient() == INVALID_VOLUME) {
+ // Initialize group ambient from smaller side ambient value
+ mLocalDataManager.updateGroupAmbient(device, smallerVolumeAmongGroup);
+ }
+ }
+ }
+ mLocalDataManager.flush();
+ }
+
+ private void syncMuteStateIfNeeded(@Nullable BluetoothDevice device,
+ @Nullable AmbientVolumeController.RemoteAmbientState state, boolean muted) {
+ if (isDeviceConnectedToVcp(device) && state != null && state.isMutable()) {
+ if (state.isMuted() != muted) {
+ mVolumeController.setMuted(device, muted);
+ }
+ }
+ }
+
+ private boolean isDeviceConnectedToVcp(@Nullable BluetoothDevice device) {
+ return device != null && device.isConnected()
+ && mProfileManager.getVolumeControlProfile().getConnectionStatus(device)
+ == BluetoothProfile.STATE_CONNECTED;
+ }
+
+ private void postOnMainThread(Runnable runnable) {
+ mContext.getMainThreadHandler().post(runnable);
+ }
+
+ private void postDelayedOnMainThread(Runnable runnable, long delay) {
+ mContext.getMainThreadHandler().postDelayed(runnable, delay);
+ }
+
+ private void showErrorToast(int stringResId) {
+ Toast.makeText(mContext, stringResId, Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
index 6725558cd2bd..3cd37320243f 100644
--- a/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
@@ -148,6 +148,14 @@ public class HearingDeviceLocalDataManager {
}
}
+ /** Flushes the data into Settings . */
+ public synchronized void flush() {
+ if (!mIsStarted) {
+ return;
+ }
+ putAmbientVolumeSettings();
+ }
+
/**
* Puts the local data of the corresponding hearing device.
*
@@ -274,9 +282,6 @@ public class HearingDeviceLocalDataManager {
notifyIfDataChanged(mAddrToDataMap, updatedAddrToDataMap);
mAddrToDataMap.clear();
mAddrToDataMap.putAll(updatedAddrToDataMap);
- if (DEBUG) {
- Log.v(TAG, "getLocalDataFromSettings, " + mAddrToDataMap + ", manager: " + this);
- }
}
}
@@ -287,12 +292,10 @@ public class HearingDeviceLocalDataManager {
builder.append(KEY_ADDR).append("=").append(entry.getKey());
builder.append(entry.getValue().toSettingsFormat()).append(";");
}
- if (DEBUG) {
- Log.v(TAG, "putAmbientVolumeSettings, " + builder + ", manager: " + this);
- }
- Settings.Global.putStringForUser(mContext.getContentResolver(),
- LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(),
- UserHandle.USER_SYSTEM);
+ ThreadUtils.postOnBackgroundThread(() -> {
+ Settings.Global.putStringForUser(mContext.getContentResolver(),
+ LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(), UserHandle.USER_SYSTEM);
+ });
}
}
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/AmbientVolumeUiControllerTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/AmbientVolumeUiControllerTest.java
new file mode 100644
index 000000000000..8b606e299971
--- /dev/null
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/AmbientVolumeUiControllerTest.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright (C) 2024 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.settingslib.bluetooth;
+
+import static android.bluetooth.AudioInputControl.MUTE_DISABLED;
+import static android.bluetooth.AudioInputControl.MUTE_MUTED;
+import static android.bluetooth.AudioInputControl.MUTE_NOT_MUTED;
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
+
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.stubbing.Answer;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+
+/** Tests for {@link AmbientVolumeUiController}. */
+@RunWith(RobolectricTestRunner.class)
+public class AmbientVolumeUiControllerTest {
+
+ @Rule
+ public MockitoRule mockito = MockitoJUnit.rule();
+
+ private static final String TEST_ADDRESS = "00:00:00:00:11";
+ private static final String TEST_MEMBER_ADDRESS = "00:00:00:00:22";
+
+ @Mock
+ LocalBluetoothManager mBluetoothManager;
+ @Mock
+ LocalBluetoothProfileManager mProfileManager;
+ @Mock
+ BluetoothEventManager mEventManager;
+ @Mock
+ VolumeControlProfile mVolumeControlProfile;
+ @Mock
+ AmbientVolumeUi mAmbientLayout;
+ @Mock
+ private AmbientVolumeController mVolumeController;
+ @Mock
+ private HearingDeviceLocalDataManager mLocalDataManager;
+ @Mock
+ private CachedBluetoothDevice mCachedDevice;
+ @Mock
+ private CachedBluetoothDevice mCachedMemberDevice;
+ @Mock
+ private BluetoothDevice mDevice;
+ @Mock
+ private BluetoothDevice mMemberDevice;
+ @Mock
+ private Handler mTestHandler;
+
+ @Spy
+ private final Context mContext = ApplicationProvider.getApplicationContext();
+ private AmbientVolumeUiController mController;
+
+ @Before
+ public void setUp() {
+ when(mBluetoothManager.getProfileManager()).thenReturn(mProfileManager);
+ when(mBluetoothManager.getEventManager()).thenReturn(mEventManager);
+
+ mController = spy(new AmbientVolumeUiController(mContext, mBluetoothManager,
+ mAmbientLayout, mVolumeController, mLocalDataManager));
+
+ when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile);
+ when(mVolumeControlProfile.getConnectionStatus(mDevice)).thenReturn(
+ BluetoothProfile.STATE_CONNECTED);
+ when(mVolumeControlProfile.getConnectionStatus(mMemberDevice)).thenReturn(
+ BluetoothProfile.STATE_CONNECTED);
+ when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(true);
+ when(mVolumeController.isAmbientControlAvailable(mMemberDevice)).thenReturn(true);
+ when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(
+ new HearingDeviceLocalDataManager.Data.Builder().build());
+
+ when(mContext.getMainThreadHandler()).thenReturn(mTestHandler);
+ Answer<Object> answer = invocationOnMock -> {
+ invocationOnMock.getArgument(0, Runnable.class).run();
+ return null;
+ };
+ when(mTestHandler.post(any(Runnable.class))).thenAnswer(answer);
+ when(mTestHandler.postDelayed(any(Runnable.class), anyLong())).thenAnswer(answer);
+
+ prepareDevice(/* hasMember= */ true);
+ mController.loadDevice(mCachedDevice);
+ Mockito.reset(mController);
+ Mockito.reset(mAmbientLayout);
+ }
+
+ @Test
+ public void loadDevice_deviceWithoutMember_controlNotExpandable() {
+ prepareDevice(/* hasMember= */ false);
+
+ mController.loadDevice(mCachedDevice);
+
+ verify(mAmbientLayout).setExpandable(false);
+ }
+
+ @Test
+ public void loadDevice_deviceWithMember_controlExpandable() {
+ prepareDevice(/* hasMember= */ true);
+
+ mController.loadDevice(mCachedDevice);
+
+ verify(mAmbientLayout).setExpandable(true);
+ }
+
+ @Test
+ public void loadDevice_deviceNotSupportVcp_ambientLayoutGone() {
+ when(mCachedDevice.getProfiles()).thenReturn(List.of());
+
+ mController.loadDevice(mCachedDevice);
+
+ verify(mAmbientLayout).setVisible(false);
+ }
+
+ @Test
+ public void loadDevice_ambientControlNotAvailable_ambientLayoutGone() {
+ when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(false);
+ when(mVolumeController.isAmbientControlAvailable(mMemberDevice)).thenReturn(false);
+
+ mController.loadDevice(mCachedDevice);
+
+ verify(mAmbientLayout).setVisible(false);
+ }
+
+ @Test
+ public void loadDevice_supportVcpAndAmbientControlAvailable_ambientLayoutVisible() {
+ when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
+ when(mVolumeController.isAmbientControlAvailable(mDevice)).thenReturn(true);
+
+ mController.loadDevice(mCachedDevice);
+
+ verify(mAmbientLayout).setVisible(true);
+ }
+
+ @Test
+ public void start_callbackRegistered() {
+ mController.start();
+
+ verify(mEventManager).registerCallback(mController);
+ verify(mLocalDataManager).start();
+ verify(mVolumeController).registerCallback(any(Executor.class), eq(mDevice));
+ verify(mVolumeController).registerCallback(any(Executor.class), eq(mMemberDevice));
+ verify(mCachedDevice).registerCallback(any(Executor.class),
+ any(CachedBluetoothDevice.Callback.class));
+ verify(mCachedMemberDevice).registerCallback(any(Executor.class),
+ any(CachedBluetoothDevice.Callback.class));
+ }
+
+ @Test
+ public void stop_callbackUnregistered() {
+ mController.stop();
+
+ verify(mEventManager).unregisterCallback(mController);
+ verify(mLocalDataManager).stop();
+ verify(mVolumeController).unregisterCallback(mDevice);
+ verify(mVolumeController).unregisterCallback(mMemberDevice);
+ verify(mCachedDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
+ verify(mCachedMemberDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
+ }
+
+ @Test
+ public void onDeviceLocalDataChange_verifySetExpandedAndDataUpdated() {
+ final boolean testExpanded = true;
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(0).groupAmbient(0).ambientControlExpanded(testExpanded).build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+
+ mController.onDeviceLocalDataChange(TEST_ADDRESS, data);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mAmbientLayout).setExpanded(testExpanded);
+ verifyDeviceDataUpdated(mDevice);
+ }
+
+ @Test
+ public void onAmbientChanged_refreshWhenNotInitiateFromUi() {
+ HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+ .ambient(10).groupAmbient(10).ambientControlExpanded(true).build();
+ when(mLocalDataManager.get(mDevice)).thenReturn(data);
+ when(mAmbientLayout.isExpanded()).thenReturn(true);
+
+ mController.onAmbientChanged(mDevice, 10);
+ verify(mController, never()).refresh();
+
+ mController.onAmbientChanged(mDevice, 20);
+ verify(mController).refresh();
+ }
+
+ @Test
+ public void onMuteChanged_refreshWhenNotInitiateFromUi() {
+ AmbientVolumeController.RemoteAmbientState state =
+ new AmbientVolumeController.RemoteAmbientState(MUTE_NOT_MUTED, 0);
+ when(mVolumeController.refreshAmbientState(mDevice)).thenReturn(state);
+ when(mAmbientLayout.isExpanded()).thenReturn(false);
+
+ mController.onMuteChanged(mDevice, MUTE_NOT_MUTED);
+ verify(mController, never()).refresh();
+
+ mController.onMuteChanged(mDevice, MUTE_MUTED);
+ verify(mController).refresh();
+ }
+
+ @Test
+ public void refresh_leftAndRightDifferentGainSetting_expandControl() {
+ prepareRemoteData(mDevice, 10, MUTE_NOT_MUTED);
+ prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
+ when(mAmbientLayout.isExpanded()).thenReturn(false);
+
+ mController.refresh();
+
+ verify(mAmbientLayout).setExpanded(true);
+ }
+
+ @Test
+ public void refresh_oneSideNotMutable_controlNotMutableAndNotMuted() {
+ prepareRemoteData(mDevice, 10, MUTE_DISABLED);
+ prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
+
+ mController.refresh();
+
+ verify(mAmbientLayout).setMutable(false);
+ verify(mAmbientLayout).setMuted(false);
+ }
+
+ @Test
+ public void refresh_oneSideNotMuted_controlNotMutedAndSyncToRemote() {
+ prepareRemoteData(mDevice, 10, MUTE_MUTED);
+ prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED);
+
+ mController.refresh();
+
+ verify(mAmbientLayout).setMutable(true);
+ verify(mAmbientLayout).setMuted(false);
+ verify(mVolumeController).setMuted(mDevice, false);
+ }
+
+ private void prepareDevice(boolean hasMember) {
+ when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT);
+ when(mCachedDevice.getDevice()).thenReturn(mDevice);
+ when(mCachedDevice.getBondState()).thenReturn(BOND_BONDED);
+ when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
+ when(mDevice.getAddress()).thenReturn(TEST_ADDRESS);
+ when(mDevice.getAnonymizedAddress()).thenReturn(TEST_ADDRESS);
+ when(mDevice.isConnected()).thenReturn(true);
+ if (hasMember) {
+ when(mCachedDevice.getMemberDevice()).thenReturn(Set.of(mCachedMemberDevice));
+ when(mCachedMemberDevice.getDeviceSide()).thenReturn(SIDE_RIGHT);
+ when(mCachedMemberDevice.getDevice()).thenReturn(mMemberDevice);
+ when(mCachedMemberDevice.getBondState()).thenReturn(BOND_BONDED);
+ when(mCachedMemberDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
+ when(mMemberDevice.getAddress()).thenReturn(TEST_MEMBER_ADDRESS);
+ when(mMemberDevice.getAnonymizedAddress()).thenReturn(TEST_MEMBER_ADDRESS);
+ when(mMemberDevice.isConnected()).thenReturn(true);
+ } else {
+ when(mCachedDevice.getMemberDevice()).thenReturn(Set.of());
+ }
+ }
+
+ private void prepareRemoteData(BluetoothDevice device, int gainSetting, int mute) {
+ when(mVolumeController.refreshAmbientState(device)).thenReturn(
+ new AmbientVolumeController.RemoteAmbientState(gainSetting, mute));
+ }
+
+ private void verifyDeviceDataUpdated(BluetoothDevice device) {
+ verify(mLocalDataManager).updateAmbient(eq(device), anyInt());
+ verify(mLocalDataManager).updateGroupAmbient(eq(device), anyInt());
+ verify(mLocalDataManager).updateAmbientControlExpanded(eq(device),
+ anyBoolean());
+ }
+}
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
index 6d83588e0f6e..6485636079dd 100644
--- a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
@@ -31,6 +31,8 @@ import android.provider.Settings;
import androidx.test.core.app.ApplicationProvider;
+import com.android.settingslib.utils.ThreadUtils;
+
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -49,7 +51,10 @@ import java.util.Map;
/** Tests for {@link HearingDeviceLocalDataManager}. */
@RunWith(RobolectricTestRunner.class)
-@Config(shadows = {HearingDeviceLocalDataManagerTest.ShadowGlobal.class})
+@Config(shadows = {
+ HearingDeviceLocalDataManagerTest.ShadowGlobal.class,
+ HearingDeviceLocalDataManagerTest.ShadowThreadUtils.class,
+})
public class HearingDeviceLocalDataManagerTest {
private static final String TEST_ADDRESS = "XX:XX:XX:XX:11:22";
@@ -249,4 +254,12 @@ public class HearingDeviceLocalDataManagerTest {
return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
}
}
+
+ @Implements(value = ThreadUtils.class)
+ public static class ShadowThreadUtils {
+ @Implementation
+ protected static void postOnBackgroundThread(Runnable runnable) {
+ runnable.run();
+ }
+ }
}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayoutTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayoutTest.java
new file mode 100644
index 000000000000..455329f54864
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayoutTest.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) 2024 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.systemui.accessibility.hearingaid;
+
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+import static com.android.systemui.accessibility.hearingaid.AmbientVolumeLayout.ROTATION_COLLAPSED;
+import static com.android.systemui.accessibility.hearingaid.AmbientVolumeLayout.ROTATION_EXPANDED;
+import static com.android.systemui.accessibility.hearingaid.AmbientVolumeLayout.SIDE_UNIFIED;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.util.ArrayMap;
+import android.view.View;
+import android.widget.ImageView;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.settingslib.bluetooth.AmbientVolumeUi;
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.util.Map;
+
+/** Tests for {@link AmbientVolumeLayout}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class AmbientVolumeLayoutTest extends SysuiTestCase {
+
+ private static final int TEST_LEFT_VOLUME_LEVEL = 1;
+ private static final int TEST_RIGHT_VOLUME_LEVEL = 2;
+ private static final int TEST_UNIFIED_VOLUME_LEVEL = 3;
+
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Spy
+ private Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock
+ private AmbientVolumeUi.AmbientVolumeUiListener mListener;
+
+ private AmbientVolumeLayout mLayout;
+ private ImageView mExpandIcon;
+ private ImageView mVolumeIcon;
+ private final Map<Integer, BluetoothDevice> mSideToDeviceMap = new ArrayMap<>();
+
+ @Before
+ public void setUp() {
+ mLayout = new AmbientVolumeLayout(mContext);
+ mLayout.setListener(mListener);
+ mLayout.setExpandable(true);
+ mLayout.setMutable(true);
+
+ prepareDevices();
+ mLayout.setupSliders(mSideToDeviceMap);
+ mLayout.getSliders().forEach((side, slider) -> {
+ slider.setMin(0);
+ slider.setMax(4);
+ if (side == SIDE_LEFT) {
+ slider.setValue(TEST_LEFT_VOLUME_LEVEL);
+ } else if (side == SIDE_RIGHT) {
+ slider.setValue(TEST_RIGHT_VOLUME_LEVEL);
+ } else if (side == SIDE_UNIFIED) {
+ slider.setValue(TEST_UNIFIED_VOLUME_LEVEL);
+ }
+ });
+
+ mExpandIcon = mLayout.getExpandIcon();
+ mVolumeIcon = mLayout.getVolumeIcon();
+ }
+
+ @Test
+ public void setExpandable_expandable_expandIconVisible() {
+ mLayout.setExpandable(true);
+
+ assertThat(mExpandIcon.getVisibility()).isEqualTo(VISIBLE);
+ }
+
+ @Test
+ public void setExpandable_notExpandable_expandIconGone() {
+ mLayout.setExpandable(false);
+
+ assertThat(mExpandIcon.getVisibility()).isEqualTo(View.GONE);
+ }
+
+ @Test
+ public void setExpanded_expanded_assertControlUiCorrect() {
+ mLayout.setExpanded(true);
+
+ assertControlUiCorrect();
+ }
+
+ @Test
+ public void setExpanded_notExpanded_assertControlUiCorrect() {
+ mLayout.setExpanded(false);
+
+ assertControlUiCorrect();
+ }
+
+ @Test
+ public void setMutable_mutable_clickOnMuteIconChangeMuteState() {
+ mLayout.setMutable(true);
+ mLayout.setMuted(false);
+
+ mVolumeIcon.callOnClick();
+
+ assertThat(mLayout.isMuted()).isTrue();
+ }
+
+ @Test
+ public void setMutable_notMutable_clickOnMuteIconWontChangeMuteState() {
+ mLayout.setMutable(false);
+ mLayout.setMuted(false);
+
+ mVolumeIcon.callOnClick();
+
+ assertThat(mLayout.isMuted()).isFalse();
+ }
+
+ @Test
+ public void updateLayout_mute_volumeIconIsCorrect() {
+ mLayout.setMuted(true);
+ mLayout.updateLayout();
+
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(0);
+ }
+
+ @Test
+ public void updateLayout_unmuteAndExpanded_volumeIconIsCorrect() {
+ mLayout.setMuted(false);
+ mLayout.setExpanded(true);
+ mLayout.updateLayout();
+
+ int expectedLevel = calculateVolumeLevel(TEST_LEFT_VOLUME_LEVEL, TEST_RIGHT_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ @Test
+ public void updateLayout_unmuteAndNotExpanded_volumeIconIsCorrect() {
+ mLayout.setMuted(false);
+ mLayout.setExpanded(false);
+ mLayout.updateLayout();
+
+ int expectedLevel = calculateVolumeLevel(TEST_UNIFIED_VOLUME_LEVEL,
+ TEST_UNIFIED_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ @Test
+ public void setSliderEnabled_expandedAndLeftIsDisabled_volumeIconIsCorrect() {
+ mLayout.setExpanded(true);
+ mLayout.setSliderEnabled(SIDE_LEFT, false);
+
+ int expectedLevel = calculateVolumeLevel(0, TEST_RIGHT_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ @Test
+ public void setSliderValue_expandedAndLeftValueChanged_volumeIconIsCorrect() {
+ mLayout.setExpanded(true);
+ mLayout.setSliderValue(SIDE_LEFT, 4);
+
+ int expectedLevel = calculateVolumeLevel(4, TEST_RIGHT_VOLUME_LEVEL);
+ assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel);
+ }
+
+ private int calculateVolumeLevel(int left, int right) {
+ return left * 5 + right;
+ }
+
+ private void assertControlUiCorrect() {
+ final boolean expanded = mLayout.isExpanded();
+ final Map<Integer, AmbientVolumeSlider> sliders = mLayout.getSliders();
+ if (expanded) {
+ assertThat(sliders.get(SIDE_UNIFIED).getVisibility()).isEqualTo(GONE);
+ assertThat(sliders.get(SIDE_LEFT).getVisibility()).isEqualTo(VISIBLE);
+ assertThat(sliders.get(SIDE_RIGHT).getVisibility()).isEqualTo(VISIBLE);
+ assertThat(mExpandIcon.getRotation()).isEqualTo(ROTATION_EXPANDED);
+ } else {
+ assertThat(sliders.get(SIDE_UNIFIED).getVisibility()).isEqualTo(VISIBLE);
+ assertThat(sliders.get(SIDE_LEFT).getVisibility()).isEqualTo(GONE);
+ assertThat(sliders.get(SIDE_RIGHT).getVisibility()).isEqualTo(GONE);
+ assertThat(mExpandIcon.getRotation()).isEqualTo(ROTATION_COLLAPSED);
+ }
+ }
+
+ private void prepareDevices() {
+ mSideToDeviceMap.put(SIDE_LEFT, mock(BluetoothDevice.class));
+ mSideToDeviceMap.put(SIDE_RIGHT, mock(BluetoothDevice.class));
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSliderTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSliderTest.java
new file mode 100644
index 000000000000..78dfda88a526
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSliderTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 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.systemui.accessibility.hearingaid;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+/** Tests for {@link AmbientVolumeLayout}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class AmbientVolumeSliderTest extends SysuiTestCase {
+
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Spy
+ private Context mContext = ApplicationProvider.getApplicationContext();
+
+ private AmbientVolumeSlider mSlider;
+
+ @Before
+ public void setUp() {
+ mSlider = new AmbientVolumeSlider(mContext);
+ }
+
+ @Test
+ public void setTitle_titleCorrect() {
+ final String testTitle = "test";
+ mSlider.setTitle(testTitle);
+
+ assertThat(mSlider.getTitle()).isEqualTo(testTitle);
+ }
+
+ @Test
+ public void getVolumeLevel_valueMin_volumeLevelIsZero() {
+ prepareSlider(/* min= */ 0, /* max= */ 100, /* value= */ 0);
+
+ // The volume level is divided into 5 levels:
+ // Level 0 corresponds to the minimum volume value. The range between the minimum and
+ // maximum volume is divided into 4 equal intervals, represented by levels 1 to 4.
+ assertThat(mSlider.getVolumeLevel()).isEqualTo(0);
+ }
+
+ @Test
+ public void getVolumeLevel_valueMax_volumeLevelIsFour() {
+ prepareSlider(/* min= */ 0, /* max= */ 100, /* value= */ 100);
+
+ assertThat(mSlider.getVolumeLevel()).isEqualTo(4);
+ }
+
+ @Test
+ public void getVolumeLevel_volumeLevelIsCorrect() {
+ prepareSlider(/* min= */ 0, /* max= */ 100, /* value= */ 73);
+
+ assertThat(mSlider.getVolumeLevel()).isEqualTo(3);
+ }
+
+ private void prepareSlider(float min, float max, float value) {
+ mSlider.setMin(min);
+ mSlider.setMax(max);
+ mSlider.setValue(value);
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java
index ad12c61ab5d1..43d0d69c428f 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java
@@ -16,8 +16,11 @@
package com.android.systemui.accessibility.hearingaid;
+import static android.bluetooth.BluetoothDevice.BOND_BONDED;
import static android.bluetooth.BluetoothHapClient.PRESET_INDEX_UNAVAILABLE;
+import static android.bluetooth.BluetoothProfile.STATE_CONNECTED;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
import static com.android.systemui.accessibility.hearingaid.HearingDevicesDialogDelegate.LIVE_CAPTION_INTENT;
import static com.google.common.truth.Truth.assertThat;
@@ -31,6 +34,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import android.bluetooth.AudioInputControl;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothHapPresetInfo;
import android.bluetooth.BluetoothProfile;
@@ -61,6 +65,7 @@ import com.android.settingslib.bluetooth.HapClientProfile;
import com.android.settingslib.bluetooth.LocalBluetoothAdapter;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
import com.android.systemui.Flags;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.animation.DialogTransitionAnimator;
@@ -90,6 +95,7 @@ import java.util.List;
@TestableLooper.RunWithLooper(setAsMainLooper = true)
@SmallTest
public class HearingDevicesDialogDelegateTest extends SysuiTestCase {
+
@Rule
public MockitoRule mockito = MockitoJUnit.rule();
@@ -120,6 +126,8 @@ public class HearingDevicesDialogDelegateTest extends SysuiTestCase {
@Mock
private HapClientProfile mHapClientProfile;
@Mock
+ private VolumeControlProfile mVolumeControlProfile;
+ @Mock
private CachedBluetoothDeviceManager mCachedDeviceManager;
@Mock
private BluetoothEventManager mBluetoothEventManager;
@@ -151,21 +159,25 @@ public class HearingDevicesDialogDelegateTest extends SysuiTestCase {
when(mLocalBluetoothManager.getBluetoothAdapter()).thenReturn(mLocalBluetoothAdapter);
when(mLocalBluetoothManager.getProfileManager()).thenReturn(mProfileManager);
when(mProfileManager.getHapClientProfile()).thenReturn(mHapClientProfile);
+ when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile);
when(mLocalBluetoothAdapter.isEnabled()).thenReturn(true);
when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(List.of(mCachedDevice));
when(mLocalBluetoothManager.getEventManager()).thenReturn(mBluetoothEventManager);
when(mSysUiState.setFlag(anyLong(), anyBoolean())).thenReturn(mSysUiState);
- when(mDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED);
+ when(mDevice.getBondState()).thenReturn(BOND_BONDED);
when(mDevice.isConnected()).thenReturn(true);
when(mCachedDevice.getDevice()).thenReturn(mDevice);
when(mCachedDevice.getAddress()).thenReturn(DEVICE_ADDRESS);
when(mCachedDevice.getName()).thenReturn(DEVICE_NAME);
- when(mCachedDevice.getProfiles()).thenReturn(List.of(mHapClientProfile));
+ when(mCachedDevice.getProfiles()).thenReturn(
+ List.of(mHapClientProfile, mVolumeControlProfile));
when(mCachedDevice.isActiveDevice(BluetoothProfile.HEARING_AID)).thenReturn(true);
when(mCachedDevice.isConnectedHearingAidDevice()).thenReturn(true);
when(mCachedDevice.isConnectedHapClientDevice()).thenReturn(true);
when(mCachedDevice.getDrawableWithDescription()).thenReturn(new Pair<>(mDrawable, ""));
+ when(mCachedDevice.getBondState()).thenReturn(BOND_BONDED);
+ when(mCachedDevice.getDeviceSide()).thenReturn(SIDE_LEFT);
when(mHearingDeviceItem.getCachedBluetoothDevice()).thenReturn(mCachedDevice);
mContext.setMockPackageManager(mPackageManager);
@@ -292,6 +304,46 @@ public class HearingDevicesDialogDelegateTest extends SysuiTestCase {
}
@Test
+ @EnableFlags(com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL)
+ public void showDialog_deviceNotSupportVcp_ambientLayoutGone() {
+ when(mCachedDevice.getProfiles()).thenReturn(List.of());
+
+ setUpDeviceDialogWithoutPairNewDeviceButton();
+ mDialog.show();
+
+ ViewGroup ambientLayout = getAmbientLayout(mDialog);
+ assertThat(ambientLayout.getVisibility()).isEqualTo(View.GONE);
+ }
+
+ @Test
+ @EnableFlags(com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL)
+ public void showDialog_ambientControlNotAvailable_ambientLayoutGone() {
+ when(mVolumeControlProfile.getAudioInputControlServices(mDevice)).thenReturn(List.of());
+
+ setUpDeviceDialogWithoutPairNewDeviceButton();
+ mDialog.show();
+
+ ViewGroup ambientLayout = getAmbientLayout(mDialog);
+ assertThat(ambientLayout.getVisibility()).isEqualTo(View.GONE);
+ }
+
+ @Test
+ @EnableFlags(com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL)
+ public void showDialog_supportVcpAndAmbientControlAvailable_ambientLayoutVisible() {
+ when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile));
+ AudioInputControl audioInputControl = prepareAudioInputControl();
+ when(mVolumeControlProfile.getAudioInputControlServices(mDevice)).thenReturn(
+ List.of(audioInputControl));
+ when(mVolumeControlProfile.getConnectionStatus(mDevice)).thenReturn(STATE_CONNECTED);
+
+ setUpDeviceDialogWithoutPairNewDeviceButton();
+ mDialog.show();
+
+ ViewGroup ambientLayout = getAmbientLayout(mDialog);
+ assertThat(ambientLayout.getVisibility()).isEqualTo(View.VISIBLE);
+ }
+
+ @Test
public void onActiveDeviceChanged_presetExist_presetSelected() {
setUpDeviceDialogWithoutPairNewDeviceButton();
mDialog.show();
@@ -368,6 +420,10 @@ public class HearingDevicesDialogDelegateTest extends SysuiTestCase {
return dialog.requireViewById(R.id.preset_layout);
}
+ private ViewGroup getAmbientLayout(SystemUIDialog dialog) {
+ return dialog.requireViewById(R.id.ambient_layout);
+ }
+
private int countChildWithoutSpace(ViewGroup viewGroup) {
int spaceCount = 0;
@@ -388,6 +444,16 @@ public class HearingDevicesDialogDelegateTest extends SysuiTestCase {
assertThat(toolsLayout.getVisibility()).isEqualTo(targetVisibility);
}
+ private AudioInputControl prepareAudioInputControl() {
+ AudioInputControl audioInputControl = mock(AudioInputControl.class);
+ when(audioInputControl.getAudioInputType()).thenReturn(
+ AudioInputControl.AUDIO_INPUT_TYPE_AMBIENT);
+ when(audioInputControl.getGainMode()).thenReturn(AudioInputControl.GAIN_MODE_MANUAL);
+ when(audioInputControl.getAudioInputStatus()).thenReturn(
+ AudioInputControl.AUDIO_INPUT_STATUS_ACTIVE);
+ return audioInputControl;
+ }
+
@After
public void reset() {
if (mDialogDelegate != null) {
diff --git a/packages/SystemUI/res/drawable/hearing_devices_spinner_background.xml b/packages/SystemUI/res/drawable/hearing_devices_spinner_background.xml
index dfefb9d166af..4b7be3512f53 100644
--- a/packages/SystemUI/res/drawable/hearing_devices_spinner_background.xml
+++ b/packages/SystemUI/res/drawable/hearing_devices_spinner_background.xml
@@ -27,18 +27,7 @@
<solid android:color="@android:color/transparent"/>
</shape>
</item>
- <item
- android:end="20dp"
- android:gravity="end|center_vertical">
- <vector
- android:width="@dimen/hearing_devices_preset_spinner_icon_size"
- android:height="@dimen/hearing_devices_preset_spinner_icon_size"
- android:viewportWidth="24"
- android:viewportHeight="24"
- android:tint="?androidprv:attr/colorControlNormal">
- <path
- android:fillColor="#FF000000"
- android:pathData="M7.41 7.84L12 12.42l4.59-4.58L18 9.25l-6 6-6-6z" />
- </vector>
- </item>
+ <item android:end="20dp"
+ android:gravity="end|center_vertical"
+ android:drawable="@drawable/ic_hearing_device_expand" />
</layer-list> \ No newline at end of file
diff --git a/packages/SystemUI/res/drawable/ic_hearing_device_expand.xml b/packages/SystemUI/res/drawable/ic_hearing_device_expand.xml
new file mode 100644
index 000000000000..fdfe7134a748
--- /dev/null
+++ b/packages/SystemUI/res/drawable/ic_hearing_device_expand.xml
@@ -0,0 +1,27 @@
+<!--
+ Copyright (C) 2024 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="@androidprv:color/materialColorOnSurface">
+ <path
+ android:fillColor="#FFFFFFFF"
+ android:pathData="M7.41 7.84L12 12.42l4.59-4.58L18 9.25l-6 6-6-6z" />
+</vector> \ No newline at end of file
diff --git a/packages/SystemUI/res/layout/hearing_device_ambient_volume_layout.xml b/packages/SystemUI/res/layout/hearing_device_ambient_volume_layout.xml
new file mode 100644
index 000000000000..fd409a5a8bb1
--- /dev/null
+++ b/packages/SystemUI/res/layout/hearing_device_ambient_volume_layout.xml
@@ -0,0 +1,67 @@
+<!--
+ Copyright (C) 2024 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.
+-->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:id="@+id/ambient_header"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/bluetooth_dialog_layout_margin"
+ android:layout_marginEnd="@dimen/bluetooth_dialog_layout_margin"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+ <ImageView
+ android:id="@+id/ambient_volume_icon"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:padding="12dp"
+ android:contentDescription="@string/hearing_devices_ambient_unmute"
+ android:src="@drawable/ic_ambient_volume"
+ android:tint="@androidprv:color/materialColorOnSurface" />
+ <TextView
+ android:id="@+id/ambient_title"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:paddingStart="10dp"
+ android:text="@string/hearing_devices_ambient_label"
+ android:textAppearance="@style/TextAppearance.Dialog.Title"
+ android:textDirection="locale"
+ android:textSize="16sp"
+ android:gravity="center_vertical"
+ android:fontFamily="@*android:string/config_headlineFontFamilyMedium" />
+ <ImageView
+ android:id="@+id/ambient_expand_icon"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:padding="10dp"
+ android:contentDescription="@string/hearing_devices_ambient_expand_controls"
+ android:src="@drawable/ic_hearing_device_expand"
+ android:tint="@androidprv:color/materialColorOnSurface" />
+ </LinearLayout>
+ <LinearLayout
+ android:id="@+id/ambient_control_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+</LinearLayout>
diff --git a/packages/SystemUI/res/layout/hearing_device_ambient_volume_slider.xml b/packages/SystemUI/res/layout/hearing_device_ambient_volume_slider.xml
new file mode 100644
index 000000000000..44ada8943b12
--- /dev/null
+++ b/packages/SystemUI/res/layout/hearing_device_ambient_volume_slider.xml
@@ -0,0 +1,46 @@
+<!--
+ Copyright (C) 2024 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.
+-->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/bluetooth_dialog_layout_margin"
+ android:layout_marginEnd="@dimen/bluetooth_dialog_layout_margin"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/ambient_volume_slider_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:paddingStart="@dimen/hearing_devices_small_title_padding_horizontal"
+ android:textAppearance="@style/TextAppearance.Dialog.Title"
+ android:textDirection="locale"
+ android:textSize="14sp"
+ android:labelFor="@+id/ambient_volume_slider"
+ android:gravity="center_vertical" />
+ <com.google.android.material.slider.Slider
+ style="@style/SystemUI.Material3.Slider"
+ android:id="@+id/ambient_volume_slider"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/bluetooth_dialog_device_height"
+ android:layout_gravity="center_vertical"
+ android:theme="@style/Theme.Material3.DayNight"
+ app:labelBehavior="gone" />
+
+</LinearLayout>
diff --git a/packages/SystemUI/res/layout/hearing_devices_tile_dialog.xml b/packages/SystemUI/res/layout/hearing_devices_tile_dialog.xml
index bf04a6f64d6a..949a6abb9b9d 100644
--- a/packages/SystemUI/res/layout/hearing_devices_tile_dialog.xml
+++ b/packages/SystemUI/res/layout/hearing_devices_tile_dialog.xml
@@ -85,13 +85,22 @@
android:longClickable="false"/>
</LinearLayout>
+ <com.android.systemui.accessibility.hearingaid.AmbientVolumeLayout
+ android:id="@+id/ambient_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/preset_layout"
+ android:layout_marginTop="@dimen/hearing_devices_layout_margin" />
+
<LinearLayout
android:id="@+id/tools_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/preset_layout"
+ app:layout_constraintTop_toBottomOf="@id/ambient_layout"
android:layout_marginTop="@dimen/hearing_devices_layout_margin"
android:orientation="vertical">
<TextView
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index d3ee63ba0dd1..c3d84ff39485 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -1004,6 +1004,20 @@
<string name="hearing_devices_preset_label">Preset</string>
<!-- QuickSettings: Content description for the icon that indicates the item is selected [CHAR LIMIT=NONE]-->
<string name="hearing_devices_spinner_item_selected">Selected</string>
+ <!-- QuickSettings: Title for ambient controls. [CHAR LIMIT=40]-->
+ <string name="hearing_devices_ambient_label">Surroundings</string>
+ <!-- QuickSettings: The text to show the control is for left side device. [CHAR LIMIT=30] -->
+ <string name="hearing_devices_ambient_control_left">Left</string>
+ <!-- QuickSettings: The text to show the control is for right side device. [CHAR LIMIT=30] -->
+ <string name="hearing_devices_ambient_control_right">Right</string>
+ <!-- QuickSettings: Content description for a button, that expands ambient volume sliders [CHAR_LIMIT=NONE] -->
+ <string name="hearing_devices_ambient_expand_controls">Expand to left and right separated controls</string>
+ <!-- QuickSettings: Content description for a button, that collapses ambient volume sliders [CHAR LIMIT=NONE] -->
+ <string name="hearing_devices_ambient_collapse_controls">Collapse to unified control</string>
+ <!-- QuickSettings: Content description for a button, that mute ambient volume [CHAR_LIMIT=NONE] -->
+ <string name="hearing_devices_ambient_mute">Mute surroundings</string>
+ <!-- QuickSettings: Content description for a button, that unmute ambient volume [CHAR LIMIT=NONE] -->
+ <string name="hearing_devices_ambient_unmute">Unmute surroundings</string>
<!-- QuickSettings: Title for related tools of hearing. [CHAR LIMIT=40]-->
<string name="hearing_devices_tools_label">Tools</string>
<!-- QuickSettings: Tool name for hearing devices dialog related tools [CHAR LIMIT=40] [BACKUP_MESSAGE_ID=8916875614623730005]-->
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayout.java b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayout.java
new file mode 100644
index 000000000000..7c141c1b561e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeLayout.java
@@ -0,0 +1,322 @@
+/*
+ * Copyright (C) 2024 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.systemui.accessibility.hearingaid;
+
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT;
+import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settingslib.bluetooth.AmbientVolumeUi;
+import com.android.systemui.res.R;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.primitives.Ints;
+
+import java.util.Map;
+
+/**
+ * A view of ambient volume controls.
+ *
+ * <p> It consists of a header with an expand icon and volume sliders for unified control and
+ * separated control for devices in the same set. Toggle the expand icon will make the UI switch
+ * between unified and separated control.
+ */
+public class AmbientVolumeLayout extends LinearLayout implements AmbientVolumeUi {
+
+ @Nullable
+ private AmbientVolumeUiListener mListener;
+ private ImageView mExpandIcon;
+ private ImageView mVolumeIcon;
+ private boolean mExpandable = true;
+ private boolean mExpanded = false;
+ private boolean mMutable = false;
+ private boolean mMuted = false;
+ private final BiMap<Integer, AmbientVolumeSlider> mSideToSliderMap = HashBiMap.create();
+ private int mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT;
+
+ private final AmbientVolumeSlider.OnChangeListener mSliderOnChangeListener =
+ (slider, value) -> {
+ if (mListener != null) {
+ final int side = mSideToSliderMap.inverse().get(slider);
+ mListener.onSliderValueChange(side, value);
+ }
+ };
+
+ public AmbientVolumeLayout(@Nullable Context context) {
+ this(context, /* attrs= */ null);
+ }
+
+ public AmbientVolumeLayout(@Nullable Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, /* defStyleAttr= */ 0);
+ }
+
+ public AmbientVolumeLayout(@Nullable Context context, @Nullable AttributeSet attrs,
+ int defStyleAttr) {
+ this(context, attrs, defStyleAttr, /* defStyleRes= */ 0);
+ }
+
+ public AmbientVolumeLayout(@Nullable Context context, @Nullable AttributeSet attrs,
+ int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ inflate(context, R.layout.hearing_device_ambient_volume_layout, /* root= */ this);
+ init();
+ }
+
+ private void init() {
+ mVolumeIcon = requireViewById(R.id.ambient_volume_icon);
+ mVolumeIcon.setImageResource(com.android.settingslib.R.drawable.ic_ambient_volume);
+ mVolumeIcon.setOnClickListener(v -> {
+ if (!mMutable) {
+ return;
+ }
+ setMuted(!mMuted);
+ if (mListener != null) {
+ mListener.onAmbientVolumeIconClick();
+ }
+ });
+ updateVolumeIcon();
+
+ mExpandIcon = requireViewById(R.id.ambient_expand_icon);
+ mExpandIcon.setOnClickListener(v -> {
+ setExpanded(!mExpanded);
+ if (mListener != null) {
+ mListener.onExpandIconClick();
+ }
+ });
+ updateExpandIcon();
+ }
+
+ @Override
+ public void setVisible(boolean visible) {
+ setVisibility(visible ? VISIBLE : GONE);
+ }
+
+ @Override
+ public void setExpandable(boolean expandable) {
+ mExpandable = expandable;
+ if (!mExpandable) {
+ setExpanded(false);
+ }
+ updateExpandIcon();
+ }
+
+ @Override
+ public boolean isExpandable() {
+ return mExpandable;
+ }
+
+ @Override
+ public void setExpanded(boolean expanded) {
+ if (!mExpandable && expanded) {
+ return;
+ }
+ mExpanded = expanded;
+ updateExpandIcon();
+ updateLayout();
+ }
+
+ @Override
+ public boolean isExpanded() {
+ return mExpanded;
+ }
+
+ @Override
+ public void setMutable(boolean mutable) {
+ mMutable = mutable;
+ if (!mMutable) {
+ mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT;
+ setMuted(false);
+ }
+ updateVolumeIcon();
+ }
+
+ @Override
+ public boolean isMutable() {
+ return mMutable;
+ }
+
+ @Override
+ public void setMuted(boolean muted) {
+ if (!mMutable && muted) {
+ return;
+ }
+ mMuted = muted;
+ if (mMutable && mMuted) {
+ for (AmbientVolumeSlider slider : mSideToSliderMap.values()) {
+ slider.setValue(slider.getMin());
+ }
+ }
+ updateVolumeIcon();
+ }
+
+ @Override
+ public boolean isMuted() {
+ return mMuted;
+ }
+
+ @Override
+ public void setListener(@Nullable AmbientVolumeUiListener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ public void setupSliders(@NonNull Map<Integer, BluetoothDevice> sideToDeviceMap) {
+ sideToDeviceMap.forEach((side, device) -> createSlider(side));
+ createSlider(SIDE_UNIFIED);
+
+ LinearLayout controlContainer = requireViewById(R.id.ambient_control_container);
+ controlContainer.removeAllViews();
+ if (!mSideToSliderMap.isEmpty()) {
+ for (int side : VALID_SIDES) {
+ final AmbientVolumeSlider slider = mSideToSliderMap.get(side);
+ if (slider != null) {
+ controlContainer.addView(slider);
+ }
+ }
+ }
+ updateLayout();
+ }
+
+ @Override
+ public void setSliderEnabled(int side, boolean enabled) {
+ AmbientVolumeSlider slider = mSideToSliderMap.get(side);
+ if (slider != null && slider.isEnabled() != enabled) {
+ slider.setEnabled(enabled);
+ updateLayout();
+ }
+ }
+
+ @Override
+ public void setSliderValue(int side, int value) {
+ AmbientVolumeSlider slider = mSideToSliderMap.get(side);
+ if (slider != null && slider.getValue() != value) {
+ slider.setValue(value);
+ updateVolumeLevel();
+ }
+ }
+
+ @Override
+ public void setSliderRange(int side, int min, int max) {
+ AmbientVolumeSlider slider = mSideToSliderMap.get(side);
+ if (slider != null) {
+ slider.setMin(min);
+ slider.setMax(max);
+ }
+ }
+
+ @Override
+ public void updateLayout() {
+ mSideToSliderMap.forEach((side, slider) -> {
+ if (side == SIDE_UNIFIED) {
+ slider.setVisibility(mExpanded ? GONE : VISIBLE);
+ } else {
+ slider.setVisibility(mExpanded ? VISIBLE : GONE);
+ }
+ if (!slider.isEnabled()) {
+ slider.setValue(slider.getMin());
+ }
+ });
+ updateVolumeLevel();
+ }
+
+ private void updateVolumeLevel() {
+ int leftLevel, rightLevel;
+ if (mExpanded) {
+ leftLevel = getVolumeLevel(SIDE_LEFT);
+ rightLevel = getVolumeLevel(SIDE_RIGHT);
+ } else {
+ final int unifiedLevel = getVolumeLevel(SIDE_UNIFIED);
+ leftLevel = unifiedLevel;
+ rightLevel = unifiedLevel;
+ }
+ mVolumeLevel = Ints.constrainToRange(leftLevel * 5 + rightLevel,
+ AMBIENT_VOLUME_LEVEL_MIN, AMBIENT_VOLUME_LEVEL_MAX);
+ updateVolumeIcon();
+ }
+
+ private int getVolumeLevel(int side) {
+ AmbientVolumeSlider slider = mSideToSliderMap.get(side);
+ if (slider == null || !slider.isEnabled()) {
+ return 0;
+ }
+ return slider.getVolumeLevel();
+ }
+
+ private void updateExpandIcon() {
+ mExpandIcon.setVisibility(mExpandable ? VISIBLE : GONE);
+ mExpandIcon.setRotation(mExpanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED);
+ if (mExpandable) {
+ final int stringRes = mExpanded ? R.string.hearing_devices_ambient_collapse_controls
+ : R.string.hearing_devices_ambient_expand_controls;
+ mExpandIcon.setContentDescription(mContext.getString(stringRes));
+ } else {
+ mExpandIcon.setContentDescription(null);
+ }
+ }
+
+ private void updateVolumeIcon() {
+ mVolumeIcon.setImageLevel(mMuted ? 0 : mVolumeLevel);
+ if (mMutable) {
+ final int stringRes = mMuted ? R.string.hearing_devices_ambient_unmute
+ : R.string.hearing_devices_ambient_mute;
+ mVolumeIcon.setContentDescription(mContext.getString(stringRes));
+ mVolumeIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ } else {
+ mVolumeIcon.setContentDescription(null);
+ mVolumeIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ }
+ }
+
+ private void createSlider(int side) {
+ if (mSideToSliderMap.containsKey(side)) {
+ return;
+ }
+ AmbientVolumeSlider slider = new AmbientVolumeSlider(mContext);
+ slider.addOnChangeListener(mSliderOnChangeListener);
+ if (side == SIDE_LEFT) {
+ slider.setTitle(mContext.getString(R.string.hearing_devices_ambient_control_left));
+ } else if (side == SIDE_RIGHT) {
+ slider.setTitle(mContext.getString(R.string.hearing_devices_ambient_control_right));
+ }
+ mSideToSliderMap.put(side, slider);
+ }
+
+ @VisibleForTesting
+ ImageView getVolumeIcon() {
+ return mVolumeIcon;
+ }
+
+ @VisibleForTesting
+ ImageView getExpandIcon() {
+ return mExpandIcon;
+ }
+
+ @VisibleForTesting
+ Map<Integer, AmbientVolumeSlider> getSliders() {
+ return mSideToSliderMap;
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java
new file mode 100644
index 000000000000..92338ef3773c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2024 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.systemui.accessibility.hearingaid;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.systemui.res.R;
+
+import com.google.android.material.slider.Slider;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A view of ambient volume slider.
+ * <p> It consists by a title {@link TextView} with a volume control {@link Slider}.
+ */
+public class AmbientVolumeSlider extends LinearLayout {
+
+ private final TextView mTitle;
+ private final Slider mSlider;
+ private final List<OnChangeListener> mChangeListeners = new ArrayList<>();
+ private final Slider.OnSliderTouchListener mSliderTouchListener =
+ new Slider.OnSliderTouchListener() {
+ @Override
+ public void onStartTrackingTouch(@NonNull Slider slider) {
+ }
+
+ @Override
+ public void onStopTrackingTouch(@NonNull Slider slider) {
+ final int value = Math.round(slider.getValue());
+ for (OnChangeListener listener : mChangeListeners) {
+ listener.onValueChange(AmbientVolumeSlider.this, value);
+ }
+ }
+ };
+ public AmbientVolumeSlider(@Nullable Context context) {
+ this(context, /* attrs= */ null);
+ }
+
+ public AmbientVolumeSlider(@Nullable Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, /* defStyleAttr= */ 0);
+ }
+
+ public AmbientVolumeSlider(@Nullable Context context, @Nullable AttributeSet attrs,
+ int defStyleAttr) {
+ this(context, attrs, defStyleAttr, /* defStyleRes= */ 0);
+ }
+
+ public AmbientVolumeSlider(@Nullable Context context, @Nullable AttributeSet attrs,
+ int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+
+ inflate(context, R.layout.hearing_device_ambient_volume_slider, /* root= */ this);
+ mTitle = requireViewById(R.id.ambient_volume_slider_title);
+ mSlider = requireViewById(R.id.ambient_volume_slider);
+ mSlider.addOnSliderTouchListener(mSliderTouchListener);
+ }
+
+ /**
+ * Sets title for the ambient volume slider.
+ * <p> If text is null or empty, then {@link TextView} is hidden.
+ */
+ public void setTitle(@Nullable String text) {
+ mTitle.setText(text);
+ mTitle.setVisibility(TextUtils.isEmpty(text) ? GONE : VISIBLE);
+ }
+
+ /** Gets title for the ambient volume slider. */
+ public CharSequence getTitle() {
+ return mTitle.getText();
+ }
+
+ /**
+ * Adds the callback to the ambient volume slider to get notified when the value is changed by
+ * user.
+ * <p> Note: The {@link OnChangeListener#onValueChange(AmbientVolumeSlider, int)} will be
+ * called when user's finger take off from the slider.
+ */
+ public void addOnChangeListener(@Nullable OnChangeListener listener) {
+ if (listener == null) {
+ return;
+ }
+ mChangeListeners.add(listener);
+ }
+
+ /** Sets max value to the ambient volume slider. */
+ public void setMax(float max) {
+ mSlider.setValueTo(max);
+ }
+
+ /** Gets max value from the ambient volume slider. */
+ public float getMax() {
+ return mSlider.getValueTo();
+ }
+
+ /** Sets min value to the ambient volume slider. */
+ public void setMin(float min) {
+ mSlider.setValueFrom(min);
+ }
+
+ /** Gets min value from the ambient volume slider. */
+ public float getMin() {
+ return mSlider.getValueFrom();
+ }
+
+ /** Sets value to the ambient volume slider. */
+ public void setValue(float value) {
+ mSlider.setValue(value);
+ }
+
+ /** Gets value from the ambient volume slider. */
+ public float getValue() {
+ return mSlider.getValue();
+ }
+
+ /** Sets the enable state to the ambient volume slider. */
+ public void setEnabled(boolean enabled) {
+ mSlider.setEnabled(enabled);
+ }
+
+ /** Gets the enable state of the ambient volume slider. */
+ public boolean isEnabled() {
+ return mSlider.isEnabled();
+ }
+
+ /**
+ * Gets the volume value of the ambient volume slider.
+ * <p> The volume level is divided into 5 levels:
+ * Level 0 corresponds to the minimum volume value. The range between the minimum and maximum
+ * volume is divided into 4 equal intervals, represented by levels 1 to 4.
+ */
+ public int getVolumeLevel() {
+ if (!mSlider.isEnabled()) {
+ return 0;
+ }
+ final double min = mSlider.getValueFrom();
+ final double max = mSlider.getValueTo();
+ final double levelGap = (max - min) / 4.0;
+ final double value = mSlider.getValue();
+ return (int) Math.ceil((value - min) / levelGap);
+ }
+
+ /** Interface definition for a callback invoked when a slider's value is changed. */
+ public interface OnChangeListener {
+ /** Called when the finger is take off from the slider. */
+ void onValueChange(@NonNull AmbientVolumeSlider slider, int value);
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegate.java b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegate.java
index 56435df1ad2c..73aabc3cf95a 100644
--- a/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegate.java
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegate.java
@@ -52,10 +52,12 @@ import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
+import com.android.settingslib.bluetooth.AmbientVolumeUiController;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
import com.android.systemui.accessibility.hearingaid.HearingDevicesListAdapter.HearingDeviceItemCallback;
import com.android.systemui.animation.DialogTransitionAnimator;
import com.android.systemui.bluetooth.qsdialog.ActiveHearingDeviceItemFactory;
@@ -108,7 +110,6 @@ public class HearingDevicesDialogDelegate implements SystemUIDialog.Delegate,
private SystemUIDialog mDialog;
- private RecyclerView mDeviceList;
private List<DeviceItem> mHearingDeviceItemList;
private HearingDevicesListAdapter mDeviceListAdapter;
@@ -134,6 +135,8 @@ public class HearingDevicesDialogDelegate implements SystemUIDialog.Delegate,
}
};
+ private AmbientVolumeUiController mAmbientController;
+
private final List<DeviceItemFactory> mHearingDeviceItemFactoryList = List.of(
new ActiveHearingDeviceItemFactory(),
new AvailableHearingDeviceItemFactory(),
@@ -225,13 +228,17 @@ public class HearingDevicesDialogDelegate implements SystemUIDialog.Delegate,
public void onActiveDeviceChanged(@Nullable CachedBluetoothDevice activeDevice,
int bluetoothProfile) {
refreshDeviceUi();
- if (mPresetController != null) {
- mPresetController.setDevice(getActiveHearingDevice());
- mMainHandler.post(() -> {
+ mMainHandler.post(() -> {
+ CachedBluetoothDevice device = getActiveHearingDevice();
+ if (mPresetController != null) {
+ mPresetController.setDevice(device);
mPresetLayout.setVisibility(
mPresetController.isPresetControlAvailable() ? VISIBLE : GONE);
- });
- }
+ }
+ if (mAmbientController != null) {
+ mAmbientController.loadDevice(device);
+ }
+ });
}
@Override
@@ -272,13 +279,13 @@ public class HearingDevicesDialogDelegate implements SystemUIDialog.Delegate,
}
mUiEventLogger.log(HearingDevicesUiEvent.HEARING_DEVICES_DIALOG_SHOW, mLaunchSourceId);
- mDeviceList = dialog.requireViewById(R.id.device_list);
- mPresetLayout = dialog.requireViewById(R.id.preset_layout);
- mPresetSpinner = dialog.requireViewById(R.id.preset_spinner);
setupDeviceListView(dialog);
- setupPresetSpinner(dialog);
setupPairNewDeviceButton(dialog);
+ setupPresetSpinner(dialog);
+ if (com.android.settingslib.flags.Flags.hearingDevicesAmbientVolumeControl()) {
+ setupAmbientControls();
+ }
if (com.android.systemui.Flags.hearingDevicesDialogRelatedTools()) {
setupRelatedToolsView(dialog);
}
@@ -286,41 +293,50 @@ public class HearingDevicesDialogDelegate implements SystemUIDialog.Delegate,
@Override
public void onStart(@NonNull SystemUIDialog dialog) {
- if (mLocalBluetoothManager == null) {
- return;
- }
- mLocalBluetoothManager.getEventManager().registerCallback(this);
- if (mPresetController != null) {
- mPresetController.registerHapCallback();
- }
+ ThreadUtils.postOnBackgroundThread(() -> {
+ if (mLocalBluetoothManager != null) {
+ mLocalBluetoothManager.getEventManager().registerCallback(this);
+ }
+ if (mPresetController != null) {
+ mPresetController.registerHapCallback();
+ }
+ if (mAmbientController != null) {
+ mAmbientController.start();
+ }
+ });
}
@Override
public void onStop(@NonNull SystemUIDialog dialog) {
- if (mLocalBluetoothManager == null) {
- return;
- }
-
- if (mPresetController != null) {
- mPresetController.unregisterHapCallback();
- }
- mLocalBluetoothManager.getEventManager().unregisterCallback(this);
+ ThreadUtils.postOnBackgroundThread(() -> {
+ if (mLocalBluetoothManager != null) {
+ mLocalBluetoothManager.getEventManager().unregisterCallback(this);
+ }
+ if (mPresetController != null) {
+ mPresetController.unregisterHapCallback();
+ }
+ if (mAmbientController != null) {
+ mAmbientController.stop();
+ }
+ });
}
private void setupDeviceListView(SystemUIDialog dialog) {
- mDeviceList.setLayoutManager(new LinearLayoutManager(dialog.getContext()));
+ final RecyclerView deviceList = dialog.requireViewById(R.id.device_list);
+ deviceList.setLayoutManager(new LinearLayoutManager(dialog.getContext()));
mHearingDeviceItemList = getHearingDeviceItemList();
mDeviceListAdapter = new HearingDevicesListAdapter(mHearingDeviceItemList, this);
- mDeviceList.setAdapter(mDeviceListAdapter);
+ deviceList.setAdapter(mDeviceListAdapter);
}
private void setupPresetSpinner(SystemUIDialog dialog) {
mPresetController = new HearingDevicesPresetsController(mProfileManager, mPresetCallback);
mPresetController.setDevice(getActiveHearingDevice());
+ mPresetSpinner = dialog.requireViewById(R.id.preset_spinner);
mPresetInfoAdapter = new HearingDevicesSpinnerAdapter(dialog.getContext());
mPresetSpinner.setAdapter(mPresetInfoAdapter);
- // disable redundant Touch & Hold accessibility action for Switch Access
+ // Disable redundant Touch & Hold accessibility action for Switch Access
mPresetSpinner.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host,
@@ -349,12 +365,20 @@ public class HearingDevicesDialogDelegate implements SystemUIDialog.Delegate,
}
});
+ mPresetLayout = dialog.requireViewById(R.id.preset_layout);
mPresetLayout.setVisibility(mPresetController.isPresetControlAvailable() ? VISIBLE : GONE);
}
+ private void setupAmbientControls() {
+ final AmbientVolumeLayout ambientLayout = mDialog.requireViewById(R.id.ambient_layout);
+ mAmbientController = new AmbientVolumeUiController(
+ mDialog.getContext(), mLocalBluetoothManager, ambientLayout);
+ mAmbientController.setShowUiWhenLocalDataExist(false);
+ mAmbientController.loadDevice(getActiveHearingDevice());
+ }
+
private void setupPairNewDeviceButton(SystemUIDialog dialog) {
final Button pairButton = dialog.requireViewById(R.id.pair_new_device_button);
-
pairButton.setVisibility(mShowPairNewDevice ? VISIBLE : GONE);
if (mShowPairNewDevice) {
pairButton.setOnClickListener(v -> {