diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 7e3a49962f..0f6e64c352 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -133,6 +133,10 @@ jobs: working-directory: packages/core/RNSentryAndroidTester run: ./gradlew testDebugUnitTest + - name: Verify SDK compiles without sentry-android-replay + working-directory: packages/core/RNSentryAndroidTester + run: ./gradlew :RNSentry:compileDebugJavaWithJavac --init-script exclude-replay.init.gradle + - name: Setup KVM shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b1d5d77b..5917ef21c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixes +- Fix Android build failing with `cannot find symbol ReplayIntegration` when `sentry-android-replay` is excluded ([#6803](https://github.com/getsentry/sentry-react-native/pull/6803)) - Preserve already-quoted React Native bundle script paths in the Expo iOS plugin ([#6796](https://github.com/getsentry/sentry-react-native/pull/6796)) ### 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 deleted file mode 100644 index 59725e5ef3..0000000000 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayFragmentLifecycleTracerTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -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/RNSentryAndroidTester/exclude-replay.init.gradle b/packages/core/RNSentryAndroidTester/exclude-replay.init.gradle new file mode 100644 index 0000000000..4869e03d88 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/exclude-replay.init.gradle @@ -0,0 +1,13 @@ +// Simulate an app that excludes Session Replay to guard the excluded-replay build path. +// +// Some apps drop `sentry-android-replay` to reduce APK size (see the docs at +// https://docs.sentry.io/platforms/react-native/session-replay/performance-overhead/#excluding-session-replay-from-android). +// When they do, the SDK's Android sources must still compile against the replay +// stubs in `android/libs/replay-stubs.jar` alone. This init script reproduces that +// classpath so CI fails fast if the SDK gains a hard dependency on a non-stubbed +// replay symbol (regression guard for issue #6802). +allprojects { + configurations.all { + exclude group: 'io.sentry', module: 'sentry-android-replay' + } +} 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 deleted file mode 100644 index 4f41960b7d..0000000000 --- a/packages/core/android/src/main/java/io/sentry/react/replay/RNSentryReplayFragmentLifecycleTracer.java +++ /dev/null @@ -1,137 +0,0 @@ -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; - } -}