diff --git a/CHANGELOG.md b/CHANGELOG.md index b918eb74a7..07f38f8075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Send UI Profiling app start chunk when it finishes ([#4423](https://github.com/getsentry/sentry-java/pull/4423)) - Republish Javadoc [#4457](https://github.com/getsentry/sentry-java/pull/4457) - Finalize `OkHttpEvent` even if no active span in `SentryOkHttpInterceptor` [#4469](https://github.com/getsentry/sentry-java/pull/4469) +- Correctly capture Dialogs and non full-sized windows ([#4354](https://github.com/getsentry/sentry-java/pull/4354)) ## 8.13.2 diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 4566158f0a..5d6df28f7b 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -34,9 +34,11 @@ public final class io/sentry/android/replay/ModifierExtensionsKt { } public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + public abstract fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public abstract fun pause ()V + public abstract fun reset ()V public abstract fun resume ()V - public abstract fun start (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public abstract fun start ()V public abstract fun stop ()V } @@ -50,11 +52,11 @@ 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 : 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 - public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun disableDebugMaskingOverlay ()V @@ -64,13 +66,13 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z - public fun onConfigurationChanged (Landroid/content/res/Configuration;)V + public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V - public fun onLowMemory ()V public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V 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 @@ -124,6 +126,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/Recorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt index 0946018c78..88864fb352 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt @@ -8,11 +8,15 @@ public interface Recorder : Closeable { * at which the screenshots should be taken, and the screenshots size/resolution, which can * change e.g. in the case of orientation change or window size change */ - public fun start(recorderConfig: ScreenshotRecorderConfig) + public fun start() + + public fun onConfigurationChanged(config: ScreenshotRecorderConfig) public fun resume() public fun pause() + public fun reset() + public fun stop() } 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 3f7099d131..3ff0432c36 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 @@ -1,8 +1,6 @@ package io.sentry.android.replay -import android.content.ComponentCallbacks import android.content.Context -import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import android.view.MotionEvent @@ -60,16 +58,15 @@ public class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider, private val recorderProvider: (() -> Recorder)? = null, - private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, - ComponentCallbacks, IConnectionStatusObserver, - IRateLimitObserver { + IRateLimitObserver, + WindowCallback { private companion object { init { @@ -83,7 +80,6 @@ public class ReplayIntegration( context.appContext(), dateProvider, null, - null, null ) @@ -91,12 +87,11 @@ public class ReplayIntegration( context: Context, dateProvider: ICurrentDateProvider, recorderProvider: (() -> Recorder)?, - recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)?, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null - ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + ) : this(context.appContext(), dateProvider, recorderProvider, replayCacheProvider) { this.replayCaptureStrategyProvider = replayCaptureStrategyProvider this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() this.gestureRecorderProvider = gestureRecorderProvider @@ -139,22 +134,12 @@ 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) options.connectionStatusProvider.addConnectionStatusObserver(this) scopes.rateLimiter?.addRateLimitObserver(this) - if (options.sessionReplay.isTrackOrientationChange) { - try { - context.registerComponentCallbacks(this) - } catch (e: Throwable) { - options.logger.log( - INFO, - "ComponentCallbacks is not available, orientation changes won't be handled by Session replay" - ) - } - } addIntegrationToSdkVersion("Replay") @@ -183,17 +168,16 @@ public class ReplayIntegration( return } - val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) + lifecycle.currentState = STARTED captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, replayCacheProvider) } else { BufferCaptureStrategy(options, scopes, dateProvider, random, replayExecutor, replayCacheProvider) } + recorder?.start() + captureStrategy?.start() - captureStrategy?.start(recorderConfig) - recorder?.start(recorderConfig) registerRootViewListeners() - lifecycle.currentState = STARTED } } @@ -215,9 +199,9 @@ public class ReplayIntegration( return } + lifecycle.currentState = RESUMED captureStrategy?.resume() recorder?.resume() - lifecycle.currentState = RESUMED } } @@ -280,6 +264,7 @@ public class ReplayIntegration( } unregisterRootViewListeners() + recorder?.reset() recorder?.stop() gestureRecorder?.stop() captureStrategy?.stop() @@ -312,12 +297,6 @@ public class ReplayIntegration( options.connectionStatusProvider.removeConnectionStatusObserver(this) scopes?.rateLimiter?.removeRateLimitObserver(this) - if (options.sessionReplay.isTrackOrientationChange) { - try { - context.unregisterComponentCallbacks(this) - } catch (ignored: Throwable) { - } - } stop() recorder?.close() recorder = null @@ -327,24 +306,6 @@ public class ReplayIntegration( } } - override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get() || !isRecording()) { - 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() - } - } - override fun onConnectionStatusChanged(status: ConnectionStatus) { if (captureStrategy !is SessionCaptureStrategy) { // we only want to stop recording when offline for session mode @@ -372,8 +333,6 @@ public class ReplayIntegration( } } - override fun onLowMemory(): Unit = Unit - override fun onTouchEvent(event: MotionEvent) { if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) { return @@ -474,6 +433,30 @@ public class ReplayIntegration( } } + override fun onWindowSizeChanged(width: Int, height: Int) { + if (!isEnabled.get() || !isRecording()) { + return + } + if (options.sessionReplay.isTrackConfiguration) { + val recorderConfig = + ScreenshotRecorderConfig.fromSize(context, options.sessionReplay, width, height) + onConfigurationChanged(recorderConfig) + } + } + + public fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.onConfigurationChanged(config) + recorder?.onConfigurationChanged(config) + + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.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 8d86636ca7..795a42f762 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 @@ -8,15 +8,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 @@ -196,6 +192,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") @@ -303,35 +302,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 ) @@ -360,3 +350,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..5eb0baeea0 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.addOnPreDrawListenerSafe import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.hasSize +import io.sentry.android.replay.util.removeOnPreDrawListenerSafe import io.sentry.android.replay.util.scheduleAtFixedRateSafely import io.sentry.util.AutoClosableReentrantLock import java.lang.ref.WeakReference @@ -19,6 +24,7 @@ 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 { @@ -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,19 +64,62 @@ internal class WindowRecorder( } } - override fun start(recorderConfig: ScreenshotRecorderConfig) { - if (isRecording.getAndSet(true)) { + 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.addOnPreDrawListenerSafe(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val currentRoot = rootViews.lastOrNull()?.get() + // in case the root changed in the meantime, ignore the preDraw of the outdate root + if (root != currentRoot) { + root.removeOnPreDrawListenerSafe(this) + return true + } + if (root.hasSize()) { + root.removeOnPreDrawListenerSafe(this) + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + } + return true + } + }) + } + } + + override fun start() { + isRecording.getAndSet(true) + } + + override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + if (!isRecording.get()) { return } - recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) + recorder = ScreenshotRecorder( + config, + 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( options, "$TAG.capture", 100L, // delay the first run by a bit, to allow root view listener to register - 1000L / recorderConfig.frameRate, + 1000L / config.frameRate, MILLISECONDS ) { recorder?.capture() @@ -77,15 +129,20 @@ internal class WindowRecorder( override fun resume() { recorder?.resume() } + override fun pause() { recorder?.pause() } - override fun stop() { + override fun reset() { + lastKnownWindowSize.set(0, 0) rootViewsLock.acquire().use { rootViews.forEach { recorder?.unbind(it.get()) } rootViews.clear() } + } + + override fun stop() { recorder?.close() recorder = null capturingTask?.cancel(false) @@ -94,6 +151,7 @@ internal class WindowRecorder( } override fun close() { + reset() stop() capturer.gracefullyShutdown(options) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index c170dcfbcd..f29aaa3cac 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -61,10 +61,10 @@ internal abstract class BaseCaptureStrategy( protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + protected var recorderConfig: ScreenshotRecorderConfig? by persistableAtomicNullable(propertyName = "") { _, _, newValue -> if (newValue == null) { // recorderConfig is only nullable on init, but never after - return@persistableAtomic + return@persistableAtomicNullable } cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) @@ -85,7 +85,6 @@ internal abstract class BaseCaptureStrategy( protected val currentEvents: Deque = ConcurrentLinkedDeque() override fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, replayType: ReplayType? @@ -95,7 +94,6 @@ internal abstract class BaseCaptureStrategy( this.currentReplayId = replayId this.currentSegment = segmentId this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER) - this.recorderConfig = recorderConfig segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -122,10 +120,10 @@ internal abstract class BaseCaptureStrategy( segmentId: Int, height: Int, width: Int, + frameRate: Int, + bitRate: Int, replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, - frameRate: Int = recorderConfig.frameRate, - bitRate: Int = recorderConfig.bitRate, screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: Deque = this.currentEvents @@ -153,9 +151,11 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - val rrwebEvents = gestureConverter.convert(event, recorderConfig) - if (rrwebEvents != null) { - currentEvents += rrwebEvents + recorderConfig?.let { config -> + val rrwebEvents = gestureConverter.convert(event, config) + if (rrwebEvents != null) { + currentEvents += rrwebEvents + } } } @@ -210,9 +210,4 @@ internal abstract class BaseCaptureStrategy( } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty - - private inline fun persistableAtomic( - crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit - ): ReadWriteProperty = - persistableAtomicNullable(null, "", onChange) as ReadWriteProperty } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index e0ec0a91e2..1f4c1b7660 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -131,7 +131,7 @@ internal class BufferCaptureStrategy( } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) - captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) + captureStrategy.start(segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) return captureStrategy } @@ -189,6 +189,14 @@ internal class BufferCaptureStrategy( } private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val currentConfig = recorderConfig + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not creating segment for task: $taskName" + ) + return + } val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { @@ -200,12 +208,19 @@ internal class BufferCaptureStrategy( val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - val height = this.recorderConfig.recordingHeight - val width = this.recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + segmentId, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate + ) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 93cb5200f6..854a42d081 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -31,7 +31,6 @@ internal interface CaptureStrategy { var segmentTimestamp: Date? fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 0c8b99b400..34a4510be7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -29,12 +29,11 @@ internal class SessionCaptureStrategy( } override fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, replayType: ReplayType? ) { - super.start(recorderConfig, segmentId, replayId, replayType) + super.start(segmentId, replayId, replayType) // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode scopes?.configureScope { @@ -79,9 +78,8 @@ internal class SessionCaptureStrategy( override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured + val currentConfig = recorderConfig val frameTimestamp = dateProvider.currentTimeMillis - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.store(frameTimestamp) @@ -96,6 +94,14 @@ internal class SessionCaptureStrategy( return@submitSafely } + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not recording frame" + ) + return@submitSafely + } + val now = dateProvider.currentTimeMillis if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { val segment = @@ -104,8 +110,10 @@ internal class SessionCaptureStrategy( currentSegmentTimestamp, currentReplayId, currentSegment, - height, - width + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate ) if (segment is ReplaySegment.Created) { segment.capture(scopes) @@ -140,16 +148,32 @@ internal class SessionCaptureStrategy( override fun convert(): CaptureStrategy = this private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val currentConfig = recorderConfig + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not creating segment for task: $taskName" + ) + return + } + val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp ?: return val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + segmentId, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate + ) onSegmentCreated(segment) } } 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..d1dcaadd27 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 @@ -186,7 +186,7 @@ internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListen } try { viewTreeObserver.addOnDrawListener(listener) - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { // viewTreeObserver is already dead } } @@ -197,7 +197,33 @@ internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawLis } try { viewTreeObserver.removeOnDrawListener(listener) - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { // viewTreeObserver is already dead } } + +internal fun View?.addOnPreDrawListenerSafe(listener: ViewTreeObserver.OnPreDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + try { + viewTreeObserver.addOnPreDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } +} + +internal fun View?.removeOnPreDrawListenerSafe(listener: ViewTreeObserver.OnPreDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + try { + viewTreeObserver.removeOnPreDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } +} + +internal fun View.hasSize(): Boolean { + return width != 0 && height != 0 +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index b3c092553f..6966f74d86 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -110,7 +110,6 @@ class ReplayIntegrationTest { isRateLimited: Boolean = false, recorderProvider: (() -> Recorder)? = null, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, - recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { @@ -128,7 +127,6 @@ class ReplayIntegrationTest { context, dateProvider, recorderProvider, - recorderConfigProvider = recorderConfigProvider, replayCacheProvider = { _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider, gestureRecorderProvider = gestureRecorderProvider @@ -186,7 +184,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start(any(), any(), any(), anyOrNull()) + verify(captureStrategy, never()).start(any(), any(), anyOrNull()) } @Test @@ -210,7 +208,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, times(1)).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -226,7 +223,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, never()).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -242,7 +238,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, times(1)).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -257,7 +252,7 @@ class ReplayIntegrationTest { replay.register(fixture.scopes, fixture.options) replay.start() - verify(recorder).start(any()) + verify(recorder).start() } @Test @@ -432,25 +427,21 @@ class ReplayIntegrationTest { @Test fun `onConfigurationChanged stops and restarts recorder with a new recorder config`() { - var configChanged = false val recorderConfig = mock() val captureStrategy = mock() val recorder = mock() val replay = fixture.getSut( context, recorderProvider = { recorder }, - replayCaptureStrategyProvider = { captureStrategy }, - recorderConfigProvider = { configChanged = it; recorderConfig } + replayCaptureStrategyProvider = { captureStrategy } ) replay.register(fixture.scopes, fixture.options) replay.start() - replay.onConfigurationChanged(mock()) + replay.onConfigurationChanged(recorderConfig) - verify(recorder).stop() verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) - verify(recorder, times(2)).start(eq(recorderConfig)) - assertTrue(configChanged) + verify(recorder, times(1)).start() } @Test @@ -710,27 +701,23 @@ class ReplayIntegrationTest { @Test fun `if recording is paused in configChanges re-pauses it again`() { - var configChanged = false val recorderConfig = mock() val captureStrategy = mock() val recorder = mock() val replay = fixture.getSut( context, recorderProvider = { recorder }, - replayCaptureStrategyProvider = { captureStrategy }, - recorderConfigProvider = { configChanged = it; recorderConfig } + replayCaptureStrategyProvider = { captureStrategy } ) replay.register(fixture.scopes, fixture.options) replay.start() replay.pause() - replay.onConfigurationChanged(mock()) + replay.onConfigurationChanged(recorderConfig) - verify(recorder).stop() verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) - verify(recorder, times(2)).start(eq(recorderConfig)) + verify(recorder, times(1)).start() verify(recorder, times(2)).pause() - assertTrue(configChanged) } @Test diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index 4e81d76df4..e8b2679051 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -57,14 +57,12 @@ class ReplayIntegrationWithRecorderTest { fun getSut( context: Context, recorder: Recorder, - recorderConfig: ScreenshotRecorderConfig, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, - recorderProvider = { recorder }, - recorderConfigProvider = { recorderConfig } + recorderProvider = { recorder } ) } } @@ -90,18 +88,22 @@ class ReplayIntegrationWithRecorderTest { System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration } + fixture.options.sessionReplay.isTrackConfiguration = false fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) val recorder = object : Recorder { var state: LifecycleState = INITALIZED - override fun start(recorderConfig: ScreenshotRecorderConfig) { + override fun start() { state = STARTED } + override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + // no-op + } + override fun resume() { state = RESUMED } @@ -110,6 +112,10 @@ class ReplayIntegrationWithRecorderTest { state = PAUSED } + override fun reset() { + state = STOPPED + } + override fun stop() { state = STOPPED } @@ -119,12 +125,23 @@ class ReplayIntegrationWithRecorderTest { } } - replay = fixture.getSut(context, recorder, recorderConfig, dateProvider) + replay = fixture.getSut(context, recorder, dateProvider) replay.register(fixture.scopes, fixture.options) assertEquals(INITALIZED, recorder.state) replay.start() + + // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed + // inside recorder.start() + val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + replay.onWindowSizeChanged(640, 480) assertEquals(STARTED, recorder.state) replay.pause() @@ -133,20 +150,19 @@ class ReplayIntegrationWithRecorderTest { replay.resume() assertEquals(RESUMED, recorder.state) + // this should be ignored, as no manual onConfigurationChanged was called so far + replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) + replay.stop() assertEquals(STOPPED, recorder.state) // start again and capture some frames replay.start() - // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed - // inside recorder.start() - val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + // E.g. Flutter will trigger onConfigurationChanged + val flutterConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + replay.onConfigurationChanged(flutterConfig) - screenshot.outputStream().use { - Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) - it.flush() - } replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) // verify @@ -161,12 +177,12 @@ class ReplayIntegrationWithRecorderTest { }, check { val metaEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(200, metaEvents?.first()?.height) - assertEquals(100, metaEvents?.first()?.width) + assertEquals(flutterConfig.recordingHeight, metaEvents?.first()?.height) + assertEquals(flutterConfig.recordingWidth, metaEvents?.first()?.width) val videoEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(200, videoEvents?.first()?.height) - assertEquals(100, videoEvents?.first()?.width) + assertEquals(flutterConfig.recordingHeight, videoEvents?.first()?.height) + assertEquals(flutterConfig.recordingWidth, videoEvents?.first()?.width) assertEquals(5000, videoEvents?.first()?.durationMs) assertEquals(5, videoEvents?.first()?.frameCount) assertEquals(1, videoEvents?.first()?.frameRate) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 8a7aa6611d..cd57111d3b 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -78,7 +78,6 @@ class ReplaySmokeTest { context, dateProvider, recorderProvider = null, - recorderConfigProvider = null, replayCaptureStrategyProvider = null, replayCacheProvider = null, mainLooperHandler = mock { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 863d5c85f8..4147e5a0af 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -123,7 +123,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -135,7 +135,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -149,7 +149,8 @@ class BufferCaptureStrategyTest { @Test fun `pause creates but does not capture current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId()) + strategy.start(0, SentryId()) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.pause() @@ -166,7 +167,7 @@ class BufferCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) strategy.stop() @@ -185,7 +186,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -199,7 +200,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -210,7 +211,8 @@ class BufferCaptureStrategyTest { @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) strategy.onConfigurationChanged(newConfig) @@ -224,7 +226,7 @@ class BufferCaptureStrategyTest { @Test fun `convert does nothing when process is terminating`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(true) {} @@ -235,7 +237,7 @@ class BufferCaptureStrategyTest { @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() val converted = strategy.convert() assertTrue(converted is SessionCaptureStrategy) @@ -245,7 +247,7 @@ class BufferCaptureStrategyTest { @Test fun `convert persists buffer replayType when converting to session strategy`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() val converted = strategy.convert() assertEquals( @@ -257,7 +259,7 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay does not replayId to scope when not sampled`() { val strategy = fixture.getSut(onErrorSampleRate = 0.0) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(false) {} @@ -268,7 +270,9 @@ class BufferCaptureStrategyTest { fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() strategy.captureReplay(false) { @@ -284,7 +288,9 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay sets new segment timestamp to new strategy after successful creation`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val oldTimestamp = strategy.segmentTimestamp strategy.captureReplay(false) { newTimestamp -> @@ -299,7 +305,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals( replayId.toString(), diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index b704350125..66e0c49705 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -122,7 +122,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals(replayId, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -134,7 +134,8 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -164,7 +165,8 @@ class SessionCaptureStrategyTest { @Test fun `pause creates and captures current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId()) + strategy.start(0, SentryId()) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.pause() @@ -184,7 +186,8 @@ class SessionCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.stop() @@ -204,7 +207,7 @@ class SessionCaptureStrategyTest { @Test fun `captureReplay does nothing for non-crashed event`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(false) {} @@ -218,7 +221,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(true) {} strategy.onScreenshotRecorded(mock()) {} @@ -233,7 +236,8 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -272,7 +276,8 @@ class SessionCaptureStrategyTest { } } ) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(mock()) strategy.onScreenshotRecorded(mock()) {} @@ -282,7 +287,8 @@ class SessionCaptureStrategyTest { @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) strategy.onConfigurationChanged(newConfig) @@ -317,7 +323,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) @@ -341,7 +348,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) @@ -367,7 +375,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} @@ -388,7 +397,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals( replayId.toString(), @@ -408,7 +417,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} @@ -452,7 +462,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} verify(fixture.scopes).captureReplay( 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 cd1d326a90..4befd811fa 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; @@ -273,6 +274,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(); + }); + binding.enableReplayDebugMode.setOnClickListener( view -> { Sentry.replay().enableDebugMaskingOverlay(); 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 e991be43b3..7a699a94f3 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 fcb0553a58..ba7ae0a653 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"/> +