diff options
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 -> { |