diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbbede5794..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 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-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() { 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..0fe6cda581f --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ReplayAnimationsActivity.kt @@ -0,0 +1,302 @@ +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 androidx.core.content.ContextCompat +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 primaryColor = Color(ContextCompat.getColor(this, R.color.colorPrimary)) + val accentColor = Color(ContextCompat.getColor(this, R.color.colorAccent)) + val colorScheme = + if (isSystemInDarkTheme()) + darkColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) + else + lightColorScheme(primary = primaryColor, secondary = accentColor, tertiary = primaryColor) + + 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 + } + ] +}