[Ambient Volume] Show value with remote data

Sync local data with remote data when UI need to refresh and set the
corresponding local value to remote when the control expanded/collapsed.

Flag: com.android.settingslib.flags.hearing_devices_ambient_volume_control
Bug: 357878944
Test: atest BluetoothDetailsAmbientVolumePreferenceControllerTest
Change-Id: If748e696eb62b199d4fd9abafa2300d301a8079c
diff --git a/res/values/strings.xml b/res/values/strings.xml
index ee80dae..5ab38a4 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -174,6 +174,8 @@
     <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>
+    <!-- Message when changing ambient state failed. [CHAR LIMIT=NONE] -->
+    <string name="bluetooth_ambient_volume_error">Couldn\u2019t update surroundings</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 9072703..887c220 100644
--- a/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceController.java
@@ -28,9 +28,11 @@
 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;
@@ -42,9 +44,12 @@
 
 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;
@@ -54,12 +59,14 @@
 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 {
+        HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener, OnStart, OnStop,
+        AmbientVolumeController.AmbientVolumeControlCallback, BluetoothCallback {
 
     private static final boolean DEBUG = true;
     private static final String TAG = "AmbientPrefController";
@@ -69,34 +76,45 @@
     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<CachedBluetoothDevice> mCachedDevices = new ArraySet<>();
     private final BiMap<Integer, BluetoothDevice> mSideToDeviceMap = HashBiMap.create();
     private final BiMap<Integer, SeekBarPreference> 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 HearingDeviceLocalDataManager localSettings,
+            @NonNull AmbientVolumeController volumeController) {
         super(context, fragment, device, lifecycle);
+        mBluetoothManager = manager;
         mLocalDataManager = localSettings;
+        mVolumeController = volumeController;
     }
 
     @Override
@@ -111,19 +129,33 @@
     @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());
             });
         });
     }
@@ -133,8 +165,17 @@
         if (!isAvailable()) {
             return;
         }
-        // TODO: load data from remote
-        loadLocalDataToUi();
+        boolean shouldShowAmbientControl = isAmbientControlAvailable();
+        if (shouldShowAmbientControl) {
+            if (mPreference != null) {
+                mPreference.setVisible(true);
+            }
+            loadRemoteDataToUi();
+        } else {
+            if (mPreference != null) {
+                mPreference.setVisible(false);
+            }
+        }
     }
 
     @Override
@@ -160,9 +201,10 @@
             setVolumeIfValid(side, value);
 
             if (side == SIDE_UNIFIED) {
-                // TODO: set the value on the devices
+                mSideToDeviceMap.forEach((s, d) -> mVolumeController.setAmbient(d, value));
             } else {
-                // TODO: set the value on the side device
+                final BluetoothDevice device = mSideToDeviceMap.get(side);
+                mVolumeController.setAmbient(device, value);
             }
             return true;
         }
@@ -170,9 +212,22 @@
     }
 
     @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();
@@ -182,6 +237,8 @@
             ThreadUtils.postOnBackgroundThread(() ->
                     mCachedDevices.forEach(device -> {
                         device.registerCallback(ThreadUtils.getBackgroundExecutor(), this);
+                        mVolumeController.registerCallback(ThreadUtils.getBackgroundExecutor(),
+                                device.getDevice());
                     })
             );
         });
@@ -201,6 +258,41 @@
         }
     }
 
+    @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 onCommandFailed(@NonNull BluetoothDevice device) {
+        Log.w(TAG, "onCommandFailed, device:" + device);
+        mContext.getMainExecutor().execute(() -> {
+            showErrorToast();
+            refresh();
+        });
+    }
+
     private void loadDevices() {
         mSideToDeviceMap.clear();
         mCachedDevices.clear();
@@ -234,6 +326,11 @@
         mPreference.setOrder(ORDER_AMBIENT_VOLUME);
         mPreference.setOnIconClickListener(() -> {
             mSideToDeviceMap.forEach((s, d) -> {
+                // 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());
             });
@@ -269,6 +366,16 @@
     /** Refreshes the control UI visibility and enabled state. */
     private void refreshControlUi() {
         if (mPreference != null) {
+            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;
+                mPreference.setSliderEnabled(side, enabled);
+            }
+            mPreference.setSliderEnabled(SIDE_UNIFIED, isAnySliderEnabled);
             mPreference.updateLayout();
         }
     }
@@ -299,12 +406,74 @@
             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());
+        if (isDeviceConnectedToVcp(device)) {
+            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();
+
+        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();
     }
@@ -318,4 +487,41 @@
             mLocalDataManager.updateAmbientControlExpanded(d, expanded);
         });
     }
+
+    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 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 7236d8c..8af0879 100644
--- a/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsHearingDeviceController.java
@@ -110,7 +110,7 @@
         }
         if (com.android.settingslib.flags.Flags.hearingDevicesAmbientVolumeControl()) {
             mControllers.add(new BluetoothDetailsAmbientVolumePreferenceController(mContext,
-                    mFragment, mCachedDevice, mLifecycle));
+                    mManager, mFragment, mCachedDevice, mLifecycle));
         }
     }
 
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
index 71da4b2..b7aaab4 100644
--- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsAmbientVolumePreferenceControllerTest.java
@@ -29,14 +29,19 @@
 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;
 
@@ -44,8 +49,13 @@
 
 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;
@@ -90,6 +100,18 @@
     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;
 
@@ -97,11 +119,29 @@
     public void setUp() {
         super.setUp();
 
+        mContext = spy(mContext);
         PreferenceCategory deviceControls = new PreferenceCategory(mContext);
         deviceControls.setKey(KEY_HEARING_DEVICE_GROUP);
         mScreen.addPreference(deviceControls);
-        mController = new BluetoothDetailsAmbientVolumePreferenceController(mContext, mFragment,
-                mCachedDevice, mLifecycle, mLocalDataManager);
+        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(mContext.getMainThreadHandler()).thenReturn(mTestHandler);
+        when(mTestHandler.postDelayed(any(Runnable.class), anyLong())).thenAnswer(
+                invocationOnMock -> {
+                    invocationOnMock.getArgument(0, Runnable.class).run();
+                    return null;
+                });
     }
 
     @Test
@@ -128,10 +168,13 @@
 
     @Test
     public void onDeviceLocalDataChange_noMemberAndExpanded_uiCorrectAndDataUpdated() {
-        prepareDevice(/* hasMember= */ false, /* controlExpanded= */ true);
-
+        prepareDevice(/* hasMember= */ false);
         mController.init(mScreen);
-        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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);
@@ -142,10 +185,13 @@
 
     @Test
     public void onDeviceLocalDataChange_noMemberAndCollapsed_uiCorrectAndDataUpdated() {
-        prepareDevice(/* hasMember= */ false, /* controlExpanded= */ false);
-
+        prepareDevice(/* hasMember= */ false);
         mController.init(mScreen);
-        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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);
@@ -156,10 +202,13 @@
 
     @Test
     public void onDeviceLocalDataChange_hasMemberAndExpanded_uiCorrectAndDataUpdated() {
-        prepareDevice(/* hasMember= */ true, /* controlExpanded= */ true);
-
+        prepareDevice(/* hasMember= */ true);
         mController.init(mScreen);
-        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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);
@@ -170,10 +219,13 @@
 
     @Test
     public void onDeviceLocalDataChange_hasMemberAndCollapsed_uiCorrectAndDataUpdated() {
-        prepareDevice(/* hasMember= */ true, /* controlExpanded= */ false);
-
+        prepareDevice(/* hasMember= */ true);
         mController.init(mScreen);
-        mController.onDeviceLocalDataChange(TEST_ADDRESS, prepareEmptyData());
+        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);
@@ -185,11 +237,13 @@
     @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),
@@ -199,11 +253,13 @@
     @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));
     }
@@ -211,7 +267,6 @@
     @Test
     public void onDeviceAttributesChanged_newDevice_newPreference() {
         prepareDevice(/* hasMember= */ false);
-
         mController.init(mScreen);
 
         // check the right control is null before onDeviceAttributesChanged()
@@ -231,16 +286,34 @@
         assertThat(updatedRightControl).isNotNull();
     }
 
-    private void prepareDevice(boolean hasMember) {
-        prepareDevice(hasMember, false);
+    @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();
     }
 
-    private void prepareDevice(boolean hasMember, boolean controlExpanded) {
+    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);
@@ -248,14 +321,8 @@
             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);
         }
-        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) {
@@ -265,6 +332,10 @@
                 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<ContentResolver, Map<String, String>> sDataMap = new HashMap<>();