diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/util/FirstDrawDoneListener.java b/firebase-perf/src/main/java/com/google/firebase/perf/util/FirstDrawDoneListener.java new file mode 100644 index 00000000000..740278ad4a4 --- /dev/null +++ b/firebase-perf/src/main/java/com/google/firebase/perf/util/FirstDrawDoneListener.java @@ -0,0 +1,101 @@ +// Copyright 2022 Google LLC +// +// 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.google.firebase.perf.util; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewTreeObserver; +import androidx.annotation.RequiresApi; +import java.util.concurrent.atomic.AtomicReference; + +/** + * OnDrawListener that unregisters itself and invokes callback when the next draw is done. This API + * 16+ implementation is an approximation of the initial display time. {@link + * android.view.Choreographer#postFrameCallback} is an Android API that provides a simpler and more + * accurate initial display time, but it was bugged before API 30, hence we use this backported + * implementation. + */ +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) +public class FirstDrawDoneListener implements ViewTreeObserver.OnDrawListener { + private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + private final AtomicReference viewReference; + private final Runnable callback; + + /** Registers a post-draw callback for the next draw of a view. */ + public static void registerForNextDraw(View view, Runnable drawDoneCallback) { + final FirstDrawDoneListener listener = new FirstDrawDoneListener(view, drawDoneCallback); + // Handle bug prior to API 26 where OnDrawListener from the floating ViewTreeObserver is not + // merged into the real ViewTreeObserver. + // https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3 + if (Build.VERSION.SDK_INT < 26 && !isAliveAndAttached(view)) { + view.addOnAttachStateChangeListener( + new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View view) { + view.getViewTreeObserver().addOnDrawListener(listener); + view.removeOnAttachStateChangeListener(this); + } + + @Override + public void onViewDetachedFromWindow(View view) { + view.removeOnAttachStateChangeListener(this); + } + }); + } else { + view.getViewTreeObserver().addOnDrawListener(listener); + } + } + + private FirstDrawDoneListener(View view, Runnable callback) { + this.viewReference = new AtomicReference<>(view); + this.callback = callback; + } + + @Override + public void onDraw() { + // Set viewReference to null so any onDraw past the first is a no-op + View view = viewReference.getAndSet(null); + if (view == null) { + return; + } + // OnDrawListeners cannot be removed within onDraw, so we remove it with a + // GlobalLayoutListener + view.getViewTreeObserver() + .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + mainThreadHandler.postAtFrontOfQueue(callback); + } + + /** + * Helper to avoid bug + * prior to API 26, where the floating ViewTreeObserver's OnDrawListeners are not merged into + * the real ViewTreeObserver during attach. + * + * @return true if the View is already attached and the ViewTreeObserver is not a floating + * placeholder. + */ + private static boolean isAliveAndAttached(View view) { + return view.getViewTreeObserver().isAlive() && isAttachedToWindow(view); + } + + /** Backport {@link View#isAttachedToWindow()} which is API 19+ only. */ + private static boolean isAttachedToWindow(View view) { + if (Build.VERSION.SDK_INT >= 19) { + return view.isAttachedToWindow(); + } + return view.getWindowToken() != null; + } +} diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/util/FirstDrawDoneListenerTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/util/FirstDrawDoneListenerTest.java new file mode 100644 index 00000000000..76aad67e5b7 --- /dev/null +++ b/firebase-perf/src/test/java/com/google/firebase/perf/util/FirstDrawDoneListenerTest.java @@ -0,0 +1,181 @@ +// Copyright 2022 Google LLC +// +// 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.google.firebase.perf.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.ViewTreeObserver.OnDrawListener; +import androidx.test.core.app.ApplicationProvider; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.LooperMode; + +/** Unit tests for {@link FirstDrawDoneListener}. */ +@RunWith(RobolectricTestRunner.class) +@LooperMode(LooperMode.Mode.PAUSED) +public class FirstDrawDoneListenerTest { + private View testView; + + @Before + public void setUp() { + testView = new View(ApplicationProvider.getApplicationContext()); + } + + @Test + @Config(sdk = 25) + public void registerForNextDraw_delaysAddingListenerForAPIsBelow26() + throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException { + ArrayList mOnDrawListeners = + initViewTreeObserverWithListener(testView.getViewTreeObserver()); + assertThat(mOnDrawListeners.size()).isEqualTo(0); + + // OnDrawListener is not registered, it is delayed for later + FirstDrawDoneListener.registerForNextDraw(testView, () -> {}); + assertThat(mOnDrawListeners.size()).isEqualTo(0); + + // Register listener after the view is attached to a window + List attachListeners = dispatchAttachedToWindow(testView); + assertThat(mOnDrawListeners.size()).isEqualTo(1); + assertThat(mOnDrawListeners.get(0)).isInstanceOf(FirstDrawDoneListener.class); + assertThat(attachListeners).isEmpty(); + } + + @Test + @Config(sdk = 26) + public void registerForNextDraw_directlyAddsListenerForApi26AndAbove() + throws NoSuchFieldException, IllegalAccessException { + ArrayList mOnDrawListeners = + initViewTreeObserverWithListener(testView.getViewTreeObserver()); + assertThat(mOnDrawListeners.size()).isEqualTo(0); + + // Immediately register an OnDrawListener to ViewTreeObserver + FirstDrawDoneListener.registerForNextDraw(testView, () -> {}); + assertThat(mOnDrawListeners.size()).isEqualTo(1); + assertThat(mOnDrawListeners.get(0)).isInstanceOf(FirstDrawDoneListener.class); + } + + @Test + @Config(sdk = 26) + public void onDraw_postsCallbackToFrontOfQueue() { + Handler handler = new Handler(Looper.getMainLooper()); + Runnable drawDoneCallback = mock(Runnable.class); + Runnable otherCallback = mock(Runnable.class); + InOrder inOrder = inOrder(drawDoneCallback, otherCallback); + + FirstDrawDoneListener.registerForNextDraw(testView, drawDoneCallback); + handler.post(otherCallback); // 3rd in queue + handler.postAtFrontOfQueue(otherCallback); // 2nd in queue + testView.getViewTreeObserver().dispatchOnDraw(); // 1st in queue + verify(drawDoneCallback, never()).run(); + verify(otherCallback, never()).run(); + + // Execute all posted tasks + shadowOf(Looper.getMainLooper()).idle(); + inOrder.verify(drawDoneCallback, times(1)).run(); + inOrder.verify(otherCallback, times(2)).run(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + @Config(sdk = 26) + public void onDraw_unregistersItself_inLayoutChangeListener() + throws NoSuchFieldException, IllegalAccessException { + ArrayList mOnDrawListeners = + initViewTreeObserverWithListener(testView.getViewTreeObserver()); + FirstDrawDoneListener.registerForNextDraw(testView, () -> {}); + assertThat(mOnDrawListeners.size()).isEqualTo(1); + + // Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered + testView.getViewTreeObserver().dispatchOnGlobalLayout(); + assertThat(mOnDrawListeners.size()).isEqualTo(1); + + // Removes OnDrawListener in the next OnGlobalLayout after onDraw + testView.getViewTreeObserver().dispatchOnDraw(); + testView.getViewTreeObserver().dispatchOnGlobalLayout(); + assertThat(mOnDrawListeners.size()).isEqualTo(0); + } + + /** + * Returns ViewTreeObserver.mOnDrawListeners field through reflection. Since reflections are + * employed, prefer to be used in tests with fixed API level using @Config(sdk = X). + * + * @param vto ViewTreeObserver instance to initialize and return the mOnDrawListeners from + */ + private static ArrayList initViewTreeObserverWithListener(ViewTreeObserver vto) + throws NoSuchFieldException, IllegalAccessException { + // Adding a listener forces ViewTreeObserver.mOnDrawListeners to be initialized and non-null. + OnDrawListener placeHolder = () -> {}; + vto.addOnDrawListener(placeHolder); + vto.removeOnDrawListener(placeHolder); + + // Obtain mOnDrawListeners field through reflection + Field mOnDrawListeners = + android.view.ViewTreeObserver.class.getDeclaredField("mOnDrawListeners"); + mOnDrawListeners.setAccessible(true); + ArrayList listeners = (ArrayList) mOnDrawListeners.get(vto); + assertThat(listeners).isNotNull(); + assertThat(listeners.size()).isEqualTo(0); + return listeners; + } + + /** + * Simulates {@link View}'s dispatchAttachedToWindow() on API 25 using reflection. + * + *

This only simulates the part where dispatchAttachedToWindow() notifies the list of {@link + * View.OnAttachStateChangeListener}. + * + * @param view the view in which we are simulating dispatchAttachedToWindow(). + * @return list of {@link View.OnAttachStateChangeListener} from the input {@link View} + */ + private static List dispatchAttachedToWindow(View view) + throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { + assert Build.VERSION.SDK_INT == 25; + Class listenerInfo = Class.forName("android.view.View$ListenerInfo"); + Field mListenerInfo = View.class.getDeclaredField("mListenerInfo"); + mListenerInfo.setAccessible(true); + Object li = mListenerInfo.get(view); + assertThat(li).isNotNull(); + Field mOnAttachStateChangeListeners = + listenerInfo.getDeclaredField("mOnAttachStateChangeListeners"); + mOnAttachStateChangeListeners.setAccessible(true); + CopyOnWriteArrayList listeners = + (CopyOnWriteArrayList) + mOnAttachStateChangeListeners.get(li); + assertThat(listeners).isNotNull(); + assertThat(listeners).isNotEmpty(); + for (View.OnAttachStateChangeListener listener : listeners) { + listener.onViewAttachedToWindow(view); + } + return listeners; + } +}