Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
8 changes: 7 additions & 1 deletion sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public final class io/sentry/android/replay/ModifierExtensionsKt {

public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable {
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 stop ()V
Expand All @@ -50,7 +51,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 +69,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 +123,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 @@ -14,5 +14,7 @@ public interface Recorder : Closeable {

public fun pause()

public fun reset()

public fun stop()
}
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,17 +184,13 @@ 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)
}

captureStrategy?.start(recorderConfig)
recorder?.start(recorderConfig)
registerRootViewListeners()
lifecycle.currentState = STARTED
}
}

Expand All @@ -215,9 +212,9 @@ public class ReplayIntegration(
return
}

lifecycle.currentState = RESUMED
captureStrategy?.resume()
recorder?.resume()
lifecycle.currentState = RESUMED
}
}

Expand Down Expand Up @@ -270,6 +267,7 @@ public class ReplayIntegration(
}

unregisterRootViewListeners()
recorder?.reset()
recorder?.stop()
gestureRecorder?.stop()
captureStrategy?.stop()
Expand Down Expand Up @@ -324,15 +322,8 @@ public class ReplayIntegration(

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()
}
// 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 +455,30 @@ 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()
}
}

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,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 {
Expand All @@ -29,6 +35,7 @@ internal class 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,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)
Expand All @@ -94,6 +147,7 @@ internal class WindowRecorder(
}

override fun close() {
reset()
stop()
capturer.gracefullyShutdown(options)
}
Expand Down
Loading