[Ambient Volume] Show value with local data

Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control
Bug: 357878944
Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest
Change-Id: I3dad0f5424b44fee6d049fd778c4f8f71db0b58e
diff --git a/res/values/strings.xml b/res/values/strings.xml
index c7cdd6e..ee80dae 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -170,6 +170,10 @@
     <string name="bluetooth_ambient_volume_control_expand">Expand to left and right separated controls</string>
     <!-- Connected devices settings. Content description for the icon to collapse the left and right separated ambient volume controls to unified control. [CHAR LIMIT=NONE] -->
     <string name="bluetooth_ambient_volume_control_collapse">Collapse to unified control</string>
+    <!-- Connected devices settings. The text to show the control is for left side device. [CHAR LIMIT=30] -->
+    <string name="bluetooth_ambient_volume_control_left">Left</string>
+    <!-- Connected devices settings. The text to show the control is for right side device. [CHAR LIMIT=30] -->
+    <string name="bluetooth_ambient_volume_control_right">Right</string>
     <!-- Connected devices settings. Title of the preference to show the entrance of the audio output page. It can change different types of audio are played on phone or other bluetooth devices. [CHAR LIMIT=35] -->
     <string name="bluetooth_audio_routing_title">Audio output</string>
     <!-- Title for bluetooth audio routing page footer. [CHAR LIMIT=30] -->
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
index 0629e6e..9072703 100644
--- a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
@@ -16,11 +16,16 @@
 
 package com.android.settings.bluetooth;
 
+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.content.Context;
@@ -29,15 +34,21 @@
 
 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.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager;
+import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data;
 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;
@@ -47,7 +58,8 @@
 
 /** A {@link BluetoothDetailsController} that manages ambient volume control preferences. */
 public class BluetoothDetailsAmbientVolumePreferenceController extends
-        BluetoothDetailsController implements Preference.OnPreferenceChangeListener {
+        BluetoothDetailsController implements Preference.OnPreferenceChangeListener,
+        HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, OnStart, OnStop {
 
     private static final boolean DEBUG = true;
     private static final String TAG = "AmbientPrefController";
@@ -60,6 +72,7 @@
     private final Set<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
     private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
     private final BiMap<Integer, SeekBarPreference> mSideToSliderMap = HashBiMap.create();
+    private final HearingDeviceLocalDataManager mLocalDataManager;
 
     @Nullable
     private PreferenceCategory mDeviceControls;
@@ -71,6 +84,19 @@
             @NonNull CachedBluetoothDevice device,
             @NonNull Lifecycle lifecycle) {
         super(context, fragment, device, lifecycle);
+        mLocalDataManager = new HearingDeviceLocalDataManager(context);
+        mLocalDataManager.setOnDeviceLocalDataChangeListener(this,
+                ThreadUtils.getBackgroundExecutor());
+    }
+
+    @VisibleForTesting
+    BluetoothDetailsAmbientVolumePreferenceController(@NonNull Context context,
+            @NonNull PreferenceFragmentCompat fragment,
+            @NonNull CachedBluetoothDevice device,
+            @NonNull Lifecycle lifecycle,
+            @NonNull HearingDeviceLocalDataManager localSettings) {
+        super(context, fragment, device, lifecycle);
+        mLocalDataManager = localSettings;
     }
 
     @Override
@@ -83,12 +109,32 @@
     }
 
     @Override
+    public void onStart() {
+        ThreadUtils.postOnBackgroundThread(() -> {
+            mLocalDataManager.start();
+            mCachedDevices.forEach(device -> {
+                device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+            });
+        });
+    }
+
+    @Override
+    public void onStop() {
+        ThreadUtils.postOnBackgroundThread(() -> {
+            mLocalDataManager.stop();
+            mCachedDevices.forEach(device -> {
+                device.unregisterCallback(this);
+            });
+        });
+    }
+
+    @Override
     protected void refresh() {
         if (!isAvailable()) {
             return;
         }
         // TODO: load data from remote
-        refreshControlUi();
+        loadLocalDataToUi();
     }
 
     @Override
@@ -111,6 +157,8 @@
             if (DEBUG) {
                 Log.d(TAG, "onPreferenceChange: side=" + side + ", value=" + value);
             }
+            setVolumeIfValid(side, value);
+
             if (side == SIDE_UNIFIED) {
                 // TODO: set the value on the devices
             } else {
@@ -139,15 +187,31 @@
         });
     }
 
+    @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;
+            }
+        }
+    }
+
     private void loadDevices() {
         mSideToDeviceMap.clear();
         mCachedDevices.clear();
-        if (VALID_SIDES.contains(mCachedDevice.getDeviceSide())) {
+        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())) {
+            if (VALID_SIDES.contains(memberDevice.getDeviceSide())
+                    && memberDevice.getBondState() == BOND_BONDED) {
                 mSideToDeviceMap.put(memberDevice.getDeviceSide(), memberDevice.getDevice());
                 mCachedDevices.add(memberDevice);
             }
@@ -164,9 +228,16 @@
         if (mPreference != null || mDeviceControls == null) {
             return;
         }
+
         mPreference = new AmbientVolumePreference(mDeviceControls.getContext());
         mPreference.setKey(KEY_AMBIENT_VOLUME);
         mPreference.setOrder(ORDER_AMBIENT_VOLUME);
+        mPreference.setOnIconClickListener(() -> {
+            mSideToDeviceMap.forEach((s, d) -> {
+                // Update new value to local data
+                mLocalDataManager.updateAmbientControlExpanded(d, isControlExpanded());
+            });
+        });
         if (mDeviceControls.findPreference(mPreference.getKey()) == null) {
             mDeviceControls.addPreference(mPreference);
         }
@@ -186,6 +257,12 @@
         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);
     }
 
@@ -195,4 +272,50 @@
             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);
+        setVolumeIfValid(side, data.ambient());
+        setVolumeIfValid(SIDE_UNIFIED, data.groupAmbient());
+        setControlExpanded(data.ambientControlExpanded());
+        refreshControlUi();
+    }
+
+    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);
+        });
+    }
 }
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
index 89209d1..71da4b2 100644
--- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
@@ -16,6 +16,8 @@
 
 package com.android.settings.bluetooth;
 
+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;
@@ -24,17 +26,26 @@
 
 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.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.robolectric.Shadows.shadowOf;
 
 import android.bluetooth.BluetoothDevice;
+import android.content.ContentResolver;
 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.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.HearingDeviceLocalDataManager;
 
 import org.junit.Before;
 import org.junit.Rule;
@@ -45,12 +56,19 @@
 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.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
@@ -70,6 +88,8 @@
     private BluetoothDevice mDevice;
     @Mock
     private BluetoothDevice mMemberDevice;
+    @Mock
+    private HearingDeviceLocalDataManager mLocalDataManager;
 
     private BluetoothDetailsAmbientVolumePreferenceController mController;
 
@@ -81,7 +101,7 @@
         deviceControls.setKey(KEY_HEARING_DEVICE_GROUP);
         mScreen.addPreference(deviceControls);
         mController = new BluetoothDetailsAmbientVolumePreferenceController(mContext, mFragment,
-                mCachedDevice, mLifecycle);
+                mCachedDevice, mLifecycle, mLocalDataManager);
     }
 
     @Test
@@ -107,6 +127,88 @@
     }
 
     @Test
+    public void onDeviceLocalDataChange_noMemberAndExpanded_uiCorrectAndDataUpdated() {
+        prepareDevice(/* hasMember= */ false, /* controlExpanded= */ true);
+
+        mController.init(mScreen);
+        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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, /* controlExpanded= */ false);
+
+        mController.init(mScreen);
+        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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, /* controlExpanded= */ true);
+
+        mController.init(mScreen);
+        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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, /* controlExpanded= */ false);
+
+        mController.init(mScreen);
+        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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(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(mCachedDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
+        verify(mCachedMemberDevice).unregisterCallback(any(CachedBluetoothDevice.Callback.class));
+    }
+
+    @Test
     public void onDeviceAttributesChanged_newDevice_newPreference() {
         prepareDevice(/* hasMember= */ false);
 
@@ -130,14 +232,57 @@
     }
 
     private void prepareDevice(boolean hasMember) {
+        prepareDevice(hasMember, false);
+    }
+
+    private void prepareDevice(boolean hasMember, boolean controlExpanded) {
         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);
         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);
+        }
+        HearingDeviceLocalDataManager.Data data = new HearingDeviceLocalDataManager.Data.Builder()
+                .ambient(0).groupAmbient(0).ambientControlExpanded(controlExpanded).build();
+        when(mLocalDataManager.get(any(BluetoothDevice.class))).thenReturn(data);
+    }
+
+    private HearingDeviceLocalDataManager.Data prepareEmptyData() {
+        return new HearingDeviceLocalDataManager.Data.Builder().build();
+    }
+
+    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());
+    }
+
+    @Implements(value = Settings.Global.class)
+    public static class ShadowGlobal extends ShadowSettings.ShadowGlobal {
+        private static final Map<ContentResolver, Map<String, String>> 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<String, String> get(ContentResolver cr) {
+            return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
         }
     }
 }