Skip to content

Commit 90d524d

Browse files
authored
[SR] Reduce memory and disk consumption (#4016)
1 parent 9aa5279 commit 90d524d

File tree

6 files changed

+70
-37
lines changed

6 files changed

+70
-37
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
### Fixes
66

77
- Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937))
8+
- Session Replay: Reduce memory allocations, disk space consumption, and payload size ([#4016](https://github.com/getsentry/sentry-java/pull/4016))
9+
- Session Replay: Do not try to encode corrupted frames multiple times ([#4016](https://github.com/getsentry/sentry-java/pull/4016))
810

911
### Internal
1012

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public class ReplayCache(
8585
it.createNewFile()
8686
}
8787
screenshot.outputStream().use {
88-
bitmap.compress(JPEG, 80, it)
88+
bitmap.compress(JPEG, options.experimental.sessionReplay.quality.screenshotQuality, it)
8989
it.flush()
9090
}
9191

@@ -162,7 +162,7 @@ public class ReplayCache(
162162

163163
val step = 1000 / frameRate.toLong()
164164
var frameCount = 0
165-
var lastFrame: ReplayFrame = frames.first()
165+
var lastFrame: ReplayFrame? = frames.first()
166166
for (timestamp in from until (from + (duration)) step step) {
167167
val iter = frames.iterator()
168168
while (iter.hasNext()) {
@@ -182,6 +182,12 @@ public class ReplayCache(
182182
// to respect the video duration
183183
if (encode(lastFrame)) {
184184
frameCount++
185+
} else if (lastFrame != null) {
186+
// if we failed to encode the frame, we delete the screenshot right away as the
187+
// likelihood of it being able to be encoded later is low
188+
deleteFile(lastFrame.screenshot)
189+
frames.remove(lastFrame)
190+
lastFrame = null
185191
}
186192
}
187193

@@ -206,7 +212,10 @@ public class ReplayCache(
206212
return GeneratedVideo(videoFile, frameCount, videoDuration)
207213
}
208214

209-
private fun encode(frame: ReplayFrame): Boolean {
215+
private fun encode(frame: ReplayFrame?): Boolean {
216+
if (frame == null) {
217+
return false
218+
}
210219
return try {
211220
val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath)
212221
synchronized(encoderLock) {

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

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package io.sentry.android.replay
33
import android.annotation.TargetApi
44
import android.content.Context
55
import android.graphics.Bitmap
6-
import android.graphics.Bitmap.Config.ARGB_8888
76
import android.graphics.Canvas
87
import android.graphics.Color
98
import android.graphics.Matrix
@@ -54,9 +53,14 @@ internal class ScreenshotRecorder(
5453
Bitmap.createBitmap(
5554
1,
5655
1,
57-
Bitmap.Config.ARGB_8888
56+
Bitmap.Config.RGB_565
5857
)
5958
}
59+
private val screenshot = Bitmap.createBitmap(
60+
config.recordingWidth,
61+
config.recordingHeight,
62+
Bitmap.Config.RGB_565
63+
)
6064
private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
6165
private val prescaledMatrix by lazy(NONE) {
6266
Matrix().apply {
@@ -65,22 +69,18 @@ internal class ScreenshotRecorder(
6569
}
6670
private val contentChanged = AtomicBoolean(false)
6771
private val isCapturing = AtomicBoolean(true)
68-
private var lastScreenshot: Bitmap? = null
72+
private val lastCaptureSuccessful = AtomicBoolean(false)
6973

7074
fun capture() {
7175
if (!isCapturing.get()) {
7276
options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot")
7377
return
7478
}
7579

76-
if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) {
80+
if (!contentChanged.get() && lastCaptureSuccessful.get()) {
7781
options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame")
7882

79-
lastScreenshot?.let {
80-
screenshotRecorderCallback?.onScreenshotRecorded(
81-
it.copy(ARGB_8888, false)
82-
)
83-
}
83+
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
8484
return
8585
}
8686

@@ -96,38 +96,33 @@ internal class ScreenshotRecorder(
9696
return
9797
}
9898

99-
val bitmap = Bitmap.createBitmap(
100-
config.recordingWidth,
101-
config.recordingHeight,
102-
Bitmap.Config.ARGB_8888
103-
)
104-
10599
// postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible
106100
mainLooperHandler.post {
107101
try {
108102
contentChanged.set(false)
109103
PixelCopy.request(
110104
window,
111-
bitmap,
105+
screenshot,
112106
{ copyResult: Int ->
113107
if (copyResult != PixelCopy.SUCCESS) {
114108
options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
115-
bitmap.recycle()
109+
lastCaptureSuccessful.set(false)
116110
return@request
117111
}
118112

119113
// TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture)
120114
if (contentChanged.get()) {
121115
options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
122-
bitmap.recycle()
116+
lastCaptureSuccessful.set(false)
123117
return@request
124118
}
125119

120+
// TODO: disableAllMasking here and dont traverse?
126121
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
127122
root.traverse(viewHierarchy, options)
128123

129124
recorder.submitSafely(options, "screenshot_recorder.mask") {
130-
val canvas = Canvas(bitmap)
125+
val canvas = Canvas(screenshot)
131126
canvas.setMatrix(prescaledMatrix)
132127
viewHierarchy.traverse { node ->
133128
if (node.shouldMask && (node.width > 0 && node.height > 0)) {
@@ -141,7 +136,7 @@ internal class ScreenshotRecorder(
141136
val (visibleRects, color) = when (node) {
142137
is ImageViewHierarchyNode -> {
143138
listOf(node.visibleRect) to
144-
bitmap.dominantColorForRect(node.visibleRect)
139+
screenshot.dominantColorForRect(node.visibleRect)
145140
}
146141

147142
is TextViewHierarchyNode -> {
@@ -168,20 +163,16 @@ internal class ScreenshotRecorder(
168163
return@traverse true
169164
}
170165

171-
val screenshot = bitmap.copy(ARGB_8888, false)
172166
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
173-
lastScreenshot?.recycle()
174-
lastScreenshot = screenshot
167+
lastCaptureSuccessful.set(true)
175168
contentChanged.set(false)
176-
177-
bitmap.recycle()
178169
}
179170
},
180171
mainLooperHandler.handler
181172
)
182173
} catch (e: Throwable) {
183174
options.logger.log(WARNING, "Failed to capture replay recording", e)
184-
bitmap.recycle()
175+
lastCaptureSuccessful.set(false)
185176
}
186177
}
187178
}
@@ -226,7 +217,7 @@ internal class ScreenshotRecorder(
226217
fun close() {
227218
unbind(rootView?.get())
228219
rootView?.clear()
229-
lastScreenshot?.recycle()
220+
screenshot.recycle()
230221
isCapturing.set(false)
231222
}
232223

sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import org.junit.Rule
2525
import org.junit.rules.TemporaryFolder
2626
import org.junit.runner.RunWith
2727
import org.robolectric.annotation.Config
28+
import org.robolectric.shadows.ShadowBitmapFactory
2829
import java.io.File
2930
import kotlin.test.BeforeTest
3031
import kotlin.test.Test
@@ -61,6 +62,7 @@ class ReplayCacheTest {
6162
@BeforeTest
6263
fun `set up`() {
6364
ReplayShadowMediaCodec.framesToEncode = 5
65+
ShadowBitmapFactory.setAllowInvalidImageData(true)
6466
}
6567

6668
@Test
@@ -500,4 +502,28 @@ class ReplayCacheTest {
500502

501503
assertEquals(0, lastSegment.id)
502504
}
505+
506+
@Test
507+
fun `when screenshot is corrupted, deletes it immediately`() {
508+
ShadowBitmapFactory.setAllowInvalidImageData(false)
509+
ReplayShadowMediaCodec.framesToEncode = 1
510+
val replayCache = fixture.getSut(
511+
tmpDir
512+
)
513+
514+
val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888)
515+
replayCache.addFrame(bitmap, 1)
516+
517+
// corrupt the image
518+
File(replayCache.replayCacheDir, "1.jpg").outputStream().use {
519+
it.write(Int.MIN_VALUE)
520+
it.flush()
521+
}
522+
523+
val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, 1, 20_000)
524+
assertNull(segment0)
525+
526+
assertTrue(replayCache.frames.isEmpty())
527+
assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" })
528+
}
503529
}

sentry/api/sentry.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2759,6 +2759,7 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang
27592759
public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality;
27602760
public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality;
27612761
public final field bitRate I
2762+
public final field screenshotQuality I
27622763
public final field sizeScale F
27632764
public fun serializedName ()Ljava/lang/String;
27642765
public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality;

sentry/src/main/java/io/sentry/SentryReplayOptions.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ public final class SentryReplayOptions {
2121
"com.google.android.exoplayer2.ui.StyledPlayerView";
2222

2323
public enum SentryReplayQuality {
24-
/** Video Scale: 80% Bit Rate: 50.000 */
25-
LOW(0.8f, 50_000),
24+
/** Video Scale: 80% Bit Rate: 50.000 JPEG Compression: 10 */
25+
LOW(0.8f, 50_000, 10),
2626

27-
/** Video Scale: 100% Bit Rate: 75.000 */
28-
MEDIUM(1.0f, 75_000),
27+
/** Video Scale: 100% Bit Rate: 75.000 JPEG Compression: 30 */
28+
MEDIUM(1.0f, 75_000, 30),
2929

30-
/** Video Scale: 100% Bit Rate: 100.000 */
31-
HIGH(1.0f, 100_000);
30+
/** Video Scale: 100% Bit Rate: 100.000 JPEG Compression: 50 */
31+
HIGH(1.0f, 100_000, 50);
3232

3333
/** The scale related to the window size (in dp) at which the replay will be created. */
3434
public final float sizeScale;
@@ -39,9 +39,13 @@ public enum SentryReplayQuality {
3939
*/
4040
public final int bitRate;
4141

42-
SentryReplayQuality(final float sizeScale, final int bitRate) {
42+
/** Defines the compression quality with which the screenshots are stored to disk. */
43+
public final int screenshotQuality;
44+
45+
SentryReplayQuality(final float sizeScale, final int bitRate, final int screenshotQuality) {
4346
this.sizeScale = sizeScale;
4447
this.bitRate = bitRate;
48+
this.screenshotQuality = screenshotQuality;
4549
}
4650

4751
public @NotNull String serializedName() {

0 commit comments

Comments
 (0)