Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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,20 @@ private void initFragmentInitialFrameTracking() {
}
}

private void initFragmentReplayTracking() {
final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer =
new RNSentryReplayFragmentLifecycleTracer(logger);

final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume it should be always a FragmentActivity but maybe worth to have an instanceof check just in case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used the pattern from above but I think it's a good idea to add the check 👍 Updated with a8ad163

if (fragmentActivity != null) {
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 +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
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;

@SuppressWarnings("PMD.AvoidUsingVolatile")
private @Nullable volatile ReplayIntegration replayIntegration;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume volatile is not really needed here since everything is being accessed from the main thread (OnGlobalLayoutListener is likely called on the main thread too). But it's also fine as it is

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated with f963590


private int lastWidth = -1;
private int lastHeight = -1;

private @Nullable View currentView;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theoretically this shouldn't leak since we nullify it in onFragmentViewDestroyed but maybe worth to have it as a WeakRef just in case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea 👍 Updated with f56e75c

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);
}
}
};

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;
} else {
logger.log(SentryLevel.DEBUG, "Error getting replay integration");
}
} catch (Exception e) {
logger.log(SentryLevel.DEBUG, "Error getting replay integration", e);
}
return null;
}
}
Loading