AudioService: move volume callback on AudioService

Robustness to crash of AudioServer.
Take benefit of future permission enforcement.

Bug: 293236285
Flag: EXEMPT refactor
Test: atest com.android.server.audio

Signed-off-by: Francois Gaffie <francois.gaffie@renault.com>
Change-Id: If556a668f45d608b98b6e8073426f5bb8128c7a2
diff --git a/Android.bp b/Android.bp
index 127556f..e7c2041 100644
--- a/Android.bp
+++ b/Android.bp
@@ -427,6 +427,7 @@
         "framework-permission-aidl-java",
         "spatializer-aidl-java",
         "audiopolicy-aidl-java",
+        "volumegroupcallback-aidl-java",
         "sounddose-aidl-java",
         "modules-utils-expresslog",
         "perfetto_trace_javastream_protos_jarjar",
diff --git a/boot/boot-image-profile.txt b/boot/boot-image-profile.txt
index d7c409f..4c3e5dc 100644
--- a/boot/boot-image-profile.txt
+++ b/boot/boot-image-profile.txt
@@ -28829,7 +28829,6 @@
 Landroid/media/audiopolicy/AudioProductStrategy;
 Landroid/media/audiopolicy/AudioVolumeGroup$1;
 Landroid/media/audiopolicy/AudioVolumeGroup;
-Landroid/media/audiopolicy/AudioVolumeGroupChangeHandler;
 Landroid/media/audiopolicy/IAudioPolicyCallback$Stub$Proxy;
 Landroid/media/audiopolicy/IAudioPolicyCallback$Stub;
 Landroid/media/audiopolicy/IAudioPolicyCallback;
diff --git a/boot/preloaded-classes b/boot/preloaded-classes
index 7f4b324..0486877 100644
--- a/boot/preloaded-classes
+++ b/boot/preloaded-classes
@@ -5509,7 +5509,6 @@
 android.media.audiopolicy.AudioProductStrategy
 android.media.audiopolicy.AudioVolumeGroup$1
 android.media.audiopolicy.AudioVolumeGroup
-android.media.audiopolicy.AudioVolumeGroupChangeHandler
 android.media.audiopolicy.IAudioPolicyCallback$Stub$Proxy
 android.media.audiopolicy.IAudioPolicyCallback$Stub
 android.media.audiopolicy.IAudioPolicyCallback
diff --git a/config/preloaded-classes b/config/preloaded-classes
index 707acb0..3f0e00b 100644
--- a/config/preloaded-classes
+++ b/config/preloaded-classes
@@ -5514,7 +5514,6 @@
 android.media.audiopolicy.AudioProductStrategy
 android.media.audiopolicy.AudioVolumeGroup$1
 android.media.audiopolicy.AudioVolumeGroup
-android.media.audiopolicy.AudioVolumeGroupChangeHandler
 android.media.audiopolicy.IAudioPolicyCallback$Stub$Proxy
 android.media.audiopolicy.IAudioPolicyCallback$Stub
 android.media.audiopolicy.IAudioPolicyCallback
diff --git a/core/jni/Android.bp b/core/jni/Android.bp
index bfa0aa9..7ed73d7 100644
--- a/core/jni/Android.bp
+++ b/core/jni/Android.bp
@@ -210,7 +210,6 @@
                 "android_media_AudioAttributes.cpp",
                 "android_media_AudioProductStrategies.cpp",
                 "android_media_AudioVolumeGroups.cpp",
-                "android_media_AudioVolumeGroupCallback.cpp",
                 "android_media_DeviceCallback.cpp",
                 "android_media_MediaMetricsJNI.cpp",
                 "android_media_MicrophoneInfo.cpp",
@@ -311,6 +310,7 @@
                 "audioflinger-aidl-cpp",
                 "audiopolicy-types-aidl-cpp",
                 "spatializer-aidl-cpp",
+                "volumegroupcallback-aidl-cpp",
                 "av-types-aidl-cpp",
                 "android.hardware.camera.device@3.2",
                 "camera_platform_flags_c_lib",
diff --git a/core/jni/AndroidRuntime.cpp b/core/jni/AndroidRuntime.cpp
index b2b8263..1ff0774 100644
--- a/core/jni/AndroidRuntime.cpp
+++ b/core/jni/AndroidRuntime.cpp
@@ -101,7 +101,6 @@
 extern int register_android_media_AudioAttributes(JNIEnv *env);
 extern int register_android_media_AudioProductStrategies(JNIEnv *env);
 extern int register_android_media_AudioVolumeGroups(JNIEnv *env);
-extern int register_android_media_AudioVolumeGroupChangeHandler(JNIEnv *env);
 extern int register_android_media_ImageReader(JNIEnv *env);
 extern int register_android_media_ImageWriter(JNIEnv *env);
 extern int register_android_media_MicrophoneInfo(JNIEnv *env);
@@ -1660,7 +1659,6 @@
         REG_JNI(register_android_media_AudioAttributes),
         REG_JNI(register_android_media_AudioProductStrategies),
         REG_JNI(register_android_media_AudioVolumeGroups),
-        REG_JNI(register_android_media_AudioVolumeGroupChangeHandler),
         REG_JNI(register_android_media_ImageReader),
         REG_JNI(register_android_media_ImageWriter),
         REG_JNI(register_android_media_MediaMetrics),
diff --git a/core/jni/android_media_AudioSystem.cpp b/core/jni/android_media_AudioSystem.cpp
index b679688..1bbf811 100644
--- a/core/jni/android_media_AudioSystem.cpp
+++ b/core/jni/android_media_AudioSystem.cpp
@@ -20,16 +20,17 @@
 
 #include <atomic>
 #define LOG_TAG "AudioSystem-JNI"
+#include <android-base/properties.h>
 #include <android/binder_ibinder_jni.h>
 #include <android/binder_libbinder.h>
 #include <android/media/AudioVibratorInfo.h>
+#include <android/media/INativeAudioVolumeGroupCallback.h>
 #include <android/media/INativeSpatializerCallback.h>
 #include <android/media/ISpatializer.h>
 #include <android/media/audio/common/AudioConfigBase.h>
 #include <android_media_audiopolicy.h>
 #include <android_os_Parcel.h>
 #include <audiomanager/AudioManager.h>
-#include <android-base/properties.h>
 #include <binder/IBinder.h>
 #include <jni.h>
 #include <media/AidlConversion.h>
@@ -41,14 +42,14 @@
 #include <nativehelper/ScopedLocalRef.h>
 #include <nativehelper/ScopedPrimitiveArray.h>
 #include <nativehelper/jni_macros.h>
+#include <sys/system_properties.h>
 #include <system/audio.h>
 #include <system/audio_policy.h>
-#include <sys/system_properties.h>
 #include <utils/Log.h>
 
+#include <memory>
 #include <optional>
 #include <sstream>
-#include <memory>
 #include <vector>
 
 #include "android_media_AudioAttributes.h"
@@ -59,8 +60,8 @@
 #include "android_media_AudioFormat.h"
 #include "android_media_AudioMixerAttributes.h"
 #include "android_media_AudioProfile.h"
-#include "android_media_MicrophoneInfo.h"
 #include "android_media_JNIUtils.h"
+#include "android_media_MicrophoneInfo.h"
 #include "android_util_Binder.h"
 #include "core_jni_helpers.h"
 
@@ -3442,6 +3443,21 @@
     }
 }
 
+static int android_media_AudioSystem_registerAudioVolumeGroupCallback(
+        JNIEnv *env, jobject thiz, jobject jIAudioVolumeGroupCallback) {
+    sp<media::INativeAudioVolumeGroupCallback> nIAudioVolumeGroupCallback =
+            interface_cast<media::INativeAudioVolumeGroupCallback>(
+                    ibinderForJavaObject(env, jIAudioVolumeGroupCallback));
+    return AudioSystem::addAudioVolumeGroupCallback(nIAudioVolumeGroupCallback);
+}
+
+static int android_media_AudioSystem_unregisterAudioVolumeGroupCallback(
+        JNIEnv *env, jobject thiz, jobject jIAudioVolumeGroupCallback) {
+    sp<media::INativeAudioVolumeGroupCallback> nIAudioVolumeGroupCallback =
+            interface_cast<media::INativeAudioVolumeGroupCallback>(
+                    ibinderForJavaObject(env, jIAudioVolumeGroupCallback));
+    return AudioSystem::removeAudioVolumeGroupCallback(nIAudioVolumeGroupCallback);
+}
 
 // ----------------------------------------------------------------------------
 
@@ -3612,6 +3628,12 @@
         MAKE_JNI_NATIVE_METHOD("clearPreferredMixerAttributes",
                                "(Landroid/media/AudioAttributes;II)I",
                                android_media_AudioSystem_clearPreferredMixerAttributes),
+        MAKE_JNI_NATIVE_METHOD("registerAudioVolumeGroupCallback",
+                               "(Landroid/media/INativeAudioVolumeGroupCallback;)I",
+                               android_media_AudioSystem_registerAudioVolumeGroupCallback),
+        MAKE_JNI_NATIVE_METHOD("unregisterAudioVolumeGroupCallback",
+                               "(Landroid/media/INativeAudioVolumeGroupCallback;)I",
+                               android_media_AudioSystem_unregisterAudioVolumeGroupCallback),
         MAKE_AUDIO_SYSTEM_METHOD(supportsBluetoothVariableLatency),
         MAKE_AUDIO_SYSTEM_METHOD(setBluetoothVariableLatencyEnabled),
         MAKE_AUDIO_SYSTEM_METHOD(isBluetoothVariableLatencyEnabled),
diff --git a/core/jni/android_media_AudioVolumeGroupCallback.cpp b/core/jni/android_media_AudioVolumeGroupCallback.cpp
deleted file mode 100644
index d130a4b..0000000
--- a/core/jni/android_media_AudioVolumeGroupCallback.cpp
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright (C) 2018 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.
- */
-#undef ANDROID_UTILS_REF_BASE_DISABLE_IMPLICIT_CONSTRUCTION // TODO:remove this and fix code
-
-//#define LOG_NDEBUG 0
-
-#define LOG_TAG "AudioVolumeGroupCallback-JNI"
-
-#include <utils/Log.h>
-#include <nativehelper/JNIHelp.h>
-#include "core_jni_helpers.h"
-
-#include "android_media_AudioVolumeGroupCallback.h"
-
-
-// ----------------------------------------------------------------------------
-using namespace android;
-
-static const char* const kAudioVolumeGroupChangeHandlerClassPathName =
-        "android/media/audiopolicy/AudioVolumeGroupChangeHandler";
-
-static struct {
-    jfieldID    mJniCallback;
-} gAudioVolumeGroupChangeHandlerFields;
-
-static struct {
-    jmethodID    postEventFromNative;
-} gAudioVolumeGroupChangeHandlerMethods;
-
-static Mutex gLock;
-
-JNIAudioVolumeGroupCallback::JNIAudioVolumeGroupCallback(JNIEnv* env,
-                                                         jobject thiz,
-                                                         jobject weak_thiz)
-{
-    jclass clazz = env->GetObjectClass(thiz);
-    if (clazz == NULL) {
-        ALOGE("Can't find class %s", kAudioVolumeGroupChangeHandlerClassPathName);
-        return;
-    }
-    mClass = (jclass)env->NewGlobalRef(clazz);
-
-    // We use a weak reference so the AudioVolumeGroupChangeHandler object can be garbage collected.
-    // The reference is only used as a proxy for callbacks.
-    mObject  = env->NewGlobalRef(weak_thiz);
-}
-
-JNIAudioVolumeGroupCallback::~JNIAudioVolumeGroupCallback()
-{
-    // remove global references
-    JNIEnv *env = AndroidRuntime::getJNIEnv();
-    if (env == NULL) {
-        return;
-    }
-    env->DeleteGlobalRef(mObject);
-    env->DeleteGlobalRef(mClass);
-}
-
-void JNIAudioVolumeGroupCallback::onAudioVolumeGroupChanged(volume_group_t group, int flags)
-{
-    JNIEnv *env = AndroidRuntime::getJNIEnv();
-    if (env == NULL) {
-        return;
-    }
-    ALOGV("%s volume group id %d", __FUNCTION__, group);
-    env->CallStaticVoidMethod(mClass,
-                              gAudioVolumeGroupChangeHandlerMethods.postEventFromNative,
-                              mObject,
-                              AUDIOVOLUMEGROUP_EVENT_VOLUME_CHANGED, group, flags, NULL);
-    if (env->ExceptionCheck()) {
-        ALOGW("An exception occurred while notifying an event.");
-        env->ExceptionClear();
-    }
-}
-
-void JNIAudioVolumeGroupCallback::onServiceDied()
-{
-    JNIEnv *env = AndroidRuntime::getJNIEnv();
-    if (env == NULL) {
-        return;
-    }
-    env->CallStaticVoidMethod(mClass,
-                              gAudioVolumeGroupChangeHandlerMethods.postEventFromNative,
-                              mObject,
-                              AUDIOVOLUMEGROUP_EVENT_SERVICE_DIED, 0, 0, NULL);
-    if (env->ExceptionCheck()) {
-        ALOGW("An exception occurred while notifying an event.");
-        env->ExceptionClear();
-    }
-}
-
-static
-sp<JNIAudioVolumeGroupCallback> setJniCallback(JNIEnv* env,
-                                               jobject thiz,
-                                               const sp<JNIAudioVolumeGroupCallback>& callback)
-{
-    Mutex::Autolock l(gLock);
-    sp<JNIAudioVolumeGroupCallback> old = (JNIAudioVolumeGroupCallback*)env->GetLongField(
-                thiz, gAudioVolumeGroupChangeHandlerFields.mJniCallback);
-    if (callback.get()) {
-        callback->incStrong((void*)setJniCallback);
-    }
-    if (old != 0) {
-        old->decStrong((void*)setJniCallback);
-    }
-    env->SetLongField(thiz, gAudioVolumeGroupChangeHandlerFields.mJniCallback,
-                      (jlong)callback.get());
-    return old;
-}
-
-static void
-android_media_AudioVolumeGroupChangeHandler_eventHandlerSetup(JNIEnv *env,
-                                                              jobject thiz,
-                                                              jobject weak_this)
-{
-    ALOGV("%s", __FUNCTION__);
-    sp<JNIAudioVolumeGroupCallback> callback =
-            new JNIAudioVolumeGroupCallback(env, thiz, weak_this);
-
-    if (AudioSystem::addAudioVolumeGroupCallback(callback) == NO_ERROR) {
-        setJniCallback(env, thiz, callback);
-    }
-}
-
-static void
-android_media_AudioVolumeGroupChangeHandler_eventHandlerFinalize(JNIEnv *env, jobject thiz)
-{
-    ALOGV("%s", __FUNCTION__);
-    sp<JNIAudioVolumeGroupCallback> callback = setJniCallback(env, thiz, 0);
-    if (callback != 0) {
-        AudioSystem::removeAudioVolumeGroupCallback(callback);
-    }
-}
-
-/*
- * JNI registration.
- */
-static const JNINativeMethod gMethods[] = {
-    {"native_setup", "(Ljava/lang/Object;)V",
-        (void *)android_media_AudioVolumeGroupChangeHandler_eventHandlerSetup},
-    {"native_finalize",  "()V",
-        (void *)android_media_AudioVolumeGroupChangeHandler_eventHandlerFinalize},
-};
-
-int register_android_media_AudioVolumeGroupChangeHandler(JNIEnv *env)
-{
-    jclass audioVolumeGroupChangeHandlerClass =
-            FindClassOrDie(env, kAudioVolumeGroupChangeHandlerClassPathName);
-    gAudioVolumeGroupChangeHandlerMethods.postEventFromNative =
-            GetStaticMethodIDOrDie(env, audioVolumeGroupChangeHandlerClass, "postEventFromNative",
-                                   "(Ljava/lang/Object;IIILjava/lang/Object;)V");
-
-    gAudioVolumeGroupChangeHandlerFields.mJniCallback =
-            GetFieldIDOrDie(env, audioVolumeGroupChangeHandlerClass, "mJniCallback", "J");
-
-    env->DeleteLocalRef(audioVolumeGroupChangeHandlerClass);
-
-    return RegisterMethodsOrDie(env,
-                                kAudioVolumeGroupChangeHandlerClassPathName,
-                                gMethods,
-                                NELEM(gMethods));
-}
-
diff --git a/core/jni/android_media_AudioVolumeGroupCallback.h b/core/jni/android_media_AudioVolumeGroupCallback.h
deleted file mode 100644
index de06549..0000000
--- a/core/jni/android_media_AudioVolumeGroupCallback.h
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (C) 2018 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.
- */
-
-#pragma once
-
-#include <system/audio.h>
-#include <media/AudioSystem.h>
-
-namespace android {
-
-// keep in sync with AudioManager.AudioVolumeGroupChangeHandler.java
-#define AUDIOVOLUMEGROUP_EVENT_VOLUME_CHANGED      1000
-#define AUDIOVOLUMEGROUP_EVENT_SERVICE_DIED        1001
-
-class JNIAudioVolumeGroupCallback: public AudioSystem::AudioVolumeGroupCallback
-{
-public:
-    JNIAudioVolumeGroupCallback(JNIEnv* env, jobject thiz, jobject weak_thiz);
-    ~JNIAudioVolumeGroupCallback();
-
-    void onAudioVolumeGroupChanged(volume_group_t group, int flags) override;
-    void onServiceDied() override;
-
-private:
-    void sendEvent(int event);
-
-    jclass      mClass; /**< Reference to AudioVolumeGroupChangeHandler class. */
-    jobject     mObject; /**< Weak ref to AudioVolumeGroupChangeHandler object to call on. */
-};
-
-} // namespace android
diff --git a/media/java/android/media/AudioManager.java b/media/java/android/media/AudioManager.java
index 4aba491..f0890d1 100644
--- a/media/java/android/media/AudioManager.java
+++ b/media/java/android/media/AudioManager.java
@@ -65,7 +65,7 @@
 import android.media.audiopolicy.AudioPolicy.AudioPolicyFocusListener;
 import android.media.audiopolicy.AudioProductStrategy;
 import android.media.audiopolicy.AudioVolumeGroup;
-import android.media.audiopolicy.AudioVolumeGroupChangeHandler;
+import android.media.audiopolicy.IAudioVolumeChangeDispatcher;
 import android.media.projection.MediaProjection;
 import android.media.session.MediaController;
 import android.media.session.MediaSession;
@@ -128,8 +128,6 @@
     private static final String TAG = "AudioManager";
     private static final boolean DEBUG = false;
     private static final AudioPortEventHandler sAudioPortEventHandler = new AudioPortEventHandler();
-    private static final AudioVolumeGroupChangeHandler sAudioAudioVolumeGroupChangedHandler =
-            new AudioVolumeGroupChangeHandler();
 
     private static WeakReference<Context> sContext;
 
@@ -8761,9 +8759,13 @@
         }
     }
 
+    //====================================================================
+    // Notification of volume group changes
     /**
+     * Callback to receive updates on volume group changes, register using
+     * {@link AudioManager#registerVolumeGroupCallback(Executor, AudioVolumeCallback)}.
+     *
      * @hide
-     * Callback registered by client to be notified upon volume group change.
      */
     @SystemApi
     public abstract static class VolumeGroupCallback {
@@ -8774,35 +8776,63 @@
         public void onAudioVolumeGroupChanged(int group, int flags) {}
     }
 
-   /**
-    * @hide
-    * Register an audio volume group change listener.
-    * @param callback the {@link VolumeGroupCallback} to register
-    */
+    /**
+     * @hide
+     * Register an audio volume group change listener.
+     *
+     * @param executor {@link Executor} to handle the callbacks
+     * @param callback the callback to receive the audio volume group changes
+     * @throws SecurityException if the caller doesn't have the required permission.
+     */
     @SystemApi
-    public void registerVolumeGroupCallback(
-            @NonNull Executor executor,
+    public void registerVolumeGroupCallback(@NonNull Executor executor,
             @NonNull VolumeGroupCallback callback) {
-        Preconditions.checkNotNull(executor, "executor must not be null");
-        Preconditions.checkNotNull(callback, "volume group change cb must not be null");
-        sAudioAudioVolumeGroupChangedHandler.init();
-        // TODO: make use of executor
-        sAudioAudioVolumeGroupChangedHandler.registerListener(callback);
+        mVolumeChangedListenerMgr.addListener(executor, callback, "registerVolumeGroupCallback",
+                () -> new AudioVolumeChangeDispatcherStub());
     }
 
-   /**
-    * @hide
-    * Unregister an audio volume group change listener.
-    * @param callback the {@link VolumeGroupCallback} to unregister
-    */
+    /**
+     * @hide
+     * Unregister an audio volume group change listener.
+     * @param callback the {@link VolumeGroupCallback} to unregister
+     */
     @SystemApi
-    public void unregisterVolumeGroupCallback(
-            @NonNull VolumeGroupCallback callback) {
-        Preconditions.checkNotNull(callback, "volume group change cb must not be null");
-        sAudioAudioVolumeGroupChangedHandler.unregisterListener(callback);
+    public void unregisterVolumeGroupCallback(@NonNull VolumeGroupCallback callback) {
+        mVolumeChangedListenerMgr.removeListener(callback, "unregisterVolumeGroupCallback");
     }
 
     /**
+     * Manages the VolumeGroupCallback listeners and the AudioVolumeChangeDispatcherStub
+     */
+    private final CallbackUtil.LazyListenerManager<VolumeGroupCallback> mVolumeChangedListenerMgr =
+            new CallbackUtil.LazyListenerManager();
+
+    final class AudioVolumeChangeDispatcherStub extends IAudioVolumeChangeDispatcher.Stub
+            implements CallbackUtil.DispatcherStub {
+
+        @Override
+        public void register(boolean register) {
+            try {
+                if (register) {
+                    getService().registerAudioVolumeCallback(this);
+                } else {
+                    getService().unregisterAudioVolumeCallback(this);
+                }
+            } catch (RemoteException e) {
+                e.rethrowFromSystemServer();
+            }
+        }
+
+        @Override
+        public void onAudioVolumeGroupChanged(int group, int flags) {
+            mVolumeChangedListenerMgr.callListeners((listener) ->
+                    listener.onAudioVolumeGroupChanged(group, flags));
+        }
+    }
+
+    //====================================================================
+
+    /**
      * Return if an asset contains haptic channels or not.
      *
      * @param context the {@link Context} to resolve the uri.
diff --git a/media/java/android/media/AudioSystem.java b/media/java/android/media/AudioSystem.java
index ad6f2e52..4906cd3 100644
--- a/media/java/android/media/AudioSystem.java
+++ b/media/java/android/media/AudioSystem.java
@@ -2732,4 +2732,25 @@
      * @hide
      */
     public static native void triggerSystemPropertyUpdate(long handle);
+
+    /**
+     * Registers the given {@link INativeAudioVolumeGroupCallback} to native audioserver.
+     * @param callback to register
+     * @return {@link #SUCCESS} if successfully registered.
+     *
+     * @hide
+     */
+    public static native int registerAudioVolumeGroupCallback(
+            INativeAudioVolumeGroupCallback callback);
+
+    /**
+     * Unegisters the given {@link INativeAudioVolumeGroupCallback} from native audioserver
+     * previously registered via {@link #registerAudioVolumeGroupCallback}.
+     * @param callback to register
+     * @return {@link #SUCCESS} if successfully registered.
+     *
+     * @hide
+     */
+    public static native int unregisterAudioVolumeGroupCallback(
+            INativeAudioVolumeGroupCallback callback);
 }
diff --git a/media/java/android/media/IAudioService.aidl b/media/java/android/media/IAudioService.aidl
index 8aadb41..b97b943 100644
--- a/media/java/android/media/IAudioService.aidl
+++ b/media/java/android/media/IAudioService.aidl
@@ -65,6 +65,7 @@
 import android.media.audiopolicy.AudioProductStrategy;
 import android.media.audiopolicy.AudioVolumeGroup;
 import android.media.audiopolicy.IAudioPolicyCallback;
+import android.media.audiopolicy.IAudioVolumeChangeDispatcher;
 import android.media.projection.IMediaProjection;
 import android.net.Uri;
 import android.os.PersistableBundle;
@@ -446,6 +447,10 @@
 
     boolean isAudioServerRunning();
 
+    void registerAudioVolumeCallback(IAudioVolumeChangeDispatcher avc);
+
+    oneway void unregisterAudioVolumeCallback(IAudioVolumeChangeDispatcher avc);
+
     int setUidDeviceAffinity(in IAudioPolicyCallback pcb, in int uid, in int[] deviceTypes,
              in String[] deviceAddresses);
 
diff --git a/media/java/android/media/audiopolicy/AudioVolumeGroupChangeHandler.java b/media/java/android/media/audiopolicy/AudioVolumeGroupChangeHandler.java
deleted file mode 100644
index 022cfee..0000000
--- a/media/java/android/media/audiopolicy/AudioVolumeGroupChangeHandler.java
+++ /dev/null
@@ -1,166 +0,0 @@
-/*
- * Copyright (C) 2018 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 android.media.audiopolicy;
-
-import android.annotation.NonNull;
-import android.media.AudioManager;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.Message;
-
-import com.android.internal.util.Preconditions;
-
-import java.lang.ref.WeakReference;
-import java.util.ArrayList;
-
-/**
- * The AudioVolumeGroupChangeHandler handles AudioManager.OnAudioVolumeGroupChangedListener
- * callbacks posted from JNI
- *
- * TODO: Make use of Executor of callbacks.
- * @hide
- */
-public class AudioVolumeGroupChangeHandler {
-    private Handler mHandler;
-    private HandlerThread mHandlerThread;
-    private final ArrayList<AudioManager.VolumeGroupCallback> mListeners =
-            new ArrayList<AudioManager.VolumeGroupCallback>();
-
-    private static final String TAG = "AudioVolumeGroupChangeHandler";
-
-    private static final int AUDIOVOLUMEGROUP_EVENT_VOLUME_CHANGED = 1000;
-    private static final int AUDIOVOLUMEGROUP_EVENT_NEW_LISTENER = 4;
-
-    /**
-     * Accessed by native methods: JNI Callback context.
-     */
-    @SuppressWarnings("unused")
-    private long mJniCallback;
-
-    /**
-     * Initialization
-     */
-    public void init() {
-        synchronized (this) {
-            if (mHandler != null) {
-                return;
-            }
-            // create a new thread for our new event handler
-            mHandlerThread = new HandlerThread(TAG);
-            mHandlerThread.start();
-
-            if (mHandlerThread.getLooper() == null) {
-                mHandler = null;
-                return;
-            }
-            mHandler = new Handler(mHandlerThread.getLooper()) {
-                @Override
-                public void handleMessage(Message msg) {
-                    ArrayList<AudioManager.VolumeGroupCallback> listeners;
-                    synchronized (this) {
-                        if (msg.what == AUDIOVOLUMEGROUP_EVENT_NEW_LISTENER) {
-                            listeners =
-                                    new ArrayList<AudioManager.VolumeGroupCallback>();
-                            if (mListeners.contains(msg.obj)) {
-                                listeners.add(
-                                        (AudioManager.VolumeGroupCallback) msg.obj);
-                            }
-                        } else {
-                            listeners = (ArrayList<AudioManager.VolumeGroupCallback>)
-                                    mListeners.clone();
-                        }
-                    }
-                    if (listeners.isEmpty()) {
-                        return;
-                    }
-
-                    switch (msg.what) {
-                        case AUDIOVOLUMEGROUP_EVENT_VOLUME_CHANGED:
-                            for (int i = 0; i < listeners.size(); i++) {
-                                listeners.get(i).onAudioVolumeGroupChanged((int) msg.arg1,
-                                                                           (int) msg.arg2);
-                            }
-                            break;
-
-                        default:
-                            break;
-                    }
-                }
-            };
-            native_setup(new WeakReference<AudioVolumeGroupChangeHandler>(this));
-        }
-    }
-
-    private native void native_setup(Object moduleThis);
-
-    @Override
-    protected void finalize() {
-        native_finalize();
-        if (mHandlerThread.isAlive()) {
-            mHandlerThread.quit();
-        }
-    }
-    private native void native_finalize();
-
-   /**
-    * @param cb the {@link AudioManager.VolumeGroupCallback} to register
-    */
-    public void registerListener(@NonNull AudioManager.VolumeGroupCallback cb) {
-        Preconditions.checkNotNull(cb, "volume group callback shall not be null");
-        synchronized (this) {
-            mListeners.add(cb);
-        }
-        if (mHandler != null) {
-            Message m = mHandler.obtainMessage(
-                    AUDIOVOLUMEGROUP_EVENT_NEW_LISTENER, 0, 0, cb);
-            mHandler.sendMessage(m);
-        }
-    }
-
-   /**
-    * @param cb the {@link AudioManager.VolumeGroupCallback} to unregister
-    */
-    public void unregisterListener(@NonNull AudioManager.VolumeGroupCallback cb) {
-        Preconditions.checkNotNull(cb, "volume group callback shall not be null");
-        synchronized (this) {
-            mListeners.remove(cb);
-        }
-    }
-
-    Handler handler() {
-        return mHandler;
-    }
-
-    @SuppressWarnings("unused")
-    private static void postEventFromNative(Object moduleRef,
-                                            int what, int arg1, int arg2, Object obj) {
-        AudioVolumeGroupChangeHandler eventHandler =
-                (AudioVolumeGroupChangeHandler) ((WeakReference) moduleRef).get();
-        if (eventHandler == null) {
-            return;
-        }
-
-        if (eventHandler != null) {
-            Handler handler = eventHandler.handler();
-            if (handler != null) {
-                Message m = handler.obtainMessage(what, arg1, arg2, obj);
-                // Do not remove previous messages, as we would lose notification of group changes
-                handler.sendMessage(m);
-            }
-        }
-    }
-}
diff --git a/media/java/android/media/audiopolicy/IAudioVolumeChangeDispatcher.aidl b/media/java/android/media/audiopolicy/IAudioVolumeChangeDispatcher.aidl
new file mode 100644
index 0000000..e6f9024
--- /dev/null
+++ b/media/java/android/media/audiopolicy/IAudioVolumeChangeDispatcher.aidl
@@ -0,0 +1,31 @@
+/* Copyright (C) 2025 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 android.media.audiopolicy;
+
+/**
+ * AIDL for the AudioService to signal audio volume groups changes
+ *
+ * {@hide}
+ */
+oneway interface IAudioVolumeChangeDispatcher {
+
+    /**
+     * Called when a volume group has been changed
+     * @param group id of the volume group that has changed.
+     * @param flags one or more flags to describe the volume change.
+     */
+    void onAudioVolumeGroupChanged(int group, int flags);
+}
diff --git a/media/tests/AudioPolicyTest/src/com/android/audiopolicytest/AudioVolumeGroupChangeHandlerTest.java b/media/tests/AudioPolicyTest/src/com/android/audiopolicytest/AudioVolumeGroupChangeHandlerTest.java
deleted file mode 100644
index 82394a2..0000000
--- a/media/tests/AudioPolicyTest/src/com/android/audiopolicytest/AudioVolumeGroupChangeHandlerTest.java
+++ /dev/null
@@ -1,211 +0,0 @@
-/*
- * Copyright (C) 2020 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.audiopolicytest;
-
-import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
-
-import static com.android.audiopolicytest.AudioVolumeTestUtil.DEFAULT_ATTRIBUTES;
-import static com.android.audiopolicytest.AudioVolumeTestUtil.incrementVolumeIndex;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertThrows;
-import static org.junit.Assert.assertTrue;
-
-import android.media.AudioAttributes;
-import android.media.AudioManager;
-import android.media.audiopolicy.AudioVolumeGroup;
-import android.media.audiopolicy.AudioVolumeGroupChangeHandler;
-import android.platform.test.annotations.Presubmit;
-
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.util.ArrayList;
-import java.util.List;
-
-@Presubmit
-@RunWith(AndroidJUnit4.class)
-public class AudioVolumeGroupChangeHandlerTest {
-    private static final String TAG = "AudioVolumeGroupChangeHandlerTest";
-
-    @Rule
-    public final AudioVolumesTestRule rule = new AudioVolumesTestRule();
-
-    private AudioManager mAudioManager;
-
-    @Before
-    public void setUp() {
-        mAudioManager = getApplicationContext().getSystemService(AudioManager.class);
-    }
-
-    @Test
-    public void testRegisterInvalidCallback() {
-        final AudioVolumeGroupChangeHandler audioAudioVolumeGroupChangedHandler =
-                new AudioVolumeGroupChangeHandler();
-
-        audioAudioVolumeGroupChangedHandler.init();
-
-        assertThrows(NullPointerException.class, () -> {
-            AudioManager.VolumeGroupCallback nullCb = null;
-            audioAudioVolumeGroupChangedHandler.registerListener(nullCb);
-        });
-    }
-
-    @Test
-    public void testUnregisterInvalidCallback() {
-        final AudioVolumeGroupChangeHandler audioAudioVolumeGroupChangedHandler =
-                new AudioVolumeGroupChangeHandler();
-
-        audioAudioVolumeGroupChangedHandler.init();
-
-        final AudioVolumeGroupCallbackHelper cb = new AudioVolumeGroupCallbackHelper();
-        audioAudioVolumeGroupChangedHandler.registerListener(cb);
-
-        assertThrows(NullPointerException.class, () -> {
-            AudioManager.VolumeGroupCallback nullCb = null;
-            audioAudioVolumeGroupChangedHandler.unregisterListener(nullCb);
-        });
-        audioAudioVolumeGroupChangedHandler.unregisterListener(cb);
-    }
-
-    @Test
-    public void testRegisterUnregisterCallback() {
-        final AudioVolumeGroupChangeHandler audioAudioVolumeGroupChangedHandler =
-                new AudioVolumeGroupChangeHandler();
-
-        audioAudioVolumeGroupChangedHandler.init();
-        final AudioVolumeGroupCallbackHelper validCb = new AudioVolumeGroupCallbackHelper();
-
-        // Should not assert, otherwise test will fail
-        audioAudioVolumeGroupChangedHandler.registerListener(validCb);
-
-        // Should not assert, otherwise test will fail
-        audioAudioVolumeGroupChangedHandler.unregisterListener(validCb);
-    }
-
-    @Test
-    public void testCallbackReceived() {
-        final AudioVolumeGroupChangeHandler audioAudioVolumeGroupChangedHandler =
-                new AudioVolumeGroupChangeHandler();
-
-        audioAudioVolumeGroupChangedHandler.init();
-
-        final AudioVolumeGroupCallbackHelper validCb = new AudioVolumeGroupCallbackHelper();
-        audioAudioVolumeGroupChangedHandler.registerListener(validCb);
-
-        List<AudioVolumeGroup> audioVolumeGroups = mAudioManager.getAudioVolumeGroups();
-        assertTrue(audioVolumeGroups.size() > 0);
-
-        try {
-            for (final AudioVolumeGroup audioVolumeGroup : audioVolumeGroups) {
-                int volumeGroupId = audioVolumeGroup.getId();
-
-                List<AudioAttributes> avgAttributes = audioVolumeGroup.getAudioAttributes();
-                // Set the volume per attributes (if valid) and wait the callback
-                if (avgAttributes.size() == 0 || avgAttributes.get(0).equals(DEFAULT_ATTRIBUTES)) {
-                    // Some volume groups may not have valid attributes, used for internal
-                    // volume management like patch/rerouting
-                    // so bailing out strategy retrieval from attributes
-                    continue;
-                }
-                final AudioAttributes aa = avgAttributes.get(0);
-
-                int index = mAudioManager.getVolumeIndexForAttributes(aa);
-                int indexMax = mAudioManager.getMaxVolumeIndexForAttributes(aa);
-                int indexMin = mAudioManager.getMinVolumeIndexForAttributes(aa);
-
-                final int indexForAa = incrementVolumeIndex(index, indexMin, indexMax);
-
-                // Set the receiver to filter only the current group callback
-                validCb.setExpectedVolumeGroup(volumeGroupId);
-                mAudioManager.setVolumeIndexForAttributes(aa, indexForAa, 0/*flags*/);
-                assertTrue(validCb.waitForExpectedVolumeGroupChanged(
-                        AudioVolumeGroupCallbackHelper.ASYNC_TIMEOUT_MS));
-
-                final int readIndex = mAudioManager.getVolumeIndexForAttributes(aa);
-                assertEquals(readIndex, indexForAa);
-            }
-        } finally {
-            audioAudioVolumeGroupChangedHandler.unregisterListener(validCb);
-        }
-    }
-
-    @Test
-    public void testMultipleCallbackReceived() {
-
-        final AudioVolumeGroupChangeHandler audioAudioVolumeGroupChangedHandler =
-                new AudioVolumeGroupChangeHandler();
-
-        audioAudioVolumeGroupChangedHandler.init();
-
-        final int callbackCount = 10;
-        final List<AudioVolumeGroupCallbackHelper> validCbs =
-                new ArrayList<AudioVolumeGroupCallbackHelper>();
-        for (int i = 0; i < callbackCount; i++) {
-            validCbs.add(new AudioVolumeGroupCallbackHelper());
-        }
-        for (final AudioVolumeGroupCallbackHelper cb : validCbs) {
-            audioAudioVolumeGroupChangedHandler.registerListener(cb);
-        }
-
-        List<AudioVolumeGroup> audioVolumeGroups = mAudioManager.getAudioVolumeGroups();
-        assertTrue(audioVolumeGroups.size() > 0);
-
-        try {
-            for (final AudioVolumeGroup audioVolumeGroup : audioVolumeGroups) {
-                int volumeGroupId = audioVolumeGroup.getId();
-
-                List<AudioAttributes> avgAttributes = audioVolumeGroup.getAudioAttributes();
-                // Set the volume per attributes (if valid) and wait the callback
-                if (avgAttributes.size() == 0 || avgAttributes.get(0).equals(DEFAULT_ATTRIBUTES)) {
-                    // Some volume groups may not have valid attributes, used for internal
-                    // volume management like patch/rerouting
-                    // so bailing out strategy retrieval from attributes
-                    continue;
-                }
-                AudioAttributes aa = avgAttributes.get(0);
-
-                int index = mAudioManager.getVolumeIndexForAttributes(aa);
-                int indexMax = mAudioManager.getMaxVolumeIndexForAttributes(aa);
-                int indexMin = mAudioManager.getMinVolumeIndexForAttributes(aa);
-
-                final int indexForAa = incrementVolumeIndex(index, indexMin, indexMax);
-
-                // Set the receiver to filter only the current group callback
-                for (final AudioVolumeGroupCallbackHelper cb : validCbs) {
-                    cb.setExpectedVolumeGroup(volumeGroupId);
-                }
-                mAudioManager.setVolumeIndexForAttributes(aa, indexForAa, 0/*flags*/);
-
-                for (final AudioVolumeGroupCallbackHelper cb : validCbs) {
-                    assertTrue(cb.waitForExpectedVolumeGroupChanged(
-                            AudioVolumeGroupCallbackHelper.ASYNC_TIMEOUT_MS));
-                }
-                int readIndex = mAudioManager.getVolumeIndexForAttributes(aa);
-                assertEquals(readIndex, indexForAa);
-            }
-        } finally {
-            for (final AudioVolumeGroupCallbackHelper cb : validCbs) {
-                audioAudioVolumeGroupChangedHandler.unregisterListener(cb);
-            }
-        }
-    }
-}
diff --git a/services/core/java/com/android/server/audio/AudioService.java b/services/core/java/com/android/server/audio/AudioService.java
index 6b3661a..25fc1ff 100644
--- a/services/core/java/com/android/server/audio/AudioService.java
+++ b/services/core/java/com/android/server/audio/AudioService.java
@@ -186,6 +186,7 @@
 import android.media.audiopolicy.AudioProductStrategy;
 import android.media.audiopolicy.AudioVolumeGroup;
 import android.media.audiopolicy.IAudioPolicyCallback;
+import android.media.audiopolicy.IAudioVolumeChangeDispatcher;
 import android.media.permission.ClearCallingIdentityContext;
 import android.media.permission.SafeCloseable;
 import android.media.projection.IMediaProjection;
@@ -1388,6 +1389,7 @@
         mUseVolumeGroupAliases = mContext.getResources().getBoolean(
                 com.android.internal.R.bool.config_handleVolumeAliasesUsingVolumeGroups);
 
+        mAudioVolumeChangeHandler = new AudioVolumeChangeHandler(mAudioSystem);
         // Initialize volume
         // Priority 1 - Android Property
         // Priority 2 - Audio Policy Service
@@ -4452,6 +4454,21 @@
         }
     }
 
+    //================================
+    // Audio Volume Change Dispatcher
+    //================================
+    private final AudioVolumeChangeHandler mAudioVolumeChangeHandler;
+
+    /** @see AudioManager#registerVolumeGroupCallback(executor, callback) */
+    public void registerAudioVolumeCallback(IAudioVolumeChangeDispatcher callback) {
+        mAudioVolumeChangeHandler.registerListener(callback);
+    }
+
+    /** @see AudioManager#unregisterVolumeGroupCallback(callback) */
+    public void unregisterAudioVolumeCallback(IAudioVolumeChangeDispatcher callback) {
+        mAudioVolumeChangeHandler.unregisterListener(callback);
+    }
+
     @Override
     @android.annotation.EnforcePermission(anyOf = {
             MODIFY_AUDIO_SETTINGS_PRIVILEGED, MODIFY_AUDIO_ROUTING })
diff --git a/services/core/java/com/android/server/audio/AudioSystemAdapter.java b/services/core/java/com/android/server/audio/AudioSystemAdapter.java
index a6267c1..ced5fae 100644
--- a/services/core/java/com/android/server/audio/AudioSystemAdapter.java
+++ b/services/core/java/com/android/server/audio/AudioSystemAdapter.java
@@ -23,6 +23,7 @@
 import android.media.AudioMixerAttributes;
 import android.media.AudioSystem;
 import android.media.IDevicesForAttributesCallback;
+import android.media.INativeAudioVolumeGroupCallback;
 import android.media.ISoundDose;
 import android.media.ISoundDoseCallback;
 import android.media.audiopolicy.AudioMix;
@@ -758,6 +759,29 @@
     }
 
     /**
+     * Same as {@link AudioSystem#registerAudioVolumeGroupCallback(INativeAudioVolumeGroupCallback)}
+     * @param callback to register
+     * @return {@link #SUCCESS} if successfully registered.
+     *
+     * @hide
+     */
+    public int registerAudioVolumeGroupCallback(INativeAudioVolumeGroupCallback callback) {
+        return AudioSystem.registerAudioVolumeGroupCallback(callback);
+    }
+
+    /**
+     * Same as
+     * {@link AudioSystem#unregisterAudioVolumeGroupCallback(INativeAudioVolumeGroupCallback)}.
+     * @param callback to register
+     * @return {@link #SUCCESS} if successfully registered.
+     *
+     * @hide
+     */
+    public int unregisterAudioVolumeGroupCallback(INativeAudioVolumeGroupCallback callback) {
+        return AudioSystem.unregisterAudioVolumeGroupCallback(callback);
+    }
+
+    /**
      * Part of AudioService dump
      * @param pw
      */
diff --git a/services/core/java/com/android/server/audio/AudioVolumeChangeHandler.java b/services/core/java/com/android/server/audio/AudioVolumeChangeHandler.java
new file mode 100644
index 0000000..2bb4301
--- /dev/null
+++ b/services/core/java/com/android/server/audio/AudioVolumeChangeHandler.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2025 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.server.audio;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.media.INativeAudioVolumeGroupCallback;
+import android.media.audio.common.AudioVolumeGroupChangeEvent;
+import android.media.audiopolicy.IAudioVolumeChangeDispatcher;
+import android.os.RemoteCallbackList;
+import android.os.RemoteException;
+import android.util.Slog;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
+
+/**
+ * The AudioVolumeChangeHandler handles AudioVolume callbacks invoked by native
+ * {@link INativeAudioVolumeGroupCallback} callback.
+ */
+/* private package */ class AudioVolumeChangeHandler {
+    private static final String TAG = "AudioVolumeChangeHandler";
+
+    private final Object mLock = new Object();
+    @GuardedBy("mLock")
+    private final RemoteCallbackList<IAudioVolumeChangeDispatcher> mListeners =
+            new RemoteCallbackList<>();
+    private final @NonNull AudioSystemAdapter mAudioSystem;
+    private @Nullable AudioVolumeGroupCallback mAudioVolumeGroupCallback;
+
+    AudioVolumeChangeHandler(@NonNull AudioSystemAdapter asa) {
+        mAudioSystem = asa;
+    }
+
+    @GuardedBy("mLock")
+    private void lazyInitLocked() {
+        mAudioVolumeGroupCallback = new AudioVolumeGroupCallback();
+        mAudioSystem.registerAudioVolumeGroupCallback(mAudioVolumeGroupCallback);
+    }
+
+    private void sendAudioVolumeGroupChangedToClients(int groupId, int index) {
+        RemoteCallbackList<IAudioVolumeChangeDispatcher> listeners;
+        int nbDispatchers;
+        synchronized (mLock) {
+            listeners = mListeners;
+            nbDispatchers = mListeners.beginBroadcast();
+        }
+        for (int i = 0; i < nbDispatchers; i++) {
+            try {
+                listeners.getBroadcastItem(i).onAudioVolumeGroupChanged(groupId, index);
+            } catch (RemoteException e) {
+                Slog.e(TAG, "Failed to broadcast Volume Changed event");
+            }
+        }
+        synchronized (mLock) {
+            mListeners.finishBroadcast();
+        }
+    }
+
+   /**
+    * @param cb the {@link IAudioVolumeChangeDispatcher} to register
+    */
+    public void registerListener(@NonNull IAudioVolumeChangeDispatcher cb) {
+        Preconditions.checkNotNull(cb, "Volume group callback must not be null");
+        synchronized (mLock) {
+            if (mAudioVolumeGroupCallback == null) {
+                lazyInitLocked();
+            }
+            mListeners.register(cb);
+        }
+    }
+
+   /**
+    * @param cb the {@link IAudioVolumeChangeDispatcher} to unregister
+    */
+    public void unregisterListener(@NonNull IAudioVolumeChangeDispatcher cb) {
+        Preconditions.checkNotNull(cb, "Volume group callback must not be null");
+        synchronized (mLock) {
+            mListeners.unregister(cb);
+        }
+    }
+
+    private final class AudioVolumeGroupCallback extends INativeAudioVolumeGroupCallback.Stub {
+        public void onAudioVolumeGroupChanged(AudioVolumeGroupChangeEvent volumeEvent) {
+            Slog.v(TAG, "onAudioVolumeGroupChanged volumeEvent=" + volumeEvent);
+            sendAudioVolumeGroupChangedToClients(volumeEvent.groupId, volumeEvent.flags);
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/audio/AudioVolumeChangeHandlerTest.java b/services/tests/servicestests/src/com/android/server/audio/AudioVolumeChangeHandlerTest.java
new file mode 100644
index 0000000..f252a98
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/audio/AudioVolumeChangeHandlerTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2025 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.server.audio;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.media.INativeAudioVolumeGroupCallback;
+import android.media.audio.common.AudioVolumeGroupChangeEvent;
+import android.media.audiopolicy.IAudioVolumeChangeDispatcher;
+import android.os.IBinder;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.filters.MediumTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@MediumTest
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+public class AudioVolumeChangeHandlerTest {
+    private static final long DEFAULT_TIMEOUT_MS = 1000;
+
+    private AudioSystemAdapter mSpyAudioSystem;
+
+    AudioVolumeChangeHandler mAudioVolumeChangedHandler;
+
+    private final IAudioVolumeChangeDispatcher.Stub mMockDispatcher =
+            mock(IAudioVolumeChangeDispatcher.Stub.class);
+
+    @Before
+    public void setUp() {
+        mSpyAudioSystem = spy(new NoOpAudioSystemAdapter());
+        when(mMockDispatcher.asBinder()).thenReturn(mock(IBinder.class));
+        mAudioVolumeChangedHandler = new AudioVolumeChangeHandler(mSpyAudioSystem);
+    }
+
+    @Test
+    public void registerListener_withInvalidCallback() {
+        IAudioVolumeChangeDispatcher.Stub nullCb = null;
+        NullPointerException thrown = assertThrows(NullPointerException.class, () -> {
+            mAudioVolumeChangedHandler.registerListener(nullCb);
+        });
+
+        assertWithMessage("Exception for invalid registration").that(thrown).hasMessageThat()
+                .contains("Volume group callback");
+    }
+
+    @Test
+    public void unregisterListener_withInvalidCallback() {
+        IAudioVolumeChangeDispatcher.Stub nullCb = null;
+        mAudioVolumeChangedHandler.registerListener(mMockDispatcher);
+
+        NullPointerException thrown = assertThrows(NullPointerException.class, () -> {
+            mAudioVolumeChangedHandler.unregisterListener(nullCb);
+        });
+
+        assertWithMessage("Exception for invalid un-registration").that(thrown).hasMessageThat()
+                .contains("Volume group callback");
+    }
+
+    @Test
+    public void registerListener() {
+        mAudioVolumeChangedHandler.registerListener(mMockDispatcher);
+
+        verify(mSpyAudioSystem).registerAudioVolumeGroupCallback(any());
+    }
+
+    @Test
+    public void onAudioVolumeGroupChanged() throws Exception {
+        mAudioVolumeChangedHandler.registerListener(mMockDispatcher);
+        AudioVolumeGroupChangeEvent volEvent = new AudioVolumeGroupChangeEvent();
+        volEvent.groupId = 666;
+        volEvent.flags = AudioVolumeGroupChangeEvent.VOLUME_FLAG_FROM_KEY;
+
+        captureRegisteredNativeCallback().onAudioVolumeGroupChanged(volEvent);
+
+        verify(mMockDispatcher,  timeout(DEFAULT_TIMEOUT_MS)).onAudioVolumeGroupChanged(
+                eq(volEvent.groupId), eq(volEvent.flags));
+    }
+
+    @Test
+    public void onAudioVolumeGroupChanged_withMultipleCallback() throws Exception {
+        int callbackCount = 10;
+        List<IAudioVolumeChangeDispatcher.Stub> validCbs =
+                new ArrayList<IAudioVolumeChangeDispatcher.Stub>();
+        for (int i = 0; i < callbackCount; i++) {
+            IAudioVolumeChangeDispatcher.Stub cb = mock(IAudioVolumeChangeDispatcher.Stub.class);
+            when(cb.asBinder()).thenReturn(mock(IBinder.class));
+            validCbs.add(cb);
+        }
+        for (IAudioVolumeChangeDispatcher.Stub cb : validCbs) {
+            mAudioVolumeChangedHandler.registerListener(cb);
+        }
+        AudioVolumeGroupChangeEvent volEvent = new AudioVolumeGroupChangeEvent();
+        volEvent.groupId = 666;
+        volEvent.flags = AudioVolumeGroupChangeEvent.VOLUME_FLAG_FROM_KEY;
+        captureRegisteredNativeCallback().onAudioVolumeGroupChanged(volEvent);
+
+        for (IAudioVolumeChangeDispatcher.Stub cb : validCbs) {
+            verify(cb,  timeout(DEFAULT_TIMEOUT_MS)).onAudioVolumeGroupChanged(
+                    eq(volEvent.groupId), eq(volEvent.flags));
+        }
+    }
+
+    private INativeAudioVolumeGroupCallback captureRegisteredNativeCallback() {
+        ArgumentCaptor<INativeAudioVolumeGroupCallback> nativeAudioVolumeGroupCallbackCaptor =
+                ArgumentCaptor.forClass(INativeAudioVolumeGroupCallback.class);
+        verify(mSpyAudioSystem, timeout(DEFAULT_TIMEOUT_MS))
+                .registerAudioVolumeGroupCallback(nativeAudioVolumeGroupCallbackCaptor.capture());
+        return nativeAudioVolumeGroupCallbackCaptor.getValue();
+    }
+}