Skip to content

Commit 1eac2fc

Browse files
authored
Cherry-pick: Session Replay: Fix various crashes and issues (#4135) (#4145)
* Cherry-pick session replay fixes * Fix test
1 parent 47a3903 commit 1eac2fc

File tree

13 files changed

+461
-37
lines changed

13 files changed

+461
-37
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44

55
### Fixes
66

7+
- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135))
8+
- Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file
9+
- Fix `IllegalStateException` when registering `onDrawListener`
10+
- Fix SIGABRT native crashes on Motorola devices when encoding a video
711
- (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029))
8-
- This allows Composables that use this modifier to be skippable
12+
- This allows Composables that use this modifier to be skippable
913

1014
## 7.21.0
1115

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
@@ -10,7 +10,6 @@
1010
import io.sentry.transport.ICurrentDateProvider;
1111
import java.util.Timer;
1212
import java.util.TimerTask;
13-
import java.util.concurrent.atomic.AtomicBoolean;
1413
import java.util.concurrent.atomic.AtomicLong;
1514
import org.jetbrains.annotations.NotNull;
1615
import org.jetbrains.annotations.Nullable;
@@ -19,7 +18,6 @@
1918
final class LifecycleWatcher implements DefaultLifecycleObserver {
2019

2120
private final AtomicLong lastUpdatedSession = new AtomicLong(0L);
22-
private final AtomicBoolean isFreshSession = new AtomicBoolean(false);
2321

2422
private final long sessionIntervalMillis;
2523

@@ -80,7 +78,6 @@ private void startSession() {
8078
final @Nullable Session currentSession = scope.getSession();
8179
if (currentSession != null && currentSession.getStarted() != null) {
8280
lastUpdatedSession.set(currentSession.getStarted().getTime());
83-
isFreshSession.set(true);
8481
}
8582
}
8683
});
@@ -92,11 +89,8 @@ private void startSession() {
9289
hub.startSession();
9390
}
9491
hub.getOptions().getReplayController().start();
95-
} else if (!isFreshSession.get()) {
96-
// only resume if it's not a fresh session, which has been started in SentryAndroid.init
97-
hub.getOptions().getReplayController().resume();
9892
}
99-
isFreshSession.set(false);
93+
hub.getOptions().getReplayController().resume();
10094
this.lastUpdatedSession.set(currentTimeMillis);
10195
}
10296

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
@@ -53,7 +53,7 @@ public class ReplayCache(
5353
internal val frames = mutableListOf<ReplayFrame>()
5454

5555
private val ongoingSegment = LinkedHashMap<String, String>()
56-
private val ongoingSegmentFile: File? by lazy {
56+
internal val ongoingSegmentFile: File? by lazy {
5757
if (replayCacheDir == null) {
5858
return@lazy null
5959
}
@@ -273,6 +273,9 @@ public class ReplayCache(
273273
if (isClosed.get()) {
274274
return
275275
}
276+
if (ongoingSegmentFile?.exists() != true) {
277+
ongoingSegmentFile?.createNewFile()
278+
}
276279
if (ongoingSegment.isEmpty()) {
277280
ongoingSegmentFile?.useLines { lines ->
278281
lines.associateTo(ongoingSegment) {

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

Lines changed: 55 additions & 17 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
@@ -100,15 +105,15 @@ public class ReplayIntegration(
100105
Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())
101106
}
102107

103-
// TODO: probably not everything has to be thread-safe here
104108
internal val isEnabled = AtomicBoolean(false)
105-
private val isRecording = AtomicBoolean(false)
109+
internal val isManualPause = AtomicBoolean(false)
106110
private var captureStrategy: CaptureStrategy? = null
107111
public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir
108112
private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance()
109113
private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null
110114
private var mainLooperHandler: MainLooperHandler = MainLooperHandler()
111115
private var gestureRecorderProvider: (() -> GestureRecorder)? = null
116+
private val lifecycle = ReplayLifecycle()
112117

113118
override fun register(hub: IHub, options: SentryOptions) {
114119
this.options = options
@@ -151,15 +156,15 @@ public class ReplayIntegration(
151156
finalizePreviousReplay()
152157
}
153158

154-
override fun isRecording() = isRecording.get()
159+
override fun isRecording() = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED
155160

161+
@Synchronized
156162
override fun start() {
157-
// TODO: add lifecycle state instead and manage it in start/pause/resume/stop
158163
if (!isEnabled.get()) {
159164
return
160165
}
161166

162-
if (isRecording.getAndSet(true)) {
167+
if (!lifecycle.isAllowed(STARTED)) {
163168
options.logger.log(
164169
DEBUG,
165170
"Session replay is already being recorded, not starting a new one"
@@ -183,19 +188,35 @@ public class ReplayIntegration(
183188
captureStrategy?.start(recorderConfig)
184189
recorder?.start(recorderConfig)
185190
registerRootViewListeners()
191+
lifecycle.currentState = STARTED
186192
}
187193

188194
override fun resume() {
189-
if (!isEnabled.get() || !isRecording.get()) {
195+
isManualPause.set(false)
196+
resumeInternal()
197+
}
198+
199+
@Synchronized
200+
private fun resumeInternal() {
201+
if (!isEnabled.get() || !lifecycle.isAllowed(RESUMED)) {
202+
return
203+
}
204+
205+
if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED ||
206+
hub?.rateLimiter?.isActiveForCategory(All) == true ||
207+
hub?.rateLimiter?.isActiveForCategory(Replay) == true
208+
) {
190209
return
191210
}
192211

193212
captureStrategy?.resume()
194213
recorder?.resume()
214+
lifecycle.currentState = RESUMED
195215
}
196216

217+
@Synchronized
197218
override fun captureReplay(isTerminating: Boolean?) {
198-
if (!isEnabled.get() || !isRecording.get()) {
219+
if (!isEnabled.get() || !isRecording()) {
199220
return
200221
}
201222

@@ -220,25 +241,33 @@ public class ReplayIntegration(
220241
override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter
221242

222243
override fun pause() {
223-
if (!isEnabled.get() || !isRecording.get()) {
244+
isManualPause.set(true)
245+
pauseInternal()
246+
}
247+
248+
@Synchronized
249+
private fun pauseInternal() {
250+
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {
224251
return
225252
}
226253

227254
recorder?.pause()
228255
captureStrategy?.pause()
256+
lifecycle.currentState = PAUSED
229257
}
230258

259+
@Synchronized
231260
override fun stop() {
232-
if (!isEnabled.get() || !isRecording.get()) {
261+
if (!isEnabled.get() || !lifecycle.isAllowed(STOPPED)) {
233262
return
234263
}
235264

236265
unregisterRootViewListeners()
237266
recorder?.stop()
238267
gestureRecorder?.stop()
239268
captureStrategy?.stop()
240-
isRecording.set(false)
241269
captureStrategy = null
270+
lifecycle.currentState = STOPPED
242271
}
243272

244273
override fun onScreenshotRecorded(bitmap: Bitmap) {
@@ -257,8 +286,9 @@ public class ReplayIntegration(
257286
}
258287
}
259288

289+
@Synchronized
260290
override fun close() {
261-
if (!isEnabled.get()) {
291+
if (!isEnabled.get() || !lifecycle.isAllowed(CLOSED)) {
262292
return
263293
}
264294

@@ -275,10 +305,11 @@ public class ReplayIntegration(
275305
recorder = null
276306
rootViewsSpy.close()
277307
replayExecutor.gracefullyShutdown(options)
308+
lifecycle.currentState = CLOSED
278309
}
279310

280311
override fun onConfigurationChanged(newConfig: Configuration) {
281-
if (!isEnabled.get() || !isRecording.get()) {
312+
if (!isEnabled.get() || !isRecording()) {
282313
return
283314
}
284315

@@ -289,6 +320,10 @@ public class ReplayIntegration(
289320
captureStrategy?.onConfigurationChanged(recorderConfig)
290321

291322
recorder?.start(recorderConfig)
323+
// we have to restart recorder with a new config and pause immediately if the replay is paused
324+
if (lifecycle.currentState == PAUSED) {
325+
recorder?.pause()
326+
}
292327
}
293328

294329
override fun onConnectionStatusChanged(status: ConnectionStatus) {
@@ -298,10 +333,10 @@ public class ReplayIntegration(
298333
}
299334

300335
if (status == DISCONNECTED) {
301-
pause()
336+
pauseInternal()
302337
} else {
303338
// being positive for other states, even if it's NO_PERMISSION
304-
resume()
339+
resumeInternal()
305340
}
306341
}
307342

@@ -312,15 +347,18 @@ public class ReplayIntegration(
312347
}
313348

314349
if (rateLimiter.isActiveForCategory(All) || rateLimiter.isActiveForCategory(Replay)) {
315-
pause()
350+
pauseInternal()
316351
} else {
317-
resume()
352+
resumeInternal()
318353
}
319354
}
320355

321356
override fun onLowMemory() = Unit
322357

323358
override fun onTouchEvent(event: MotionEvent) {
359+
if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) {
360+
return
361+
}
324362
captureStrategy?.onTouchEvent(event)
325363
}
326364

@@ -336,7 +374,7 @@ public class ReplayIntegration(
336374
hub?.rateLimiter?.isActiveForCategory(Replay) == true
337375
)
338376
) {
339-
pause()
377+
pauseInternal()
340378
}
341379
}
342380

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+
internal 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+
}

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.view.MotionEvent
55
import io.sentry.Breadcrumb
66
import io.sentry.DateUtils
77
import io.sentry.IHub
8+
import io.sentry.SentryLevel.ERROR
89
import io.sentry.SentryOptions
910
import io.sentry.SentryReplayEvent.ReplayType
1011
import io.sentry.SentryReplayEvent.ReplayType.BUFFER
@@ -183,7 +184,11 @@ internal abstract class BaseCaptureStrategy(
183184
task()
184185
}
185186
} else {
186-
task()
187+
try {
188+
task()
189+
} catch (e: Throwable) {
190+
options.logger.log(ERROR, "Failed to execute task $TAG.runInBackground", e)
191+
}
187192
}
188193
}
189194

0 commit comments

Comments
 (0)