Skip to content

Commit 5202700

Browse files
authored
[SR] Fix crashes and add replay lifecycle management (#4135)
* Wrap viewTreeObserver into try-catch * Fix FileNotFoundException when storing segment values * Use software canvas for Motorola devices * Manage ReplayIntegration lifecycle properly * Make ReplayState internal * Changelog * Fix * Remove import * Always resume the replay in lifecycle watcher
1 parent 1f149c3 commit 5202700

File tree

13 files changed

+517
-87
lines changed

13 files changed

+517
-87
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
- Do not log if `OtelContextScopesStorage` cannot be found ([#4127](https://github.com/getsentry/sentry-java/pull/4127))
1212
- Previously `java.lang.ClassNotFoundException: io.sentry.opentelemetry.OtelContextScopesStorage` was shown in the log if the class could not be found.
1313
- This is just a lookup the SDK performs to configure itself. The SDK also works without OpenTelemetry.
14+
- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135))
15+
- Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file
16+
- Fix `IllegalStateException` when registering `onDrawListener`
17+
- Fix SIGABRT native crashes on Motorola devices when encoding a video
1418
- Mention javadoc and sources for published artifacts in Gradle `.module` metadata ([#3936](https://github.com/getsentry/sentry-java/pull/3936))
1519

1620
### Dependencies

sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import io.sentry.util.AutoClosableReentrantLock;
1313
import java.util.Timer;
1414
import java.util.TimerTask;
15-
import java.util.concurrent.atomic.AtomicBoolean;
1615
import java.util.concurrent.atomic.AtomicLong;
1716
import org.jetbrains.annotations.NotNull;
1817
import org.jetbrains.annotations.Nullable;
@@ -21,7 +20,6 @@
2120
final class LifecycleWatcher implements DefaultLifecycleObserver {
2221

2322
private final AtomicLong lastUpdatedSession = new AtomicLong(0L);
24-
private final AtomicBoolean isFreshSession = new AtomicBoolean(false);
2523

2624
private final long sessionIntervalMillis;
2725

@@ -82,7 +80,6 @@ private void startSession() {
8280
final @Nullable Session currentSession = scope.getSession();
8381
if (currentSession != null && currentSession.getStarted() != null) {
8482
lastUpdatedSession.set(currentSession.getStarted().getTime());
85-
isFreshSession.set(true);
8683
}
8784
}
8885
});
@@ -94,11 +91,8 @@ private void startSession() {
9491
scopes.startSession();
9592
}
9693
scopes.getOptions().getReplayController().start();
97-
} else if (!isFreshSession.get()) {
98-
// only resume if it's not a fresh session, which has been started in SentryAndroid.init
99-
scopes.getOptions().getReplayController().resume();
10094
}
101-
isFreshSession.set(false);
95+
scopes.getOptions().getReplayController().resume();
10296
this.lastUpdatedSession.set(currentTimeMillis);
10397
}
10498

sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ class LifecycleWatcherTest {
254254
}
255255

256256
@Test
257-
fun `if the hub has already a fresh session running, doesn't resume replay`() {
257+
fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() {
258258
val watcher = fixture.getSUT(
259259
enableAppLifecycleBreadcrumbs = false,
260260
session = Session(
@@ -276,7 +276,7 @@ class LifecycleWatcherTest {
276276
)
277277

278278
watcher.onStart(fixture.ownerMock)
279-
verify(fixture.replayController, never()).resume()
279+
verify(fixture.replayController).resume()
280280
}
281281

282282
@Test
@@ -293,7 +293,7 @@ class LifecycleWatcherTest {
293293
verify(fixture.replayController).pause()
294294

295295
watcher.onStart(fixture.ownerMock)
296-
verify(fixture.replayController).resume()
296+
verify(fixture.replayController, times(2)).resume()
297297

298298
watcher.onStop(fixture.ownerMock)
299299
verify(fixture.replayController, timeout(10000)).stop()

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public class ReplayCache(
5555
internal val frames = mutableListOf<ReplayFrame>()
5656

5757
private val ongoingSegment = LinkedHashMap<String, String>()
58-
private val ongoingSegmentFile: File? by lazy {
58+
internal val ongoingSegmentFile: File? by lazy {
5959
if (replayCacheDir == null) {
6060
return@lazy null
6161
}
@@ -275,6 +275,9 @@ public class ReplayCache(
275275
if (isClosed.get()) {
276276
return
277277
}
278+
if (ongoingSegmentFile?.exists() != true) {
279+
ongoingSegmentFile?.createNewFile()
280+
}
278281
if (ongoingSegment.isEmpty()) {
279282
ongoingSegmentFile?.useLines { lines ->
280283
lines.associateTo(ongoingSegment) {

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(): 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

Comments
 (0)