@@ -21,6 +21,11 @@ import io.sentry.SentryIntegrationPackageStorage
2121import io.sentry.SentryLevel.DEBUG
2222import io.sentry.SentryLevel.INFO
2323import io.sentry.SentryOptions
24+ import io.sentry.android.replay.ReplayState.CLOSED
25+ import io.sentry.android.replay.ReplayState.PAUSED
26+ import io.sentry.android.replay.ReplayState.RESUMED
27+ import io.sentry.android.replay.ReplayState.STARTED
28+ import io.sentry.android.replay.ReplayState.STOPPED
2429import io.sentry.android.replay.capture.BufferCaptureStrategy
2530import io.sentry.android.replay.capture.CaptureStrategy
2631import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
@@ -40,6 +45,7 @@ import io.sentry.protocol.SentryId
4045import io.sentry.transport.ICurrentDateProvider
4146import io.sentry.transport.RateLimiter
4247import io.sentry.transport.RateLimiter.IRateLimitObserver
48+ import io.sentry.util.AutoClosableReentrantLock
4349import io.sentry.util.FileUtils
4450import io.sentry.util.HintUtils
4551import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
@@ -100,15 +106,16 @@ public class ReplayIntegration(
100106 Executors .newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory ())
101107 }
102108
103- // TODO: probably not everything has to be thread-safe here
104109 internal val isEnabled = AtomicBoolean (false )
105- private val isRecording = AtomicBoolean (false )
110+ internal val isManualPause = AtomicBoolean (false )
106111 private var captureStrategy: CaptureStrategy ? = null
107112 public val replayCacheDir: File ? get() = captureStrategy?.replayCacheDir
108113 private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter .getInstance()
109114 private var replayCaptureStrategyProvider: ((isFullSession: Boolean ) -> CaptureStrategy )? = null
110115 private var mainLooperHandler: MainLooperHandler = MainLooperHandler ()
111116 private var gestureRecorderProvider: (() -> GestureRecorder )? = null
117+ private val lifecycleLock = AutoClosableReentrantLock ()
118+ private val lifecycle = ReplayLifecycle ()
112119
113120 override fun register (scopes : IScopes , options : SentryOptions ) {
114121 this .options = options
@@ -151,51 +158,68 @@ public class ReplayIntegration(
151158 finalizePreviousReplay()
152159 }
153160
154- override fun isRecording (): Boolean = isRecording.get()
161+ override fun isRecording (): Boolean = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED
155162
156163 override fun start () {
157- // TODO: add lifecycle state instead and manage it in start/pause/resume/stop
158- if (! isEnabled.get()) {
159- return
160- }
164+ lifecycleLock.acquire().use {
165+ if (! isEnabled.get()) {
166+ return
167+ }
161168
162- if (isRecording.getAndSet( true )) {
163- options.logger.log(
164- DEBUG ,
165- " Session replay is already being recorded, not starting a new one"
166- )
167- return
168- }
169+ if (! lifecycle.isAllowed( STARTED )) {
170+ options.logger.log(
171+ DEBUG ,
172+ " Session replay is already being recorded, not starting a new one"
173+ )
174+ return
175+ }
169176
170- val isFullSession = random.sample(options.sessionReplay.sessionSampleRate)
171- if (! isFullSession && ! options.sessionReplay.isSessionReplayForErrorsEnabled) {
172- options.logger.log(INFO , " Session replay is not started, full session was not sampled and onErrorSampleRate is not specified" )
173- return
174- }
177+ val isFullSession = random.sample(options.sessionReplay.sessionSampleRate)
178+ if (! isFullSession && ! options.sessionReplay.isSessionReplayForErrorsEnabled) {
179+ options.logger.log(INFO , " Session replay is not started, full session was not sampled and onErrorSampleRate is not specified" )
180+ return
181+ }
175182
176- val recorderConfig = recorderConfigProvider?.invoke(false ) ? : ScreenshotRecorderConfig .from(context, options.sessionReplay)
177- captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ? : if (isFullSession) {
178- SessionCaptureStrategy (options, scopes, dateProvider, replayExecutor, replayCacheProvider)
179- } else {
180- BufferCaptureStrategy (options, scopes, dateProvider, random, replayExecutor, replayCacheProvider)
181- }
183+ val recorderConfig = recorderConfigProvider?.invoke(false ) ? : ScreenshotRecorderConfig .from(context, options.sessionReplay)
184+ captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ? : if (isFullSession) {
185+ SessionCaptureStrategy (options, scopes, dateProvider, replayExecutor, replayCacheProvider)
186+ } else {
187+ BufferCaptureStrategy (options, scopes, dateProvider, random, replayExecutor, replayCacheProvider)
188+ }
182189
183- captureStrategy?.start(recorderConfig)
184- recorder?.start(recorderConfig)
185- registerRootViewListeners()
190+ captureStrategy?.start(recorderConfig)
191+ recorder?.start(recorderConfig)
192+ registerRootViewListeners()
193+ lifecycle.currentState = STARTED
194+ }
186195 }
187196
188197 override fun resume () {
189- if ( ! isEnabled.get() || ! isRecording.get()) {
190- return
191- }
198+ isManualPause.set( false )
199+ resumeInternal()
200+ }
192201
193- captureStrategy?.resume()
194- recorder?.resume()
202+ private fun resumeInternal () {
203+ lifecycleLock.acquire().use {
204+ if (! isEnabled.get() || ! lifecycle.isAllowed(RESUMED )) {
205+ return
206+ }
207+
208+ if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED ||
209+ scopes?.rateLimiter?.isActiveForCategory(All ) == true ||
210+ scopes?.rateLimiter?.isActiveForCategory(Replay ) == true
211+ ) {
212+ return
213+ }
214+
215+ captureStrategy?.resume()
216+ recorder?.resume()
217+ lifecycle.currentState = RESUMED
218+ }
195219 }
196220
197221 override fun captureReplay (isTerminating : Boolean? ) {
198- if (! isEnabled.get() || ! isRecording.get ()) {
222+ if (! isEnabled.get() || ! isRecording()) {
199223 return
200224 }
201225
@@ -220,25 +244,35 @@ public class ReplayIntegration(
220244 override fun getBreadcrumbConverter (): ReplayBreadcrumbConverter = replayBreadcrumbConverter
221245
222246 override fun pause () {
223- if ( ! isEnabled.get() || ! isRecording.get()) {
224- return
225- }
247+ isManualPause.set( true )
248+ pauseInternal()
249+ }
226250
227- recorder?.pause()
228- captureStrategy?.pause()
251+ private fun pauseInternal () {
252+ lifecycleLock.acquire().use {
253+ if (! isEnabled.get() || ! lifecycle.isAllowed(PAUSED )) {
254+ return
255+ }
256+
257+ recorder?.pause()
258+ captureStrategy?.pause()
259+ lifecycle.currentState = PAUSED
260+ }
229261 }
230262
231263 override fun stop () {
232- if (! isEnabled.get() || ! isRecording.get()) {
233- return
234- }
264+ lifecycleLock.acquire().use {
265+ if (! isEnabled.get() || ! lifecycle.isAllowed(STOPPED )) {
266+ return
267+ }
235268
236- unregisterRootViewListeners()
237- recorder?.stop()
238- gestureRecorder?.stop()
239- captureStrategy?.stop()
240- isRecording.set(false )
241- captureStrategy = null
269+ unregisterRootViewListeners()
270+ recorder?.stop()
271+ gestureRecorder?.stop()
272+ captureStrategy?.stop()
273+ captureStrategy = null
274+ lifecycle.currentState = STOPPED
275+ }
242276 }
243277
244278 override fun onScreenshotRecorded (bitmap : Bitmap ) {
@@ -258,27 +292,30 @@ public class ReplayIntegration(
258292 }
259293
260294 override fun close () {
261- if (! isEnabled.get()) {
262- return
263- }
295+ lifecycleLock.acquire().use {
296+ if (! isEnabled.get() || ! lifecycle.isAllowed(CLOSED )) {
297+ return
298+ }
264299
265- options.connectionStatusProvider.removeConnectionStatusObserver(this )
266- scopes?.rateLimiter?.removeRateLimitObserver(this )
267- if (options.sessionReplay.isTrackOrientationChange) {
268- try {
269- context.unregisterComponentCallbacks(this )
270- } catch (ignored: Throwable ) {
300+ options.connectionStatusProvider.removeConnectionStatusObserver(this )
301+ scopes?.rateLimiter?.removeRateLimitObserver(this )
302+ if (options.sessionReplay.isTrackOrientationChange) {
303+ try {
304+ context.unregisterComponentCallbacks(this )
305+ } catch (ignored: Throwable ) {
306+ }
271307 }
308+ stop()
309+ recorder?.close()
310+ recorder = null
311+ rootViewsSpy.close()
312+ replayExecutor.gracefullyShutdown(options)
313+ lifecycle.currentState = CLOSED
272314 }
273- stop()
274- recorder?.close()
275- recorder = null
276- rootViewsSpy.close()
277- replayExecutor.gracefullyShutdown(options)
278315 }
279316
280317 override fun onConfigurationChanged (newConfig : Configuration ) {
281- if (! isEnabled.get() || ! isRecording.get ()) {
318+ if (! isEnabled.get() || ! isRecording()) {
282319 return
283320 }
284321
@@ -289,6 +326,10 @@ public class ReplayIntegration(
289326 captureStrategy?.onConfigurationChanged(recorderConfig)
290327
291328 recorder?.start(recorderConfig)
329+ // we have to restart recorder with a new config and pause immediately if the replay is paused
330+ if (lifecycle.currentState == PAUSED ) {
331+ recorder?.pause()
332+ }
292333 }
293334
294335 override fun onConnectionStatusChanged (status : ConnectionStatus ) {
@@ -298,10 +339,10 @@ public class ReplayIntegration(
298339 }
299340
300341 if (status == DISCONNECTED ) {
301- pause ()
342+ pauseInternal ()
302343 } else {
303344 // being positive for other states, even if it's NO_PERMISSION
304- resume ()
345+ resumeInternal ()
305346 }
306347 }
307348
@@ -312,15 +353,18 @@ public class ReplayIntegration(
312353 }
313354
314355 if (rateLimiter.isActiveForCategory(All ) || rateLimiter.isActiveForCategory(Replay )) {
315- pause ()
356+ pauseInternal ()
316357 } else {
317- resume ()
358+ resumeInternal ()
318359 }
319360 }
320361
321362 override fun onLowMemory (): Unit = Unit
322363
323364 override fun onTouchEvent (event : MotionEvent ) {
365+ if (! isEnabled.get() || ! lifecycle.isTouchRecordingAllowed()) {
366+ return
367+ }
324368 captureStrategy?.onTouchEvent(event)
325369 }
326370
@@ -336,7 +380,7 @@ public class ReplayIntegration(
336380 scopes?.rateLimiter?.isActiveForCategory(Replay ) == true
337381 )
338382 ) {
339- pause ()
383+ pauseInternal ()
340384 }
341385 }
342386
0 commit comments