From 960f8509a15bedc94d97a00784940e42c29371a0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Oct 2025 14:00:42 +0100 Subject: [PATCH 01/20] fix(session-replay): Fixes orientation change misalignment for session replay --- .../RNSentryReactFragmentLifecycleTracer.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 4ee8700214..61fc53309c 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -3,6 +3,7 @@ import android.os.Bundle; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -13,9 +14,13 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.EventDispatcherListener; import io.sentry.ILogger; +import io.sentry.Integration; +import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.android.replay.ReplayIntegration; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -25,6 +30,10 @@ public class RNSentryReactFragmentLifecycleTracer extends FragmentLifecycleCallb private @NotNull final Runnable emitNewFrameEvent; private @NotNull final ILogger logger; + private @Nullable ReplayIntegration replayIntegration; + private int lastWidth = -1; + private int lastHeight = -1; + public RNSentryReactFragmentLifecycleTracer( @NotNull BuildInfoProvider buildInfoProvider, @NotNull Runnable emitNewFrameEvent, @@ -95,6 +104,75 @@ public void onEventDispatch(Event event) { } } }); + + // Add layout listener to detect configuration changes + attachLayoutChangeListener(v); + } + + private void attachLayoutChangeListener(final View view) { + view.getViewTreeObserver() + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + checkAndNotifyWindowSizeChange(view); + } + }); + } + + private void checkAndNotifyWindowSizeChange(View view) { + try { + android.util.DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); + int currentWidth = metrics.widthPixels; + int currentHeight = metrics.heightPixels; + + if (lastWidth != currentWidth || lastHeight != currentHeight) { + lastWidth = currentWidth; + lastHeight = currentHeight; + + notifyReplayIntegrationOfSizeChange(currentWidth, currentHeight); + } + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to check window size", e); + } + } + + private void notifyReplayIntegrationOfSizeChange(int width, int height) { + try { + if (replayIntegration == null) { + replayIntegration = getReplayIntegration(); + } + + if (replayIntegration == null) { + return; + } + + if (!replayIntegration.isRecording()) { + return; + } + + replayIntegration.onWindowSizeChanged(width, height); + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to notify replay integration of size change", e); + } + } + + private @Nullable ReplayIntegration getReplayIntegration() { + try { + final SentryOptions options = ScopesAdapter.getInstance().getOptions(); + if (options == null) { + return null; + } + + for (Integration integration : options.getIntegrations()) { + if (integration instanceof ReplayIntegration) { + return (ReplayIntegration) integration; + } + } + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Error getting replay integration", e); + } + return null; } private static @Nullable EventDispatcher getEventDispatcherForReactTag( From 185b92096d7c10334e817e9993d7181c8f828fc2 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Oct 2025 14:04:45 +0100 Subject: [PATCH 02/20] Adds changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f59bae5098..18e1f0406e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ }); ``` +### Fixes + +- Fixes orientation change misalignment for session replay on Android ([#5321](https://github.com/getsentry/sentry-react-native/pull/5321)) + ### Dependencies - Bump Bundler Plugins from v4.4.0 to v4.6.0 ([#5283](https://github.com/getsentry/sentry-react-native/pull/5283), [#5314](https://github.com/getsentry/sentry-react-native/pull/5314)) From 87ce2088ff875fdb91f550ee9b7ebbce1228ba28 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Oct 2025 14:15:50 +0100 Subject: [PATCH 03/20] Removing check since the Android sdk does that --- .../io/sentry/react/RNSentryReactFragmentLifecycleTracer.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 61fc53309c..72446e0678 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -147,10 +147,6 @@ private void notifyReplayIntegrationOfSizeChange(int width, int height) { return; } - if (!replayIntegration.isRecording()) { - return; - } - replayIntegration.onWindowSizeChanged(width, height); } catch (Exception e) { logger.log(SentryLevel.DEBUG, "Failed to notify replay integration of size change", e); From c8266c346e5dca6dbff37cd8f9e5c8464e081997 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Oct 2025 14:59:27 +0100 Subject: [PATCH 04/20] Detach listener on destroy --- .../RNSentryReactFragmentLifecycleTracer.java | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 72446e0678..693fa44849 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -34,6 +34,9 @@ public class RNSentryReactFragmentLifecycleTracer extends FragmentLifecycleCallb private int lastWidth = -1; private int lastHeight = -1; + private @Nullable View currentView; + private @Nullable ViewTreeObserver.OnGlobalLayoutListener currentListener; + public RNSentryReactFragmentLifecycleTracer( @NotNull BuildInfoProvider buildInfoProvider, @NotNull Runnable emitNewFrameEvent, @@ -109,15 +112,37 @@ public void onEventDispatch(Event event) { attachLayoutChangeListener(v); } + @Override + public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) { + detachLayoutChangeListener(); + } + private void attachLayoutChangeListener(final View view) { - view.getViewTreeObserver() - .addOnGlobalLayoutListener( - new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - checkAndNotifyWindowSizeChange(view); - } - }); + final ViewTreeObserver.OnGlobalLayoutListener listener = + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + checkAndNotifyWindowSizeChange(view); + } + }; + + currentView = view; + currentListener = listener; + + view.getViewTreeObserver().addOnGlobalLayoutListener(listener); + } + + private void detachLayoutChangeListener() { + if (currentView != null && currentListener != null) { + try { + currentView.getViewTreeObserver().removeOnGlobalLayoutListener(currentListener); + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e); + } + } + + currentView = null; + currentListener = null; } private void checkAndNotifyWindowSizeChange(View view) { From 1672112fe10a7f4e989a66ec3fdd080dac79c602 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Oct 2025 15:02:42 +0100 Subject: [PATCH 05/20] Fix tests --- .../RNSentryReactFragmentLifecycleTracerTest.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt index d7599ea274..79ea64c6eb 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt @@ -2,6 +2,7 @@ package io.sentry.rnsentryandroidtester import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import androidx.fragment.app.Fragment import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper @@ -108,51 +109,63 @@ class RNSentryReactFragmentLifecycleTracerTest { } private fun mockScreenViewWithReactContext(): View { + val mockViewTreeObserver = mock() val screenMock: View = mock { whenever(it.id).thenReturn(123) whenever(it.context).thenReturn(mock()) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } val mockView = mock { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(screenMock) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } return mockView } private fun mockScreenViewWithGenericContext(): View { + val mockViewTreeObserver = mock() val screenMock: View = mock { whenever(it.id).thenReturn(123) whenever(it.context).thenReturn(mock()) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } val mockView = mock { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(screenMock) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } return mockView } private fun mockScreenViewWithNoId(): View { + val mockViewTreeObserver = mock() val screenMock: View = mock { whenever(it.id).thenReturn(-1) whenever(it.context).thenReturn(mock()) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } val mockView = mock { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(screenMock) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } return mockView } - private fun mockScreenViewWithoutChild(): View = - mock { + private fun mockScreenViewWithoutChild(): View { + val mockViewTreeObserver = mock() + return mock { whenever(it.childCount).thenReturn(0) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } + } private fun mockUIManager(mockEventDispatcher: EventDispatcher) { mockUIManager = mockStatic(UIManagerHelper::class.java) From 464601eb3218d8b12c8b95f2ad2396aacb1bb967 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 30 Oct 2025 15:11:05 +0100 Subject: [PATCH 06/20] Adds tests --- ...NSentryReactFragmentLifecycleTracerTest.kt | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt index 79ea64c6eb..3e19576ba3 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt @@ -85,6 +85,34 @@ class RNSentryReactFragmentLifecycleTracerTest { callOnFragmentViewCreated(mock(), mockScreenViewWithGenericContext()) } + @Test + fun tracerAttachesLayoutListener() { + val mockEventDispatcher = mock() + val mockViewTreeObserver = mock() + mockUIManager(mockEventDispatcher) + + val mockView = mockScreenViewWithReactContext(mockViewTreeObserver) + callOnFragmentViewCreated(mock(), mockView) + + verify(mockViewTreeObserver, times(1)).addOnGlobalLayoutListener(any()) + } + + @Test + fun tracerRemovesLayoutListenerWhenFragmentViewDestroyed() { + val mockEventDispatcher = mock() + val mockViewTreeObserver = mock() + mockUIManager(mockEventDispatcher) + + val mockFragment = mock() + val mockView = mockScreenViewWithReactContext(mockViewTreeObserver) + + val tracer = createSutWith() + tracer.onFragmentViewCreated(mock(), mockFragment, mockView, null) + tracer.onFragmentViewDestroyed(mock(), mockFragment) + + verify(mockViewTreeObserver, times(1)).removeOnGlobalLayoutListener(any()) + } + private fun callOnFragmentViewCreated( mockFragment: Fragment, mockView: View, @@ -108,8 +136,7 @@ class RNSentryReactFragmentLifecycleTracerTest { ) } - private fun mockScreenViewWithReactContext(): View { - val mockViewTreeObserver = mock() + private fun mockScreenViewWithReactContext(mockViewTreeObserver: ViewTreeObserver = mock()): View { val screenMock: View = mock { whenever(it.id).thenReturn(123) @@ -125,8 +152,7 @@ class RNSentryReactFragmentLifecycleTracerTest { return mockView } - private fun mockScreenViewWithGenericContext(): View { - val mockViewTreeObserver = mock() + private fun mockScreenViewWithGenericContext(mockViewTreeObserver: ViewTreeObserver = mock()): View { val screenMock: View = mock { whenever(it.id).thenReturn(123) @@ -142,8 +168,7 @@ class RNSentryReactFragmentLifecycleTracerTest { return mockView } - private fun mockScreenViewWithNoId(): View { - val mockViewTreeObserver = mock() + private fun mockScreenViewWithNoId(mockViewTreeObserver: ViewTreeObserver = mock()): View { val screenMock: View = mock { whenever(it.id).thenReturn(-1) @@ -159,13 +184,11 @@ class RNSentryReactFragmentLifecycleTracerTest { return mockView } - private fun mockScreenViewWithoutChild(): View { - val mockViewTreeObserver = mock() - return mock { + private fun mockScreenViewWithoutChild(mockViewTreeObserver: ViewTreeObserver = mock()): View = + mock { whenever(it.childCount).thenReturn(0) whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } - } private fun mockUIManager(mockEventDispatcher: EventDispatcher) { mockUIManager = mockStatic(UIManagerHelper::class.java) From cf4ca99ea1aea848d392f03b5d9cd1cd5d31fbe8 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 31 Oct 2025 12:31:04 +0100 Subject: [PATCH 07/20] Detach previous listeners --- .../io/sentry/react/RNSentryReactFragmentLifecycleTracer.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 693fa44849..44e4156e5a 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -108,7 +108,8 @@ public void onEventDispatch(Event event) { } }); - // Add layout listener to detect configuration changes + // Add layout listener to detect configuration changes after detaching any previous one + detachLayoutChangeListener(); attachLayoutChangeListener(v); } From 919dff2013c6fcfaf4b07d8124d81aff5b2af38f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 31 Oct 2025 12:35:38 +0100 Subject: [PATCH 08/20] change catch scope --- .../RNSentryReactFragmentLifecycleTracer.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 44e4156e5a..dd196af04d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -164,15 +164,15 @@ private void checkAndNotifyWindowSizeChange(View view) { } private void notifyReplayIntegrationOfSizeChange(int width, int height) { - try { - if (replayIntegration == null) { - replayIntegration = getReplayIntegration(); - } - - if (replayIntegration == null) { - return; - } + if (replayIntegration == null) { + replayIntegration = getReplayIntegration(); + } + if (replayIntegration == null) { + return; + } + + try { replayIntegration.onWindowSizeChanged(width, height); } catch (Exception e) { logger.log(SentryLevel.DEBUG, "Failed to notify replay integration of size change", e); From 5c6a512968ec3e0bf6d5408d16eced2a2e669ffe Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 31 Oct 2025 12:45:56 +0100 Subject: [PATCH 09/20] Check observer for null --- .../sentry/react/RNSentryReactFragmentLifecycleTracer.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index dd196af04d..b7c240678b 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -136,7 +136,10 @@ public void onGlobalLayout() { private void detachLayoutChangeListener() { if (currentView != null && currentListener != null) { try { - currentView.getViewTreeObserver().removeOnGlobalLayoutListener(currentListener); + ViewTreeObserver observer = currentView.getViewTreeObserver(); + if (observer != null) { + observer.removeOnGlobalLayoutListener(currentListener); + } } catch (Exception e) { logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e); } @@ -171,7 +174,7 @@ private void notifyReplayIntegrationOfSizeChange(int width, int height) { if (replayIntegration == null) { return; } - + try { replayIntegration.onWindowSizeChanged(width, height); } catch (Exception e) { From 1f6a939314991ab74b14f01112a843018668062b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 31 Oct 2025 13:54:13 +0100 Subject: [PATCH 10/20] Review feedback --- .../RNSentryReactFragmentLifecycleTracer.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index b7c240678b..253d107c5f 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -1,6 +1,7 @@ package io.sentry.react; import android.os.Bundle; +import android.util.DisplayMetrics; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; @@ -21,6 +22,7 @@ import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.android.replay.ReplayIntegration; +import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,7 +32,9 @@ public class RNSentryReactFragmentLifecycleTracer extends FragmentLifecycleCallb private @NotNull final Runnable emitNewFrameEvent; private @NotNull final ILogger logger; - private @Nullable ReplayIntegration replayIntegration; + @SuppressWarnings("PMD.AvoidUsingVolatile") + private @Nullable volatile ReplayIntegration replayIntegration; + private int lastWidth = -1; private int lastHeight = -1; @@ -119,11 +123,16 @@ public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragme } private void attachLayoutChangeListener(final View view) { + final WeakReference weakView = new WeakReference<>(view); + final ViewTreeObserver.OnGlobalLayoutListener listener = new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { - checkAndNotifyWindowSizeChange(view); + final View v = weakView.get(); + if (v != null) { + checkAndNotifyWindowSizeChange(v); + } } }; @@ -151,16 +160,17 @@ private void detachLayoutChangeListener() { private void checkAndNotifyWindowSizeChange(View view) { try { - android.util.DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); + DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); int currentWidth = metrics.widthPixels; int currentHeight = metrics.heightPixels; - if (lastWidth != currentWidth || lastHeight != currentHeight) { - lastWidth = currentWidth; - lastHeight = currentHeight; - - notifyReplayIntegrationOfSizeChange(currentWidth, currentHeight); + if (lastWidth == currentWidth && lastHeight == currentHeight) { + return; } + lastWidth = currentWidth; + lastHeight = currentHeight; + + notifyReplayIntegrationOfSizeChange(currentWidth, currentHeight); } catch (Exception e) { logger.log(SentryLevel.DEBUG, "Failed to check window size", e); } From dbee1d40fe3d01701dca81f55ed4dbd27ceb7808 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 3 Nov 2025 16:41:22 +0100 Subject: [PATCH 11/20] Use ReplayController to get ReplayIntegration --- .../RNSentryReactFragmentLifecycleTracer.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 253d107c5f..0b3bd45877 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -15,10 +15,9 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.EventDispatcherListener; import io.sentry.ILogger; -import io.sentry.Integration; +import io.sentry.ReplayController; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; -import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.android.replay.ReplayIntegration; @@ -194,15 +193,10 @@ private void notifyReplayIntegrationOfSizeChange(int width, int height) { private @Nullable ReplayIntegration getReplayIntegration() { try { - final SentryOptions options = ScopesAdapter.getInstance().getOptions(); - if (options == null) { - return null; - } + final ReplayController replayController = ScopesAdapter.getInstance().getOptions().getReplayController(); - for (Integration integration : options.getIntegrations()) { - if (integration instanceof ReplayIntegration) { - return (ReplayIntegration) integration; - } + if (replayController instanceof ReplayIntegration) { + return (ReplayIntegration) replayController; } } catch (Exception e) { logger.log(SentryLevel.DEBUG, "Error getting replay integration", e); From 21a37a5212f1504afb5cc7c0c6254380eb1d7ba3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 3 Nov 2025 16:50:59 +0100 Subject: [PATCH 12/20] Fix lint issue --- .../io/sentry/react/RNSentryReactFragmentLifecycleTracer.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index 0b3bd45877..be5f357936 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -193,7 +193,8 @@ private void notifyReplayIntegrationOfSizeChange(int width, int height) { private @Nullable ReplayIntegration getReplayIntegration() { try { - final ReplayController replayController = ScopesAdapter.getInstance().getOptions().getReplayController(); + final ReplayController replayController = + ScopesAdapter.getInstance().getOptions().getReplayController(); if (replayController instanceof ReplayIntegration) { return (ReplayIntegration) replayController; From bbddc271a3089ddb8e718df8e356969b09804d5e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 11:34:33 +0100 Subject: [PATCH 13/20] Move implementation to replay fragment lifecycle listener --- ...NSentryReactFragmentLifecycleTracerTest.kt | 44 +----- ...SentryReplayFragmentLifecycleTracerTest.kt | 106 +++++++++++++ .../io/sentry/react/RNSentryModuleImpl.java | 16 ++ .../RNSentryReactFragmentLifecycleTracer.java | 108 -------------- ...RNSentryReplayFragmentLifecycleTracer.java | 140 ++++++++++++++++++ 5 files changed, 266 insertions(+), 148 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt create mode 100644 packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt index 3e19576ba3..d7599ea274 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt @@ -2,7 +2,6 @@ package io.sentry.rnsentryandroidtester import android.view.View import android.view.ViewGroup -import android.view.ViewTreeObserver import androidx.fragment.app.Fragment import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper @@ -85,34 +84,6 @@ class RNSentryReactFragmentLifecycleTracerTest { callOnFragmentViewCreated(mock(), mockScreenViewWithGenericContext()) } - @Test - fun tracerAttachesLayoutListener() { - val mockEventDispatcher = mock() - val mockViewTreeObserver = mock() - mockUIManager(mockEventDispatcher) - - val mockView = mockScreenViewWithReactContext(mockViewTreeObserver) - callOnFragmentViewCreated(mock(), mockView) - - verify(mockViewTreeObserver, times(1)).addOnGlobalLayoutListener(any()) - } - - @Test - fun tracerRemovesLayoutListenerWhenFragmentViewDestroyed() { - val mockEventDispatcher = mock() - val mockViewTreeObserver = mock() - mockUIManager(mockEventDispatcher) - - val mockFragment = mock() - val mockView = mockScreenViewWithReactContext(mockViewTreeObserver) - - val tracer = createSutWith() - tracer.onFragmentViewCreated(mock(), mockFragment, mockView, null) - tracer.onFragmentViewDestroyed(mock(), mockFragment) - - verify(mockViewTreeObserver, times(1)).removeOnGlobalLayoutListener(any()) - } - private fun callOnFragmentViewCreated( mockFragment: Fragment, mockView: View, @@ -136,58 +107,51 @@ class RNSentryReactFragmentLifecycleTracerTest { ) } - private fun mockScreenViewWithReactContext(mockViewTreeObserver: ViewTreeObserver = mock()): View { + private fun mockScreenViewWithReactContext(): View { val screenMock: View = mock { whenever(it.id).thenReturn(123) whenever(it.context).thenReturn(mock()) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } val mockView = mock { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(screenMock) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } return mockView } - private fun mockScreenViewWithGenericContext(mockViewTreeObserver: ViewTreeObserver = mock()): View { + private fun mockScreenViewWithGenericContext(): View { val screenMock: View = mock { whenever(it.id).thenReturn(123) whenever(it.context).thenReturn(mock()) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } val mockView = mock { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(screenMock) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } return mockView } - private fun mockScreenViewWithNoId(mockViewTreeObserver: ViewTreeObserver = mock()): View { + private fun mockScreenViewWithNoId(): View { val screenMock: View = mock { whenever(it.id).thenReturn(-1) whenever(it.context).thenReturn(mock()) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } val mockView = mock { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(screenMock) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } return mockView } - private fun mockScreenViewWithoutChild(mockViewTreeObserver: ViewTreeObserver = mock()): View = + private fun mockScreenViewWithoutChild(): View = mock { whenever(it.childCount).thenReturn(0) - whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) } private fun mockUIManager(mockEventDispatcher: EventDispatcher) { diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt new file mode 100644 index 0000000000..ac9786a939 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt @@ -0,0 +1,106 @@ +package io.sentry.rnsentryandroidtester + +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import androidx.fragment.app.Fragment +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.EventDispatcher +import com.swmansion.rnscreens.ScreenStackFragment +import io.sentry.ILogger +import io.sentry.react.RNSentryReplayFragmentLifecycleTracer +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(JUnit4::class) +class RNSentryReplayFragmentLifecycleTracerTest { + private var mockUIManager: MockedStatic? = null + + @After + fun after() { + mockUIManager?.close() + } + + @Test + fun tracerAttachesLayoutListener() { + val mockEventDispatcher = mock() + val mockViewTreeObserver = mock() + mockUIManager(mockEventDispatcher) + + val mockView = mockScreenViewWithReactContext(mockViewTreeObserver) + callOnFragmentViewCreated(mock(), mockView) + + verify(mockViewTreeObserver, times(1)).addOnGlobalLayoutListener(any()) + } + + @Test + fun tracerRemovesLayoutListenerWhenFragmentViewDestroyed() { + val mockEventDispatcher = mock() + val mockViewTreeObserver = mock() + mockUIManager(mockEventDispatcher) + + val mockFragment = mock() + val mockView = mockScreenViewWithReactContext(mockViewTreeObserver) + + val tracer = createSutWith() + tracer.onFragmentViewCreated(mock(), mockFragment, mockView, null) + tracer.onFragmentViewDestroyed(mock(), mockFragment) + + verify(mockViewTreeObserver, times(1)).removeOnGlobalLayoutListener(any()) + } + + private fun callOnFragmentViewCreated( + mockFragment: Fragment, + mockView: View, + ) { + createSutWith().onFragmentViewCreated( + mock(), + mockFragment, + mockView, + null, + ) + } + + private fun createSutWith(): RNSentryReplayFragmentLifecycleTracer { + val logger: ILogger = mock() + + return RNSentryReplayFragmentLifecycleTracer(logger) + } + + private fun mockScreenViewWithReactContext(mockViewTreeObserver: ViewTreeObserver = mock()): View { + val screenMock: View = + mock { + whenever(it.id).thenReturn(123) + whenever(it.context).thenReturn(mock()) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) + } + val mockView = + mock { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(screenMock) + whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver) + } + return mockView + } + + private fun mockUIManager(mockEventDispatcher: EventDispatcher) { + mockUIManager = mockStatic(UIManagerHelper::class.java) + mockUIManager + ?.`when` { UIManagerHelper.getReactContext(any()) } + ?.thenReturn(mock()) + mockUIManager + ?.`when` { UIManagerHelper.getEventDispatcherForReactTag(any(), anyInt()) } + ?.thenReturn(mockEventDispatcher) + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index c3f1b8a8c0..a50700e589 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -69,6 +69,7 @@ import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; +import io.sentry.react.replay.RNSentryReplayFragmentLifecycleTracer; import io.sentry.react.replay.RNSentryReplayMask; import io.sentry.react.replay.RNSentryReplayUnmask; import io.sentry.util.DebugMetaPropertiesApplier; @@ -183,6 +184,20 @@ private void initFragmentInitialFrameTracking() { } } + private void initFragmentReplayTracking() { + final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReplayFragmentLifecycleTracer(logger); + + final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); + if (fragmentActivity != null) { + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); + if (supportFragmentManager != null) { + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); + } + } + } + public void initNativeReactNavigationNewFrameTracking(Promise promise) { this.initFragmentInitialFrameTracking(); } @@ -309,6 +324,7 @@ protected void getSentryAndroidOptions( loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); if (isReplayEnabled(replayOptions) && isReplayAvailable) { options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); + initFragmentReplayTracking(); } // Exclude Dev Server and Sentry Dsn request from Breadcrumbs diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java index be5f357936..4ee8700214 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -1,10 +1,8 @@ package io.sentry.react; import android.os.Bundle; -import android.util.DisplayMetrics; import android.view.View; import android.view.ViewGroup; -import android.view.ViewTreeObserver; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -15,13 +13,9 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.EventDispatcherListener; import io.sentry.ILogger; -import io.sentry.ReplayController; -import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; -import io.sentry.android.replay.ReplayIntegration; -import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -31,15 +25,6 @@ public class RNSentryReactFragmentLifecycleTracer extends FragmentLifecycleCallb private @NotNull final Runnable emitNewFrameEvent; private @NotNull final ILogger logger; - @SuppressWarnings("PMD.AvoidUsingVolatile") - private @Nullable volatile ReplayIntegration replayIntegration; - - private int lastWidth = -1; - private int lastHeight = -1; - - private @Nullable View currentView; - private @Nullable ViewTreeObserver.OnGlobalLayoutListener currentListener; - public RNSentryReactFragmentLifecycleTracer( @NotNull BuildInfoProvider buildInfoProvider, @NotNull Runnable emitNewFrameEvent, @@ -110,99 +95,6 @@ public void onEventDispatch(Event event) { } } }); - - // Add layout listener to detect configuration changes after detaching any previous one - detachLayoutChangeListener(); - attachLayoutChangeListener(v); - } - - @Override - public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) { - detachLayoutChangeListener(); - } - - private void attachLayoutChangeListener(final View view) { - final WeakReference weakView = new WeakReference<>(view); - - final ViewTreeObserver.OnGlobalLayoutListener listener = - new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - final View v = weakView.get(); - if (v != null) { - checkAndNotifyWindowSizeChange(v); - } - } - }; - - currentView = view; - currentListener = listener; - - view.getViewTreeObserver().addOnGlobalLayoutListener(listener); - } - - private void detachLayoutChangeListener() { - if (currentView != null && currentListener != null) { - try { - ViewTreeObserver observer = currentView.getViewTreeObserver(); - if (observer != null) { - observer.removeOnGlobalLayoutListener(currentListener); - } - } catch (Exception e) { - logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e); - } - } - - currentView = null; - currentListener = null; - } - - private void checkAndNotifyWindowSizeChange(View view) { - try { - DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); - int currentWidth = metrics.widthPixels; - int currentHeight = metrics.heightPixels; - - if (lastWidth == currentWidth && lastHeight == currentHeight) { - return; - } - lastWidth = currentWidth; - lastHeight = currentHeight; - - notifyReplayIntegrationOfSizeChange(currentWidth, currentHeight); - } catch (Exception e) { - logger.log(SentryLevel.DEBUG, "Failed to check window size", e); - } - } - - private void notifyReplayIntegrationOfSizeChange(int width, int height) { - if (replayIntegration == null) { - replayIntegration = getReplayIntegration(); - } - - if (replayIntegration == null) { - return; - } - - try { - replayIntegration.onWindowSizeChanged(width, height); - } catch (Exception e) { - logger.log(SentryLevel.DEBUG, "Failed to notify replay integration of size change", e); - } - } - - private @Nullable ReplayIntegration getReplayIntegration() { - try { - final ReplayController replayController = - ScopesAdapter.getInstance().getOptions().getReplayController(); - - if (replayController instanceof ReplayIntegration) { - return (ReplayIntegration) replayController; - } - } catch (Exception e) { - logger.log(SentryLevel.DEBUG, "Error getting replay integration", e); - } - return null; } private static @Nullable EventDispatcher getEventDispatcherForReactTag( diff --git a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java new file mode 100644 index 0000000000..b1141bfca5 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java @@ -0,0 +1,140 @@ +package io.sentry.react.replay; + +import android.os.Build; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewTreeObserver; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks; +import io.sentry.ILogger; +import io.sentry.ReplayController; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.android.replay.ReplayIntegration; +import java.lang.ref.WeakReference; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class RNSentryReplayFragmentLifecycleTracer extends FragmentLifecycleCallbacks { + private @NotNull final ILogger logger; + + @SuppressWarnings("PMD.AvoidUsingVolatile") + private @Nullable volatile ReplayIntegration replayIntegration; + + private int lastWidth = -1; + private int lastHeight = -1; + + private @Nullable View currentView; + private @Nullable ViewTreeObserver.OnGlobalLayoutListener currentListener; + + public RNSentryReplayFragmentLifecycleTracer(@NotNull ILogger logger) { + this.logger = logger; + } + + @Override + public void onFragmentViewCreated( + @NotNull FragmentManager fm, + @NotNull Fragment f, + @NotNull View v, + @Nullable Bundle savedInstanceState) { + // Add layout listener to detect configuration changes after detaching any previous one + detachLayoutChangeListener(); + attachLayoutChangeListener(v); + } + + @Override + public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) { + detachLayoutChangeListener(); + } + + private void attachLayoutChangeListener(final View view) { + final WeakReference weakView = new WeakReference<>(view); + + final ViewTreeObserver.OnGlobalLayoutListener listener = + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + final View v = weakView.get(); + if (v != null) { + checkAndNotifyWindowSizeChange(v); + } + } + }; + + currentView = view; + currentListener = listener; + + view.getViewTreeObserver().addOnGlobalLayoutListener(listener); + } + + private void detachLayoutChangeListener() { + if (currentView != null && currentListener != null) { + try { + ViewTreeObserver observer = currentView.getViewTreeObserver(); + if (observer != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + observer.removeOnGlobalLayoutListener(currentListener); + } + } + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e); + } + } + + currentView = null; + currentListener = null; + } + + private void checkAndNotifyWindowSizeChange(View view) { + try { + DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); + int currentWidth = metrics.widthPixels; + int currentHeight = metrics.heightPixels; + + if (lastWidth == currentWidth && lastHeight == currentHeight) { + return; + } + lastWidth = currentWidth; + lastHeight = currentHeight; + + notifyReplayIntegrationOfSizeChange(currentWidth, currentHeight); + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to check window size", e); + } + } + + private void notifyReplayIntegrationOfSizeChange(int width, int height) { + if (replayIntegration == null) { + replayIntegration = getReplayIntegration(); + } + + if (replayIntegration == null) { + return; + } + + try { + replayIntegration.onWindowSizeChanged(width, height); + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to notify replay integration of size change", e); + } + } + + private @Nullable ReplayIntegration getReplayIntegration() { + try { + final ReplayController replayController = + ScopesAdapter.getInstance().getOptions().getReplayController(); + + if (replayController instanceof ReplayIntegration) { + return (ReplayIntegration) replayController; + } else { + logger.log(SentryLevel.DEBUG, "Error getting replay integration"); + } + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Error getting replay integration", e); + } + return null; + } +} From b408aedc5f6a7928814900b06561abc78e9dc87e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 11:51:12 +0100 Subject: [PATCH 14/20] fix test import --- .../RNSentryReplayFragmentLifecycleTracerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt index ac9786a939..59725e5ef3 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt @@ -9,7 +9,7 @@ import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.EventDispatcher import com.swmansion.rnscreens.ScreenStackFragment import io.sentry.ILogger -import io.sentry.react.RNSentryReplayFragmentLifecycleTracer +import io.sentry.react.replay.RNSentryReplayFragmentLifecycleTracer import org.junit.After import org.junit.Test import org.junit.runner.RunWith From e8e516fba4514d81264b379fbbe542bafadc153a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 13:07:50 +0100 Subject: [PATCH 15/20] Remove unneeded sdk version check --- .../react/replay/RNSentryReplayFragmentLifecycleTracer.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java index b1141bfca5..89d1415313 100644 --- a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java @@ -75,9 +75,7 @@ private void detachLayoutChangeListener() { try { ViewTreeObserver observer = currentView.getViewTreeObserver(); if (observer != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - observer.removeOnGlobalLayoutListener(currentListener); - } + observer.removeOnGlobalLayoutListener(currentListener); } } catch (Exception e) { logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e); From e8debff1c5956c662543cfe91c2511955e3837ab Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 13:54:42 +0100 Subject: [PATCH 16/20] Remove unused import --- .../react/replay/RNSentryReplayFragmentLifecycleTracer.java | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java index 89d1415313..f6274af648 100644 --- a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java @@ -1,6 +1,5 @@ package io.sentry.react.replay; -import android.os.Build; import android.os.Bundle; import android.util.DisplayMetrics; import android.view.View; From a8ad16396cf1d5d854924f48bb27fc6172f79bb4 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 14:34:40 +0100 Subject: [PATCH 17/20] Check current activity type --- .../io/sentry/react/RNSentryModuleImpl.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index a50700e589..5650d723e1 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -188,13 +188,16 @@ private void initFragmentReplayTracking() { final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReplayFragmentLifecycleTracer(logger); - final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); - if (fragmentActivity != null) { - final @Nullable FragmentManager supportFragmentManager = - fragmentActivity.getSupportFragmentManager(); - if (supportFragmentManager != null) { - supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); - } + final @Nullable Activity currentActivity = getCurrentActivity(); + if (currentActivity == null || !(currentActivity instanceof FragmentActivity)) { + return; + } + + final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity; + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); + if (supportFragmentManager != null) { + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } } From f96359044bb65155eee407a0185b337775913da2 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 14:38:37 +0100 Subject: [PATCH 18/20] Remove volatile --- .../react/replay/RNSentryReplayFragmentLifecycleTracer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java index f6274af648..7bb1a26826 100644 --- a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java @@ -20,8 +20,7 @@ public class RNSentryReplayFragmentLifecycleTracer extends FragmentLifecycleCallbacks { private @NotNull final ILogger logger; - @SuppressWarnings("PMD.AvoidUsingVolatile") - private @Nullable volatile ReplayIntegration replayIntegration; + private @Nullable ReplayIntegration replayIntegration; private int lastWidth = -1; private int lastHeight = -1; From affda3d908eeefa201898e6b2bdd8cb27e5be700 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 14:48:42 +0100 Subject: [PATCH 19/20] SimplifyConditional: No need to check for null before an instanceof --- .../src/main/java/io/sentry/react/RNSentryModuleImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 5650d723e1..39a419acdf 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -189,7 +189,7 @@ private void initFragmentReplayTracking() { new RNSentryReplayFragmentLifecycleTracer(logger); final @Nullable Activity currentActivity = getCurrentActivity(); - if (currentActivity == null || !(currentActivity instanceof FragmentActivity)) { + if (!(currentActivity instanceof FragmentActivity)) { return; } From f56e75cc97e8bad611a04ff032ecf9279fbe7316 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Nov 2025 14:49:00 +0100 Subject: [PATCH 20/20] Use week reference for current view --- .../replay/RNSentryReplayFragmentLifecycleTracer.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java index 7bb1a26826..4f41960b7d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java +++ b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java @@ -25,7 +25,7 @@ public class RNSentryReplayFragmentLifecycleTracer extends FragmentLifecycleCall private int lastWidth = -1; private int lastHeight = -1; - private @Nullable View currentView; + private @Nullable WeakReference currentViewRef; private @Nullable ViewTreeObserver.OnGlobalLayoutListener currentListener; public RNSentryReplayFragmentLifecycleTracer(@NotNull ILogger logger) { @@ -62,16 +62,17 @@ public void onGlobalLayout() { } }; - currentView = view; + currentViewRef = new WeakReference<>(view); currentListener = listener; view.getViewTreeObserver().addOnGlobalLayoutListener(listener); } private void detachLayoutChangeListener() { - if (currentView != null && currentListener != null) { + final View view = currentViewRef != null ? currentViewRef.get() : null; + if (view != null && currentListener != null) { try { - ViewTreeObserver observer = currentView.getViewTreeObserver(); + ViewTreeObserver observer = view.getViewTreeObserver(); if (observer != null) { observer.removeOnGlobalLayoutListener(currentListener); } @@ -80,7 +81,7 @@ private void detachLayoutChangeListener() { } } - currentView = null; + currentViewRef = null; currentListener = null; }