Add Display#getHdrSdrRatio

Bug: 242764315
Test: Logs in SilkFX; verified onDisplayChanged is not firing
      when this situation happens
Change-Id: I26a081e07fb4bb2d021c47db4258c0ce0c4f6f8f
diff --git a/core/java/android/hardware/display/DisplayManager.java b/core/java/android/hardware/display/DisplayManager.java
index d49cc44..65e5802 100644
--- a/core/java/android/hardware/display/DisplayManager.java
+++ b/core/java/android/hardware/display/DisplayManager.java
@@ -540,7 +540,8 @@
             EVENT_FLAG_DISPLAY_ADDED,
             EVENT_FLAG_DISPLAY_CHANGED,
             EVENT_FLAG_DISPLAY_REMOVED,
-            EVENT_FLAG_DISPLAY_BRIGHTNESS
+            EVENT_FLAG_DISPLAY_BRIGHTNESS,
+            EVENT_FLAG_HDR_SDR_RATIO_CHANGED
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface EventsMask {}
@@ -583,6 +584,19 @@
      */
     public static final long EVENT_FLAG_DISPLAY_BRIGHTNESS = 1L << 3;
 
+    /**
+     * Event flag to register for a display's hdr/sdr ratio changes. This notification is sent
+     * through the {@link DisplayListener#onDisplayChanged} callback method. New hdr/sdr
+     * values can be retrieved via {@link Display#getHdrSdrRatio()}.
+     *
+     * Requires that {@link Display#isHdrSdrRatioAvailable()} is true.
+     *
+     * @see #registerDisplayListener(DisplayListener, Handler, long)
+     *
+     * @hide
+     */
+    public static final long EVENT_FLAG_HDR_SDR_RATIO_CHANGED = 1L << 4;
+
     /** @hide */
     public DisplayManager(Context context) {
         mContext = context;
diff --git a/core/java/android/hardware/display/DisplayManagerGlobal.java b/core/java/android/hardware/display/DisplayManagerGlobal.java
index d9db177..f269feb 100644
--- a/core/java/android/hardware/display/DisplayManagerGlobal.java
+++ b/core/java/android/hardware/display/DisplayManagerGlobal.java
@@ -38,6 +38,7 @@
 import android.media.projection.IMediaProjection;
 import android.media.projection.MediaProjection;
 import android.os.Handler;
+import android.os.HandlerExecutor;
 import android.os.IBinder;
 import android.os.Looper;
 import android.os.Message;
@@ -56,11 +57,12 @@
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * Manager communication with the display manager service on behalf of
@@ -82,11 +84,12 @@
     // orientation change before the display info cache has actually been invalidated.
     private static final boolean USE_CACHE = false;
 
-    @IntDef(prefix = {"SWITCHING_TYPE_"}, value = {
+    @IntDef(prefix = {"EVENT_DISPLAY_"}, flag = true, value = {
             EVENT_DISPLAY_ADDED,
             EVENT_DISPLAY_CHANGED,
             EVENT_DISPLAY_REMOVED,
-            EVENT_DISPLAY_BRIGHTNESS_CHANGED
+            EVENT_DISPLAY_BRIGHTNESS_CHANGED,
+            EVENT_DISPLAY_HDR_SDR_RATIO_CHANGED,
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface DisplayEvent {}
@@ -95,6 +98,7 @@
     public static final int EVENT_DISPLAY_CHANGED = 2;
     public static final int EVENT_DISPLAY_REMOVED = 3;
     public static final int EVENT_DISPLAY_BRIGHTNESS_CHANGED = 4;
+    public static final int EVENT_DISPLAY_HDR_SDR_RATIO_CHANGED = 5;
 
     @UnsupportedAppUsage
     private static DisplayManagerGlobal sInstance;
@@ -109,7 +113,8 @@
 
     private DisplayManagerCallback mCallback;
     private @EventsMask long mRegisteredEventsMask = 0;
-    private final ArrayList<DisplayListenerDelegate> mDisplayListeners = new ArrayList<>();
+    private final CopyOnWriteArrayList<DisplayListenerDelegate> mDisplayListeners =
+            new CopyOnWriteArrayList<>();
 
     private final SparseArray<DisplayInfo> mDisplayInfoCache = new SparseArray<>();
     private final ColorSpace mWideColorSpace;
@@ -315,6 +320,19 @@
      */
     public void registerDisplayListener(@NonNull DisplayListener listener,
             @Nullable Handler handler, @EventsMask long eventsMask) {
+        Looper looper = getLooperForHandler(handler);
+        Handler springBoard = new Handler(looper);
+        registerDisplayListener(listener, new HandlerExecutor(springBoard), eventsMask);
+    }
+
+    /**
+     * Register a listener for display-related changes.
+     *
+     * @param listener The listener that will be called when display changes occur.
+     * @param executor Executor for the thread that will be receiving the callbacks. Cannot be null.
+     */
+    public void registerDisplayListener(@NonNull DisplayListener listener,
+            @NonNull Executor executor, @EventsMask long eventsMask) {
         if (listener == null) {
             throw new IllegalArgumentException("listener must not be null");
         }
@@ -326,8 +344,7 @@
         synchronized (mLock) {
             int index = findDisplayListenerLocked(listener);
             if (index < 0) {
-                Looper looper = getLooperForHandler(handler);
-                mDisplayListeners.add(new DisplayListenerDelegate(listener, looper, eventsMask));
+                mDisplayListeners.add(new DisplayListenerDelegate(listener, executor, eventsMask));
                 registerCallbackIfNeededLocked();
             } else {
                 mDisplayListeners.get(index).setEventsMask(eventsMask);
@@ -408,6 +425,7 @@
     }
 
     private void handleDisplayEvent(int displayId, @DisplayEvent int event) {
+        final DisplayInfo info;
         synchronized (mLock) {
             if (USE_CACHE) {
                 mDisplayInfoCache.remove(displayId);
@@ -417,11 +435,7 @@
                 }
             }
 
-            final int numListeners = mDisplayListeners.size();
-            DisplayInfo info = getDisplayInfo(displayId);
-            for (int i = 0; i < numListeners; i++) {
-                mDisplayListeners.get(i).sendDisplayEvent(displayId, event, info);
-            }
+            info = getDisplayInfoLocked(displayId);
             if (event == EVENT_DISPLAY_CHANGED && mDispatchNativeCallbacks) {
                 // Choreographer only supports a single display, so only dispatch refresh rate
                 // changes for the default display.
@@ -438,6 +452,11 @@
                 }
             }
         }
+        // Accepting an Executor means the listener may be synchronously invoked, so we must
+        // not be holding mLock when we do so
+        for (DisplayListenerDelegate listener : mDisplayListeners) {
+            listener.sendDisplayEvent(displayId, event, info);
+        }
     }
 
     public void startWifiDisplayScan() {
@@ -1075,34 +1094,42 @@
         }
     }
 
-    private static final class DisplayListenerDelegate extends Handler {
+    private static final class DisplayListenerDelegate {
         public final DisplayListener mListener;
         public volatile long mEventsMask;
 
         private final DisplayInfo mDisplayInfo = new DisplayInfo();
+        private final Executor mExecutor;
+        private AtomicLong mGenerationId = new AtomicLong(1);
 
-        DisplayListenerDelegate(DisplayListener listener, @NonNull Looper looper,
+        DisplayListenerDelegate(DisplayListener listener, @NonNull Executor executor,
                 @EventsMask long eventsMask) {
-            super(looper, null, true /*async*/);
+            mExecutor = executor;
             mListener = listener;
             mEventsMask = eventsMask;
         }
 
         public void sendDisplayEvent(int displayId, @DisplayEvent int event, DisplayInfo info) {
-            Message msg = obtainMessage(event, displayId, 0, info);
-            sendMessage(msg);
+            long generationId = mGenerationId.get();
+            Message msg = Message.obtain(null, event, displayId, 0, info);
+            mExecutor.execute(() -> {
+                // If the generation id's don't match we were canceled but still need to recycle()
+                if (generationId == mGenerationId.get()) {
+                    handleMessage(msg);
+                }
+                msg.recycle();
+            });
         }
 
         public void clearEvents() {
-            removeCallbacksAndMessages(null);
+            mGenerationId.incrementAndGet();
         }
 
         public void setEventsMask(@EventsMask long newEventsMask) {
             mEventsMask = newEventsMask;
         }
 
-        @Override
-        public void handleMessage(Message msg) {
+        private void handleMessage(Message msg) {
             if (DEBUG) {
                 Trace.beginSection(
                         "DisplayListenerDelegate(" + eventToString(msg.what)
@@ -1134,6 +1161,11 @@
                         mListener.onDisplayRemoved(msg.arg1);
                     }
                     break;
+                case EVENT_DISPLAY_HDR_SDR_RATIO_CHANGED:
+                    if ((mEventsMask & DisplayManager.EVENT_FLAG_HDR_SDR_RATIO_CHANGED) != 0) {
+                        mListener.onDisplayChanged(msg.arg1);
+                    }
+                    break;
             }
             if (DEBUG) {
                 Trace.endSection();
@@ -1255,6 +1287,8 @@
                 return "REMOVED";
             case EVENT_DISPLAY_BRIGHTNESS_CHANGED:
                 return "BRIGHTNESS_CHANGED";
+            case EVENT_DISPLAY_HDR_SDR_RATIO_CHANGED:
+                return "HDR_SDR_RATIO_CHANGED";
         }
         return "UNKNOWN";
     }
diff --git a/core/java/android/view/Display.java b/core/java/android/view/Display.java
index 6b1499f..1563fc0 100644
--- a/core/java/android/view/Display.java
+++ b/core/java/android/view/Display.java
@@ -56,6 +56,8 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
 
 /**
  * Provides information about the size and density of a logical display.
@@ -111,6 +113,8 @@
     private int mCachedAppWidthCompat;
     private int mCachedAppHeightCompat;
 
+    private ArrayList<HdrSdrRatioListenerWrapper> mHdrSdrRatioListeners = new ArrayList<>();
+
     /**
      * The default Display id, which is the id of the primary display assuming there is one.
      */
@@ -1292,6 +1296,102 @@
     }
 
     /**
+     * @return Whether the display supports reporting an hdr/sdr ratio. If this is false,
+     *         {@link #getHdrSdrRatio()} will always be 1.0f
+     * @hide
+     * TODO: make public
+     */
+    public boolean isHdrSdrRatioAvailable() {
+        synchronized (mLock) {
+            updateDisplayInfoLocked();
+            return !Float.isNaN(mDisplayInfo.hdrSdrRatio);
+        }
+    }
+
+    /**
+     * @return The current hdr/sdr ratio expressed as the ratio of targetHdrPeakBrightnessInNits /
+     *         targetSdrWhitePointInNits. If {@link #isHdrSdrRatioAvailable()} is false, this
+     *         always returns 1.0f.
+     *
+     * @hide
+     * TODO: make public
+     */
+    public float getHdrSdrRatio() {
+        synchronized (mLock) {
+            updateDisplayInfoLocked();
+            return Float.isNaN(mDisplayInfo.hdrSdrRatio)
+                    ? 1.0f : mDisplayInfo.hdrSdrRatio;
+        }
+    }
+
+    private int findHdrSdrRatioListenerLocked(Consumer<Display> listener) {
+        for (int i = 0; i < mHdrSdrRatioListeners.size(); i++) {
+            final HdrSdrRatioListenerWrapper wrapper = mHdrSdrRatioListeners.get(i);
+            if (wrapper.mListener == listener) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Registers a listener that will be invoked whenever the display's hdr/sdr ratio has changed.
+     * After receiving the callback on the specified Executor, call {@link #getHdrSdrRatio()} to
+     * get the updated value.
+     * If {@link #isHdrSdrRatioAvailable()} is false, then an IllegalStateException will be thrown
+     *
+     * @see #unregisterHdrSdrRatioChangedListener(Consumer)
+     * @param executor The executor to invoke the listener on
+     * @param listener The listener to invoke when the HDR/SDR ratio changes
+     * @throws IllegalStateException if {@link #isHdrSdrRatioAvailable()} is false
+     * @hide
+     * TODO: Make public
+     */
+    public void registerHdrSdrRatioChangedListener(@NonNull Executor executor,
+            @NonNull Consumer<Display> listener) {
+        if (!isHdrSdrRatioAvailable()) {
+            throw new IllegalStateException("HDR/SDR ratio changed not available");
+        }
+        HdrSdrRatioListenerWrapper toRegister = null;
+        synchronized (mLock) {
+            if (findHdrSdrRatioListenerLocked(listener) == -1) {
+                toRegister = new HdrSdrRatioListenerWrapper(listener);
+                mHdrSdrRatioListeners.add(toRegister);
+            } // else already listening, don't do anything
+        }
+        if (toRegister != null) {
+            // Although we only care about the HDR/SDR ratio changing, that can also come in the
+            // form of the larger DISPLAY_CHANGED event
+            mGlobal.registerDisplayListener(toRegister, executor,
+                    DisplayManager.EVENT_FLAG_HDR_SDR_RATIO_CHANGED
+                            | DisplayManagerGlobal.EVENT_DISPLAY_CHANGED);
+        }
+
+    }
+
+    /**
+     * @param listener  The previously
+     *                  {@link #registerHdrSdrRatioChangedListener(Executor, Consumer) registered}
+     *                  hdr/sdr ratio listener to remove.
+     *
+     * @see #registerHdrSdrRatioChangedListener(Executor, Consumer)
+     * @hide
+     * TODO: Make public
+     */
+    public void unregisterHdrSdrRatioChangedListener(Consumer<Display> listener) {
+        HdrSdrRatioListenerWrapper toRemove = null;
+        synchronized (mLock) {
+            int index = findHdrSdrRatioListenerLocked(listener);
+            if (index != -1) {
+                toRemove = mHdrSdrRatioListeners.remove(index);
+            }
+        }
+        if (toRemove != null) {
+            mGlobal.unregisterDisplayListener(toRemove);
+        }
+    }
+
+    /**
      * Sets the default {@link Display.Mode} to use for the display.  The display mode includes
      * preference for resolution and refresh rate.
      * If the mode specified is not supported by the display, then no mode change occurs.
@@ -2528,4 +2628,33 @@
             }
         }
     }
+
+    private class HdrSdrRatioListenerWrapper implements DisplayManager.DisplayListener {
+        Consumer<Display> mListener;
+        float mLastReportedRatio = 1.f;
+
+        private HdrSdrRatioListenerWrapper(Consumer<Display> listener) {
+            mListener = listener;
+        }
+
+        @Override
+        public void onDisplayAdded(int displayId) {
+            // don't care
+        }
+
+        @Override
+        public void onDisplayRemoved(int displayId) {
+            // don't care
+        }
+
+        @Override
+        public void onDisplayChanged(int displayId) {
+            if (displayId == getDisplayId()) {
+                float newRatio = getHdrSdrRatio();
+                if (newRatio != mLastReportedRatio) {
+                    mListener.accept(Display.this);
+                }
+            }
+        }
+    }
 }
diff --git a/core/java/android/view/DisplayInfo.java b/core/java/android/view/DisplayInfo.java
index 3a02c48..0368918 100644
--- a/core/java/android/view/DisplayInfo.java
+++ b/core/java/android/view/DisplayInfo.java
@@ -39,6 +39,8 @@
 import android.util.DisplayMetrics;
 import android.util.proto.ProtoOutputStream;
 
+import com.android.internal.display.BrightnessSynchronizer;
+
 import java.util.Arrays;
 import java.util.Objects;
 
@@ -340,6 +342,13 @@
     @Nullable
     public SurfaceControl.RefreshRateRange layoutLimitedRefreshRate;
 
+    /**
+     * The current hdr/sdr ratio for the display. If the display doesn't support hdr/sdr ratio
+     * queries then this is NaN
+     */
+    public float hdrSdrRatio = Float.NaN;
+
+
     public static final @android.annotation.NonNull Creator<DisplayInfo> CREATOR = new Creator<DisplayInfo>() {
         @Override
         public DisplayInfo createFromParcel(Parcel source) {
@@ -415,7 +424,8 @@
                 && Objects.equals(roundedCorners, other.roundedCorners)
                 && installOrientation == other.installOrientation
                 && Objects.equals(displayShape, other.displayShape)
-                && Objects.equals(layoutLimitedRefreshRate, other.layoutLimitedRefreshRate);
+                && Objects.equals(layoutLimitedRefreshRate, other.layoutLimitedRefreshRate)
+                && BrightnessSynchronizer.floatEquals(hdrSdrRatio, other.hdrSdrRatio);
     }
 
     @Override
@@ -471,6 +481,7 @@
         installOrientation = other.installOrientation;
         displayShape = other.displayShape;
         layoutLimitedRefreshRate = other.layoutLimitedRefreshRate;
+        hdrSdrRatio = other.hdrSdrRatio;
     }
 
     public void readFromParcel(Parcel source) {
@@ -532,6 +543,7 @@
         installOrientation = source.readInt();
         displayShape = source.readTypedObject(DisplayShape.CREATOR);
         layoutLimitedRefreshRate = source.readTypedObject(SurfaceControl.RefreshRateRange.CREATOR);
+        hdrSdrRatio = source.readFloat();
     }
 
     @Override
@@ -591,6 +603,7 @@
         dest.writeInt(installOrientation);
         dest.writeTypedObject(displayShape, flags);
         dest.writeTypedObject(layoutLimitedRefreshRate, flags);
+        dest.writeFloat(hdrSdrRatio);
     }
 
     @Override
@@ -852,6 +865,12 @@
         sb.append(Surface.rotationToString(installOrientation));
         sb.append(", layoutLimitedRefreshRate ");
         sb.append(layoutLimitedRefreshRate);
+        sb.append(", hdrSdrRatio ");
+        if (Float.isNaN(hdrSdrRatio)) {
+            sb.append("not_available");
+        } else {
+            sb.append(hdrSdrRatio);
+        }
         sb.append("}");
         return sb.toString();
     }
diff --git a/services/core/java/com/android/server/display/DisplayDeviceConfig.java b/services/core/java/com/android/server/display/DisplayDeviceConfig.java
index a107f33..2e17b5c 100644
--- a/services/core/java/com/android/server/display/DisplayDeviceConfig.java
+++ b/services/core/java/com/android/server/display/DisplayDeviceConfig.java
@@ -447,7 +447,7 @@
     // so -2 is used instead
     private static final float INVALID_BRIGHTNESS_IN_CONFIG = -2f;
 
-    private static final float NITS_INVALID = -1;
+    static final float NITS_INVALID = -1;
 
     // Length of the ambient light horizon used to calculate the long term estimate of ambient
     // light.
@@ -828,7 +828,7 @@
     /**
      * Calculates the nits value for the specified backlight value if a mapping exists.
      *
-     * @return The mapped nits or 0 if no mapping exits.
+     * @return The mapped nits or {@link #NITS_INVALID} if no mapping exits.
      */
     public float getNitsFromBacklight(float backlight) {
         if (mBacklightToNitsSpline == null) {
diff --git a/services/core/java/com/android/server/display/DisplayDeviceInfo.java b/services/core/java/com/android/server/display/DisplayDeviceInfo.java
index b7b7031..213ee64 100644
--- a/services/core/java/com/android/server/display/DisplayDeviceInfo.java
+++ b/services/core/java/com/android/server/display/DisplayDeviceInfo.java
@@ -226,6 +226,16 @@
     public static final int DIFF_COLOR_MODE = 1 << 2;
 
     /**
+     * Diff result: The hdr/sdr ratio differs
+     */
+    public static final int DIFF_HDR_SDR_RATIO = 1 << 3;
+
+    /**
+     * Diff result: Catch-all for "everything changed"
+     */
+    public static final int DIFF_EVERYTHING = 0XFFFFFFFF;
+
+    /**
      * Gets the name of the display device, which may be derived from EDID or
      * other sources. The name may be localized and displayed to the user.
      */
@@ -414,6 +424,9 @@
     public float brightnessMaximum;
     public float brightnessDefault;
 
+    // NaN means unsupported
+    public float hdrSdrRatio = Float.NaN;
+
     /**
      * Install orientation of display panel relative to its natural orientation.
      */
@@ -449,6 +462,9 @@
         if (colorMode != other.colorMode) {
             diff |= DIFF_COLOR_MODE;
         }
+        if (!BrightnessSynchronizer.floatEquals(hdrSdrRatio, other.hdrSdrRatio)) {
+            diff |= DIFF_HDR_SDR_RATIO;
+        }
         if (!Objects.equals(name, other.name)
                 || !Objects.equals(uniqueId, other.uniqueId)
                 || width != other.width
@@ -527,6 +543,7 @@
         brightnessMinimum = other.brightnessMinimum;
         brightnessMaximum = other.brightnessMaximum;
         brightnessDefault = other.brightnessDefault;
+        hdrSdrRatio = other.hdrSdrRatio;
         roundedCorners = other.roundedCorners;
         installOrientation = other.installOrientation;
         displayShape = other.displayShape;
@@ -575,6 +592,7 @@
         sb.append(", brightnessMinimum ").append(brightnessMinimum);
         sb.append(", brightnessMaximum ").append(brightnessMaximum);
         sb.append(", brightnessDefault ").append(brightnessDefault);
+        sb.append(", hdrSdrRatio ").append(hdrSdrRatio);
         if (roundedCorners != null) {
             sb.append(", roundedCorners ").append(roundedCorners);
         }
diff --git a/services/core/java/com/android/server/display/DisplayDeviceRepository.java b/services/core/java/com/android/server/display/DisplayDeviceRepository.java
index 33a63a9..ea52a3d 100644
--- a/services/core/java/com/android/server/display/DisplayDeviceRepository.java
+++ b/services/core/java/com/android/server/display/DisplayDeviceRepository.java
@@ -42,7 +42,6 @@
     private static final Boolean DEBUG = false;
 
     public static final int DISPLAY_DEVICE_EVENT_ADDED = 1;
-    public static final int DISPLAY_DEVICE_EVENT_CHANGED = 2;
     public static final int DISPLAY_DEVICE_EVENT_REMOVED = 3;
 
     /**
@@ -83,15 +82,15 @@
             Trace.beginAsyncSection(tag, 0);
         }
         switch (event) {
-            case DISPLAY_DEVICE_EVENT_ADDED:
+            case DisplayAdapter.DISPLAY_DEVICE_EVENT_ADDED:
                 handleDisplayDeviceAdded(device);
                 break;
 
-            case DISPLAY_DEVICE_EVENT_CHANGED:
+            case DisplayAdapter.DISPLAY_DEVICE_EVENT_CHANGED:
                 handleDisplayDeviceChanged(device);
                 break;
 
-            case DISPLAY_DEVICE_EVENT_REMOVED:
+            case DisplayAdapter.DISPLAY_DEVICE_EVENT_REMOVED:
                 handleDisplayDeviceRemoved(device);
                 break;
         }
@@ -174,7 +173,7 @@
             if (diff == DisplayDeviceInfo.DIFF_STATE) {
                 Slog.i(TAG, "Display device changed state: \"" + info.name
                         + "\", " + Display.stateToString(info.state));
-            } else if (diff != 0) {
+            } else if (diff != DisplayDeviceInfo.DIFF_HDR_SDR_RATIO) {
                 Slog.i(TAG, "Display device changed: " + info);
             }
 
@@ -188,7 +187,7 @@
             device.mDebugLastLoggedDeviceInfo = info;
 
             device.applyPendingDisplayDeviceInfoChangesLocked();
-            sendEventLocked(device, DISPLAY_DEVICE_EVENT_CHANGED);
+            sendChangedEventLocked(device, diff);
             if (DEBUG) {
                 Trace.traceEnd(Trace.TRACE_TAG_POWER);
             }
@@ -216,12 +215,22 @@
         }
     }
 
+    @GuardedBy("mSyncRoot")
+    private void sendChangedEventLocked(DisplayDevice device, int diff) {
+        final int size = mListeners.size();
+        for (int i = 0; i < size; i++) {
+            mListeners.get(i).onDisplayDeviceChangedLocked(device, diff);
+        }
+    }
+
     /**
      * Listens to {@link DisplayDevice} events from {@link DisplayDeviceRepository}.
      */
     public interface Listener {
         void onDisplayDeviceEventLocked(DisplayDevice device, int event);
 
+        void onDisplayDeviceChangedLocked(DisplayDevice device, int diff);
+
         // TODO: multi-display - Try to remove the need for requestTraversal...it feels like
         // a shoe-horned method for a shoe-horned feature.
         void onTraversalRequested();
diff --git a/services/core/java/com/android/server/display/DisplayManagerService.java b/services/core/java/com/android/server/display/DisplayManagerService.java
index 237e78b..6e16f9f 100644
--- a/services/core/java/com/android/server/display/DisplayManagerService.java
+++ b/services/core/java/com/android/server/display/DisplayManagerService.java
@@ -1745,6 +1745,10 @@
         mHandler.sendEmptyMessage(MSG_LOAD_BRIGHTNESS_CONFIGURATIONS);
     }
 
+    private void handleLogicalDisplayHdrSdrRatioChangedLocked(@NonNull LogicalDisplay display) {
+        sendDisplayEventLocked(display, DisplayManagerGlobal.EVENT_DISPLAY_HDR_SDR_RATIO_CHANGED);
+    }
+
     private void notifyDefaultDisplayDeviceUpdated(LogicalDisplay display) {
         mDisplayModeDirector.defaultDisplayDeviceUpdated(display.getPrimaryDisplayDeviceLocked()
                 .mDisplayDeviceConfig);
@@ -2983,6 +2987,10 @@
                 case LogicalDisplayMapper.LOGICAL_DISPLAY_EVENT_DEVICE_STATE_TRANSITION:
                     handleLogicalDisplayDeviceStateTransitionLocked(display);
                     break;
+
+                case LogicalDisplayMapper.LOGICAL_DISPLAY_EVENT_HDR_SDR_RATIO_CHANGED:
+                    handleLogicalDisplayHdrSdrRatioChangedLocked(display);
+                    break;
             }
         }
 
@@ -3052,6 +3060,8 @@
                     return (mask & DisplayManager.EVENT_FLAG_DISPLAY_BRIGHTNESS) != 0;
                 case DisplayManagerGlobal.EVENT_DISPLAY_REMOVED:
                     return (mask & DisplayManager.EVENT_FLAG_DISPLAY_REMOVED) != 0;
+                case DisplayManagerGlobal.EVENT_DISPLAY_HDR_SDR_RATIO_CHANGED:
+                    return (mask & DisplayManager.EVENT_FLAG_HDR_SDR_RATIO_CHANGED) != 0;
                 default:
                     // This should never happen.
                     Slog.e(TAG, "Unknown display event " + event);
diff --git a/services/core/java/com/android/server/display/LocalDisplayAdapter.java b/services/core/java/com/android/server/display/LocalDisplayAdapter.java
index be5980b..8f52c97 100644
--- a/services/core/java/com/android/server/display/LocalDisplayAdapter.java
+++ b/services/core/java/com/android/server/display/LocalDisplayAdapter.java
@@ -204,6 +204,7 @@
         // This is only set in the runnable returned from requestDisplayStateLocked.
         private float mBrightnessState = PowerManager.BRIGHTNESS_INVALID_FLOAT;
         private float mSdrBrightnessState = PowerManager.BRIGHTNESS_INVALID_FLOAT;
+        private float mCurrentHdrSdrRatio = Float.NaN;
         private int mDefaultModeId = INVALID_MODE_ID;
         private int mSystemPreferredModeId = INVALID_MODE_ID;
         private int mDefaultModeGroup;
@@ -729,6 +730,7 @@
                 mInfo.brightnessMinimum = PowerManager.BRIGHTNESS_MIN;
                 mInfo.brightnessMaximum = PowerManager.BRIGHTNESS_MAX;
                 mInfo.brightnessDefault = getDisplayDeviceConfig().getBrightnessDefault();
+                mInfo.hdrSdrRatio = mCurrentHdrSdrRatio;
             }
             return mInfo;
         }
@@ -840,12 +842,10 @@
 
                     private void setCommittedState(int state) {
                         // After the display state is set, let's update the committed state.
-                        getHandler().post(() -> {
-                            synchronized (getSyncRoot()) {
-                                mCommittedState = state;
-                                updateDeviceInfoLocked();
-                            }
-                        });
+                        synchronized (getSyncRoot()) {
+                            mCommittedState = state;
+                            updateDeviceInfoLocked();
+                        }
                     }
 
                     private void setDisplayBrightness(float brightnessState,
@@ -881,6 +881,9 @@
                                     "SdrScreenBrightness",
                                     BrightnessSynchronizer.brightnessFloatToInt(
                                             sdrBrightnessState));
+
+                            handleHdrSdrNitsChanged(nits, sdrNits);
+
                         } finally {
                             Trace.traceEnd(Trace.TRACE_TAG_POWER);
                         }
@@ -897,6 +900,23 @@
                     private float backlightToNits(float backlight) {
                         return getDisplayDeviceConfig().getNitsFromBacklight(backlight);
                     }
+
+                    void handleHdrSdrNitsChanged(float displayNits, float sdrNits) {
+                        final float newHdrSdrRatio;
+                        if (displayNits != DisplayDeviceConfig.NITS_INVALID
+                                && sdrNits != DisplayDeviceConfig.NITS_INVALID) {
+                            newHdrSdrRatio = displayNits / sdrNits;
+                        } else {
+                            newHdrSdrRatio = Float.NaN;
+                        }
+                        if (!BrightnessSynchronizer.floatEquals(
+                                mCurrentHdrSdrRatio, newHdrSdrRatio)) {
+                            synchronized (getSyncRoot()) {
+                                mCurrentHdrSdrRatio = newHdrSdrRatio;
+                                updateDeviceInfoLocked();
+                            }
+                        }
+                    }
                 };
             }
             return null;
diff --git a/services/core/java/com/android/server/display/LogicalDisplay.java b/services/core/java/com/android/server/display/LogicalDisplay.java
index 1086c55..64eb95b 100644
--- a/services/core/java/com/android/server/display/LogicalDisplay.java
+++ b/services/core/java/com/android/server/display/LogicalDisplay.java
@@ -448,6 +448,7 @@
             mBaseDisplayInfo.brightnessMinimum = deviceInfo.brightnessMinimum;
             mBaseDisplayInfo.brightnessMaximum = deviceInfo.brightnessMaximum;
             mBaseDisplayInfo.brightnessDefault = deviceInfo.brightnessDefault;
+            mBaseDisplayInfo.hdrSdrRatio = deviceInfo.hdrSdrRatio;
             mBaseDisplayInfo.roundedCorners = deviceInfo.roundedCorners;
             mBaseDisplayInfo.installOrientation = deviceInfo.installOrientation;
             mBaseDisplayInfo.displayShape = deviceInfo.displayShape;
diff --git a/services/core/java/com/android/server/display/LogicalDisplayMapper.java b/services/core/java/com/android/server/display/LogicalDisplayMapper.java
index c695862..fad8a56 100644
--- a/services/core/java/com/android/server/display/LogicalDisplayMapper.java
+++ b/services/core/java/com/android/server/display/LogicalDisplayMapper.java
@@ -71,6 +71,7 @@
     public static final int LOGICAL_DISPLAY_EVENT_SWAPPED = 4;
     public static final int LOGICAL_DISPLAY_EVENT_FRAME_RATE_OVERRIDES_CHANGED = 5;
     public static final int LOGICAL_DISPLAY_EVENT_DEVICE_STATE_TRANSITION = 6;
+    public static final int LOGICAL_DISPLAY_EVENT_HDR_SDR_RATIO_CHANGED = 7;
 
     public static final int DISPLAY_GROUP_EVENT_ADDED = 1;
     public static final int DISPLAY_GROUP_EVENT_CHANGED = 2;
@@ -221,14 +222,6 @@
                 handleDisplayDeviceAddedLocked(device);
                 break;
 
-            case DisplayDeviceRepository.DISPLAY_DEVICE_EVENT_CHANGED:
-                if (DEBUG) {
-                    Slog.d(TAG, "Display device changed: " + device.getDisplayDeviceInfoLocked());
-                }
-                finishStateTransitionLocked(false /*force*/);
-                updateLogicalDisplaysLocked();
-                break;
-
             case DisplayDeviceRepository.DISPLAY_DEVICE_EVENT_REMOVED:
                 if (DEBUG) {
                     Slog.d(TAG, "Display device removed: " + device.getDisplayDeviceInfoLocked());
@@ -240,6 +233,15 @@
     }
 
     @Override
+    public void onDisplayDeviceChangedLocked(DisplayDevice device, int diff) {
+        if (DEBUG) {
+            Slog.d(TAG, "Display device changed: " + device.getDisplayDeviceInfoLocked());
+        }
+        finishStateTransitionLocked(false /*force*/);
+        updateLogicalDisplaysLocked(diff);
+    }
+
+    @Override
     public void onTraversalRequested() {
         mListener.onTraversalRequested();
     }
@@ -649,13 +651,20 @@
         }
     }
 
+    private void updateLogicalDisplaysLocked() {
+        updateLogicalDisplaysLocked(DisplayDeviceInfo.DIFF_EVERYTHING);
+    }
+
     /**
      * Updates the rest of the display system once all the changes are applied for display
      * devices and logical displays. The includes releasing invalid/empty LogicalDisplays,
      * creating/adjusting/removing DisplayGroups, and notifying the rest of the system of the
      * relevant changes.
+     *
+     * @param diff The DisplayDeviceInfo.DIFF_* of what actually changed to enable finer-grained
+     *             display update listeners
      */
-    private void updateLogicalDisplaysLocked() {
+    private void updateLogicalDisplaysLocked(int diff) {
         // Go through all the displays and figure out if they need to be updated.
         // Loops in reverse so that displays can be removed during the loop without affecting the
         // rest of the loop.
@@ -709,7 +718,13 @@
             } else if (!mTempDisplayInfo.equals(newDisplayInfo)) {
                 // FLAG_OWN_DISPLAY_GROUP could have changed, recalculate just in case
                 assignDisplayGroupLocked(display);
-                mLogicalDisplaysToUpdate.put(displayId, LOGICAL_DISPLAY_EVENT_CHANGED);
+                // If only the hdr/sdr ratio changed, then send just the event for that case
+                if ((diff == DisplayDeviceInfo.DIFF_HDR_SDR_RATIO)) {
+                    mLogicalDisplaysToUpdate.put(displayId,
+                            LOGICAL_DISPLAY_EVENT_HDR_SDR_RATIO_CHANGED);
+                } else {
+                    mLogicalDisplaysToUpdate.put(displayId, LOGICAL_DISPLAY_EVENT_CHANGED);
+                }
 
             // The display is involved in a display layout transition
             } else if (updateState == UPDATE_STATE_TRANSITION) {
@@ -768,6 +783,7 @@
         sendUpdatesForDisplaysLocked(LOGICAL_DISPLAY_EVENT_FRAME_RATE_OVERRIDES_CHANGED);
         sendUpdatesForDisplaysLocked(LOGICAL_DISPLAY_EVENT_SWAPPED);
         sendUpdatesForDisplaysLocked(LOGICAL_DISPLAY_EVENT_ADDED);
+        sendUpdatesForDisplaysLocked(LOGICAL_DISPLAY_EVENT_HDR_SDR_RATIO_CHANGED);
         sendUpdatesForGroupsLocked(DISPLAY_GROUP_EVENT_CHANGED);
         sendUpdatesForGroupsLocked(DISPLAY_GROUP_EVENT_REMOVED);
 
@@ -1110,6 +1126,8 @@
                 return "swapped";
             case LOGICAL_DISPLAY_EVENT_REMOVED:
                 return "removed";
+            case LOGICAL_DISPLAY_EVENT_HDR_SDR_RATIO_CHANGED:
+                return "hdr_sdr_ratio_changed";
         }
         return null;
     }
diff --git a/services/tests/mockingservicestests/src/com/android/server/display/LocalDisplayAdapterTest.java b/services/tests/mockingservicestests/src/com/android/server/display/LocalDisplayAdapterTest.java
index 4524759..95a5884 100644
--- a/services/tests/mockingservicestests/src/com/android/server/display/LocalDisplayAdapterTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/display/LocalDisplayAdapterTest.java
@@ -69,6 +69,8 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.LinkedList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 
 
 @SmallTest
@@ -692,7 +694,10 @@
 
         // Verify that committed triggered a new change event and is set correctly.
         verify(mSurfaceControlProxy, never()).setDisplayPowerMode(display.token, Display.STATE_OFF);
-        assertThat(mListener.changedDisplays.size()).isEqualTo(1);
+        // We expect at least 1 update for the state change, but
+        // could get a second update for the initial brightness change if a nits mapping
+        // is available
+        assertThat(mListener.changedDisplays.size()).isAnyOf(1, 2);
         assertThat(displayDevice.getDisplayDeviceInfoLocked().state).isEqualTo(Display.STATE_OFF);
         assertThat(displayDevice.getDisplayDeviceInfoLocked().committedState).isEqualTo(
                 Display.STATE_OFF);
@@ -964,6 +969,43 @@
                 .isTrue();
     }
 
+    @Test
+    public void testHdrSdrRatio_notifiesOnChange() throws Exception {
+        FakeDisplay display = new FakeDisplay(PORT_A);
+        setUpDisplay(display);
+        updateAvailableDisplays();
+        mAdapter.registerLocked();
+        waitForHandlerToComplete(mHandler, HANDLER_WAIT_MS);
+        assertThat(mListener.addedDisplays.size()).isEqualTo(1);
+        DisplayDevice displayDevice = mListener.addedDisplays.get(0);
+
+        // Turn on / initialize
+        Runnable changeStateRunnable = displayDevice.requestDisplayStateLocked(Display.STATE_ON, 0,
+                0);
+        changeStateRunnable.run();
+        waitForHandlerToComplete(mHandler, HANDLER_WAIT_MS);
+        mListener.changedDisplays.clear();
+
+        assertEquals(1.0f, displayDevice.getDisplayDeviceInfoLocked().hdrSdrRatio, 0.001f);
+
+        // HDR time!
+        Runnable goHdrRunnable = displayDevice.requestDisplayStateLocked(Display.STATE_ON, 1f,
+                0);
+        waitForHandlerToComplete(mHandler, HANDLER_WAIT_MS);
+        // Display state didn't change, no listeners should have happened
+        assertThat(mListener.changedDisplays.size()).isEqualTo(0);
+
+        // Execute hdr change.
+        goHdrRunnable.run();
+        waitForHandlerToComplete(mHandler, HANDLER_WAIT_MS);
+        // Display state didn't change, expect to only get the HDR/SDR ratio change notification
+        assertThat(mListener.changedDisplays.size()).isEqualTo(1);
+
+        final float expectedRatio = DISPLAY_RANGE_NITS[1] / DISPLAY_RANGE_NITS[0];
+        assertEquals(expectedRatio, displayDevice.getDisplayDeviceInfoLocked().hdrSdrRatio,
+                0.001f);
+    }
+
     private void assertDisplayDpi(DisplayDeviceInfo info, int expectedPort,
                                   float expectedXdpi,
                                   float expectedYDpi,
@@ -1107,15 +1149,9 @@
 
     private static void waitForHandlerToComplete(Handler handler, long waitTimeMs)
             throws InterruptedException {
-        final Object lock = new Object();
-        synchronized (lock) {
-            handler.post(() -> {
-                synchronized (lock) {
-                    lock.notify();
-                }
-            });
-            lock.wait(waitTimeMs);
-        }
+        final CountDownLatch fence = new CountDownLatch(1);
+        handler.post(fence::countDown);
+        assertTrue(fence.await(waitTimeMs, TimeUnit.MILLISECONDS));
     }
 
     private class HotplugTransmitter {
diff --git a/tests/SilkFX/src/com/android/test/silkfx/common/ColorModeControls.kt b/tests/SilkFX/src/com/android/test/silkfx/common/ColorModeControls.kt
index 046174c..1bd8f6a 100644
--- a/tests/SilkFX/src/com/android/test/silkfx/common/ColorModeControls.kt
+++ b/tests/SilkFX/src/com/android/test/silkfx/common/ColorModeControls.kt
@@ -20,12 +20,15 @@
 import android.content.pm.ActivityInfo
 import android.hardware.display.DisplayManager
 import android.util.AttributeSet
+import android.util.Log
+import android.view.Display
 import android.view.Window
 import android.widget.Button
 import android.widget.LinearLayout
 import android.widget.TextView
 import com.android.test.silkfx.R
 import com.android.test.silkfx.app.WindowObserver
+import java.util.function.Consumer
 
 class ColorModeControls : LinearLayout, WindowObserver {
     private val COLOR_MODE_HDR10 = 3
@@ -66,6 +69,38 @@
         }
     }
 
+    private val hdrsdrListener = Consumer<Display> { display ->
+        Log.d("SilkFX", "HDR/SDR changed ${display.hdrSdrRatio}")
+    }
+
+    private val displayChangedListener = object : DisplayManager.DisplayListener {
+        override fun onDisplayAdded(displayId: Int) {
+            Log.d("SilkFX", "onDisplayAdded")
+        }
+
+        override fun onDisplayRemoved(displayId: Int) {
+            Log.d("SilkFX", "onDisplayRemoved")
+        }
+
+        override fun onDisplayChanged(displayId: Int) {
+            Log.d("SilkFX", "onDisplayChanged")
+        }
+    }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        Log.d("SilkFX", "is hdr/sdr available: ${display.isHdrSdrRatioAvailable}; " +
+                "current ration = ${display.hdrSdrRatio}")
+        display.registerHdrSdrRatioChangedListener({ it.run() }, hdrsdrListener)
+        displayManager.registerDisplayListener(displayChangedListener, handler)
+    }
+
+    override fun onDetachedFromWindow() {
+        super.onDetachedFromWindow()
+        display.unregisterHdrSdrRatioChangedListener(hdrsdrListener)
+        displayManager.unregisterDisplayListener(displayChangedListener)
+    }
+
     private fun setColorMode(newMode: Int) {
         val window = window!!
         var sdrWhitepointChanged = false