Skip to content

Commit f9dd020

Browse files
authored
fix(session-replay): Fixes orientation change misalignment for session replay on Android (#5321)
* fix(session-replay): Fixes orientation change misalignment for session replay * Adds changelog * Removing check since the Android sdk does that * Detach listener on destroy * Fix tests * Adds tests * Detach previous listeners * change catch scope * Check observer for null * Review feedback * Use ReplayController to get ReplayIntegration * Fix lint issue * Move implementation to replay fragment lifecycle listener * fix test import * Remove unneeded sdk version check * Remove unused import * Check current activity type * Remove volatile * SimplifyConditional: No need to check for null before an instanceof * Use week reference for current view
1 parent 136effd commit f9dd020

File tree

4 files changed

+263
-0
lines changed

4 files changed

+263
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
### Fixes
3333

34+
- Fixes orientation change misalignment for session replay on Android ([#5321](https://github.com/getsentry/sentry-react-native/pull/5321))
3435
- Sync `user.geo` from `SetUser` to the native layer ([#5302](https://github.com/getsentry/sentry-react-native/pull/5302))
3536

3637
### Dependencies
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package io.sentry.rnsentryandroidtester
2+
3+
import android.view.View
4+
import android.view.ViewGroup
5+
import android.view.ViewTreeObserver
6+
import androidx.fragment.app.Fragment
7+
import com.facebook.react.bridge.ReactContext
8+
import com.facebook.react.uimanager.UIManagerHelper
9+
import com.facebook.react.uimanager.events.EventDispatcher
10+
import com.swmansion.rnscreens.ScreenStackFragment
11+
import io.sentry.ILogger
12+
import io.sentry.react.replay.RNSentryReplayFragmentLifecycleTracer
13+
import org.junit.After
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import org.junit.runners.JUnit4
17+
import org.mockito.ArgumentMatchers.any
18+
import org.mockito.ArgumentMatchers.anyInt
19+
import org.mockito.MockedStatic
20+
import org.mockito.Mockito.mockStatic
21+
import org.mockito.kotlin.mock
22+
import org.mockito.kotlin.times
23+
import org.mockito.kotlin.verify
24+
import org.mockito.kotlin.whenever
25+
26+
@RunWith(JUnit4::class)
27+
class RNSentryReplayFragmentLifecycleTracerTest {
28+
private var mockUIManager: MockedStatic<UIManagerHelper>? = null
29+
30+
@After
31+
fun after() {
32+
mockUIManager?.close()
33+
}
34+
35+
@Test
36+
fun tracerAttachesLayoutListener() {
37+
val mockEventDispatcher = mock<EventDispatcher>()
38+
val mockViewTreeObserver = mock<ViewTreeObserver>()
39+
mockUIManager(mockEventDispatcher)
40+
41+
val mockView = mockScreenViewWithReactContext(mockViewTreeObserver)
42+
callOnFragmentViewCreated(mock<ScreenStackFragment>(), mockView)
43+
44+
verify(mockViewTreeObserver, times(1)).addOnGlobalLayoutListener(any())
45+
}
46+
47+
@Test
48+
fun tracerRemovesLayoutListenerWhenFragmentViewDestroyed() {
49+
val mockEventDispatcher = mock<EventDispatcher>()
50+
val mockViewTreeObserver = mock<ViewTreeObserver>()
51+
mockUIManager(mockEventDispatcher)
52+
53+
val mockFragment = mock<ScreenStackFragment>()
54+
val mockView = mockScreenViewWithReactContext(mockViewTreeObserver)
55+
56+
val tracer = createSutWith()
57+
tracer.onFragmentViewCreated(mock(), mockFragment, mockView, null)
58+
tracer.onFragmentViewDestroyed(mock(), mockFragment)
59+
60+
verify(mockViewTreeObserver, times(1)).removeOnGlobalLayoutListener(any())
61+
}
62+
63+
private fun callOnFragmentViewCreated(
64+
mockFragment: Fragment,
65+
mockView: View,
66+
) {
67+
createSutWith().onFragmentViewCreated(
68+
mock(),
69+
mockFragment,
70+
mockView,
71+
null,
72+
)
73+
}
74+
75+
private fun createSutWith(): RNSentryReplayFragmentLifecycleTracer {
76+
val logger: ILogger = mock()
77+
78+
return RNSentryReplayFragmentLifecycleTracer(logger)
79+
}
80+
81+
private fun mockScreenViewWithReactContext(mockViewTreeObserver: ViewTreeObserver = mock()): View {
82+
val screenMock: View =
83+
mock {
84+
whenever(it.id).thenReturn(123)
85+
whenever(it.context).thenReturn(mock<ReactContext>())
86+
whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver)
87+
}
88+
val mockView =
89+
mock<ViewGroup> {
90+
whenever(it.childCount).thenReturn(1)
91+
whenever(it.getChildAt(0)).thenReturn(screenMock)
92+
whenever(it.viewTreeObserver).thenReturn(mockViewTreeObserver)
93+
}
94+
return mockView
95+
}
96+
97+
private fun mockUIManager(mockEventDispatcher: EventDispatcher) {
98+
mockUIManager = mockStatic(UIManagerHelper::class.java)
99+
mockUIManager
100+
?.`when`<ReactContext> { UIManagerHelper.getReactContext(any()) }
101+
?.thenReturn(mock())
102+
mockUIManager
103+
?.`when`<EventDispatcher> { UIManagerHelper.getEventDispatcherForReactTag(any(), anyInt()) }
104+
?.thenReturn(mockEventDispatcher)
105+
}
106+
}

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import io.sentry.protocol.SentryPackage;
7070
import io.sentry.protocol.User;
7171
import io.sentry.protocol.ViewHierarchy;
72+
import io.sentry.react.replay.RNSentryReplayFragmentLifecycleTracer;
7273
import io.sentry.react.replay.RNSentryReplayMask;
7374
import io.sentry.react.replay.RNSentryReplayUnmask;
7475
import io.sentry.util.DebugMetaPropertiesApplier;
@@ -183,6 +184,23 @@ private void initFragmentInitialFrameTracking() {
183184
}
184185
}
185186

187+
private void initFragmentReplayTracking() {
188+
final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer =
189+
new RNSentryReplayFragmentLifecycleTracer(logger);
190+
191+
final @Nullable Activity currentActivity = getCurrentActivity();
192+
if (!(currentActivity instanceof FragmentActivity)) {
193+
return;
194+
}
195+
196+
final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity;
197+
final @Nullable FragmentManager supportFragmentManager =
198+
fragmentActivity.getSupportFragmentManager();
199+
if (supportFragmentManager != null) {
200+
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true);
201+
}
202+
}
203+
186204
public void initNativeReactNavigationNewFrameTracking(Promise promise) {
187205
this.initFragmentInitialFrameTracking();
188206
}
@@ -309,6 +327,7 @@ protected void getSentryAndroidOptions(
309327
loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger);
310328
if (isReplayEnabled(replayOptions) && isReplayAvailable) {
311329
options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter());
330+
initFragmentReplayTracking();
312331
}
313332

314333
// Exclude Dev Server and Sentry Dsn request from Breadcrumbs
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package io.sentry.react.replay;
2+
3+
import android.os.Bundle;
4+
import android.util.DisplayMetrics;
5+
import android.view.View;
6+
import android.view.ViewTreeObserver;
7+
import androidx.annotation.NonNull;
8+
import androidx.fragment.app.Fragment;
9+
import androidx.fragment.app.FragmentManager;
10+
import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks;
11+
import io.sentry.ILogger;
12+
import io.sentry.ReplayController;
13+
import io.sentry.ScopesAdapter;
14+
import io.sentry.SentryLevel;
15+
import io.sentry.android.replay.ReplayIntegration;
16+
import java.lang.ref.WeakReference;
17+
import org.jetbrains.annotations.NotNull;
18+
import org.jetbrains.annotations.Nullable;
19+
20+
public class RNSentryReplayFragmentLifecycleTracer extends FragmentLifecycleCallbacks {
21+
private @NotNull final ILogger logger;
22+
23+
private @Nullable ReplayIntegration replayIntegration;
24+
25+
private int lastWidth = -1;
26+
private int lastHeight = -1;
27+
28+
private @Nullable WeakReference<View> currentViewRef;
29+
private @Nullable ViewTreeObserver.OnGlobalLayoutListener currentListener;
30+
31+
public RNSentryReplayFragmentLifecycleTracer(@NotNull ILogger logger) {
32+
this.logger = logger;
33+
}
34+
35+
@Override
36+
public void onFragmentViewCreated(
37+
@NotNull FragmentManager fm,
38+
@NotNull Fragment f,
39+
@NotNull View v,
40+
@Nullable Bundle savedInstanceState) {
41+
// Add layout listener to detect configuration changes after detaching any previous one
42+
detachLayoutChangeListener();
43+
attachLayoutChangeListener(v);
44+
}
45+
46+
@Override
47+
public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {
48+
detachLayoutChangeListener();
49+
}
50+
51+
private void attachLayoutChangeListener(final View view) {
52+
final WeakReference<View> weakView = new WeakReference<>(view);
53+
54+
final ViewTreeObserver.OnGlobalLayoutListener listener =
55+
new ViewTreeObserver.OnGlobalLayoutListener() {
56+
@Override
57+
public void onGlobalLayout() {
58+
final View v = weakView.get();
59+
if (v != null) {
60+
checkAndNotifyWindowSizeChange(v);
61+
}
62+
}
63+
};
64+
65+
currentViewRef = new WeakReference<>(view);
66+
currentListener = listener;
67+
68+
view.getViewTreeObserver().addOnGlobalLayoutListener(listener);
69+
}
70+
71+
private void detachLayoutChangeListener() {
72+
final View view = currentViewRef != null ? currentViewRef.get() : null;
73+
if (view != null && currentListener != null) {
74+
try {
75+
ViewTreeObserver observer = view.getViewTreeObserver();
76+
if (observer != null) {
77+
observer.removeOnGlobalLayoutListener(currentListener);
78+
}
79+
} catch (Exception e) {
80+
logger.log(SentryLevel.DEBUG, "Failed to remove layout change listener", e);
81+
}
82+
}
83+
84+
currentViewRef = null;
85+
currentListener = null;
86+
}
87+
88+
private void checkAndNotifyWindowSizeChange(View view) {
89+
try {
90+
DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics();
91+
int currentWidth = metrics.widthPixels;
92+
int currentHeight = metrics.heightPixels;
93+
94+
if (lastWidth == currentWidth && lastHeight == currentHeight) {
95+
return;
96+
}
97+
lastWidth = currentWidth;
98+
lastHeight = currentHeight;
99+
100+
notifyReplayIntegrationOfSizeChange(currentWidth, currentHeight);
101+
} catch (Exception e) {
102+
logger.log(SentryLevel.DEBUG, "Failed to check window size", e);
103+
}
104+
}
105+
106+
private void notifyReplayIntegrationOfSizeChange(int width, int height) {
107+
if (replayIntegration == null) {
108+
replayIntegration = getReplayIntegration();
109+
}
110+
111+
if (replayIntegration == null) {
112+
return;
113+
}
114+
115+
try {
116+
replayIntegration.onWindowSizeChanged(width, height);
117+
} catch (Exception e) {
118+
logger.log(SentryLevel.DEBUG, "Failed to notify replay integration of size change", e);
119+
}
120+
}
121+
122+
private @Nullable ReplayIntegration getReplayIntegration() {
123+
try {
124+
final ReplayController replayController =
125+
ScopesAdapter.getInstance().getOptions().getReplayController();
126+
127+
if (replayController instanceof ReplayIntegration) {
128+
return (ReplayIntegration) replayController;
129+
} else {
130+
logger.log(SentryLevel.DEBUG, "Error getting replay integration");
131+
}
132+
} catch (Exception e) {
133+
logger.log(SentryLevel.DEBUG, "Error getting replay integration", e);
134+
}
135+
return null;
136+
}
137+
}

0 commit comments

Comments
 (0)