Add RenderSynchronizer class

RenderSynchronizer is used to coordinate video rendering updates
to a specific frame rate target and aligned to display refresh cycles.
go/meet-android-synchronized-rendering

Bug: b/217863437
Change-Id: Ie329c4c2eccfb0c9aee9b90f7ddbc370919d5e86
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/324840
Reviewed-by: Ranveer Aggarwal‎ <ranvr@webrtc.org>
Reviewed-by: Sergey Silkin <ssilkin@webrtc.org>
Commit-Queue: Linus Nilsson <lnilsson@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#41004}
diff --git a/sdk/android/BUILD.gn b/sdk/android/BUILD.gn
index 9aae8b9..b0598b4 100644
--- a/sdk/android/BUILD.gn
+++ b/sdk/android/BUILD.gn
@@ -221,6 +221,7 @@
       "api/org/webrtc/GlTextureFrameBuffer.java",
       "api/org/webrtc/GlUtil.java",
       "api/org/webrtc/JavaI420Buffer.java",
+      "api/org/webrtc/RenderSynchronizer.java",
       "api/org/webrtc/RendererCommon.java",
       "api/org/webrtc/SurfaceTextureHelper.java",
       "api/org/webrtc/TextureBufferImpl.java",
diff --git a/sdk/android/api/org/webrtc/RenderSynchronizer.java b/sdk/android/api/org/webrtc/RenderSynchronizer.java
new file mode 100644
index 0000000..b1ade84
--- /dev/null
+++ b/sdk/android/api/org/webrtc/RenderSynchronizer.java
@@ -0,0 +1,116 @@
+/*
+ *  Copyright 2023 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+package org.webrtc;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Choreographer;
+import androidx.annotation.GuardedBy;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Class to synchronize rendering updates with display refresh cycles and save power by blocking
+ * updates that exceeds the target frame rate.
+ */
+public final class RenderSynchronizer {
+
+  /** Interface for listening to render window updates. */
+  public interface Listener {
+    void onRenderWindowOpen();
+
+    void onRenderWindowClose();
+  }
+
+  private static final String TAG = "RenderSynchronizer";
+  private static final float DEFAULT_TARGET_FPS = 30f;
+  private final Object lock = new Object();
+  private final List<Listener> listeners = new CopyOnWriteArrayList<>();
+  private final long targetFrameIntervalNanos;
+  private final Handler mainThreadHandler;
+  private Choreographer choreographer;
+
+  @GuardedBy("lock")
+  private boolean isListening;
+
+  private boolean renderWindowOpen;
+  private long lastRefreshTimeNanos;
+  private long lastOpenedTimeNanos;
+
+  public RenderSynchronizer(float targetFrameRateFps) {
+    this.targetFrameIntervalNanos = Math.round(TimeUnit.SECONDS.toNanos(1) / targetFrameRateFps);
+    this.mainThreadHandler = new Handler(Looper.getMainLooper());
+    mainThreadHandler.post(() -> this.choreographer = Choreographer.getInstance());
+    Logging.d(TAG, "Created");
+  }
+
+  public RenderSynchronizer() {
+    this(DEFAULT_TARGET_FPS);
+  }
+
+  public void registerListener(Listener listener) {
+    listeners.add(listener);
+
+    synchronized (lock) {
+      if (!isListening) {
+        Logging.d(TAG, "First listener, subscribing to frame callbacks");
+        isListening = true;
+        mainThreadHandler.post(
+            () -> choreographer.postFrameCallback(this::onDisplayRefreshCycleBegin));
+      }
+    }
+  }
+
+  public void removeListener(Listener listener) {
+    listeners.remove(listener);
+  }
+
+  private void onDisplayRefreshCycleBegin(long refreshTimeNanos) {
+    synchronized (lock) {
+      if (listeners.isEmpty()) {
+        Logging.d(TAG, "No listeners, unsubscribing to frame callbacks");
+        isListening = false;
+        return;
+      }
+    }
+    choreographer.postFrameCallback(this::onDisplayRefreshCycleBegin);
+
+    long lastOpenDeltaNanos = refreshTimeNanos - lastOpenedTimeNanos;
+    long refreshDeltaNanos = refreshTimeNanos - lastRefreshTimeNanos;
+    lastRefreshTimeNanos = refreshTimeNanos;
+
+    // Make a greedy choice whether to open (or keep open) the render window. If the current time
+    // since the render window was last opened is closer to the target than what we predict it would
+    // be in the next refresh cycle then we open the window.
+    if (Math.abs(lastOpenDeltaNanos - targetFrameIntervalNanos)
+        < Math.abs(lastOpenDeltaNanos - targetFrameIntervalNanos + refreshDeltaNanos)) {
+      lastOpenedTimeNanos = refreshTimeNanos;
+      openRenderWindow();
+    } else if (renderWindowOpen) {
+      closeRenderWindow();
+    }
+  }
+
+  private void openRenderWindow() {
+    renderWindowOpen = true;
+    for (Listener listener : listeners) {
+      listener.onRenderWindowOpen();
+    }
+  }
+
+  private void closeRenderWindow() {
+    renderWindowOpen = false;
+    for (Listener listener : listeners) {
+      listener.onRenderWindowClose();
+    }
+  }
+}