Skip to content

fix(replay): Inconsistent segment_id #4471

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Determine recording size based on active window
  • Loading branch information
markushi committed Apr 24, 2025
commit 1bf87576e0c005252b67eb2b88e0ba64871d4bee
7 changes: 6 additions & 1 deletion sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V
public fun <init> (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ public class ReplayIntegration(
ReplayController,
ComponentCallbacks,
IConnectionStatusObserver,
IRateLimitObserver {
IRateLimitObserver,
WindowCallback {

private companion object {
init {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,16 +24,18 @@ 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"
}

private val isRecording = AtomicBoolean(false)
private val rootViews = ArrayList<WeakReference<View>>()
private var lastKnownWindowSize: Point = Point()
private val rootViewsLock = AutoClosableReentrantLock()
private var recorder: ScreenshotRecorder? = null
private var capturingTask: ScheduledFuture<*>? = null
Expand All @@ -41,26 +48,64 @@ internal class WindowRecorder(
if (added) {
rootViews.add(WeakReference(root))
recorder?.bind(root)
determineWindowSize(root)
} else {
recorder?.unbind(root)
rootViews.removeAll { it.get() == root }

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
}
}
}
}

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(
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy