diff --git a/CHANGELOG.md b/CHANGELOG.md index 303d68b6d1..bdd04c2fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Fixes +- Fixes orientation change misalignment for session replay on Android ([#5321](https://github.com/getsentry/sentry-react-native/pull/5321)) - Sync `user.geo` from `SetUser` to the native layer ([#5302](https://github.com/getsentry/sentry-react-native/pull/5302)) ### Dependencies 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..59725e5ef3 --- /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.replay.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..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 @@ -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,23 @@ private void initFragmentInitialFrameTracking() { } } + private void initFragmentReplayTracking() { + final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReplayFragmentLifecycleTracer(logger); + + final @Nullable Activity currentActivity = getCurrentActivity(); + if (!(currentActivity instanceof FragmentActivity)) { + return; + } + + final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity; + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); + if (supportFragmentManager != null) { + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); + } + } + public void initNativeReactNavigationNewFrameTracking(Promise promise) { this.initFragmentInitialFrameTracking(); } @@ -309,6 +327,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/replay/RNSentryReplayFragmentLifecycleTracer.java b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java new file mode 100644 index 0000000000..4f41960b7d --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java @@ -0,0 +1,137 @@ +package io.sentry.react.replay; + +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; + + private @Nullable ReplayIntegration replayIntegration; + + private int lastWidth = -1; + private int lastHeight = -1; + + private @Nullable WeakReference currentViewRef; + 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); + } + } + }; + + currentViewRef = new WeakReference<>(view); + currentListener = listener; + + view.getViewTreeObserver().addOnGlobalLayoutListener(listener); + } + + private void detachLayoutChangeListener() { + final View view = currentViewRef != null ? currentViewRef.get() : null; + if (view != null && currentListener != null) { + try { + ViewTreeObserver observer = view.getViewTreeObserver(); + if (observer != null) { + observer.removeOnGlobalLayoutListener(currentListener); + } + } catch (Exception e) { + logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e); + } + } + + currentViewRef = 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; + } +}