Skip to content

Commit c906754

Browse files
authored
Merge f2c0c49 into 271ed53
2 parents 271ed53 + f2c0c49 commit c906754

9 files changed

Lines changed: 213 additions & 0 deletions

File tree

.github/workflows/integration-tests-ui.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ jobs:
7373
if: env.SAUCE_USERNAME != null
7474

7575

76+
- name: Install Sentry CLI
77+
if: ${{ !cancelled() && env.SAUCE_USERNAME != null }}
78+
run: curl -sL https://sentry.io/get-cli/ | bash
79+
80+
- name: Upload Replay Snapshots to Sentry
81+
if: ${{ !cancelled() && env.SAUCE_USERNAME != null }}
82+
run: |
83+
shopt -s globstar nullglob
84+
pngs=(artifacts/**/*.png)
85+
if [ ${#pngs[@]} -gt 0 ]; then
86+
mkdir -p replay-snapshots
87+
cp "${pngs[@]}" replay-snapshots/
88+
sentry-cli build snapshots ./replay-snapshots \
89+
--app-id sentry-android-replay
90+
else
91+
echo "No replay snapshot files found, skipping upload"
92+
fi
93+
env:
94+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
95+
SENTRY_ORG: sentry-sdks
96+
SENTRY_PROJECT: sentry-android
97+
7698
- name: Upload test results to Codecov
7799
if: ${{ !cancelled() }}
78100
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3

.sauce/sentry-uitest-android-ui.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ artifacts:
3232
when: always
3333
match:
3434
- junit.xml
35+
- "*.png"
3536
directory: ./artifacts/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387))
8+
- Session Replay: Add `ReplaySnapshotObserver` for observing captured replay frames ([#5386](https://github.com/getsentry/sentry-java/pull/5386))
89

910
### Dependencies
1011

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package io.sentry.uitest.android
2+
3+
import android.graphics.Bitmap
4+
import android.os.Environment
5+
import androidx.lifecycle.Lifecycle
6+
import androidx.test.core.app.launchActivity
7+
import io.sentry.Sentry
8+
import io.sentry.android.replay.ReplayIntegration
9+
import io.sentry.android.replay.ReplaySnapshotObserver
10+
import java.io.File
11+
import java.util.concurrent.CopyOnWriteArraySet
12+
import java.util.concurrent.CountDownLatch
13+
import java.util.concurrent.TimeUnit
14+
import kotlin.test.Test
15+
import kotlin.test.assertTrue
16+
import org.hamcrest.CoreMatchers.`is`
17+
import org.junit.Assume.assumeThat
18+
import org.junit.Before
19+
20+
class ReplaySnapshotTest : BaseUiTest() {
21+
22+
@Before
23+
fun setup() {
24+
// GH Actions emulators don't support capturing screenshots for replay
25+
@Suppress("KotlinConstantConditions")
26+
assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true))
27+
}
28+
29+
@Test
30+
fun captureComposeReplayFrameSnapshots() {
31+
val snapshotsDir =
32+
File(
33+
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
34+
"sauce_labs_custom_screenshots",
35+
)
36+
.apply {
37+
deleteRecursively()
38+
mkdirs()
39+
}
40+
val frameReceived = CountDownLatch(1)
41+
val capturedScreens = CopyOnWriteArraySet<String>()
42+
43+
val activityScenario = launchActivity<ComposeActivity>()
44+
activityScenario.moveToState(Lifecycle.State.RESUMED)
45+
46+
initSentry { it.sessionReplay.sessionSampleRate = 1.0 }
47+
48+
val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration
49+
integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName ->
50+
val name = screenName ?: "unknown"
51+
if (capturedScreens.add(name)) {
52+
val file = File(snapshotsDir, "${name}_$frameTimestamp.png")
53+
file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) }
54+
}
55+
frameReceived.countDown()
56+
}
57+
58+
assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame")
59+
assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured")
60+
61+
val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList()
62+
assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk")
63+
assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty")
64+
65+
activityScenario.moveToState(Lifecycle.State.DESTROYED)
66+
}
67+
}

sentry-android-replay/api/sentry-android-replay.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
8282
public fun stop ()V
8383
}
8484

85+
public abstract interface class io/sentry/android/replay/ReplaySnapshotObserver {
86+
public abstract fun onSnapshotCaptured (Landroid/graphics/Bitmap;JLjava/lang/String;)V
87+
}
88+
8589
public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback {
8690
public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V
8791
public abstract fun onScreenshotRecorded (Ljava/io/File;J)V

sentry-android-replay/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ android {
6161

6262
buildFeatures { buildConfig = true }
6363

64+
configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) }
65+
6466
androidComponents.beforeVariants {
6567
it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType)
6668
}
@@ -71,6 +73,7 @@ kotlin { explicitApi() }
7173
dependencies {
7274
api(projects.sentry)
7375

76+
compileOnly(libs.jetbrains.annotations)
7477
compileOnly(libs.androidx.compose.ui.replay)
7578
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
7679
// tests

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import io.sentry.ReplayBreadcrumbConverter
1717
import io.sentry.ReplayController
1818
import io.sentry.SentryIntegrationPackageStorage
1919
import io.sentry.SentryLevel.DEBUG
20+
import io.sentry.SentryLevel.ERROR
2021
import io.sentry.SentryLevel.INFO
2122
import io.sentry.SentryOptions
2223
import io.sentry.android.replay.ReplayState.CLOSED
@@ -122,6 +123,8 @@ public class ReplayIntegration(
122123
private val lifecycleLock = AutoClosableReentrantLock()
123124
private val lifecycle = ReplayLifecycle()
124125

126+
@Volatile internal var snapshotObserver: ReplaySnapshotObserver? = null
127+
125128
override fun register(scopes: IScopes, options: SentryOptions) {
126129
this.options = options
127130

@@ -308,6 +311,14 @@ public class ReplayIntegration(
308311
var screen: String? = null
309312
scopes?.configureScope { screen = it.screen?.substringAfterLast('.') }
310313
captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp ->
314+
val observer = snapshotObserver
315+
if (observer != null) {
316+
try {
317+
observer.onSnapshotCaptured(bitmap, frameTimeStamp, screen)
318+
} catch (e: Throwable) {
319+
options.logger.log(ERROR, "Error in ReplaySnapshotObserver", e)
320+
}
321+
}
311322
addFrame(bitmap, frameTimeStamp, screen)
312323
}
313324
checkCanRecord()

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.sentry.android.replay
22

3+
import android.graphics.Bitmap
34
import io.sentry.SentryReplayOptions
5+
import org.jetbrains.annotations.ApiStatus
46

57
// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as
68
// properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter
@@ -29,3 +31,18 @@ public var SentryReplayOptions.maskAllImages: Boolean
2931
@Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR)
3032
get() = error("Getter not supported")
3133
set(value) = setMaskAllImages(value)
34+
35+
/**
36+
* Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking
37+
* already applied.
38+
*
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.
42+
*
43+
* The callback runs on a background thread (the replay executor).
44+
*/
45+
@ApiStatus.Experimental
46+
public fun interface ReplaySnapshotObserver {
47+
public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?)
48+
}

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,93 @@ class ReplayIntegrationTest {
969969
assertFalse(replay.isDebugMaskingOverlayEnabled)
970970
}
971971

972+
@Test
973+
fun `snapshot observer is invoked with bitmap and metadata`() {
974+
var callbackInvoked = false
975+
var receivedTimestamp = 0L
976+
var receivedScreen: String? = null
977+
var receivedBitmap: Bitmap? = null
978+
979+
val captureStrategy =
980+
mock<CaptureStrategy> {
981+
doAnswer {
982+
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
983+
fixture.replayCache,
984+
1720693523997,
985+
)
986+
}
987+
.whenever(mock)
988+
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
989+
}
990+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
991+
992+
fixture.scopes.configureScope { it.screen = "MainActivity" }
993+
replay.register(fixture.scopes, fixture.options)
994+
replay.start()
995+
996+
replay.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName ->
997+
callbackInvoked = true
998+
receivedTimestamp = frameTimestamp
999+
receivedScreen = screenName
1000+
receivedBitmap = bitmap
1001+
}
1002+
1003+
replay.onScreenshotRecorded(mock<Bitmap>())
1004+
1005+
assertTrue(callbackInvoked)
1006+
assertEquals(1720693523997, receivedTimestamp)
1007+
assertEquals("MainActivity", receivedScreen)
1008+
assertTrue(receivedBitmap is Bitmap)
1009+
}
1010+
1011+
@Test
1012+
fun `snapshot observer exception does not prevent frame storage`() {
1013+
val captureStrategy =
1014+
mock<CaptureStrategy> {
1015+
doAnswer {
1016+
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
1017+
fixture.replayCache,
1018+
1720693523997,
1019+
)
1020+
}
1021+
.whenever(mock)
1022+
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
1023+
}
1024+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
1025+
1026+
replay.register(fixture.scopes, fixture.options)
1027+
replay.start()
1028+
1029+
replay.snapshotObserver = ReplaySnapshotObserver { _, _, _ -> throw RuntimeException("test") }
1030+
1031+
replay.onScreenshotRecorded(mock<Bitmap>())
1032+
1033+
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
1034+
}
1035+
1036+
@Test
1037+
fun `snapshot observer is not invoked when null`() {
1038+
val captureStrategy =
1039+
mock<CaptureStrategy> {
1040+
doAnswer {
1041+
((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(
1042+
fixture.replayCache,
1043+
1720693523997,
1044+
)
1045+
}
1046+
.whenever(mock)
1047+
.onScreenshotRecorded(anyOrNull<Bitmap>(), any())
1048+
}
1049+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
1050+
1051+
replay.register(fixture.scopes, fixture.options)
1052+
replay.start()
1053+
1054+
replay.onScreenshotRecorded(mock<Bitmap>())
1055+
1056+
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
1057+
}
1058+
9721059
private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy =
9731060
SessionCaptureStrategy(
9741061
options,

0 commit comments

Comments
 (0)