Skip to content

Commit f6e97b1

Browse files
authored
[SR] Fix Session Replay crashes (#3628)
1 parent 32eed6a commit f6e97b1

File tree

9 files changed

+86
-14
lines changed

9 files changed

+86
-14
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
- Align next segment timestamp with the end of the buffered segment when converting from buffer mode to session mode
1616
- Persist `buffer` replay type for the entire replay when converting from buffer mode to session mode
1717
- Properly store screen names for `buffer` mode
18+
- Session Replay: fix various crashes and issues ([#3628](https://github.com/getsentry/sentry-java/pull/3628))
19+
- Fix video not being encoded on Pixel devices
20+
- Fix SIGABRT native crashes on Xiaomi devices when encoding a video
21+
- Fix `RejectedExecutionException` when redacting a screenshot
22+
- Fix `FileNotFoundException` when persisting segment values
1823

1924
### Chores
2025

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import io.sentry.SentryReplayOptions
2626
import io.sentry.android.replay.util.MainLooperHandler
2727
import io.sentry.android.replay.util.getVisibleRects
2828
import io.sentry.android.replay.util.gracefullyShutdown
29+
import io.sentry.android.replay.util.submitSafely
2930
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
3031
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
3132
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
@@ -122,7 +123,7 @@ internal class ScreenshotRecorder(
122123
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
123124
root.traverse(viewHierarchy)
124125

125-
recorder.submit {
126+
recorder.submitSafely(options, "screenshot_recorder.redact") {
126127
val canvas = Canvas(bitmap)
127128
canvas.setMatrix(prescaledMatrix)
128129
viewHierarchy.traverse { node ->

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,10 @@ internal abstract class BaseCaptureStrategy(
100100
) {
101101
cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig)
102102

103+
this.currentReplayId = replayId
104+
this.currentSegment = segmentId
103105
this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER)
104106
this.recorderConfig = recorderConfig
105-
this.currentSegment = segmentId
106-
this.currentReplayId = replayId
107107

108108
segmentTimestamp = DateUtils.getCurrentDateTime()
109109
replayStartTimestamp.set(dateProvider.currentTimeMillis)

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,6 @@ internal class SessionCaptureStrategy(
124124
}
125125

126126
override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) {
127-
val currentSegmentTimestamp = segmentTimestamp ?: return
128127
createCurrentSegment("onConfigurationChanged") { segment ->
129128
if (segment is ReplaySegment.Created) {
130129
segment.capture(hub)

sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import android.os.Build.VERSION
1414
import android.os.Build.VERSION_CODES
1515
import android.text.Layout
1616
import android.view.View
17+
import android.widget.TextView
18+
import java.lang.NullPointerException
1719

1820
/**
1921
* Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718
@@ -26,7 +28,7 @@ internal fun View.isVisibleToUser(): Pair<Boolean, Rect?> {
2628
}
2729
// An invisible predecessor or one with alpha zero means
2830
// that this view is not visible to the user.
29-
var current: Any = this
31+
var current: Any? = this
3032
while (current is View) {
3133
val view = current
3234
val transitionAlpha = if (VERSION.SDK_INT >= VERSION_CODES.Q) view.transitionAlpha else 1f
@@ -53,7 +55,10 @@ internal fun Drawable?.isRedactable(): Boolean {
5355
// TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat)
5456
return when (this) {
5557
is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false
56-
is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 10 && bitmap.width > 10
58+
is BitmapDrawable -> {
59+
val bmp = bitmap ?: return false
60+
return !bmp.isRecycled && bmp.height > 10 && bmp.width > 10
61+
}
5762
else -> true
5863
}
5964
}
@@ -84,3 +89,15 @@ internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, padding
8489
}
8590
return rects
8691
}
92+
93+
/**
94+
* [TextView.getVerticalOffset] which is used by [TextView.getTotalPaddingTop] may throw an NPE on
95+
* some devices (Redmi), so we try-catch it specifically for an NPE and then fallback to
96+
* [TextView.getExtendedPaddingTop]
97+
*/
98+
internal val TextView.totalPaddingTopSafe: Int
99+
get() = try {
100+
totalPaddingTop
101+
} catch (e: NullPointerException) {
102+
extendedPaddingTop
103+
}

sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ import android.annotation.TargetApi
3333
import android.graphics.Bitmap
3434
import android.media.MediaCodec
3535
import android.media.MediaCodecInfo
36+
import android.media.MediaCodecList
3637
import android.media.MediaFormat
38+
import android.os.Build
3739
import android.view.Surface
3840
import io.sentry.SentryLevel.DEBUG
3941
import io.sentry.SentryOptions
@@ -50,8 +52,22 @@ internal class SimpleVideoEncoder(
5052
val onClose: (() -> Unit)? = null
5153
) {
5254

55+
private val hasExynosCodec: Boolean by lazy(NONE) {
56+
// MediaCodecList ctor will initialize an internal in-memory static cache of codecs, so this
57+
// call is only expensive the first time
58+
MediaCodecList(MediaCodecList.REGULAR_CODECS)
59+
.codecInfos
60+
.any { it.name.contains("c2.exynos") }
61+
}
62+
5363
internal val mediaCodec: MediaCodec = run {
54-
val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType)
64+
// c2.exynos.h264.encoder seems to have problems encoding the video (Pixel and Samsung devices)
65+
// so we use the default encoder instead
66+
val codec = if (hasExynosCodec) {
67+
MediaCodec.createByCodecName("c2.android.avc.encoder")
68+
} else {
69+
MediaCodec.createEncoderByType(muxerConfig.mimeType)
70+
}
5571

5672
codec
5773
}
@@ -139,10 +155,13 @@ internal class SimpleVideoEncoder(
139155
}
140156

141157
fun encode(image: Bitmap) {
142-
// NOTE do not use `lockCanvas` like what is done in bitmap2video
143-
// This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface()
144-
// says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results."
145-
val canvas = surface?.lockHardwareCanvas()
158+
// it seems that Xiaomi devices have problems with hardware canvas, so we have to use
159+
// lockCanvas instead https://stackoverflow.com/a/73520742
160+
val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) {
161+
surface?.lockCanvas(null)
162+
} else {
163+
surface?.lockHardwareCanvas()
164+
}
146165
canvas?.drawBitmap(image, 0f, 0f, null)
147166
surface?.unlockCanvasAndPost(canvas)
148167
drainCodec(false)

sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.widget.TextView
99
import io.sentry.SentryOptions
1010
import io.sentry.android.replay.util.isRedactable
1111
import io.sentry.android.replay.util.isVisibleToUser
12+
import io.sentry.android.replay.util.totalPaddingTopSafe
1213

1314
@TargetApi(26)
1415
sealed class ViewHierarchyNode(
@@ -245,7 +246,7 @@ sealed class ViewHierarchyNode(
245246
layout = view.layout,
246247
dominantColor = view.currentTextColor.toOpaque(),
247248
paddingLeft = view.totalPaddingLeft,
248-
paddingTop = view.totalPaddingTop,
249+
paddingTop = view.totalPaddingTopSafe,
249250
x = view.x,
250251
y = view.y,
251252
width = view.width,

sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class BufferCaptureStrategyTest {
6464
(it.arguments[0] as ScopeCallback).run(scope)
6565
}.whenever(it).configureScope(any())
6666
}
67-
var persistedSegment = mutableMapOf<String, String?>()
67+
var persistedSegment = LinkedHashMap<String, String?>()
6868
val replayCache = mock<ReplayCache> {
6969
on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997)))
7070
on { persistSegmentValues(any(), anyOrNull()) }.then {
@@ -293,4 +293,19 @@ class BufferCaptureStrategyTest {
293293

294294
verify(fixture.hub).captureReplay(any(), any())
295295
}
296+
297+
@Test
298+
fun `replayId should be set and serialized first`() {
299+
val strategy = fixture.getSut()
300+
val replayId = SentryId()
301+
302+
strategy.start(fixture.recorderConfig, 0, replayId)
303+
304+
assertEquals(
305+
replayId.toString(),
306+
fixture.persistedSegment.values.first(),
307+
"The replayId must be set first, so when we clean up stale replays" +
308+
"the current replay cache folder is not being deleted."
309+
)
310+
}
296311
}

sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class SessionCaptureStrategyTest {
7070
(it.arguments[0] as ScopeCallback).run(scope)
7171
}.whenever(it).configureScope(any())
7272
}
73-
var persistedSegment = mutableMapOf<String, String?>()
73+
var persistedSegment = LinkedHashMap<String, String?>()
7474
val replayCache = mock<ReplayCache> {
7575
on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997)))
7676
on { persistSegmentValues(any(), anyOrNull()) }.then {
@@ -352,4 +352,19 @@ class SessionCaptureStrategyTest {
352352
}
353353
)
354354
}
355+
356+
@Test
357+
fun `replayId should be set and serialized first`() {
358+
val strategy = fixture.getSut()
359+
val replayId = SentryId()
360+
361+
strategy.start(fixture.recorderConfig, 0, replayId)
362+
363+
assertEquals(
364+
replayId.toString(),
365+
fixture.persistedSegment.values.first(),
366+
"The replayId must be set first, so when we clean up stale replays" +
367+
"the current replay cache folder is not being deleted."
368+
)
369+
}
355370
}

0 commit comments

Comments
 (0)