Skip to content

Commit 4a829fe

Browse files
runningcodeclaude
andcommitted
fix(replay): Copy bitmap before passing to ReplaySnapshotObserver (JAVA-504)
Consumers of the observer API receive a copy of the bitmap instead of the replay system's shared instance. This eliminates race conditions and crashes when consumers store or use the bitmap asynchronously. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1720230 commit 4a829fe

4 files changed

Lines changed: 26 additions & 10 deletions

File tree

sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class ReplaySnapshotTest : BaseUiTest() {
5252
val file = File(snapshotsDir, "${name}_$frameTimestamp.png")
5353
file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
5454
}
55+
bitmap.recycle()
5556
frameReceived.countDown()
5657
}
5758

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,14 @@ public class ReplayIntegration(
313313
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
314314
val observer = snapshotObserver
315315
if (observer != null) {
316-
try {
317-
observer.onSnapshotCaptured(bitmap, frameTimeStamp, screen)
318-
} catch (e: Throwable) {
319-
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
316+
val copy = bitmap.copy(bitmap.config!!, false)
317+
if (copy != null) {
318+
try {
319+
observer.onSnapshotCaptured(copy, frameTimeStamp, screen)
320+
} catch (e: Throwable) {
321+
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
322+
copy.recycle()
323+
}
320324
}
321325
}
322326
addFrame(bitmap, frameTimeStamp, screen)

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ public var SentryReplayOptions.maskAllImages: Boolean
3636
* Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking
3737
* already applied.
3838
*
39-
* **Bitmap lifecycle:** The bitmap is owned by the replay system and may be reused. Do not store a
40-
* reference to it or access it after this method returns — copy the pixel data (e.g., compress to a
41-
* file) within this method if you need it later. Do not recycle the bitmap.
39+
* **Bitmap lifecycle:** The bitmap is a copy owned by the caller. You may store it or use it on
40+
* another thread. Call [Bitmap.recycle] when you no longer need it to free native memory promptly.
4241
*
4342
* The callback runs on a background thread (the replay executor).
4443
*/

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import org.mockito.kotlin.anyOrNull
6363
import org.mockito.kotlin.argThat
6464
import org.mockito.kotlin.check
6565
import org.mockito.kotlin.doAnswer
66+
import org.mockito.kotlin.doReturn
6667
import org.mockito.kotlin.eq
6768
import org.mockito.kotlin.mock
6869
import org.mockito.kotlin.never
@@ -1000,12 +1001,18 @@ class ReplayIntegrationTest {
10001001
receivedBitmap = bitmap
10011002
}
10021003

1003-
replay.onScreenshotRecorded(mock<Bitmap>())
1004+
val copyBitmap = mock<Bitmap>()
1005+
val sourceBitmap =
1006+
mock<Bitmap> {
1007+
on { config } doReturn ARGB_8888
1008+
on { copy(any(), any()) } doReturn copyBitmap
1009+
}
1010+
replay.onScreenshotRecorded(sourceBitmap)
10041011

10051012
assertTrue(callbackInvoked)
10061013
assertEquals(1720693523997, receivedTimestamp)
10071014
assertEquals("MainActivity", receivedScreen)
1008-
assertTrue(receivedBitmap is Bitmap)
1015+
assertEquals(copyBitmap, receivedBitmap)
10091016
}
10101017

10111018
@Test
@@ -1028,7 +1035,12 @@ class ReplayIntegrationTest {
10281035

10291036
replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") }
10301037

1031-
replay.onScreenshotRecorded(mock<Bitmap>())
1038+
val sourceBitmap =
1039+
mock<Bitmap> {
1040+
on { config } doReturn ARGB_8888
1041+
on { copy(any(), any()) } doReturn mock<Bitmap>()
1042+
}
1043+
replay.onScreenshotRecorded(sourceBitmap)
10321044

10331045
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
10341046
}

0 commit comments

Comments
 (0)