diff --git a/res/layout/preference_ambient_volume.xml b/res/layout/preference_ambient_volume.xml new file mode 100644 index 00000000000..a8595c64dd4 --- /dev/null +++ b/res/layout/preference_ambient_volume.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 89f6d8fb44d..5a408d2450f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -164,6 +164,22 @@ There are no presets programmed by your audiologist Couldn\u2019t update preset + + Surroundings + + Expand to left and right separated controls + + Collapse to unified control + + Left + + Right + + Mute surroundings + + Unmute surroundings + + Couldn\u2019t update surroundings Audio output diff --git a/src/com/android/settings/bluetooth/AmbientVolumePreference.java b/src/com/android/settings/bluetooth/AmbientVolumePreference.java new file mode 100644 index 00000000000..e916c046df6 --- /dev/null +++ b/src/com/android/settings/bluetooth/AmbientVolumePreference.java @@ -0,0 +1,307 @@ +/* + * 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.settings.bluetooth; + +import static android.view.View.GONE; +import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO; +import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES; +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 android.content.Context; +import android.util.ArrayMap; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceGroup; +import androidx.preference.PreferenceViewHolder; + +import com.android.settings.R; +import com.android.settings.widget.SeekBarPreference; + +import com.google.common.primitives.Ints; + +import java.util.List; +import java.util.Map; + +/** + * A preference group of ambient volume controls. + * + *

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 AmbientVolumePreference extends PreferenceGroup { + + /** Interface definition for a callback to be invoked when the icon is clicked. */ + public interface OnIconClickListener { + /** Called when the expand icon is clicked. */ + void onExpandIconClick(); + + /** Called when the ambient volume icon is clicked. */ + void onAmbientVolumeIconClick(); + }; + + static final float ROTATION_COLLAPSED = 0f; + static final float ROTATION_EXPANDED = 180f; + static final int AMBIENT_VOLUME_LEVEL_MIN = 0; + static final int AMBIENT_VOLUME_LEVEL_MAX = 24; + static final int AMBIENT_VOLUME_LEVEL_DEFAULT = 24; + static final int SIDE_UNIFIED = 999; + static final List VALID_SIDES = List.of(SIDE_UNIFIED, SIDE_LEFT, SIDE_RIGHT); + + @Nullable + private OnIconClickListener mListener; + @Nullable + private View mExpandIcon; + @Nullable + private ImageView mVolumeIcon; + private boolean mExpandable = true; + private boolean mExpanded = false; + private boolean mMutable = false; + private boolean mMuted = false; + private Map mSideToSliderMap = new ArrayMap<>(); + + /** + * Ambient volume level for hearing device ambient control icon + *

+ * This icon visually represents the current ambient gain setting. + * It displays separate levels for the left and right sides, each with 5 levels ranging from 0 + * to 4. + *

+ * To represent the combined left/right levels with a single value, the following calculation + * is used: + * finalLevel = (leftLevel * 5) + rightLevel + * For example: + *

+ */ + private int mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT; + + public AmbientVolumePreference(@NonNull Context context) { + super(context, null); + setLayoutResource(R.layout.preference_ambient_volume); + setIcon(com.android.settingslib.R.drawable.ic_ambient_volume); + setTitle(R.string.bluetooth_ambient_volume_control); + setSelectable(false); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + holder.setDividerAllowedAbove(false); + holder.setDividerAllowedBelow(false); + + mVolumeIcon = holder.itemView.requireViewById(com.android.internal.R.id.icon); + mVolumeIcon.getDrawable().mutate().setTint(getContext().getColor( + com.android.internal.R.color.materialColorOnPrimaryContainer)); + final View iconView = holder.itemView.requireViewById(R.id.icon_frame); + iconView.setOnClickListener(v -> { + if (!mMutable) { + return; + } + setMuted(!mMuted); + if (mListener != null) { + mListener.onAmbientVolumeIconClick(); + } + }); + updateVolumeIcon(); + + mExpandIcon = holder.itemView.requireViewById(R.id.expand_icon); + mExpandIcon.setOnClickListener(v -> { + setExpanded(!mExpanded); + if (mListener != null) { + mListener.onExpandIconClick(); + } + }); + updateExpandIcon(); + } + + void setExpandable(boolean expandable) { + mExpandable = expandable; + if (!mExpandable) { + setExpanded(false); + } + updateExpandIcon(); + } + + boolean isExpandable() { + return mExpandable; + } + + void setExpanded(boolean expanded) { + if (!mExpandable && expanded) { + return; + } + mExpanded = expanded; + updateExpandIcon(); + updateLayout(); + } + + boolean isExpanded() { + return mExpanded; + } + + void setMutable(boolean mutable) { + mMutable = mutable; + if (!mMutable) { + mVolumeLevel = AMBIENT_VOLUME_LEVEL_DEFAULT; + setMuted(false); + } + updateVolumeIcon(); + } + + boolean isMutable() { + return mMutable; + } + + void setMuted(boolean muted) { + if (!mMutable && muted) { + return; + } + mMuted = muted; + if (mMutable && mMuted) { + for (SeekBarPreference slider : mSideToSliderMap.values()) { + slider.setProgress(slider.getMin()); + } + } + updateVolumeIcon(); + } + + boolean isMuted() { + return mMuted; + } + + void setOnIconClickListener(@Nullable OnIconClickListener listener) { + mListener = listener; + } + + void setSliders(Map sideToSliderMap) { + mSideToSliderMap = sideToSliderMap; + for (SeekBarPreference preference : sideToSliderMap.values()) { + if (findPreference(preference.getKey()) == null) { + addPreference(preference); + } + } + updateLayout(); + } + + void setSliderEnabled(int side, boolean enabled) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider != null && slider.isEnabled() != enabled) { + slider.setEnabled(enabled); + updateLayout(); + } + } + + void setSliderValue(int side, int value) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider != null && slider.getProgress() != value) { + slider.setProgress(value); + updateVolumeLevel(); + } + } + + void setSliderRange(int side, int min, int max) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider != null) { + slider.setMin(min); + slider.setMax(max); + } + } + + void updateLayout() { + mSideToSliderMap.forEach((side, slider) -> { + if (side == SIDE_UNIFIED) { + slider.setVisible(!mExpanded); + } else { + slider.setVisible(mExpanded); + } + if (!slider.isEnabled()) { + slider.setProgress(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) { + SeekBarPreference slider = mSideToSliderMap.get(side); + if (slider == null || !slider.isEnabled()) { + return 0; + } + final double min = slider.getMin(); + final double max = slider.getMax(); + final double levelGap = (max - min) / 4.0; + final int value = slider.getProgress(); + return (int) Math.ceil((value - min) / levelGap); + } + + private void updateExpandIcon() { + if (mExpandIcon == null) { + return; + } + mExpandIcon.setVisibility(mExpandable ? VISIBLE : GONE); + mExpandIcon.setRotation(mExpanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED); + if (mExpandable) { + final int stringRes = mExpanded + ? R.string.bluetooth_ambient_volume_control_collapse + : R.string.bluetooth_ambient_volume_control_expand; + mExpandIcon.setContentDescription(getContext().getString(stringRes)); + } else { + mExpandIcon.setContentDescription(null); + } + } + + private void updateVolumeIcon() { + if (mVolumeIcon == null) { + return; + } + mVolumeIcon.setImageLevel(mMuted ? 0 : mVolumeLevel); + if (mMutable) { + final int stringRes = mMuted + ? R.string.bluetooth_ambient_volume_unmute + : R.string.bluetooth_ambient_volume_mute; + mVolumeIcon.setContentDescription(getContext().getString(stringRes)); + mVolumeIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + } else { + mVolumeIcon.setContentDescription(null); + mVolumeIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + } + } +} diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java new file mode 100644 index 00000000000..f237ffe50c3 --- /dev/null +++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java @@ -0,0 +1,614 @@ +/* + * 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.settings.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.settings.bluetooth.AmbientVolumePreference.SIDE_UNIFIED; +import static com.android.settings.bluetooth.AmbientVolumePreference.VALID_SIDES; +import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.KEY_HEARING_DEVICE_GROUP; +import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.ORDER_AMBIENT_VOLUME; +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 androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settings.widget.SeekBarPreference; +import com.android.settingslib.bluetooth.AmbientVolumeController; +import com.android.settingslib.bluetooth.BluetoothCallback; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager; +import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.bluetooth.VolumeControlProfile; +import com.android.settingslib.core.lifecycle.Lifecycle; +import com.android.settingslib.core.lifecycle.events.OnStart; +import com.android.settingslib.core.lifecycle.events.OnStop; +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; + +/** A {@link BluetoothDetailsController} that manages ambient volume control preferences. */ +public class BluetoothDetailsAmbientVolumePreferenceController extends + BluetoothDetailsController implements Preference.OnPreferenceChangeListener, + HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, OnStart, OnStop, + AmbientVolumeController.AmbientVolumeControlCallback, BluetoothCallback { + + private static final boolean DEBUG = true; + private static final String TAG = "AmbientPrefController"; + + static final String KEY_AMBIENT_VOLUME = "ambient_volume"; + static final String KEY_AMBIENT_VOLUME_SLIDER = "ambient_volume_slider"; + private static final int ORDER_AMBIENT_VOLUME_CONTROL_UNIFIED = 0; + private static final int ORDER_AMBIENT_VOLUME_CONTROL_SEPARATED = 1; + + private final LocalBluetoothManager mBluetoothManager; + private final Set mCachedDevices = new ArraySet<>(); + private final BiMap mSideToDeviceMap = HashBiMap.create(); + private final BiMap mSideToSliderMap = HashBiMap.create(); + private final HearingDeviceLocalDataManager mLocalDataManager; + private final AmbientVolumeController mVolumeController; + + @Nullable + private PreferenceCategory mDeviceControls; + @Nullable + private AmbientVolumePreference mPreference; + @Nullable + private Toast mToast; + + public BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context, + @NonNull LocalBluetoothManager manager, + @NonNull PreferenceFragmentCompat fragment, + @NonNull CachedBluetoothDevice device, + @NonNull Lifecycle lifecycle) { + super(context, fragment, device, lifecycle); + mBluetoothManager = manager; + mLocalDataManager = new HearingDeviceLocalDataManager(context); + mLocalDataManager.setOnDeviceLocalDataChangeListener(this, + ThreadUtils.getBackgroundExecutor()); + mVolumeController = new AmbientVolumeController(manager.getProfileManager(), this); + } + + @VisibleForTesting + BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context, + @NonNull LocalBluetoothManager manager, + @NonNull PreferenceFragmentCompat fragment, + @NonNull CachedBluetoothDevice device, + @NonNull Lifecycle lifecycle, + @NonNull HearingDeviceLocalDataManager localSettings, + @NonNull AmbientVolumeController volumeController) { + super(context, fragment, device, lifecycle); + mBluetoothManager = manager; + mLocalDataManager = localSettings; + mVolumeController = volumeController; + } + + @Override + protected void init(PreferenceScreen screen) { + mDeviceControls = screen.findPreference(KEY_HEARING_DEVICE_GROUP); + if (mDeviceControls == null) { + return; + } + loadDevices(); + } + + @Override + public void onStart() { + ThreadUtils.postOnBackgroundThread(() -> { + mBluetoothManager.getEventManager().registerCallback(this); + mLocalDataManager.start(); + mCachedDevices.forEach(device -> { + device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); + mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(), + device.getDevice()); + }); + }); + } + + @Override + public void onResume() { + refresh(); + } + + @Override + public void onPause() { + } + + @Override + public void onStop() { + ThreadUtils.postOnBackgroundThread(() -> { + mBluetoothManager.getEventManager().unregisterCallback(this); + mLocalDataManager.stop(); + mCachedDevices.forEach(device -> { + device.unregisterCallback(this); + mVolumeController.unregisterCallback(device.getDevice()); + }); + }); + } + + @Override + protected void refresh() { + if (!isAvailable()) { + return; + } + boolean shouldShowAmbientControl = isAmbientControlAvailable(); + if (shouldShowAmbientControl) { + if (mPreference != null) { + mPreference.setVisible(true); + } + loadRemoteDataToUi(); + } else { + if (mPreference != null) { + mPreference.setVisible(false); + } + } + } + + @Override + public boolean isAvailable() { + return mCachedDevice.getProfiles().stream().anyMatch( + profile -> profile instanceof VolumeControlProfile); + } + + @Nullable + @Override + public String getPreferenceKey() { + return KEY_AMBIENT_VOLUME; + } + + @Override + public boolean onPreferenceChange(@NonNull Preference preference, @Nullable Object newValue) { + if (preference instanceof SeekBarPreference && newValue instanceof final Integer value) { + final int side = mSideToSliderMap.inverse().getOrDefault(preference, SIDE_INVALID); + if (DEBUG) { + Log.d(TAG, "onPreferenceChange: 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 (isControlMuted()) { + // User drag on the volume slider when muted. Unmute the devices first. + if (mPreference != null) { + mPreference.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 + mContext.getMainThreadHandler().postDelayed(setAmbientRunnable, 1000L); + } else { + setAmbientRunnable.run(); + } + return true; + } + return false; + } + + @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 + mContext.getMainThreadHandler().postDelayed(this::refresh, 1000L); + } + } + + @Override + public void onDeviceAttributesChanged() { + mCachedDevices.forEach(device -> { + device.unregisterCallback(this); + mVolumeController.unregisterCallback(device.getDevice()); + }); + mContext.getMainExecutor().execute(() -> { + loadDevices(); + if (!mCachedDevices.isEmpty()) { + refresh(); + } + ThreadUtils.postOnBackgroundThread(() -> + mCachedDevices.forEach(device -> { + device.registerCallback(ThreadUtils.getBackgroundExecutor(), this); + mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(), + device.getDevice()); + }) + ); + }); + } + + @Override + public void onDeviceLocalDataChange(@NonNull String address, @Nullable Data data) { + if (data == null) { + // The local data is removed because the device is unpaired, do nothing + return; + } + for (BluetoothDevice device : mSideToDeviceMap.values()) { + if (device.getAnonymizedAddress().equals(address)) { + mContext.getMainExecutor().execute(() -> 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); + } + Data data = mLocalDataManager.get(device); + boolean isInitiatedFromUi = (isControlExpanded() && data.ambient() == gainSettings) + || (!isControlExpanded() && 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. + mContext.getMainThreadHandler().postDelayed(this::refresh, 1200L); + } + + @Override + public void onMuteChanged(@NonNull BluetoothDevice device, int mute) { + if (DEBUG) { + Log.d(TAG, "onMuteChanged, mute:" + mute + ", device:" + device); + } + boolean isInitiatedFromUi = (isControlMuted() && mute == MUTE_MUTED) + || (!isControlMuted() && 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. + mContext.getMainThreadHandler().postDelayed(this::refresh, 1200L); + } + + @Override + public void onCommandFailed(@NonNull BluetoothDevice device) { + Log.w(TAG, "onCommandFailed, device:" + device); + mContext.getMainExecutor().execute(() -> { + showErrorToast(); + refresh(); + }); + } + + private void loadDevices() { + mSideToDeviceMap.clear(); + mCachedDevices.clear(); + if (VALID_SIDES.contains(mCachedDevice.getDeviceSide()) + && mCachedDevice.getBondState() == BOND_BONDED) { + mSideToDeviceMap.put(mCachedDevice.getDeviceSide(), mCachedDevice.getDevice()); + mCachedDevices.add(mCachedDevice); + } + for (CachedBluetoothDevice memberDevice : mCachedDevice.getMemberDevice()) { + if (VALID_SIDES.contains(memberDevice.getDeviceSide()) + && memberDevice.getBondState() == BOND_BONDED) { + mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice()); + mCachedDevices.add(memberDevice); + } + } + createAmbientVolumePreference(); + createSliderPreferences(); + if (mPreference != null) { + mPreference.setExpandable(mSideToDeviceMap.size() > 1); + mPreference.setSliders((mSideToSliderMap)); + } + } + + private void createAmbientVolumePreference() { + if (mPreference != null || mDeviceControls == null) { + return; + } + + mPreference = new AmbientVolumePreference(mDeviceControls.getContext()); + mPreference.setKey(KEY_AMBIENT_VOLUME); + mPreference.setOrder(ORDER_AMBIENT_VOLUME); + mPreference.setOnIconClickListener( + new AmbientVolumePreference.OnIconClickListener() { + @Override + public void onExpandIconClick() { + mSideToDeviceMap.forEach((s, d) -> { + if (!isControlMuted()) { + // Apply previous collapsed/expanded volume to remote device + Data data = mLocalDataManager.get(d); + int volume = isControlExpanded() + ? data.ambient() : data.groupAmbient(); + mVolumeController.setAmbient(d, volume); + } + // Update new value to local data + mLocalDataManager.updateAmbientControlExpanded(d, isControlExpanded()); + }); + } + + @Override + public void onAmbientVolumeIconClick() { + if (!isControlMuted()) { + loadLocalDataToUi(); + } + for (BluetoothDevice device : mSideToDeviceMap.values()) { + mVolumeController.setMuted(device, isControlMuted()); + } + } + }); + if (mDeviceControls.findPreference(mPreference.getKey()) == null) { + mDeviceControls.addPreference(mPreference); + } + } + + private void createSliderPreferences() { + mSideToDeviceMap.forEach((s, d) -> + createSliderPreference(s, ORDER_AMBIENT_VOLUME_CONTROL_SEPARATED + s)); + createSliderPreference(SIDE_UNIFIED, ORDER_AMBIENT_VOLUME_CONTROL_UNIFIED); + } + + private void createSliderPreference(int side, int order) { + if (mSideToSliderMap.containsKey(side) || mDeviceControls == null) { + return; + } + SeekBarPreference preference = new SeekBarPreference(mDeviceControls.getContext()); + preference.setKey(KEY_AMBIENT_VOLUME_SLIDER + "_" + side); + preference.setOrder(order); + preference.setOnPreferenceChangeListener(this); + if (side == SIDE_LEFT) { + preference.setTitle(mContext.getString(R.string.bluetooth_ambient_volume_control_left)); + } else if (side == SIDE_RIGHT) { + preference.setTitle( + mContext.getString(R.string.bluetooth_ambient_volume_control_right)); + } + mSideToSliderMap.put(side, preference); + } + + /** Refreshes the control UI visibility and enabled state. */ + private void refreshControlUi() { + if (mPreference != null) { + boolean isAnySliderEnabled = false; + for (Map.Entry entry : mSideToDeviceMap.entrySet()) { + final int side = entry.getKey(); + final BluetoothDevice device = entry.getValue(); + final boolean enabled = isDeviceConnectedToVcp(device) + && mVolumeController.isAmbientControlAvailable(device); + isAnySliderEnabled |= enabled; + mPreference.setSliderEnabled(side, enabled); + } + mPreference.setSliderEnabled(SIDE_UNIFIED, isAnySliderEnabled); + mPreference.updateLayout(); + } + } + + /** Sets the volume to the corresponding control slider. */ + private void setVolumeIfValid(int side, int volume) { + if (volume == INVALID_VOLUME) { + return; + } + if (mPreference != null) { + mPreference.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); + } + } + + private void loadLocalDataToUi() { + mSideToDeviceMap.forEach((s, d) -> loadLocalDataToUi(d)); + } + + private void loadLocalDataToUi(BluetoothDevice device) { + final Data data = mLocalDataManager.get(device); + if (DEBUG) { + Log.d(TAG, "loadLocalDataToUi, data=" + data + ", device=" + device); + } + final int side = mSideToDeviceMap.inverse().getOrDefault(device, SIDE_INVALID); + if (isDeviceConnectedToVcp(device) && !isControlMuted()) { + setVolumeIfValid(side, data.ambient()); + setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient()); + } + setControlExpanded(data.ambientControlExpanded()); + refreshControlUi(); + } + + 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); + } + + if (mPreference != null) { + mSideToDeviceMap.forEach((side, device) -> { + int ambientMax = mVolumeController.getAmbientMax(device); + int ambientMin = mVolumeController.getAmbientMin(device); + if (ambientMin != ambientMax) { + mPreference.setSliderRange(side, ambientMin, ambientMax); + mPreference.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 (isControlExpanded()) { + 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); + setControlExpanded(true); + } else { + int unifiedAmbient = leftAmbient != INVALID_VOLUME ? leftAmbient : rightAmbient; + setVolumeIfValid(SIDE_UNIFIED, unifiedAmbient); + } + } + // Initialize local data between side and group value + initLocalDataIfNeeded(); + + // 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(); + } + if (mPreference != null) { + mPreference.setMutable(mutable); + mPreference.setMuted(muted); + } + + // Ensure remote device mute state is synced + syncMuteStateIfNeeded(leftDevice, leftState, muted); + syncMuteStateIfNeeded(rightDevice, rightState, muted); + + refreshControlUi(); + } + + /** Check if any device in the group has valid ambient control points */ + private boolean isAmbientControlAvailable() { + for (BluetoothDevice device : mSideToDeviceMap.values()) { + // Found ambient local data for this device, show the ambient control + if (mLocalDataManager.get(device).hasAmbientData()) { + return true; + } + // Found remote ambient control points on this device, show the ambient control + if (mVolumeController.isAmbientControlAvailable(device)) { + return true; + } + } + return false; + } + + private boolean isControlExpanded() { + return mPreference != null && mPreference.isExpanded(); + } + + private void setControlExpanded(boolean expanded) { + if (mPreference != null && mPreference.isExpanded() != expanded) { + mPreference.setExpanded(expanded); + } + mSideToDeviceMap.forEach((s, d) -> { + // Update new value to local data + mLocalDataManager.updateAmbientControlExpanded(d, expanded); + }); + } + + private boolean isControlMuted() { + return mPreference != null && mPreference.isMuted(); + } + + private void initLocalDataIfNeeded() { + int smallerVolumeAmongGroup = Integer.MAX_VALUE; + for (BluetoothDevice device : mSideToDeviceMap.values()) { + 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()) { + Data data = mLocalDataManager.get(device); + if (data.groupAmbient() == INVALID_VOLUME) { + // Initialize group ambient from smaller side ambient value + mLocalDataManager.updateGroupAmbient(device, smallerVolumeAmongGroup); + } + } + } + } + + 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() + && mBluetoothManager.getProfileManager().getVolumeControlProfile() + .getConnectionStatus(device) == BluetoothProfile.STATE_CONNECTED; + } + + private void showErrorToast() { + if (mToast != null) { + mToast.cancel(); + } + mToast = Toast.makeText(mContext, R.string.bluetooth_ambient_volume_error, + Toast.LENGTH_SHORT); + mToast.show(); + } +} diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java index 3703b7180af..8af08792180 100644 --- a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java +++ b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java @@ -42,6 +42,7 @@ public class BluetoothDetailsHearingDeviceController extends BluetoothDetailsCon public static final int ORDER_HEARING_DEVICE_SETTINGS = 1; public static final int ORDER_HEARING_AIDS_PRESETS = 2; + public static final int ORDER_AMBIENT_VOLUME = 4; static final String KEY_HEARING_DEVICE_GROUP = "hearing_device_group"; private final List mControllers = new ArrayList<>(); @@ -107,6 +108,10 @@ public class BluetoothDetailsHearingDeviceController extends BluetoothDetailsCon mControllers.add(new BluetoothDetailsHearingAidsPresetsController(mContext, mFragment, mManager, mCachedDevice, mLifecycle)); } + if (com.android.settingslib.flags.Flags.hearingDevicesAmbientVolumeControl()) { + mControllers.add(new BluetoothDetailsAmbientVolumePreferenceController(mContext, + mManager, mFragment, mCachedDevice, mLifecycle)); + } } @NonNull diff --git a/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java new file mode 100644 index 00000000000..ec406c45503 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/AmbientVolumePreferenceTest.java @@ -0,0 +1,238 @@ +/* + * 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.settings.bluetooth; + +import static com.android.settings.bluetooth.AmbientVolumePreference.ROTATION_COLLAPSED; +import static com.android.settings.bluetooth.AmbientVolumePreference.ROTATION_EXPANDED; +import static com.android.settings.bluetooth.AmbientVolumePreference.SIDE_UNIFIED; +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME; +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME_SLIDER; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.util.ArrayMap; +import android.view.View; +import android.widget.ImageView; + +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; +import androidx.preference.PreferenceViewHolder; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; +import com.android.settings.widget.SeekBarPreference; + +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 org.robolectric.RobolectricTestRunner; + +import java.util.Map; + +/** Tests for {@link AmbientVolumePreference}. */ +@RunWith(RobolectricTestRunner.class) +public class AmbientVolumePreferenceTest { + + 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; + private static final String KEY_UNIFIED_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_UNIFIED; + private static final String KEY_LEFT_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_LEFT; + private static final String KEY_RIGHT_SLIDER = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_RIGHT; + + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Spy + private Context mContext = ApplicationProvider.getApplicationContext(); + @Mock + private AmbientVolumePreference.OnIconClickListener mListener; + @Mock + private View mItemView; + + private AmbientVolumePreference mPreference; + private ImageView mExpandIcon; + private ImageView mVolumeIcon; + private final Map mSideToSlidersMap = new ArrayMap<>(); + + @Before + public void setUp() { + PreferenceManager preferenceManager = new PreferenceManager(mContext); + PreferenceScreen preferenceScreen = preferenceManager.createPreferenceScreen(mContext); + mPreference = new AmbientVolumePreference(mContext); + mPreference.setKey(KEY_AMBIENT_VOLUME); + mPreference.setOnIconClickListener(mListener); + mPreference.setExpandable(true); + mPreference.setMutable(true); + preferenceScreen.addPreference(mPreference); + + prepareSliders(); + mPreference.setSliders(mSideToSlidersMap); + + mExpandIcon = new ImageView(mContext); + mVolumeIcon = new ImageView(mContext); + mVolumeIcon.setImageResource(com.android.settingslib.R.drawable.ic_ambient_volume); + mVolumeIcon.setImageLevel(0); + when(mItemView.requireViewById(R.id.expand_icon)).thenReturn(mExpandIcon); + when(mItemView.requireViewById(com.android.internal.R.id.icon)).thenReturn(mVolumeIcon); + when(mItemView.requireViewById(R.id.icon_frame)).thenReturn(mVolumeIcon); + + PreferenceViewHolder preferenceViewHolder = PreferenceViewHolder.createInstanceForTests( + mItemView); + mPreference.onBindViewHolder(preferenceViewHolder); + } + + @Test + public void setExpandable_expandable_expandIconVisible() { + mPreference.setExpandable(true); + + assertThat(mExpandIcon.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void setExpandable_notExpandable_expandIconGone() { + mPreference.setExpandable(false); + + assertThat(mExpandIcon.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void setExpanded_expanded_assertControlUiCorrect() { + mPreference.setExpanded(true); + + assertControlUiCorrect(); + } + + @Test + public void setExpanded_notExpanded_assertControlUiCorrect() { + mPreference.setExpanded(false); + + assertControlUiCorrect(); + } + + @Test + public void setMutable_mutable_clickOnMuteIconChangeMuteState() { + mPreference.setMutable(true); + mPreference.setMuted(false); + + mVolumeIcon.callOnClick(); + + assertThat(mPreference.isMuted()).isTrue(); + } + + @Test + public void setMutable_notMutable_clickOnMuteIconWontChangeMuteState() { + mPreference.setMutable(false); + mPreference.setMuted(false); + + mVolumeIcon.callOnClick(); + + assertThat(mPreference.isMuted()).isFalse(); + } + + @Test + public void updateLayout_mute_volumeIconIsCorrect() { + mPreference.setMuted(true); + mPreference.updateLayout(); + + assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(0); + } + + @Test + public void updateLayout_unmuteAndExpanded_volumeIconIsCorrect() { + mPreference.setMuted(false); + mPreference.setExpanded(true); + mPreference.updateLayout(); + + int expectedLevel = calculateVolumeLevel(TEST_LEFT_VOLUME_LEVEL, TEST_RIGHT_VOLUME_LEVEL); + assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel); + } + + @Test + public void updateLayout_unmuteAndNotExpanded_volumeIconIsCorrect() { + mPreference.setMuted(false); + mPreference.setExpanded(false); + mPreference.updateLayout(); + + int expectedLevel = calculateVolumeLevel(TEST_UNIFIED_VOLUME_LEVEL, + TEST_UNIFIED_VOLUME_LEVEL); + assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel); + } + + @Test + public void setSliderEnabled_expandedAndLeftIsDisabled_volumeIconIcCorrect() { + mPreference.setExpanded(true); + mPreference.setSliderEnabled(SIDE_LEFT, false); + + int expectedLevel = calculateVolumeLevel(0, TEST_RIGHT_VOLUME_LEVEL); + assertThat(mVolumeIcon.getDrawable().getLevel()).isEqualTo(expectedLevel); + } + + @Test + public void setSliderValue_expandedAndLeftValueChanged_volumeIconIcCorrect() { + mPreference.setExpanded(true); + mPreference.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 = mPreference.isExpanded(); + assertThat(mSideToSlidersMap.get(SIDE_UNIFIED).isVisible()).isEqualTo(!expanded); + assertThat(mSideToSlidersMap.get(SIDE_LEFT).isVisible()).isEqualTo(expanded); + assertThat(mSideToSlidersMap.get(SIDE_RIGHT).isVisible()).isEqualTo(expanded); + final float rotation = expanded ? ROTATION_EXPANDED : ROTATION_COLLAPSED; + assertThat(mExpandIcon.getRotation()).isEqualTo(rotation); + } + + private void prepareSliders() { + prepareSlider(SIDE_UNIFIED); + prepareSlider(SIDE_LEFT); + prepareSlider(SIDE_RIGHT); + } + + private void prepareSlider(int side) { + SeekBarPreference slider = new SeekBarPreference(mContext); + slider.setMin(0); + slider.setMax(4); + if (side == SIDE_LEFT) { + slider.setKey(KEY_LEFT_SLIDER); + slider.setProgress(TEST_LEFT_VOLUME_LEVEL); + } else if (side == SIDE_RIGHT) { + slider.setKey(KEY_RIGHT_SLIDER); + slider.setProgress(TEST_RIGHT_VOLUME_LEVEL); + } else { + slider.setKey(KEY_UNIFIED_SLIDER); + slider.setProgress(TEST_UNIFIED_VOLUME_LEVEL); + } + mSideToSlidersMap.put(side, slider); + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java new file mode 100644 index 00000000000..975d3b491aa --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java @@ -0,0 +1,434 @@ +/* + * 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.settings.bluetooth; + +import static android.bluetooth.AudioInputControl.MUTE_DISABLED; +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.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME; +import static com.android.settings.bluetooth.BluetoothDetailsAmbientVolumePreferenceController.KEY_AMBIENT_VOLUME_SLIDER; +import static com.android.settings.bluetooth.BluetoothDetailsHearingDeviceController.KEY_HEARING_DEVICE_GROUP; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_LEFT; +import static com.android.settingslib.bluetooth.HearingAidInfo.DeviceSide.SIDE_RIGHT; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothProfile; +import android.content.ContentResolver; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; + +import androidx.preference.PreferenceCategory; + +import com.android.settings.testutils.shadow.ShadowThreadUtils; +import com.android.settings.widget.SeekBarPreference; +import com.android.settingslib.bluetooth.AmbientVolumeController; +import com.android.settingslib.bluetooth.BluetoothEventManager; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; +import com.android.settingslib.bluetooth.VolumeControlProfile; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowSettings; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; + +/** Tests for {@link BluetoothDetailsAmbientVolumePreferenceController}. */ +@RunWith(RobolectricTestRunner.class) +@Config(shadows = { + BluetoothDetailsAmbientVolumePreferenceControllerTest.ShadowGlobal.class, + ShadowThreadUtils.class +}) +public class BluetoothDetailsAmbientVolumePreferenceControllerTest extends + BluetoothDetailsControllerTestBase { + + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + + private static final String LEFT_CONTROL_KEY = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_LEFT; + private static final String RIGHT_CONTROL_KEY = KEY_AMBIENT_VOLUME_SLIDER + "_" + SIDE_RIGHT; + private static final String TEST_ADDRESS = "00:00:00:00:11"; + private static final String TEST_MEMBER_ADDRESS = "00:00:00:00:22"; + + @Mock + private CachedBluetoothDevice mCachedMemberDevice; + @Mock + private BluetoothDevice mDevice; + @Mock + private BluetoothDevice mMemberDevice; + @Mock + private HearingDeviceLocalDataManager mLocalDataManager; + @Mock + private LocalBluetoothManager mBluetoothManager; + @Mock + private BluetoothEventManager mEventManager; + @Mock + private LocalBluetoothProfileManager mProfileManager; + @Mock + private VolumeControlProfile mVolumeControlProfile; + @Mock + private AmbientVolumeController mVolumeController; + @Mock + private Handler mTestHandler; + + private BluetoothDetailsAmbientVolumePreferenceController mController; + + @Before + public void setUp() { + super.setUp(); + + mContext = spy(mContext); + PreferenceCategory deviceControls = new PreferenceCategory(mContext); + deviceControls.setKey(KEY_HEARING_DEVICE_GROUP); + mScreen.addPreference(deviceControls); + mController = spy( + new BluetoothDetailsAmbientVolumePreferenceController(mContext, mBluetoothManager, + mFragment, mCachedDevice, mLifecycle, mLocalDataManager, + mVolumeController)); + + when(mBluetoothManager.getEventManager()).thenReturn(mEventManager); + when(mBluetoothManager.getProfileManager()).thenReturn(mProfileManager); + when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControlProfile); + when(mVolumeControlProfile.getConnectionStatus(mDevice)).thenReturn( + BluetoothProfile.STATE_CONNECTED); + when(mVolumeControlProfile.getConnectionStatus(mMemberDevice)).thenReturn( + BluetoothProfile.STATE_CONNECTED); + when(mCachedDevice.getProfiles()).thenReturn(List.of(mVolumeControlProfile)); + when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn( + new HearingDeviceLocalDataManager.Data.Builder().build()); + + when(mContext.getMainThreadHandler()).thenReturn(mTestHandler); + when(mTestHandler.postDelayed(any(Runnable.class), anyLong())).thenAnswer( + invocationOnMock -> { + invocationOnMock.getArgument(0, Runnable.class).run(); + return null; + }); + } + + @Test + public void init_deviceWithoutMember_controlNotExpandable() { + prepareDevice(/* hasMember= */ false); + + mController.init(mScreen); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpandable()).isFalse(); + } + + @Test + public void init_deviceWithMember_controlExpandable() { + prepareDevice(/* hasMember= */ true); + + mController.init(mScreen); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpandable()).isTrue(); + } + + @Test + public void onDeviceLocalDataChange_noMemberAndExpanded_uiCorrectAndDataUpdated() { + prepareDevice(/* hasMember= */ false); + mController.init(mScreen); + HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() + .ambient(0).groupAmbient(0).ambientControlExpanded(true).build(); + when(mLocalDataManager.get(mDevice)).thenReturn(data); + + mController.onDeviceLocalDataChange(TEST_ADDRESS, data); + shadowOf(Looper.getMainLooper()).idle(); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpanded()).isFalse(); + verifyDeviceDataUpdated(mDevice); + } + + @Test + public void onDeviceLocalDataChange_noMemberAndCollapsed_uiCorrectAndDataUpdated() { + prepareDevice(/* hasMember= */ false); + mController.init(mScreen); + HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() + .ambient(0).groupAmbient(0).ambientControlExpanded(false).build(); + when(mLocalDataManager.get(mDevice)).thenReturn(data); + + mController.onDeviceLocalDataChange(TEST_ADDRESS, data); + shadowOf(Looper.getMainLooper()).idle(); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpanded()).isFalse(); + verifyDeviceDataUpdated(mDevice); + } + + @Test + public void onDeviceLocalDataChange_hasMemberAndExpanded_uiCorrectAndDataUpdated() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() + .ambient(0).groupAmbient(0).ambientControlExpanded(true).build(); + when(mLocalDataManager.get(mDevice)).thenReturn(data); + + mController.onDeviceLocalDataChange(TEST_ADDRESS, data); + shadowOf(Looper.getMainLooper()).idle(); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpanded()).isTrue(); + verifyDeviceDataUpdated(mDevice); + } + + @Test + public void onDeviceLocalDataChange_hasMemberAndCollapsed_uiCorrectAndDataUpdated() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() + .ambient(0).groupAmbient(0).ambientControlExpanded(false).build(); + when(mLocalDataManager.get(mDevice)).thenReturn(data); + + mController.onDeviceLocalDataChange(TEST_ADDRESS, data); + shadowOf(Looper.getMainLooper()).idle(); + + AmbientVolumePreference preference = mScreen.findPreference(KEY_AMBIENT_VOLUME); + assertThat(preference).isNotNull(); + assertThat(preference.isExpanded()).isFalse(); + verifyDeviceDataUpdated(mDevice); + } + + @Test + public void onStart_localDataManagerStartAndCallbackRegistered() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + + mController.onStart(); + + verify(mLocalDataManager, atLeastOnce()).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 onStop_localDataManagerStopAndCallbackUnregistered() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + + mController.onStop(); + + 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 onDeviceAttributesChanged_newDevice_newPreference() { + prepareDevice(/* hasMember= */ false); + mController.init(mScreen); + + // check the right control is null before onDeviceAttributesChanged() + SeekBarPreference leftControl = mScreen.findPreference(LEFT_CONTROL_KEY); + SeekBarPreference rightControl = mScreen.findPreference(RIGHT_CONTROL_KEY); + assertThat(leftControl).isNotNull(); + assertThat(rightControl).isNull(); + + prepareDevice(/* hasMember= */ true); + mController.onDeviceAttributesChanged(); + shadowOf(Looper.getMainLooper()).idle(); + + // check the right control is created after onDeviceAttributesChanged() + SeekBarPreference updatedLeftControl = mScreen.findPreference(LEFT_CONTROL_KEY); + SeekBarPreference updatedRightControl = mScreen.findPreference(RIGHT_CONTROL_KEY); + assertThat(updatedLeftControl).isEqualTo(leftControl); + assertThat(updatedRightControl).isNotNull(); + } + + @Test + public void onAmbientChanged_refreshWhenNotInitiateFromUi() { + prepareDevice(/* hasMember= */ false); + mController.init(mScreen); + final int testAmbient = 10; + HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder() + .ambient(testAmbient) + .groupAmbient(testAmbient) + .ambientControlExpanded(false) + .build(); + when(mLocalDataManager.get(mDevice)).thenReturn(data); + getPreference().setExpanded(true); + + mController.onAmbientChanged(mDevice, testAmbient); + verify(mController, never()).refresh(); + + final int updatedTestAmbient = 20; + mController.onAmbientChanged(mDevice, updatedTestAmbient); + verify(mController).refresh(); + } + + @Test + public void onMuteChanged_refreshWhenNotInitiateFromUi() { + prepareDevice(/* hasMember= */ false); + mController.init(mScreen); + final int testMute = MUTE_NOT_MUTED; + AmbientVolumeController.RemoteAmbientState state = + new AmbientVolumeController.RemoteAmbientState(testMute, 0); + when(mVolumeController.refreshAmbientState(mDevice)).thenReturn(state); + getPreference().setMuted(false); + + mController.onMuteChanged(mDevice, testMute); + verify(mController, never()).refresh(); + + final int updatedTestMute = MUTE_MUTED; + mController.onMuteChanged(mDevice, updatedTestMute); + verify(mController).refresh(); + } + + @Test + public void refresh_leftAndRightDifferentGainSetting_expandControl() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + prepareRemoteData(mDevice, 10, MUTE_NOT_MUTED); + prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED); + getPreference().setExpanded(false); + + mController.refresh(); + + assertThat(getPreference().isExpanded()).isTrue(); + } + + @Test + public void refresh_oneSideNotMutable_controlNotMutableAndNotMuted() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + prepareRemoteData(mDevice, 10, MUTE_DISABLED); + prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED); + getPreference().setMutable(true); + getPreference().setMuted(true); + + mController.refresh(); + + assertThat(getPreference().isMutable()).isFalse(); + assertThat(getPreference().isMuted()).isFalse(); + } + + @Test + public void refresh_oneSideNotMuted_controlNotMutedAndSyncToRemote() { + prepareDevice(/* hasMember= */ true); + mController.init(mScreen); + prepareRemoteData(mDevice, 10, MUTE_MUTED); + prepareRemoteData(mMemberDevice, 20, MUTE_NOT_MUTED); + getPreference().setMutable(true); + getPreference().setMuted(true); + + mController.refresh(); + + assertThat(getPreference().isMutable()).isTrue(); + assertThat(getPreference().isMuted()).isFalse(); + 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(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(mMemberDevice.getAddress()).thenReturn(TEST_MEMBER_ADDRESS); + when(mMemberDevice.getAnonymizedAddress()).thenReturn(TEST_MEMBER_ADDRESS); + when(mMemberDevice.isConnected()).thenReturn(true); + } + } + + private void prepareRemoteData(BluetoothDevice device, int gainSetting, int mute) { + when(mVolumeController.isAmbientControlAvailable(device)).thenReturn(true); + when(mVolumeController.refreshAmbientState(device)).thenReturn( + new AmbientVolumeController.RemoteAmbientState(gainSetting, mute)); + } + + private void verifyDeviceDataUpdated(BluetoothDevice device) { + verify(mLocalDataManager, atLeastOnce()).updateAmbient(eq(device), anyInt()); + verify(mLocalDataManager, atLeastOnce()).updateGroupAmbient(eq(device), anyInt()); + verify(mLocalDataManager, atLeastOnce()).updateAmbientControlExpanded(eq(device), + anyBoolean()); + } + + private AmbientVolumePreference getPreference() { + return mScreen.findPreference(KEY_AMBIENT_VOLUME); + } + + @Implements(value = Settings.Global.class) + public static class ShadowGlobal extends ShadowSettings.ShadowGlobal { + private static final Map> sDataMap = new HashMap<>(); + + @Implementation + protected static boolean putStringForUser( + ContentResolver cr, String name, String value, int userHandle) { + get(cr).put(name, value); + return true; + } + + @Implementation + protected static String getStringForUser(ContentResolver cr, String name, int userHandle) { + return get(cr).get(name); + } + + private static Map get(ContentResolver cr) { + return sDataMap.computeIfAbsent(cr, k -> new HashMap<>()); + } + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java index 2a50f892add..4e3c742e284 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceControllerTest.java @@ -53,12 +53,12 @@ public class BluetoothDetailsHearingDeviceControllerTest extends @Mock private LocalBluetoothProfileManager mProfileManager; @Mock - private BluetoothDetailsHearingDeviceController mHearingDeviceController; - @Mock private BluetoothDetailsHearingAidsPresetsController mPresetsController; @Mock private BluetoothDetailsHearingDeviceSettingsController mHearingDeviceSettingsController; + private BluetoothDetailsHearingDeviceController mHearingDeviceController; + @Override public void setUp() { super.setUp(); @@ -126,4 +126,24 @@ public class BluetoothDetailsHearingDeviceControllerTest extends assertThat(mHearingDeviceController.getSubControllers().stream().anyMatch( c -> c instanceof BluetoothDetailsHearingAidsPresetsController)).isFalse(); } + + @Test + @RequiresFlagsEnabled( + com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL) + public void initSubControllers_flagEnabled_ambientVolumeControllerExist() { + mHearingDeviceController.initSubControllers(false); + + assertThat(mHearingDeviceController.getSubControllers().stream().anyMatch( + c -> c instanceof BluetoothDetailsAmbientVolumePreferenceController)).isTrue(); + } + + @Test + @RequiresFlagsDisabled( + com.android.settingslib.flags.Flags.FLAG_HEARING_DEVICES_AMBIENT_VOLUME_CONTROL) + public void initSubControllers_flagDisabled_ambientVolumeControllerNotExist() { + mHearingDeviceController.initSubControllers(false); + + assertThat(mHearingDeviceController.getSubControllers().stream().anyMatch( + c -> c instanceof BluetoothDetailsAmbientVolumePreferenceController)).isFalse(); + } }