-
-
Notifications
You must be signed in to change notification settings - Fork 359
fix(session-replay): Fixes orientation change misalignment for session replay on Android #5321
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 20 commits
960f850
185b920
87ce208
c8266c3
1672112
464601e
cf4ca99
97097b5
919dff2
5c6a512
a0c30cc
1f6a939
1099bd7
f4690cc
dbee1d4
21a37a5
bbddc27
b408aed
e8e516f
e8debff
a8ad163
f963590
affda3d
f56e75c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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; | ||
|
||
|
|
||
| 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<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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
FragmentActivitybut maybe worth to have aninstanceofcheck just in case?There was a problem hiding this comment.
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