Skip to content

Commit 8d0611b

Browse files
authored
Merge 4a829fe into 271ed53
2 parents 271ed53 + 4a829fe commit 8d0611b

10 files changed

Lines changed: 235 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

sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ android {
8383

8484
val applySentryIntegrations = System.getenv("APPLY_SENTRY_INTEGRATIONS")?.toBoolean() ?: true
8585

86+
if (applySentryIntegrations) {
87+
android.sourceSets["androidTest"].java.srcDirs("src/androidTestReplay/java")
88+
}
89+
8690
dependencies {
8791
implementation(
8892
kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
bitmap.recycle()
56+
frameReceived.countDown()
57+
}
58+
59+
assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame")
60+
assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured")
61+
62+
val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList()
63+
assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk")
64+
assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty")
65+
66+
activityScenario.moveToState(Lifecycle.State.DESTROYED)
67+
}
68+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
6565
public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter;
6666
public final fun getReplayCacheDir ()Ljava/io/File;
6767
public fun getReplayId ()Lio/sentry/protocol/SentryId;
68+
public final fun getSnapshotObserver ()Lio/sentry/android/replay/ReplaySnapshotObserver;
6869
public fun isDebugMaskingOverlayEnabled ()Z
6970
public fun isRecording ()Z
7071
public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V
@@ -78,10 +79,15 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
7879
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
7980
public fun resume ()V
8081
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
82+
public final fun setSnapshotObserver (Lio/sentry/android/replay/ReplaySnapshotObserver;)V
8183
public fun start ()V
8284
public fun stop ()V
8385
}
8486

87+
public abstract interface class io/sentry/android/replay/ReplaySnapshotObserver {
88+
public abstract fun onSnapshotCaptured (Landroid/graphics/Bitmap;JLjava/lang/String;)V
89+
}
90+
8591
public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback {
8692
public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V
8793
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: 15 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 public var snapshotObserver: ReplaySnapshotObserver? = null
127+
125128
override fun register(scopes: IScopes, options: SentryOptions) {
126129
this.options = options
127130

@@ -308,6 +311,18 @@ 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+
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+
}
324+
}
325+
}
311326
addFrame(bitmap, frameTimeStamp, screen)
312327
}
313328
checkCanRecord()

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

Lines changed: 16 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,17 @@ 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 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.
41+
*
42+
* The callback runs on a background thread (the replay executor).
43+
*/
44+
@ApiStatus.Experimental
45+
public fun interface ReplaySnapshotObserver {
46+
public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?)
47+
}

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

Lines changed: 99 additions & 0 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
@@ -969,6 +970,104 @@ class ReplayIntegrationTest {
969970
assertFalse(replay.isDebugMaskingOverlayEnabled)
970971
}
971972

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

0 commit comments

Comments
 (0)