From dc98df7681868cd90191386069fe22454ca418d6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 23 Sep 2026 10:35:05 +0200 Subject: [PATCH 1/2] fix(android): Always remove FirstDrawDoneListener after the first draw The listener was removed from an OnGlobalLayoutListener, which only runs if a layout pass follows the draw. On a static screen it stayed registered and ran on every frame, retaining its callback. Post the removal to the main thread instead so it always happens. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../internal/util/FirstDrawDoneListener.java | 20 +++++----- .../util/FirstDrawDoneListenerTest.kt | 39 +------------------ 2 files changed, 11 insertions(+), 48 deletions(-) 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 0629b7a4908..533739d51d8 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 @@ -109,17 +109,15 @@ public void onDraw() { if (view == null) { return; } - // OnDrawListeners cannot be removed within onDraw, so we remove it with a - // GlobalLayoutListener - view.getViewTreeObserver() - .addOnGlobalLayoutListener( - new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - view.getViewTreeObserver().removeOnGlobalLayoutListener(this); - view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); - } - }); + // OnDrawListeners cannot be removed within onDraw. Posting the removal instead of waiting for + // the next global layout guarantees it happens, even if no layout pass follows the draw. + mainThreadHandler.post( + () -> { + final ViewTreeObserver observer = view.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnDrawListener(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 44d6d9fd03a..57e72ac4d72 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 @@ -113,49 +113,14 @@ class FirstDrawDoneListenerTest { } @Test - fun `registerForNextDraw unregister itself after onDraw`() { + fun `registerForNextDraw unregisters itself after onDraw without a layout pass`() { val view = fixture.getSut() FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) assertFalse(fixture.onDrawListeners.isEmpty()) - // Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered - view.viewTreeObserver.dispatchOnGlobalLayout() - assertFalse(fixture.onDrawListeners.isEmpty()) - - // Removes OnDrawListener in the next OnGlobalLayout after onDraw - view.viewTreeObserver.dispatchOnDraw() - view.viewTreeObserver.dispatchOnGlobalLayout() - 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()) + Shadows.shadowOf(Looper.getMainLooper()).idle() - // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners - view.viewTreeObserver.dispatchOnGlobalLayout() - assertTrue(globalLayoutListeners().isEmpty()) assertTrue(fixture.onDrawListeners.isEmpty()) } From 6afd9db387d830178a8a945e881adc71993c5394 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 23 Sep 2026 10:35:41 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e59606e335c..f4402980f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,7 @@ - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) +- Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152)) ## 8.57.0