Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UIManagerHelper>? = null

@After
fun after() {
mockUIManager?.close()
}

@Test
fun tracerAttachesLayoutListener() {
val mockEventDispatcher = mock<EventDispatcher>()
val mockViewTreeObserver = mock<ViewTreeObserver>()
mockUIManager(mockEventDispatcher)

val mockView = mockScreenViewWithReactContext(mockViewTreeObserver)
callOnFragmentViewCreated(mock<ScreenStackFragment>(), mockView)

verify(mockViewTreeObserver, times(1)).addOnGlobalLayoutListener(any())
}

@Test
fun tracerRemovesLayoutListenerWhenFragmentViewDestroyed() {
val mockEventDispatcher = mock<EventDispatcher>()
val mockViewTreeObserver = mock<ViewTreeObserver>()
mockUIManager(mockEventDispatcher)

val mockFragment = mock<ScreenStackFragment>()
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<ReactContext>())
whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver)
}
val mockView =
mock<ViewGroup> {
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`<ReactContext> { UIManagerHelper.getReactContext(any()) }
?.thenReturn(mock())
mockUIManager
?.`when`<EventDispatcher> { UIManagerHelper.getEventDispatcherForReactTag(any(), anyInt()) }
?.thenReturn(mockEventDispatcher)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<View> 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<View> 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;
}
}
Loading