Added synchronization of displays refresh rates

SurfaceFlinger supports only one refresh rate,
but in case displays have different refresh rates
this may lead to undesirable artifacts, also
during switching of internal display refresh rates
the external display may also blink for some reason.

This CL adds vote which aims to synchronize refresh
rates across displays.

Test: atest com.android.server.display.mode.DisplayObserverTest
Bug: 242093547
Change-Id: I0bf525c9b212a9b41efe43dcb32fbbe206892591
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index a3e3060..c00a776f 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -5245,6 +5245,10 @@
          config_externalDisplayPeakRefreshRate and config_externalDisplayPeakWidth are non-zero. -->
     <integer name="config_externalDisplayPeakHeight">0</integer>
 
+    <!-- Enable synchronization of the displays refresh rates by applying the default low refresh
+         rate. -->
+    <bool name="config_refreshRateSynchronizationEnabled">false</bool>
+
     <!-- The display uses different gamma curves for different refresh rates. It's hard for panel
          vendors to tune the curves to have exact same brightness for different refresh rate. So
          flicker could be observed at switch time. The issue is worse at the gamma lower end.
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 20bfea0..b0eee1c 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -4234,6 +4234,7 @@
   <java-symbol type="integer" name="config_externalDisplayPeakRefreshRate" />
   <java-symbol type="integer" name="config_externalDisplayPeakWidth" />
   <java-symbol type="integer" name="config_externalDisplayPeakHeight" />
+  <java-symbol type="bool" name="config_refreshRateSynchronizationEnabled" />
   <java-symbol type="integer" name="config_defaultRefreshRateInZone" />
   <java-symbol type="array" name="config_brightnessThresholdsOfPeakRefreshRate" />
   <java-symbol type="array" name="config_ambientThresholdsOfPeakRefreshRate" />
diff --git a/services/core/java/com/android/server/display/DisplayDeviceConfig.java b/services/core/java/com/android/server/display/DisplayDeviceConfig.java
index 507ae26..9e92c8d 100644
--- a/services/core/java/com/android/server/display/DisplayDeviceConfig.java
+++ b/services/core/java/com/android/server/display/DisplayDeviceConfig.java
@@ -500,6 +500,8 @@
 
     public static final String DEFAULT_ID = "default";
 
+    public static final int DEFAULT_LOW_REFRESH_RATE = 60;
+
     private static final float BRIGHTNESS_DEFAULT = 0.5f;
     private static final String ETC_DIR = "etc";
     private static final String DISPLAY_CONFIG_DIR = "displayconfig";
@@ -513,7 +515,6 @@
     private static final int DEFAULT_PEAK_REFRESH_RATE = 0;
     private static final int DEFAULT_REFRESH_RATE = 60;
     private static final int DEFAULT_REFRESH_RATE_IN_HBM = 0;
-    private static final int DEFAULT_LOW_REFRESH_RATE = 60;
     private static final int DEFAULT_HIGH_REFRESH_RATE = 0;
     private static final float[] DEFAULT_BRIGHTNESS_THRESHOLDS = new float[]{};
 
diff --git a/services/core/java/com/android/server/display/feature/DisplayManagerFlags.java b/services/core/java/com/android/server/display/feature/DisplayManagerFlags.java
index 950c6c8..ff768d6 100644
--- a/services/core/java/com/android/server/display/feature/DisplayManagerFlags.java
+++ b/services/core/java/com/android/server/display/feature/DisplayManagerFlags.java
@@ -59,6 +59,10 @@
             Flags.FLAG_ENABLE_MODE_LIMIT_FOR_EXTERNAL_DISPLAY,
             Flags::enableModeLimitForExternalDisplay);
 
+    private final FlagState mDisplaysRefreshRatesSynchronizationState = new FlagState(
+            Flags.FLAG_ENABLE_DISPLAYS_REFRESH_RATES_SYNCHRONIZATION,
+            Flags::enableDisplaysRefreshRatesSynchronization);
+
     /** Returns whether connected display management is enabled or not. */
     public boolean isConnectedDisplayManagementEnabled() {
         return mConnectedDisplayManagementFlagState.isEnabled();
@@ -100,6 +104,13 @@
         return mExternalDisplayLimitModeState.isEnabled();
     }
 
+    /**
+     * @return Whether displays refresh rate synchronization is enabled.
+     */
+    public boolean isDisplaysRefreshRatesSynchronizationEnabled() {
+        return mDisplaysRefreshRatesSynchronizationState.isEnabled();
+    }
+
     private static class FlagState {
 
         private final String mName;
diff --git a/services/core/java/com/android/server/display/feature/display_flags.aconfig b/services/core/java/com/android/server/display/feature/display_flags.aconfig
index ee39f71..a5b8cbb 100644
--- a/services/core/java/com/android/server/display/feature/display_flags.aconfig
+++ b/services/core/java/com/android/server/display/feature/display_flags.aconfig
@@ -57,3 +57,11 @@
     bug: "242093547"
     is_fixed_read_only: true
 }
+
+flag {
+    name: "enable_displays_refresh_rates_synchronization"
+    namespace: "display_manager"
+    description: "Enables synchronization of refresh rates across displays"
+    bug: "294015845"
+    is_fixed_read_only: true
+}
diff --git a/services/core/java/com/android/server/display/mode/DisplayModeDirector.java b/services/core/java/com/android/server/display/mode/DisplayModeDirector.java
index 1afc5ba..71ea8cc 100644
--- a/services/core/java/com/android/server/display/mode/DisplayModeDirector.java
+++ b/services/core/java/com/android/server/display/mode/DisplayModeDirector.java
@@ -21,6 +21,8 @@
 import static android.os.PowerManager.BRIGHTNESS_INVALID_FLOAT;
 import static android.view.Display.Mode.INVALID_MODE_ID;
 
+import static com.android.server.display.DisplayDeviceConfig.DEFAULT_LOW_REFRESH_RATE;
+
 import android.annotation.IntegerRes;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -86,9 +88,11 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.function.Function;
 import java.util.function.IntSupplier;
@@ -98,6 +102,8 @@
  * picked by the system based on system-wide and display-specific configuration.
  */
 public class DisplayModeDirector {
+    public static final float SYNCHRONIZED_REFRESH_RATE_TARGET = DEFAULT_LOW_REFRESH_RATE;
+    public static final float SYNCHRONIZED_REFRESH_RATE_TOLERANCE = 1;
     private static final String TAG = "DisplayModeDirector";
     private boolean mLoggingEnabled;
 
@@ -168,6 +174,8 @@
      */
     private final boolean mIsExternalDisplayLimitModeEnabled;
 
+    private final boolean mIsDisplaysRefreshRatesSynchronizationEnabled;
+
     public DisplayModeDirector(@NonNull Context context, @NonNull Handler handler,
             @NonNull DisplayManagerFlags displayManagerFlags) {
         this(context, handler, new RealInjector(context), displayManagerFlags);
@@ -179,8 +187,10 @@
         mIsDisplayResolutionRangeVotingEnabled = displayManagerFlags
                 .isDisplayResolutionRangeVotingEnabled();
         mIsUserPreferredModeVoteEnabled = displayManagerFlags.isUserPreferredModeVoteEnabled();
-        mIsExternalDisplayLimitModeEnabled =
-            displayManagerFlags.isExternalDisplayLimitModeEnabled();
+        mIsExternalDisplayLimitModeEnabled = displayManagerFlags
+            .isExternalDisplayLimitModeEnabled();
+        mIsDisplaysRefreshRatesSynchronizationEnabled = displayManagerFlags
+            .isDisplaysRefreshRatesSynchronizationEnabled();
         mContext = context;
         mHandler = new DisplayModeDirectorHandler(handler.getLooper());
         mInjector = injector;
@@ -1467,6 +1477,8 @@
         private int mExternalDisplayPeakWidth;
         private int mExternalDisplayPeakHeight;
         private int mExternalDisplayPeakRefreshRate;
+        private final boolean mRefreshRateSynchronizationEnabled;
+        private final Set<Integer> mExternalDisplaysConnected = new HashSet<>();
 
         DisplayObserver(Context context, Handler handler, VotesStorage votesStorage) {
             mContext = context;
@@ -1478,6 +1490,8 @@
                         R.integer.config_externalDisplayPeakWidth);
             mExternalDisplayPeakHeight = mContext.getResources().getInteger(
                         R.integer.config_externalDisplayPeakHeight);
+            mRefreshRateSynchronizationEnabled = mContext.getResources().getBoolean(
+                        R.bool.config_refreshRateSynchronizationEnabled);
         }
 
         private boolean isExternalDisplayLimitModeEnabled() {
@@ -1489,6 +1503,11 @@
                 && mIsUserPreferredModeVoteEnabled;
         }
 
+        private boolean isRefreshRateSynchronizationEnabled() {
+            return mRefreshRateSynchronizationEnabled
+                && mIsDisplaysRefreshRatesSynchronizationEnabled;
+        }
+
         public void observe() {
             mInjector.registerDisplayListener(this, mHandler);
 
@@ -1519,6 +1538,7 @@
             updateLayoutLimitedFrameRate(displayId, displayInfo);
             updateUserSettingDisplayPreferredSize(displayInfo);
             updateDisplaysPeakRefreshRateAndResolution(displayInfo);
+            addDisplaysSynchronizedPeakRefreshRate(displayInfo);
         }
 
         @Override
@@ -1530,6 +1550,7 @@
             updateLayoutLimitedFrameRate(displayId, null);
             removeUserSettingDisplayPreferredSize(displayId);
             removeDisplaysPeakRefreshRateAndResolution(displayId);
+            removeDisplaysSynchronizedPeakRefreshRate(displayId);
         }
 
         @Override
@@ -1619,6 +1640,46 @@
                             mExternalDisplayPeakRefreshRate));
         }
 
+        /**
+         * Sets 60Hz target refresh rate as the vote with
+         * {@link Vote#PRIORITY_SYNCHRONIZED_REFRESH_RATE} priority.
+         */
+        private void addDisplaysSynchronizedPeakRefreshRate(@Nullable final DisplayInfo info) {
+            if (info == null || info.type != Display.TYPE_EXTERNAL
+                    || !isRefreshRateSynchronizationEnabled()) {
+                return;
+            }
+            synchronized (mLock) {
+                mExternalDisplaysConnected.add(info.displayId);
+                if (mExternalDisplaysConnected.size() != 1) {
+                    return;
+                }
+            }
+            // set minRefreshRate as the max refresh rate.
+            mVotesStorage.updateGlobalVote(Vote.PRIORITY_SYNCHRONIZED_REFRESH_RATE,
+                    Vote.forPhysicalRefreshRates(
+                            SYNCHRONIZED_REFRESH_RATE_TARGET
+                                - SYNCHRONIZED_REFRESH_RATE_TOLERANCE,
+                            SYNCHRONIZED_REFRESH_RATE_TARGET
+                                + SYNCHRONIZED_REFRESH_RATE_TOLERANCE));
+        }
+
+        private void removeDisplaysSynchronizedPeakRefreshRate(final int displayId) {
+            if (!isRefreshRateSynchronizationEnabled()) {
+                return;
+            }
+            synchronized (mLock) {
+                if (!mExternalDisplaysConnected.contains(displayId)) {
+                    return;
+                }
+                mExternalDisplaysConnected.remove(displayId);
+                if (mExternalDisplaysConnected.size() != 0) {
+                    return;
+                }
+            }
+            mVotesStorage.updateGlobalVote(Vote.PRIORITY_SYNCHRONIZED_REFRESH_RATE, null);
+        }
+
         private void updateDisplayModes(int displayId, @Nullable DisplayInfo info) {
             if (info == null) {
                 return;
diff --git a/services/core/java/com/android/server/display/mode/Vote.java b/services/core/java/com/android/server/display/mode/Vote.java
index 9e62a57..b6a6069 100644
--- a/services/core/java/com/android/server/display/mode/Vote.java
+++ b/services/core/java/com/android/server/display/mode/Vote.java
@@ -80,36 +80,39 @@
     // rest of low priority voters. It votes [0, max(PEAK, MIN)]
     static final int PRIORITY_USER_SETTING_PEAK_RENDER_FRAME_RATE = 8;
 
+    // Restrict all displays to 60Hz when external display is connected. It votes [59Hz, 61Hz].
+    static final int PRIORITY_SYNCHRONIZED_REFRESH_RATE = 9;
+
     // Restrict displays max available resolution and refresh rates. It votes [0, LIMIT]
-    static final int PRIORITY_LIMIT_MODE = 9;
+    static final int PRIORITY_LIMIT_MODE = 10;
 
     // To avoid delay in switching between 60HZ -> 90HZ when activating LHBM, set refresh
     // rate to max value (same as for PRIORITY_UDFPS) on lock screen
-    static final int PRIORITY_AUTH_OPTIMIZER_RENDER_FRAME_RATE = 10;
+    static final int PRIORITY_AUTH_OPTIMIZER_RENDER_FRAME_RATE = 11;
 
     // For concurrent displays we want to limit refresh rate on all displays
-    static final int PRIORITY_LAYOUT_LIMITED_FRAME_RATE = 11;
+    static final int PRIORITY_LAYOUT_LIMITED_FRAME_RATE = 12;
 
     // LOW_POWER_MODE force the render frame rate to [0, 60HZ] if
     // Settings.Global.LOW_POWER_MODE is on.
-    static final int PRIORITY_LOW_POWER_MODE = 12;
+    static final int PRIORITY_LOW_POWER_MODE = 13;
 
     // PRIORITY_FLICKER_REFRESH_RATE_SWITCH votes for disabling refresh rate switching. If the
     // higher priority voters' result is a range, it will fix the rate to a single choice.
     // It's used to avoid refresh rate switches in certain conditions which may result in the
     // user seeing the display flickering when the switches occur.
-    static final int PRIORITY_FLICKER_REFRESH_RATE_SWITCH = 13;
+    static final int PRIORITY_FLICKER_REFRESH_RATE_SWITCH = 14;
 
     // Force display to [0, 60HZ] if skin temperature is at or above CRITICAL.
-    static final int PRIORITY_SKIN_TEMPERATURE = 14;
+    static final int PRIORITY_SKIN_TEMPERATURE = 15;
 
     // The proximity sensor needs the refresh rate to be locked in order to function, so this is
     // set to a high priority.
-    static final int PRIORITY_PROXIMITY = 15;
+    static final int PRIORITY_PROXIMITY = 16;
 
     // The Under-Display Fingerprint Sensor (UDFPS) needs the refresh rate to be locked in order
     // to function, so this needs to be the highest priority of all votes.
-    static final int PRIORITY_UDFPS = 16;
+    static final int PRIORITY_UDFPS = 17;
 
     // Whenever a new priority is added, remember to update MIN_PRIORITY, MAX_PRIORITY, and
     // APP_REQUEST_REFRESH_RATE_RANGE_PRIORITY_CUTOFF, as well as priorityToString.
@@ -276,6 +279,8 @@
                 return "PRIORITY_USER_SETTING_DISPLAY_PREFERRED_SIZE";
             case PRIORITY_LIMIT_MODE:
                 return "PRIORITY_LIMIT_MODE";
+            case PRIORITY_SYNCHRONIZED_REFRESH_RATE:
+                return "PRIORITY_SYNCHRONIZED_REFRESH_RATE";
             case PRIORITY_USER_SETTING_PEAK_RENDER_FRAME_RATE:
                 return "PRIORITY_USER_SETTING_PEAK_RENDER_FRAME_RATE";
             case PRIORITY_AUTH_OPTIMIZER_RENDER_FRAME_RATE:
diff --git a/services/core/java/com/android/server/display/mode/VotesStorage.java b/services/core/java/com/android/server/display/mode/VotesStorage.java
index 184673b..997c156 100644
--- a/services/core/java/com/android/server/display/mode/VotesStorage.java
+++ b/services/core/java/com/android/server/display/mode/VotesStorage.java
@@ -30,7 +30,8 @@
     private static final String TAG = "VotesStorage";
     // Special ID used to indicate that given vote is to be applied globally, rather than to a
     // specific display.
-    private static final int GLOBAL_ID = -1;
+    @VisibleForTesting
+    static final int GLOBAL_ID = -1;
 
     private boolean mLoggingEnabled;
 
diff --git a/services/tests/displayservicetests/src/com/android/server/display/mode/DisplayObserverTest.java b/services/tests/displayservicetests/src/com/android/server/display/mode/DisplayObserverTest.java
index 9a061be..ff91d34 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/mode/DisplayObserverTest.java
+++ b/services/tests/displayservicetests/src/com/android/server/display/mode/DisplayObserverTest.java
@@ -20,8 +20,12 @@
 import static android.view.Display.DEFAULT_DISPLAY;
 import static android.view.Display.Mode.INVALID_MODE_ID;
 
+
+import static com.android.server.display.mode.DisplayModeDirector.SYNCHRONIZED_REFRESH_RATE_TOLERANCE;
 import static com.android.server.display.mode.Vote.PRIORITY_LIMIT_MODE;
 import static com.android.server.display.mode.Vote.PRIORITY_USER_SETTING_DISPLAY_PREFERRED_SIZE;
+import static com.android.server.display.mode.Vote.PRIORITY_SYNCHRONIZED_REFRESH_RATE;
+import static com.android.server.display.mode.VotesStorage.GLOBAL_ID;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -122,6 +126,8 @@
                 .thenReturn(0);
         when(mResources.getInteger(R.integer.config_externalDisplayPeakHeight))
                 .thenReturn(0);
+        when(mResources.getBoolean(R.bool.config_refreshRateSynchronizationEnabled))
+                .thenReturn(false);
 
         // Necessary configs to initialize DisplayModeDirector
         when(mResources.getIntArray(R.array.config_brightnessThresholdsOfPeakRefreshRate))
@@ -333,6 +339,58 @@
         assertThat(getVote(EXTERNAL_DISPLAY, PRIORITY_LIMIT_MODE)).isEqualTo(null);
     }
 
+    /** External display added, applied refresh rates synchronization */
+    @Test
+    public void testExternalDisplayAdded_appliedRefreshRatesSynchronization() {
+        when(mDisplayManagerFlags.isDisplayResolutionRangeVotingEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isUserPreferredModeVoteEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isExternalDisplayLimitModeEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isDisplaysRefreshRatesSynchronizationEnabled()).thenReturn(true);
+        when(mResources.getBoolean(R.bool.config_refreshRateSynchronizationEnabled))
+                .thenReturn(true);
+        init();
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(null);
+        mObserver.onDisplayAdded(EXTERNAL_DISPLAY);
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(
+                Vote.forPhysicalRefreshRates(
+                        MAX_REFRESH_RATE - SYNCHRONIZED_REFRESH_RATE_TOLERANCE,
+                        MAX_REFRESH_RATE + SYNCHRONIZED_REFRESH_RATE_TOLERANCE));
+
+        // Remove external display and check that sync vote is no longer present.
+        mObserver.onDisplayRemoved(EXTERNAL_DISPLAY);
+
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(null);
+    }
+
+    /** External display added, disabled feature refresh rates synchronization */
+    @Test
+    public void testExternalDisplayAdded_disabledFeatureRefreshRatesSynchronization() {
+        when(mDisplayManagerFlags.isDisplayResolutionRangeVotingEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isUserPreferredModeVoteEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isExternalDisplayLimitModeEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isDisplaysRefreshRatesSynchronizationEnabled()).thenReturn(false);
+        when(mResources.getBoolean(R.bool.config_refreshRateSynchronizationEnabled))
+                .thenReturn(true);
+        init();
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(null);
+        mObserver.onDisplayAdded(EXTERNAL_DISPLAY);
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(null);
+    }
+
+    /** External display not applied refresh rates synchronization, because
+     * config_refreshRateSynchronizationEnabled is false. */
+    @Test
+    public void testExternalDisplay_notAppliedRefreshRatesSynchronization() {
+        when(mDisplayManagerFlags.isDisplayResolutionRangeVotingEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isUserPreferredModeVoteEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isExternalDisplayLimitModeEnabled()).thenReturn(true);
+        when(mDisplayManagerFlags.isDisplaysRefreshRatesSynchronizationEnabled()).thenReturn(true);
+        init();
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(null);
+        mObserver.onDisplayAdded(EXTERNAL_DISPLAY);
+        assertThat(getVote(GLOBAL_ID, PRIORITY_SYNCHRONIZED_REFRESH_RATE)).isEqualTo(null);
+    }
+
     private void init() {
         mInjector = mock(DisplayModeDirector.Injector.class);
         doAnswer(invocation -> {