From 1eac2fccd717cd6d80f14863b1afd1ba9acc5548 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Feb 2025 15:23:22 +0100 Subject: [PATCH] Cherry-pick: Session Replay: Fix various crashes and issues (#4135) (#4145) * Cherry-pick session replay fixes * Fix test --- CHANGELOG.md | 6 +- .../sentry/android/core/LifecycleWatcher.java | 8 +- .../android/core/LifecycleWatcherTest.kt | 6 +- .../io/sentry/android/replay/ReplayCache.kt | 5 +- .../android/replay/ReplayIntegration.kt | 72 +++++-- .../sentry/android/replay/ReplayLifecycle.kt | 58 ++++++ .../replay/capture/BaseCaptureStrategy.kt | 7 +- .../io/sentry/android/replay/util/Views.kt | 12 +- .../replay/video/SimpleVideoEncoder.kt | 5 +- .../sentry/android/replay/ReplayCacheTest.kt | 15 ++ .../android/replay/ReplayIntegrationTest.kt | 178 +++++++++++++++++- .../ReplayIntegrationWithRecorderTest.kt | 6 +- .../android/replay/ReplayLifecycleTest.kt | 120 ++++++++++++ 13 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dc74fdae40..0c08da8967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ ### Fixes +- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135)) + - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file + - Fix `IllegalStateException` when registering `onDrawListener` + - Fix SIGABRT native crashes on Motorola devices when encoding a video - (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) - - This allows Composables that use this modifier to be skippable + - This allows Composables that use this modifier to be skippable ## 7.21.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 23072265eb..8f7353f4e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -10,7 +10,6 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,7 +18,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); - private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; @@ -80,7 +78,6 @@ private void startSession() { final @Nullable Session currentSession = scope.getSession(); if (currentSession != null && currentSession.getStarted() != null) { lastUpdatedSession.set(currentSession.getStarted().getTime()); - isFreshSession.set(true); } } }); @@ -92,11 +89,8 @@ private void startSession() { hub.startSession(); } hub.getOptions().getReplayController().start(); - } else if (!isFreshSession.get()) { - // only resume if it's not a fresh session, which has been started in SentryAndroid.init - hub.getOptions().getReplayController().resume(); } - isFreshSession.set(false); + hub.getOptions().getReplayController().resume(); this.lastUpdatedSession.set(currentTimeMillis); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 1bc88961da..4f4f46e63f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -254,7 +254,7 @@ class LifecycleWatcherTest { } @Test - fun `if the hub has already a fresh session running, doesn't resume replay`() { + fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { val watcher = fixture.getSUT( enableAppLifecycleBreadcrumbs = false, session = Session( @@ -276,7 +276,7 @@ class LifecycleWatcherTest { ) watcher.onStart(fixture.ownerMock) - verify(fixture.replayController, never()).resume() + verify(fixture.replayController).resume() } @Test @@ -293,7 +293,7 @@ class LifecycleWatcherTest { verify(fixture.replayController).pause() watcher.onStart(fixture.ownerMock) - verify(fixture.replayController).resume() + verify(fixture.replayController, times(2)).resume() watcher.onStop(fixture.ownerMock) verify(fixture.replayController, timeout(10000)).stop() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 88638e7e16..11d3b84897 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -53,7 +53,7 @@ public class ReplayCache( internal val frames = mutableListOf() private val ongoingSegment = LinkedHashMap() - private val ongoingSegmentFile: File? by lazy { + internal val ongoingSegmentFile: File? by lazy { if (replayCacheDir == null) { return@lazy null } @@ -273,6 +273,9 @@ public class ReplayCache( if (isClosed.get()) { return } + if (ongoingSegmentFile?.exists() != true) { + ongoingSegmentFile?.createNewFile() + } if (ongoingSegment.isEmpty()) { ongoingSegmentFile?.useLines { lines -> lines.associateTo(ongoingSegment) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c0b77abc2a..655b3ca354 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -21,6 +21,11 @@ import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayState.CLOSED +import io.sentry.android.replay.ReplayState.PAUSED +import io.sentry.android.replay.ReplayState.RESUMED +import io.sentry.android.replay.ReplayState.STARTED +import io.sentry.android.replay.ReplayState.STOPPED import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment @@ -100,15 +105,15 @@ public class ReplayIntegration( Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) - private val isRecording = AtomicBoolean(false) + internal val isManualPause = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null + private val lifecycle = ReplayLifecycle() override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -151,15 +156,15 @@ public class ReplayIntegration( finalizePreviousReplay() } - override fun isRecording() = isRecording.get() + override fun isRecording() = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED + @Synchronized override fun start() { - // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { return } - if (isRecording.getAndSet(true)) { + if (!lifecycle.isAllowed(STARTED)) { options.logger.log( DEBUG, "Session replay is already being recorded, not starting a new one" @@ -183,19 +188,35 @@ public class ReplayIntegration( captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) registerRootViewListeners() + lifecycle.currentState = STARTED } override fun resume() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(false) + resumeInternal() + } + + @Synchronized + private fun resumeInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(RESUMED)) { + return + } + + if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED || + hub?.rateLimiter?.isActiveForCategory(All) == true || + hub?.rateLimiter?.isActiveForCategory(Replay) == true + ) { return } captureStrategy?.resume() recorder?.resume() + lifecycle.currentState = RESUMED } + @Synchronized override fun captureReplay(isTerminating: Boolean?) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -220,16 +241,24 @@ public class ReplayIntegration( override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter override fun pause() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(true) + pauseInternal() + } + + @Synchronized + private fun pauseInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { return } recorder?.pause() captureStrategy?.pause() + lifecycle.currentState = PAUSED } + @Synchronized override fun stop() { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(STOPPED)) { return } @@ -237,8 +266,8 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() captureStrategy?.stop() - isRecording.set(false) captureStrategy = null + lifecycle.currentState = STOPPED } override fun onScreenshotRecorded(bitmap: Bitmap) { @@ -257,8 +286,9 @@ public class ReplayIntegration( } } + @Synchronized override fun close() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(CLOSED)) { return } @@ -275,10 +305,11 @@ public class ReplayIntegration( recorder = null rootViewsSpy.close() replayExecutor.gracefullyShutdown(options) + lifecycle.currentState = CLOSED } override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -289,6 +320,10 @@ public class ReplayIntegration( captureStrategy?.onConfigurationChanged(recorderConfig) recorder?.start(recorderConfig) + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.pause() + } } override fun onConnectionStatusChanged(status: ConnectionStatus) { @@ -298,10 +333,10 @@ public class ReplayIntegration( } if (status == DISCONNECTED) { - pause() + pauseInternal() } else { // being positive for other states, even if it's NO_PERMISSION - resume() + resumeInternal() } } @@ -312,15 +347,18 @@ public class ReplayIntegration( } if (rateLimiter.isActiveForCategory(All) || rateLimiter.isActiveForCategory(Replay)) { - pause() + pauseInternal() } else { - resume() + resumeInternal() } } override fun onLowMemory() = Unit override fun onTouchEvent(event: MotionEvent) { + if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) { + return + } captureStrategy?.onTouchEvent(event) } @@ -336,7 +374,7 @@ public class ReplayIntegration( hub?.rateLimiter?.isActiveForCategory(Replay) == true ) ) { - pause() + pauseInternal() } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt new file mode 100644 index 0000000000..fba95fcb41 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt @@ -0,0 +1,58 @@ +package io.sentry.android.replay + +internal enum class ReplayState { + /** + * Initial state of a Replay session. This is the state when ReplayIntegration is constructed + * but has not been started yet. + */ + INITIAL, + + /** + * Started state for a Replay session. This state is reached after the start() method is called + * and the recording is initialized successfully. + */ + STARTED, + + /** + * Resumed state for a Replay session. This state is reached after resume() is called on an + * already started recording. + */ + RESUMED, + + /** + * Paused state for a Replay session. This state is reached after pause() is called on a + * resumed recording. + */ + PAUSED, + + /** + * Stopped state for a Replay session. This state is reached after stop() is called. + * The recording can be started again from this state. + */ + STOPPED, + + /** + * Closed state for a Replay session. This is the terminal state reached after close() is called. + * No further state transitions are possible after this. + */ + CLOSED; +} + +/** + * Class to manage state transitions for ReplayIntegration + */ +internal class ReplayLifecycle { + @field:Volatile + internal var currentState = ReplayState.INITIAL + + fun isAllowed(newState: ReplayState): Boolean = when (currentState) { + ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.STARTED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.RESUMED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.PAUSED -> newState == ReplayState.RESUMED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.STOPPED -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.CLOSED -> false + } + + fun isTouchRecordingAllowed(): Boolean = currentState == ReplayState.STARTED || currentState == ReplayState.RESUMED +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fbc80565b1..9caf92fa20 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -5,6 +5,7 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub +import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER @@ -183,7 +184,11 @@ internal abstract class BaseCaptureStrategy( task() } } else { - task() + try { + task() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $TAG.runInBackground", e) + } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index f3e667dc32..b51f2f9847 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -184,12 +184,20 @@ internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListen if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.addOnDrawListener(listener) + try { + viewTreeObserver.addOnDrawListener(listener) + } catch (e: IllegalStateException) { + // viewTreeObserver is already dead + } } internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.removeOnDrawListener(listener) + try { + viewTreeObserver.removeOnDrawListener(listener) + } catch (e: IllegalStateException) { + // viewTreeObserver is already dead + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 211decc098..0a535a439c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -157,7 +157,10 @@ internal class SimpleVideoEncoder( fun encode(image: Bitmap) { // it seems that Xiaomi devices have problems with hardware canvas, so we have to use // lockCanvas instead https://stackoverflow.com/a/73520742 - val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) { + val canvas = if ( + Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || + Build.MANUFACTURER.contains("motorola", ignoreCase = true) + ) { surface?.lockCanvas(null) } else { surface?.lockHardwareCanvas() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index b2c8836d40..a3e17d4f73 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -285,6 +285,21 @@ class ReplayCacheTest { assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) } + @Test + fun `when file does not exist upon persisting creates it`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId + ) + + replayCache.ongoingSegmentFile?.delete() + + replayCache.persistSegmentValues("key", "value") + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key=value", segmentValues[0]) + } + @Test fun `stores segment key value pairs`() { val replayId = SentryId() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 4183a780b8..353b11d8f6 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -277,6 +277,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.pause() replay.resume() verify(captureStrategy).resume() @@ -646,6 +647,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) replay.onConnectionStatusChanged(CONNECTED) verify(recorder).resume() @@ -677,16 +679,190 @@ class ReplayIntegrationTest { context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }, - isRateLimited = false + isRateLimited = true ) replay.register(fixture.hub, fixture.options) replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) replay.onRateLimitChanged(fixture.rateLimiter) verify(recorder).resume() } + @Test + fun `closed replay cannot be started`() { + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + replay.start() + replay.close() + + replay.start() + + assertFalse(replay.isRecording) + } + + @Test + fun `if recording is paused in configChanges re-pauses it again`() { + var configChanged = false + val recorderConfig = mock() + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + recorderConfigProvider = { configChanged = it; recorderConfig } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onConfigurationChanged(mock()) + + verify(recorder).stop() + verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) + verify(recorder, times(2)).start(eq(recorderConfig)) + verify(recorder, times(2)).pause() + assertTrue(configChanged) + } + + @Test + fun `onTouchEvent does nothing when not started or resumed`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + } + + @Test + fun `when paused manually onConnectionStatusChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) + replay.pause() + replay.onConnectionStatusChanged(CONNECTED) + + verify(recorder, never()).resume() + } + + @Test + fun `when paused manually onRateLimitChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + replay.pause() + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) + replay.onRateLimitChanged(fixture.rateLimiter) + + verify(recorder, never()).resume() + } + + @Test + fun `when rate limit is active manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when no connection manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isOffline = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when already paused does not pause again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.pause() + + verify(recorder).pause() + } + + @Test + fun `when already resumed does not resume again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + replay.resume() + + verify(recorder).resume() + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy { return SessionCaptureStrategy( options, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index ae817a1759..e2491d3796 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -127,12 +127,12 @@ class ReplayIntegrationWithRecorderTest { replay.start() assertEquals(STARTED, recorder.state) - replay.resume() - assertEquals(RESUMED, recorder.state) - replay.pause() assertEquals(PAUSED, recorder.state) + replay.resume() + assertEquals(RESUMED, recorder.state) + replay.stop() assertEquals(STOPPED, recorder.state) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt new file mode 100644 index 0000000000..c989237452 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt @@ -0,0 +1,120 @@ +package io.sentry.android.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReplayLifecycleTest { + @Test + fun `verify initial state`() { + val lifecycle = ReplayLifecycle() + assertEquals(ReplayState.INITIAL, lifecycle.currentState) + } + + @Test + fun `test transitions from INITIAL state`() { + val lifecycle = ReplayLifecycle() + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + } + + @Test + fun `test transitions from STARTED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STARTED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from RESUMED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.RESUMED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from PAUSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.PAUSED + + assertTrue(lifecycle.isAllowed(ReplayState.RESUMED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from STOPPED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STOPPED + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from CLOSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.CLOSED + + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + assertFalse(lifecycle.isAllowed(ReplayState.CLOSED)) + } + + @Test + fun `test touch recording is allowed only in STARTED and RESUMED states`() { + val lifecycle = ReplayLifecycle() + + // Initial state doesn't allow touch recording + assertFalse(lifecycle.isTouchRecordingAllowed()) + + // STARTED state allows touch recording + lifecycle.currentState = ReplayState.STARTED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // RESUMED state allows touch recording + lifecycle.currentState = ReplayState.RESUMED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // Other states don't allow touch recording + val otherStates = listOf( + ReplayState.INITIAL, + ReplayState.PAUSED, + ReplayState.STOPPED, + ReplayState.CLOSED + ) + + otherStates.forEach { state -> + lifecycle.currentState = state + assertFalse(lifecycle.isTouchRecordingAllowed()) + } + } +}