Skip to content

Commit ce7142a

Browse files
committed
Manage ReplayIntegration lifecycle properly
1 parent 697b382 commit ce7142a

File tree

6 files changed

+481
-72
lines changed

6 files changed

+481
-72
lines changed

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/
8383
public fun stop ()V
8484
}
8585

86+
public final class io/sentry/android/replay/ReplayState : java/lang/Enum {
87+
public static final field CLOSED Lio/sentry/android/replay/ReplayState;
88+
public static final field INITIAL Lio/sentry/android/replay/ReplayState;
89+
public static final field PAUSED Lio/sentry/android/replay/ReplayState;
90+
public static final field RESUMED Lio/sentry/android/replay/ReplayState;
91+
public static final field STARTED Lio/sentry/android/replay/ReplayState;
92+
public static final field STOPPED Lio/sentry/android/replay/ReplayState;
93+
public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/replay/ReplayState;
94+
public static fun values ()[Lio/sentry/android/replay/ReplayState;
95+
}
96+
8697
public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback {
8798
public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V
8899
public abstract fun onScreenshotRecorded (Ljava/io/File;J)V

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 112 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import io.sentry.SentryIntegrationPackageStorage
2121
import io.sentry.SentryLevel.DEBUG
2222
import io.sentry.SentryLevel.INFO
2323
import 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
2429
import io.sentry.android.replay.capture.BufferCaptureStrategy
2530
import io.sentry.android.replay.capture.CaptureStrategy
2631
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
@@ -40,6 +45,7 @@ import io.sentry.protocol.SentryId
4045
import io.sentry.transport.ICurrentDateProvider
4146
import io.sentry.transport.RateLimiter
4247
import io.sentry.transport.RateLimiter.IRateLimitObserver
48+
import io.sentry.util.AutoClosableReentrantLock
4349
import io.sentry.util.FileUtils
4450
import io.sentry.util.HintUtils
4551
import 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() = isRecording.get()
161+
override fun isRecording() = 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
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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package io.sentry.android.replay
2+
3+
enum class ReplayState {
4+
/**
5+
* Initial state of a Replay session. This is the state when ReplayIntegration is constructed
6+
* but has not been started yet.
7+
*/
8+
INITIAL,
9+
10+
/**
11+
* Started state for a Replay session. This state is reached after the start() method is called
12+
* and the recording is initialized successfully.
13+
*/
14+
STARTED,
15+
16+
/**
17+
* Resumed state for a Replay session. This state is reached after resume() is called on an
18+
* already started recording.
19+
*/
20+
RESUMED,
21+
22+
/**
23+
* Paused state for a Replay session. This state is reached after pause() is called on a
24+
* resumed recording.
25+
*/
26+
PAUSED,
27+
28+
/**
29+
* Stopped state for a Replay session. This state is reached after stop() is called.
30+
* The recording can be started again from this state.
31+
*/
32+
STOPPED,
33+
34+
/**
35+
* Closed state for a Replay session. This is the terminal state reached after close() is called.
36+
* No further state transitions are possible after this.
37+
*/
38+
CLOSED;
39+
}
40+
41+
/**
42+
* Class to manage state transitions for ReplayIntegration
43+
*/
44+
internal class ReplayLifecycle {
45+
@field:Volatile
46+
internal var currentState = ReplayState.INITIAL
47+
48+
fun isAllowed(newState: ReplayState): Boolean = when (currentState) {
49+
ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED
50+
ReplayState.STARTED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED
51+
ReplayState.RESUMED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED
52+
ReplayState.PAUSED -> newState == ReplayState.RESUMED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED
53+
ReplayState.STOPPED -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED
54+
ReplayState.CLOSED -> false
55+
}
56+
57+
fun isTouchRecordingAllowed(): Boolean = currentState == ReplayState.STARTED || currentState == ReplayState.RESUMED
58+
}

0 commit comments

Comments
 (0)