diff --git a/CHANGELOG.md b/CHANGELOG.md index 8428f033b78..411546688e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix `FirstDrawDoneListener` leaking an `OnGlobalLayoutListener` per registration ([#5567](https://github.com/getsentry/sentry-java/pull/5567)) + ### Features - Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index f2612b4aa84..0629b7a4908 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -112,7 +112,14 @@ public void onDraw() { // OnDrawListeners cannot be removed within onDraw, so we remove it with a // GlobalLayoutListener view.getViewTreeObserver() - .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + view.getViewTreeObserver().removeOnGlobalLayoutListener(this); + view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); + } + }); mainThreadHandler.postAtFrontOfQueue(callback); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index 008a036cbfc..44d6d9fd03a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -128,6 +128,37 @@ class FirstDrawDoneListenerTest { assertTrue(fixture.onDrawListeners.isEmpty()) } + @Test + fun `OnGlobalLayoutListener is removed after cleanup`() { + val view = fixture.getSut() + + // Initialize mOnGlobalLayoutListeners via a dummy add/remove + val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {} + view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener) + view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener) + + // CopyOnWriteArray wraps an internal ArrayList called mData + val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners") + val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData") + mDataField.isAccessible = true + + @Suppress("UNCHECKED_CAST") + fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*> + + assertTrue(globalLayoutListeners().isEmpty()) + + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + + // onDraw registers a cleanup OnGlobalLayoutListener + view.viewTreeObserver.dispatchOnDraw() + assertFalse(globalLayoutListeners().isEmpty()) + + // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners + view.viewTreeObserver.dispatchOnGlobalLayout() + assertTrue(globalLayoutListeners().isEmpty()) + assertTrue(fixture.onDrawListeners.isEmpty()) + } + @Test fun `registerForNextDraw calls the given callback on the main thread after onDraw`() { val view = fixture.getSut()