Skip to content

Determine recording size based on active window #4354

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixes

- Update profile chunk rate limit and client report ([#4353](https://github.com/getsentry/sentry-java/pull/4353))
- Correctly capture Dialogs and non full-sized windows ([#4354](https://github.com/getsentry/sentry-java/pull/4354))

## 8.9.0

Expand Down
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.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
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,67 @@ 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.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(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 +125,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 +139,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
Loading
Loading