From 1bf87576e0c005252b67eb2b88e0ba64871d4bee Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 24 Apr 2025 07:48:31 +0200 Subject: [PATCH 1/8] Determine recording size based on active window --- .../api/sentry-android-replay.api | 7 ++- .../android/replay/ReplayIntegration.kt | 52 ++++++++++----- .../android/replay/ScreenshotRecorder.kt | 39 ++++++------ .../sentry/android/replay/WindowRecorder.kt | 63 +++++++++++++++++-- .../java/io/sentry/android/replay/Windows.kt | 7 +++ .../io/sentry/android/replay/util/Views.kt | 4 ++ 6 files changed, 129 insertions(+), 43 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index d4e20da038..1ee6fec7e1 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -50,7 +50,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; } -public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/WindowCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V @@ -68,6 +68,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public fun onScreenshotRecorded (Ljava/io/File;J)V public fun onTouchEvent (Landroid/view/MotionEvent;)V + public fun onWindowSizeChanged (II)V public fun pause ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V public fun resume ()V @@ -121,6 +122,10 @@ public final class io/sentry/android/replay/ViewExtensionsKt { public static final fun sentryReplayUnmask (Landroid/view/View;)V } +public abstract interface class io/sentry/android/replay/WindowCallback { + public abstract fun onWindowSizeChanged (II)V +} + public abstract interface class io/sentry/android/replay/gestures/TouchRecorderCallback { public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 094ce469b1..19ad895f6e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -69,7 +69,8 @@ public class ReplayIntegration( ReplayController, ComponentCallbacks, IConnectionStatusObserver, - IRateLimitObserver { + IRateLimitObserver, + WindowCallback { private companion object { init { @@ -139,7 +140,7 @@ public class ReplayIntegration( } this.scopes = scopes - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler, replayExecutor) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) @@ -183,15 +184,12 @@ public class ReplayIntegration( return } - val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, replayCacheProvider) } else { BufferCaptureStrategy(options, scopes, dateProvider, random, replayExecutor, replayCacheProvider) } - captureStrategy?.start(recorderConfig) - recorder?.start(recorderConfig) registerRootViewListeners() lifecycle.currentState = STARTED } @@ -322,17 +320,16 @@ public class ReplayIntegration( return } - recorder?.stop() - - // refresh config based on new device configuration - val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) - captureStrategy?.onConfigurationChanged(recorderConfig) - - recorder?.start(recorderConfig) - // we have to restart recorder with a new config and pause immediately if the replay is paused - if (lifecycle.currentState == PAUSED) { - recorder?.pause() + captureStrategy?.stop() + recorder?.let { + it.stop() + if (it is ConfigurationChangedListener) { + it.onConfigurationChanged() + } } + + // once the window size is determined + // onWindowSizeChanged is triggered and we'll start the actual capturing } override fun onConnectionStatusChanged(status: ConnectionStatus) { @@ -464,6 +461,31 @@ public class ReplayIntegration( } } + override fun onWindowSizeChanged(width: Int, height: Int) { + if (!isEnabled.get() || !isRecording()) { + return + } + + recorder?.stop() + + val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.fromSize(context, options.sessionReplay, width, height) + + captureStrategy?.let { capture -> + if (capture.currentReplayId == SentryId.EMPTY_ID) { + capture.start(recorderConfig) + } else { + capture.onConfigurationChanged(recorderConfig) + } + } + recorder?.start(recorderConfig) + + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.pause() + captureStrategy?.pause() + } + } + private class PreviousReplayHint : Backfillable { override fun shouldEnrich(): Boolean = false } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 39ad176c8d..3e755fb9f8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -7,15 +7,11 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix import android.graphics.Paint -import android.graphics.Point import android.graphics.Rect import android.graphics.RectF -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import android.view.PixelCopy import android.view.View import android.view.ViewTreeObserver -import android.view.WindowManager import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryLevel.WARNING @@ -177,6 +173,9 @@ internal class ScreenshotRecorder( } override fun onDraw() { + if (!isCapturing.get()) { + return + } val root = rootView?.get() if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") @@ -280,35 +279,26 @@ public data class ScreenshotRecorderConfig( } } - fun from( + fun fromSize( context: Context, - sessionReplay: SentryReplayOptions + sessionReplay: SentryReplayOptions, + windowWidth: Int, + windowHeight: Int ): ScreenshotRecorderConfig { - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { - wm.currentWindowMetrics.bounds - } else { - val screenBounds = Point() - @Suppress("DEPRECATION") - wm.defaultDisplay.getRealSize(screenBounds) - Rect(0, 0, screenBounds.x, screenBounds.y) - } - // use the baseline density of 1x (mdpi) val (height, width) = - ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + ((windowHeight / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() to - ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + ((windowWidth / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() return ScreenshotRecorderConfig( recordingWidth = width, recordingHeight = height, - scaleFactorX = width.toFloat() / screenBounds.width(), - scaleFactorY = height.toFloat() / screenBounds.height(), + scaleFactorX = width.toFloat() / windowWidth, + scaleFactorY = height.toFloat() / windowHeight, frameRate = sessionReplay.frameRate, bitRate = sessionReplay.quality.bitRate ) @@ -337,3 +327,10 @@ public interface ScreenshotRecorderCallback { */ public fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) } + +/** + * A callback to be invoked when once current window size is determined or changes + */ +public interface WindowCallback { + public fun onWindowSizeChanged(width: Int, height: Int) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index a5de0f9a2c..d23aca7172 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,10 +1,15 @@ package io.sentry.android.replay import android.annotation.TargetApi +import android.graphics.Point import android.view.View +import android.view.ViewTreeObserver import io.sentry.SentryOptions import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.addOnDrawListenerSafe import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.hasSize +import io.sentry.android.replay.util.removeOnDrawListenerSafe import io.sentry.android.replay.util.scheduleAtFixedRateSafely import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference @@ -19,9 +24,10 @@ import java.util.concurrent.atomic.AtomicBoolean internal class WindowRecorder( private val options: SentryOptions, private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService -) : Recorder, OnRootViewsChangedListener { +) : Recorder, OnRootViewsChangedListener, ConfigurationChangedListener { internal companion object { private const val TAG = "WindowRecorder" @@ -29,6 +35,7 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() + private var lastKnownWindowSize: Point = Point() private val rootViewsLock = AutoClosableReentrantLock() private var recorder: ScreenshotRecorder? = null private var capturingTask: ScheduledFuture<*>? = null @@ -41,6 +48,7 @@ internal class WindowRecorder( if (added) { rootViews.add(WeakReference(root)) recorder?.bind(root) + determineWindowSize(root) } else { recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -48,6 +56,7 @@ internal class WindowRecorder( val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null && root != newRoot) { recorder?.bind(newRoot) + determineWindowSize(newRoot) } else { Unit // synchronized block wants us to return something lol } @@ -55,12 +64,48 @@ internal class WindowRecorder( } } + fun determineWindowSize(root: View) { + if (root.hasSize()) { + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + } else { + root.addOnDrawListenerSafe(object : ViewTreeObserver.OnDrawListener { + override fun onDraw() { + val currentRoot = rootViews.lastOrNull()?.get() + if (root != currentRoot) { + return + } + if (root.hasSize()) { + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + root.removeOnDrawListenerSafe(this) + } + } + }) + } + } + override fun start(recorderConfig: ScreenshotRecorderConfig) { if (isRecording.getAndSet(true)) { return } - recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) + recorder = ScreenshotRecorder( + recorderConfig, + options, + mainLooperHandler, + replayExecutor, + screenshotRecorderCallback + ) + + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null) { + recorder?.bind(newRoot) + } // TODO: change this to use MainThreadHandler and just post on the main thread with delay // to avoid thread context switch every time capturingTask = capturer.scheduleAtFixedRateSafely( @@ -77,15 +122,12 @@ internal class WindowRecorder( override fun resume() { recorder?.resume() } + override fun pause() { recorder?.pause() } override fun stop() { - rootViewsLock.acquire().use { - rootViews.forEach { recorder?.unbind(it.get()) } - rootViews.clear() - } recorder?.close() recorder = null capturingTask?.cancel(false) @@ -94,10 +136,19 @@ internal class WindowRecorder( } override fun close() { + onConfigurationChanged() stop() capturer.gracefullyShutdown(options) } + override fun onConfigurationChanged() { + lastKnownWindowSize.set(0, 0) + rootViewsLock.acquire().use { + rootViews.forEach { recorder?.unbind(it.get()) } + rootViews.clear() + } + } + private class RecorderExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 18f6ece8b5..8ed4497ee2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -118,6 +118,13 @@ internal fun interface OnRootViewsChangedListener { ) } +internal fun interface ConfigurationChangedListener { + /** + * Called whenever the device configuration changes + */ + fun onConfigurationChanged() +} + /** * A utility that holds the list of root views that WindowManager updates. */ diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index c36c7e9932..ff0c8d1513 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -201,3 +201,7 @@ internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawLis // viewTreeObserver is already dead } } + +internal fun View.hasSize(): Boolean { + return width != 0 && height != 0 +} From c096ba7b69f4934a16d5b3340906c5d981959ff5 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 24 Apr 2025 07:50:52 +0200 Subject: [PATCH 2/8] Extend sample app with Dialog --- .../sentry/samples/android/MainActivity.java | 14 +++++ .../android/compose/ComposeActivity.kt | 52 +++++++++++++++++++ .../src/main/res/layout/activity_main.xml | 6 +++ .../src/main/res/values/strings.xml | 1 + 4 files changed, 73 insertions(+) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 802e765a9e..5461b77570 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -3,6 +3,7 @@ import android.content.Intent; import android.os.Bundle; import android.os.Handler; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Attachment; import io.sentry.ISpan; @@ -275,6 +276,19 @@ public void run() { CoroutinesUtil.INSTANCE.throwInCoroutine(); }); + binding.showDialog.setOnClickListener( + view -> { + new AlertDialog.Builder(MainActivity.this) + .setTitle("Example Title") + .setMessage("Example Message") + .setPositiveButton( + "Close", + (dialog, which) -> { + dialog.dismiss(); + }) + .show(); + }); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 3d2e670495..2299b443a4 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -8,9 +8,18 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -55,11 +64,14 @@ class ComposeActivity : ComponentActivity() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Landing( navigateGithub: () -> Unit, navigateGithubWithArgs: () -> Unit ) { + var showDialog by remember { mutableStateOf(false) } + SentryTraced(tag = "buttons_page") { Column( verticalArrangement = Arrangement.Center, @@ -92,6 +104,46 @@ fun Landing( Text("Crash from Compose") } } + SentryTraced(tag = "button_dialog") { + Button( + onClick = { + showDialog = true + }, + modifier = Modifier + .testTag("button_show_dialog") + .padding(top = 32.dp) + ) { + Text("Show Dialog", modifier = Modifier.sentryReplayUnmask()) + } + } + if (showDialog) { + BasicAlertDialog( + onDismissRequest = { + showDialog = false + }, + content = { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(20.dp), + content = { + Text( + "Dialog Title", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.size(20.dp)) + Text("Dialog Content") + } + ) + } + } + ) + } } } } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 71c8059d58..b7ea842895 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -154,6 +154,12 @@ android:layout_height="wrap_content" android:text="@string/throw_in_coroutine"/> +