From 98e5483b285aa6072ebb2d15831bf525e4eb7470 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 May 2026 12:09:59 +0200 Subject: [PATCH 1/5] fix(android): Keep replay capturing during animations Skip only the first unstable PixelCopy capture, then continue emitting frames while the screen keeps invalidating. This prevents animated screens from freezing Session Replay visuals while preserving the existing debounce for one-off redraws. Fixes GH-5404 Co-Authored-By: Codex --- CHANGELOG.md | 4 + .../replay/screenshot/PixelCopyStrategy.kt | 59 +++++++-- .../screenshot/PixelCopyStrategyTest.kt | 114 ++++++++++++++++++ 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbbede5794..4b723b4b73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ ``` - Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) +### Fixes + +- Session Replay: Fix screenshot capture freezing on screens with continuous animations ([#5404](https://github.com/getsentry/sentry-java/issues/5404)) + ## 8.42.0 ### Features diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 81dd7c5cee5..752d1c4874d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -40,6 +40,10 @@ internal class PixelCopyStrategy( private val markContentChanged: () -> Unit = {}, ) : ScreenshotStrategy { + private companion object { + const val MAX_UNSTABLE_CAPTURES_TO_SKIP = 1 + } + private val executor = executorProvider.getExecutor() private val mainLooperHandler = executorProvider.getMainLooperHandler() private val screenshot = @@ -49,6 +53,7 @@ internal class PixelCopyStrategy( private val lastCaptureSuccessful = AtomicBoolean(false) private val maskRenderer = MaskRenderer() private val contentChanged = AtomicBoolean(false) + private val unstableCaptures = AtomicInteger(0) private val isClosed = AtomicBoolean(false) private val dstOverPaint by lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } @@ -86,15 +91,13 @@ internal class PixelCopyStrategy( if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + unstableCaptures.set(0) lastCaptureSuccessful.set(false) return@request } - // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times - // in a row, we should capture) - if (contentChanged.get()) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - lastCaptureSuccessful.set(false) + val changedDuringCapture = contentChanged.get() + if (changedDuringCapture && shouldSkipUnstableCapture()) { return@request } @@ -111,25 +114,48 @@ internal class PixelCopyStrategy( if (surfaceViewNodes.isNullOrEmpty()) { executor.submit( ReplayRunnable("screenshot_recorder.mask") { - applyMaskingAndNotify(root, viewHierarchy) + applyMaskingAndNotify( + root, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) } ) } else { // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. markContentChanged() - captureSurfaceViews(root, surfaceViewNodes, viewHierarchy) + captureSurfaceViews( + root, + surfaceViewNodes, + viewHierarchy, + resetUnstableCaptures = !changedDuringCapture, + ) } }, mainLooperHandler.handler, ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) + unstableCaptures.set(0) lastCaptureSuccessful.set(false) } } - private fun applyMaskingAndNotify(root: View, viewHierarchy: ViewHierarchyNode) { + private fun shouldSkipUnstableCapture(): Boolean { + if (unstableCaptures.incrementAndGet() <= MAX_UNSTABLE_CAPTURES_TO_SKIP) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + lastCaptureSuccessful.set(false) + return true + } + return false + } + + private fun applyMaskingAndNotify( + root: View, + viewHierarchy: ViewHierarchyNode, + resetUnstableCaptures: Boolean, + ) { if (isClosed.get() || screenshot.isRecycled) { options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping masking") return @@ -149,6 +175,9 @@ internal class PixelCopyStrategy( screenshotRecorderCallback?.onScreenshotRecorded(screenshot) lastCaptureSuccessful.set(true) contentChanged.set(false) + if (resetUnstableCaptures) { + unstableCaptures.set(0) + } } @SuppressLint("NewApi") @@ -156,6 +185,7 @@ internal class PixelCopyStrategy( root: View, surfaceViewNodes: List, viewHierarchy: ViewHierarchyNode, + resetUnstableCaptures: Boolean, ) { // Snapshot the window location into locals so the executor-side compositor reads stable // values even if a new capture cycle starts and overwrites the field. @@ -168,7 +198,14 @@ internal class PixelCopyStrategy( fun onCaptureComplete() { if (remaining.decrementAndGet() == 0) { - compositeSurfaceViewsAndMask(root, captures, viewHierarchy, windowX, windowY) + compositeSurfaceViewsAndMask( + root, + captures, + viewHierarchy, + windowX, + windowY, + resetUnstableCaptures, + ) } } @@ -229,6 +266,7 @@ internal class PixelCopyStrategy( viewHierarchy: ViewHierarchyNode, windowX: Int, windowY: Int, + resetUnstableCaptures: Boolean, ) { executor.submit( ReplayRunnable("screenshot_recorder.composite") { @@ -258,7 +296,7 @@ internal class PixelCopyStrategy( capture.bitmap.recycle() } - applyMaskingAndNotify(root, viewHierarchy) + applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) } ) } @@ -287,6 +325,7 @@ internal class PixelCopyStrategy( override fun close() { isClosed.set(true) + unstableCaptures.set(0) executor.submit( ReplayRunnable( "PixelCopyStrategy.close", diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt index 277ad941a14..779cf7d4311 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/PixelCopyStrategyTest.kt @@ -12,7 +12,10 @@ import android.graphics.RectF import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.PixelCopy import android.view.SurfaceView +import android.view.View +import android.view.Window import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.LinearLayout.LayoutParams @@ -36,12 +39,16 @@ import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowPixelCopy @Config(shadows = [ShadowPixelCopy::class], sdk = [30]) @@ -92,6 +99,7 @@ class PixelCopyStrategyTest { fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") System.setProperty("robolectric.pixelCopyRenderMode", "hardware") + DeferredWindowPixelCopyShadow.reset() } @Test @@ -132,6 +140,68 @@ class PixelCopyStrategyTest { if (failure.get() != null) throw failure.get() } + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture skips the first unstable PixelCopy result`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + + assertFalse(strategy.lastCaptureSuccessful()) + verify(fixture.callback, never()).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture emits the second consecutive unstable PixelCopy result`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `capture keeps emitting after entering continuous instability mode`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertTrue(strategy.lastCaptureSuccessful()) + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + + @Test + @Config(shadows = [DeferredWindowPixelCopyShadow::class]) + fun `stable capture resets the unstable PixelCopy counter`() { + val activity = buildActivity(SimpleActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val root = activity.get().findViewById(android.R.id.content) + + val strategy = fixture.getSut(executor = fixture.inlineExecutor()) + captureUnstableFrame(strategy, root) + captureUnstableFrame(strategy, root) + captureStableFrame(strategy, root) + captureUnstableFrame(strategy, root) + + assertFalse(strategy.lastCaptureSuccessful()) + verify(fixture.callback, times(2)).onScreenshotRecorded(any()) + } + @Test fun `capture does not call markContentChanged when option is disabled`() { val activity = buildActivity(ActivityWithSurfaceView::class.java).setup() @@ -250,6 +320,50 @@ class PixelCopyStrategyTest { assertEquals(0, dest.getPixel(4, 4)) assertEquals(0, dest.getPixel(25, 25)) } + + private fun captureUnstableFrame(strategy: PixelCopyStrategy, root: View) { + strategy.capture(root) + strategy.onContentChanged() + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + } + + private fun captureStableFrame(strategy: PixelCopyStrategy, root: View) { + strategy.capture(root) + DeferredWindowPixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + } +} + +@Implements(PixelCopy::class) +class DeferredWindowPixelCopyShadow { + companion object { + private val pendingCallbacks = mutableListOf<() -> Unit>() + + fun reset() { + pendingCallbacks.clear() + } + + fun flush() { + val callbacks = pendingCallbacks.toList() + pendingCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + @JvmStatic + @Implementation + @Suppress("UNUSED_PARAMETER") + fun request( + _source: Window, + _dest: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + listenerThread: Handler, + ) { + pendingCallbacks.add { + listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) } + } + } + } } private class SimpleActivity : Activity() { From ffec3a1db3fc38375bcb767c19978e8c0d370954 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 1 Jun 2026 23:20:14 +0200 Subject: [PATCH 2/5] test(android): Add replay animation sample screens Add separate Android sample screens for Lottie, Compose canvas, and classic View animations so replay capture behavior can be tested manually. Keep the sample app on the Canvas replay screenshot strategy while exercising these animations. Refs GH-5404 Co-Authored-By: Codex --- gradle/libs.versions.toml | 2 +- .../sentry-samples-android/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 4 + .../io/sentry/samples/android/MainActivity.kt | 12 + .../android/ReplayAnimationsActivity.kt | 307 ++++++++++++++++++ .../src/main/res/raw/replay_lottie_pulse.json | 181 +++++++++++ 6 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12e24536d7e..7ee39d75ede 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -126,6 +126,7 @@ launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk" log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } @@ -248,4 +249,3 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } - diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index bb2c3954ca6..ed8cea25661 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -150,6 +150,7 @@ dependencies { implementation(libs.androidx.browser) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) + implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 26f526124b4..e5b5ed2250b 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -64,6 +64,10 @@ android:name=".PermissionsActivity" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index e000b54e4cc..86f1aace82e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -498,6 +498,18 @@ fun SessionReplayScreen() { } } } + item { + SentryTraced("open_replay_animations") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, ReplayAnimationsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Animations", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("show_dialog") { OutlinedButton(onClick = { showDialog = true }, modifier = Modifier) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt new file mode 100644 index 00000000000..917ebcbc93b --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt @@ -0,0 +1,307 @@ +package io.sentry.samples.android + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color as AndroidColor +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +class ReplayAnimationsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val colorScheme = + if (isSystemInDarkTheme()) + darkColorScheme( + primary = Color(resources.getColor(R.color.colorPrimary, theme)), + secondary = Color(resources.getColor(R.color.colorAccent, theme)), + tertiary = Color(resources.getColor(R.color.colorPrimary, theme)), + ) + else + lightColorScheme( + primary = Color(resources.getColor(R.color.colorPrimary, theme)), + secondary = Color(resources.getColor(R.color.colorAccent, theme)), + tertiary = Color(resources.getColor(R.color.colorPrimary, theme)), + ) + + MaterialTheme(colorScheme = colorScheme) { ReplayAnimationsScreen(onClose = { finish() }) } + } + } +} + +@Composable +private fun ReplayAnimationsScreen(onClose: () -> Unit) { + var selectedSample by remember { mutableStateOf(null) } + + BackHandler(enabled = selectedSample != null) { selectedSample = null } + + selectedSample?.let { sample -> + ReplayAnimationDetailScreen(sample = sample, onBack = { selectedSample = null }) + return + } + + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = onClose, modifier = Modifier.align(Alignment.End)) { Text("Close") } + Text( + text = "Replay animations", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + ReplayAnimationSample.entries.forEach { sample -> + Button(onClick = { selectedSample = sample }, modifier = Modifier.fillMaxWidth()) { + Text(sample.title) + } + } + } +} + +@Composable +private fun ReplayAnimationDetailScreen(sample: ReplayAnimationSample, onBack: () -> Unit) { + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = onBack, modifier = Modifier.align(Alignment.End)) { Text("Back") } + Text( + text = sample.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Surface( + modifier = Modifier.fillMaxWidth().height(420.dp), + shape = RoundedCornerShape(8.dp), + tonalElevation = 2.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + when (sample) { + ReplayAnimationSample.LOTTIE -> LottieReplayAnimation() + ReplayAnimationSample.COMPOSE_CANVAS -> ComposeCanvasAnimation() + ReplayAnimationSample.ANDROID_VIEWS -> + AndroidView( + factory = { context -> ClassicAnimationLayout(context) }, + modifier = Modifier.fillMaxWidth().height(360.dp), + ) + } + } + } + } +} + +private enum class ReplayAnimationSample(val title: String) { + LOTTIE("Lottie"), + COMPOSE_CANVAS("Compose canvas"), + ANDROID_VIEWS("Android views"), +} + +@Composable +private fun LottieReplayAnimation() { + val composition by + rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.replay_lottie_pulse)) + val progress by + animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + ) + + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +private fun ComposeCanvasAnimation() { + val transition = rememberInfiniteTransition() + val angle by + transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(1600, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + val pulse by + transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + ) + + Canvas( + modifier = + Modifier.fillMaxWidth().height(160.dp).background(Color(0xFF101820), RoundedCornerShape(8.dp)) + ) { + val center = Offset(size.width / 2f, size.height / 2f) + val orbitRadius = min(size.width, size.height) * 0.32f + val ballRadius = min(size.width, size.height) * 0.1f + val radians = angle / 180f * PI.toFloat() + + drawCircle( + color = Color(0xFF8BE9FD), + radius = orbitRadius * pulse, + center = center, + style = Stroke(width = 5.dp.toPx()), + alpha = 0.55f, + ) + drawCircle( + color = Color(0xFFFF6B6B), + radius = ballRadius, + center = Offset(center.x + cos(radians) * orbitRadius, center.y + sin(radians) * orbitRadius), + ) + drawCircle( + color = Color(0xFFFFD166), + radius = ballRadius * 0.75f, + center = + Offset( + center.x + cos(radians + PI.toFloat()) * orbitRadius, + center.y + sin(radians + PI.toFloat()) * orbitRadius, + ), + ) + } +} + +private class ClassicAnimationLayout(context: Context) : FrameLayout(context) { + private val movingDot = + View(context).apply { background = ovalDrawable(AndroidColor.rgb(255, 107, 107)) } + private val rotatingSquare = + View(context).apply { background = roundedRectDrawable(AndroidColor.rgb(139, 233, 253), dp(8)) } + private val scalingBar = + View(context).apply { background = roundedRectDrawable(AndroidColor.rgb(255, 209, 102), dp(6)) } + private val animators: List + + init { + setBackgroundColor(AndroidColor.rgb(16, 24, 32)) + clipChildren = false + clipToPadding = false + + addView(scalingBar, LayoutParams(dp(180), dp(18), Gravity.CENTER).apply { topMargin = dp(116) }) + addView(rotatingSquare, LayoutParams(dp(64), dp(64), Gravity.CENTER)) + addView(movingDot, LayoutParams(dp(48), dp(48), Gravity.CENTER)) + + animators = + listOf( + ObjectAnimator.ofFloat(movingDot, View.TRANSLATION_X, -dp(92).toFloat(), dp(92).toFloat()) + .repeatable(durationMillis = 900, mode = ValueAnimator.REVERSE), + ObjectAnimator.ofFloat(movingDot, View.TRANSLATION_Y, -dp(28).toFloat(), dp(28).toFloat()) + .repeatable(durationMillis = 650, mode = ValueAnimator.REVERSE), + ObjectAnimator.ofFloat(rotatingSquare, View.ROTATION, 0f, 360f) + .repeatable(durationMillis = 1200), + ObjectAnimator.ofFloat(scalingBar, View.SCALE_X, 0.25f, 1f) + .repeatable(durationMillis = 800, mode = ValueAnimator.REVERSE), + ) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + animators.forEach { animator -> + if (!animator.isStarted) { + animator.start() + } + } + } + + override fun onDetachedFromWindow() { + animators.forEach { it.cancel() } + super.onDetachedFromWindow() + } + + private fun ObjectAnimator.repeatable( + durationMillis: Long, + mode: Int = ValueAnimator.RESTART, + ): ObjectAnimator = apply { + duration = durationMillis + interpolator = LinearInterpolator() + repeatCount = ValueAnimator.INFINITE + repeatMode = mode + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + + private fun ovalDrawable(color: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + } + + private fun roundedRectDrawable(color: Int, radius: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = radius.toFloat() + setColor(color) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json b/sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json new file mode 100644 index 00000000000..e09afc9c5e5 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/raw/replay_lottie_pulse.json @@ -0,0 +1,181 @@ +{ + "v": "5.7.4", + "fr": 60, + "ip": 0, + "op": 120, + "w": 256, + "h": 256, + "nm": "Replay pulse", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Rotating ring", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { + "a": 1, + "k": [ + { + "t": 0, + "s": [0], + "e": [360], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { "t": 120, "s": [360] } + ] + }, + "p": { "a": 0, "k": [128, 128, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { "a": 0, "k": [100, 100, 100] } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [132, 132] }, + "nm": "Ring path" + }, + { + "ty": "tm", + "s": { "a": 0, "k": 18 }, + "e": { "a": 0, "k": 86 }, + "o": { "a": 0, "k": 0 }, + "m": 1, + "nm": "Trim ring" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.545, 0.914, 0.992, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 16 }, + "lc": 2, + "lj": 2, + "nm": "Ring stroke" + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0] }, + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 }, + "sk": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "nm": "Ring transform" + } + ], + "nm": "Ring", + "np": 4, + "cix": 2, + "bm": 0 + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Pulse", + "sr": 1, + "ks": { + "o": { + "a": 1, + "k": [ + { + "t": 0, + "s": [35], + "e": [95], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { + "t": 60, + "s": [95], + "e": [35], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { "t": 120, "s": [35] } + ] + }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [128, 128, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { + "a": 1, + "k": [ + { + "t": 0, + "s": [70, 70, 100], + "e": [115, 115, 100], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { + "t": 60, + "s": [115, 115, 100], + "e": [70, 70, 100], + "i": { "x": [0.667], "y": [1] }, + "o": { "x": [0.333], "y": [0] } + }, + { "t": 120, "s": [70, 70, 100] } + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "el", + "p": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [96, 96] }, + "nm": "Pulse path" + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 0.82, 0.4, 1] }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "nm": "Pulse fill" + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0] }, + "a": { "a": 0, "k": [0, 0] }, + "s": { "a": 0, "k": [100, 100] }, + "r": { "a": 0, "k": 0 }, + "o": { "a": 0, "k": 100 }, + "sk": { "a": 0, "k": 0 }, + "sa": { "a": 0, "k": 0 }, + "nm": "Pulse transform" + } + ], + "nm": "Pulse", + "np": 3, + "cix": 2, + "bm": 0 + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + } + ] +} From 05247b678bd7b20b87f7a30a988e3b626d5efd41 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 1 Jun 2026 23:29:25 +0200 Subject: [PATCH 3/5] changelog --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b723b4b73c..71bf01b7bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Session Replay: Fix replay recording freezing on screens with continuous animations ([#5489](https://github.com/getsentry/sentry-java/pull/5489)) - Session Replay: Populate `trace_ids` in replay events to enable searching replays by trace ID ([#5473](https://github.com/getsentry/sentry-java/pull/5473)) ## 8.43.0 @@ -30,10 +31,6 @@ ``` - Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) -### Fixes - -- Session Replay: Fix screenshot capture freezing on screens with continuous animations ([#5404](https://github.com/getsentry/sentry-java/issues/5404)) - ## 8.42.0 ### Features From 741c925297e8aa9f7fcbc65fdd972e04ed6da262 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jun 2026 10:11:29 +0200 Subject: [PATCH 4/5] fix(android): Make replay animation sample colors API-safe Use ContextCompat.getColor in ReplayAnimationsActivity so release lint passes with the sample app minSdk. Refs GH-5489 Co-Authored-By: Codex --- .../samples/android/ReplayAnimationsActivity.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt index 917ebcbc93b..0fe6cda581f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants @@ -68,19 +69,13 @@ class ReplayAnimationsActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { + val primaryColor = Color(ContextCompat.getColor(this, R.color.colorPrimary)) + val accentColor = Color(ContextCompat.getColor(this, R.color.colorAccent)) val colorScheme = if (isSystemInDarkTheme()) - darkColorScheme( - primary = Color(resources.getColor(R.color.colorPrimary, theme)), - secondary = Color(resources.getColor(R.color.colorAccent, theme)), - tertiary = Color(resources.getColor(R.color.colorPrimary, theme)), - ) + darkColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) else - lightColorScheme( - primary = Color(resources.getColor(R.color.colorPrimary, theme)), - secondary = Color(resources.getColor(R.color.colorAccent, theme)), - tertiary = Color(resources.getColor(R.color.colorPrimary, theme)), - ) + lightColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) MaterialTheme(colorScheme = colorScheme) { ReplayAnimationsScreen(onClose = { finish() }) } } From 24786bff72275b7d79a61ba0804fd9e5af2d2109 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Jun 2026 16:22:00 +0200 Subject: [PATCH 5/5] docs(android): Explain unstable replay captures Document why PixelCopyStrategy caps skipped unstable captures so continuous animations keep producing replay frames. Refs GH-5489 Co-Authored-By: Codex --- .../io/sentry/android/replay/screenshot/PixelCopyStrategy.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt index 752d1c4874d..4b9618df6ec 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt @@ -41,6 +41,10 @@ internal class PixelCopyStrategy( ) : ScreenshotStrategy { private companion object { + /** + * An unstable capture means the view hierarchy changed while PixelCopy was in flight. Cap + * skipped unstable captures so continuous animations don't stop replay recording. + */ const val MAX_UNSTABLE_CAPTURES_TO_SKIP = 1 }