Skip to content

Commit 8b9eb06

Browse files
authored
Merge b92f82d into 9fbb112
2 parents 9fbb112 + b92f82d commit 8b9eb06

File tree

11 files changed

+1338
-151
lines changed

11 files changed

+1338
-151
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Session Replay: Add new experimental Canvas Capture Strategy ([#4777](https://github.com/getsentry/sentry-java/pull/4777))
8+
- A new screenshot capture strategy that uses Android's Canvas API for more accurate text masking
9+
- Any `.drawText()` calls are replaced with rectangles to ensure no text is not captured
10+
```kotlin
11+
SentryAndroid.init(context) { options ->
12+
options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS
13+
}
14+
```
15+
516
### Fixes
617

718
- Use logger from options for JVM profiler ([#4771](https://github.com/getsentry/sentry-java/pull/4771))

sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt

Lines changed: 28 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -4,60 +4,51 @@ import android.annotation.SuppressLint
44
import android.annotation.TargetApi
55
import android.content.Context
66
import android.graphics.Bitmap
7-
import android.graphics.Canvas
8-
import android.graphics.Color
9-
import android.graphics.Matrix
10-
import android.graphics.Paint
11-
import android.graphics.Rect
12-
import android.graphics.RectF
13-
import android.view.PixelCopy
147
import android.view.View
158
import android.view.ViewTreeObserver
9+
import io.sentry.ScreenshotStrategyType
1610
import io.sentry.SentryLevel.DEBUG
17-
import io.sentry.SentryLevel.INFO
1811
import io.sentry.SentryLevel.WARNING
1912
import io.sentry.SentryOptions
2013
import io.sentry.SentryReplayOptions
14+
import io.sentry.android.replay.screenshot.CanvasStrategy
15+
import io.sentry.android.replay.screenshot.PixelCopyStrategy
16+
import io.sentry.android.replay.screenshot.ScreenshotStrategy
2117
import io.sentry.android.replay.util.DebugOverlayDrawable
22-
import io.sentry.android.replay.util.MainLooperHandler
2318
import io.sentry.android.replay.util.addOnDrawListenerSafe
24-
import io.sentry.android.replay.util.getVisibleRects
2519
import io.sentry.android.replay.util.removeOnDrawListenerSafe
26-
import io.sentry.android.replay.util.submitSafely
27-
import io.sentry.android.replay.util.traverse
28-
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
29-
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode
30-
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode
3120
import java.io.File
3221
import java.lang.ref.WeakReference
33-
import java.util.concurrent.ScheduledExecutorService
3422
import java.util.concurrent.atomic.AtomicBoolean
35-
import kotlin.LazyThreadSafetyMode.NONE
3623
import kotlin.math.roundToInt
3724

3825
@SuppressLint("UseKtx")
3926
@TargetApi(26)
4027
internal class ScreenshotRecorder(
4128
val config: ScreenshotRecorderConfig,
4229
val options: SentryOptions,
43-
private val mainLooperHandler: MainLooperHandler,
44-
private val recorder: ScheduledExecutorService,
45-
private val screenshotRecorderCallback: ScreenshotRecorderCallback?,
30+
val executorProvider: ExecutorProvider,
31+
screenshotRecorderCallback: ScreenshotRecorderCallback?,
4632
) : ViewTreeObserver.OnDrawListener {
4733
private var rootView: WeakReference<View>? = null
48-
private val maskingPaint by lazy(NONE) { Paint() }
49-
private val singlePixelBitmap: Bitmap by
50-
lazy(NONE) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) }
51-
private val screenshot =
52-
Bitmap.createBitmap(config.recordingWidth, config.recordingHeight, Bitmap.Config.ARGB_8888)
53-
private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
54-
private val prescaledMatrix by
55-
lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } }
56-
private val contentChanged = AtomicBoolean(false)
5734
private val isCapturing = AtomicBoolean(true)
58-
private val lastCaptureSuccessful = AtomicBoolean(false)
5935

6036
private val debugOverlayDrawable = DebugOverlayDrawable()
37+
private val contentChanged = AtomicBoolean(false)
38+
39+
private val screenshotStrategy: ScreenshotStrategy =
40+
when (options.sessionReplay.screenshotStrategy) {
41+
ScreenshotStrategyType.CANVAS ->
42+
CanvasStrategy(executorProvider, screenshotRecorderCallback, options, config)
43+
ScreenshotStrategyType.PIXEL_COPY ->
44+
PixelCopyStrategy(
45+
executorProvider,
46+
screenshotRecorderCallback,
47+
options,
48+
config,
49+
debugOverlayDrawable,
50+
)
51+
}
6152

6253
fun capture() {
6354
if (options.sessionReplay.isDebug) {
@@ -75,12 +66,12 @@ internal class ScreenshotRecorder(
7566
DEBUG,
7667
"Capturing screenshot, contentChanged: %s, lastCaptureSuccessful: %s",
7768
contentChanged.get(),
78-
lastCaptureSuccessful.get(),
69+
screenshotStrategy.lastCaptureSuccessful(),
7970
)
8071
}
8172

82-
if (!contentChanged.get() && lastCaptureSuccessful.get()) {
83-
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
73+
if (!contentChanged.get()) {
74+
screenshotStrategy.emitLastScreenshot()
8475
return
8576
}
8677

@@ -98,93 +89,9 @@ internal class ScreenshotRecorder(
9889

9990
try {
10091
contentChanged.set(false)
101-
PixelCopy.request(
102-
window,
103-
screenshot,
104-
{ copyResult: Int ->
105-
if (copyResult != PixelCopy.SUCCESS) {
106-
options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
107-
lastCaptureSuccessful.set(false)
108-
return@request
109-
}
110-
111-
// TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times
112-
// in a row, we should capture)
113-
if (contentChanged.get()) {
114-
options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
115-
lastCaptureSuccessful.set(false)
116-
return@request
117-
}
118-
119-
// TODO: disableAllMasking here and dont traverse?
120-
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
121-
root.traverse(viewHierarchy, options)
122-
123-
recorder.submitSafely(options, "screenshot_recorder.mask") {
124-
val debugMasks = mutableListOf<Rect>()
125-
126-
val canvas = Canvas(screenshot)
127-
canvas.setMatrix(prescaledMatrix)
128-
viewHierarchy.traverse { node ->
129-
if (node.shouldMask && (node.width > 0 && node.height > 0)) {
130-
node.visibleRect ?: return@traverse false
131-
132-
// TODO: investigate why it returns true on RN when it shouldn't
133-
// if (viewHierarchy.isObscured(node)) {
134-
// return@traverse true
135-
// }
136-
137-
val (visibleRects, color) =
138-
when (node) {
139-
is ImageViewHierarchyNode -> {
140-
listOf(node.visibleRect) to screenshot.dominantColorForRect(node.visibleRect)
141-
}
142-
143-
is TextViewHierarchyNode -> {
144-
val textColor =
145-
node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK
146-
node.layout.getVisibleRects(
147-
node.visibleRect,
148-
node.paddingLeft,
149-
node.paddingTop,
150-
) to textColor
151-
}
152-
153-
else -> {
154-
listOf(node.visibleRect) to Color.BLACK
155-
}
156-
}
157-
158-
maskingPaint.setColor(color)
159-
visibleRects.forEach { rect ->
160-
canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint)
161-
}
162-
if (options.replayController.isDebugMaskingOverlayEnabled()) {
163-
debugMasks.addAll(visibleRects)
164-
}
165-
}
166-
return@traverse true
167-
}
168-
169-
if (options.replayController.isDebugMaskingOverlayEnabled()) {
170-
mainLooperHandler.post {
171-
if (debugOverlayDrawable.callback == null) {
172-
root.overlay.add(debugOverlayDrawable)
173-
}
174-
debugOverlayDrawable.updateMasks(debugMasks)
175-
root.postInvalidate()
176-
}
177-
}
178-
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
179-
lastCaptureSuccessful.set(true)
180-
contentChanged.set(false)
181-
}
182-
},
183-
mainLooperHandler.handler,
184-
)
92+
screenshotStrategy.capture(root)
18593
} catch (e: Throwable) {
18694
options.logger.log(WARNING, "Failed to capture replay recording", e)
187-
lastCaptureSuccessful.set(false)
18895
}
18996
}
19097

@@ -199,6 +106,7 @@ internal class ScreenshotRecorder(
199106
}
200107

201108
contentChanged.set(true)
109+
screenshotStrategy.onContentChanged()
202110
}
203111

204112
fun bind(root: View) {
@@ -212,6 +120,7 @@ internal class ScreenshotRecorder(
212120

213121
// invalidate the flag to capture the first frame after new window is attached
214122
contentChanged.set(true)
123+
screenshotStrategy.onContentChanged()
215124
}
216125

217126
fun unbind(root: View?) {
@@ -235,29 +144,9 @@ internal class ScreenshotRecorder(
235144
fun close() {
236145
unbind(rootView?.get())
237146
rootView?.clear()
238-
if (!screenshot.isRecycled) {
239-
screenshot.recycle()
240-
}
147+
screenshotStrategy.close()
241148
isCapturing.set(false)
242149
}
243-
244-
private fun Bitmap.dominantColorForRect(rect: Rect): Int {
245-
// TODO: maybe this ceremony can be just simplified to
246-
// TODO: multiplying the visibleRect by the prescaledMatrix
247-
val visibleRect = Rect(rect)
248-
val visibleRectF = RectF(visibleRect)
249-
250-
// since we take screenshot with lower scale, we also
251-
// have to apply the same scale to the visibleRect to get the
252-
// correct screenshot part to determine the dominant color
253-
prescaledMatrix.mapRect(visibleRectF)
254-
// round it back to integer values, because drawBitmap below accepts Rect only
255-
visibleRectF.round(visibleRect)
256-
// draw part of the screenshot (visibleRect) to a single pixel bitmap
257-
singlePixelBitmapCanvas.drawBitmap(this, visibleRect, Rect(0, 0, 1, 1), null)
258-
// get the pixel color (= dominant color)
259-
return singlePixelBitmap.getPixel(0, 0)
260-
}
261150
}
262151

263152
public data class ScreenshotRecorderConfig(

sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package io.sentry.android.replay
22

33
import android.annotation.TargetApi
44
import android.graphics.Point
5+
import android.os.Handler
6+
import android.os.HandlerThread
57
import android.view.View
68
import android.view.ViewTreeObserver
79
import io.sentry.SentryLevel.DEBUG
@@ -24,18 +26,20 @@ internal class WindowRecorder(
2426
private val windowCallback: WindowCallback,
2527
private val mainLooperHandler: MainLooperHandler,
2628
private val replayExecutor: ScheduledExecutorService,
27-
) : Recorder, OnRootViewsChangedListener {
28-
internal companion object {
29-
private const val TAG = "WindowRecorder"
30-
}
29+
) : Recorder, OnRootViewsChangedListener, ExecutorProvider {
3130

3231
private val isRecording = AtomicBoolean(false)
3332
private val rootViews = ArrayList<WeakReference<View>>()
3433
private var lastKnownWindowSize: Point = Point()
3534
private val rootViewsLock = AutoClosableReentrantLock()
3635
private val capturerLock = AutoClosableReentrantLock()
36+
private val backgroundProcessingHandlerLock = AutoClosableReentrantLock()
37+
3738
@Volatile private var capturer: Capturer? = null
3839

40+
@Volatile private var backgroundProcessingHandlerThread: HandlerThread? = null
41+
@Volatile private var backgroundProcessingHandler: Handler? = null
42+
3943
private class Capturer(
4044
private val options: SentryOptions,
4145
private val mainLooperHandler: MainLooperHandler,
@@ -177,14 +181,7 @@ internal class WindowRecorder(
177181
}
178182

179183
capturer?.config = config
180-
capturer?.recorder =
181-
ScreenshotRecorder(
182-
config,
183-
options,
184-
mainLooperHandler,
185-
replayExecutor,
186-
screenshotRecorderCallback,
187-
)
184+
capturer?.recorder = ScreenshotRecorder(config, options, this, screenshotRecorderCallback)
188185

189186
val newRoot = rootViews.lastOrNull()?.get()
190187
if (newRoot != null) {
@@ -232,6 +229,40 @@ internal class WindowRecorder(
232229
override fun close() {
233230
reset()
234231
mainLooperHandler.removeCallbacks(capturer)
232+
backgroundProcessingHandlerLock.acquire().use {
233+
backgroundProcessingHandler?.removeCallbacksAndMessages(null)
234+
backgroundProcessingHandlerThread?.quitSafely()
235+
}
235236
stop()
236237
}
238+
239+
override fun getExecutor(): ScheduledExecutorService = replayExecutor
240+
241+
override fun getMainLooperHandler(): MainLooperHandler = mainLooperHandler
242+
243+
override fun getBackgroundHandler(): Handler {
244+
// only start the background thread if it's actually needed, as it's only used by Canvas Capture
245+
// Strategy
246+
if (backgroundProcessingHandler == null) {
247+
backgroundProcessingHandlerLock.acquire().use {
248+
if (backgroundProcessingHandler == null) {
249+
backgroundProcessingHandlerThread = HandlerThread("SentryReplayBackgroundProcessing")
250+
backgroundProcessingHandlerThread?.start()
251+
backgroundProcessingHandler = Handler(backgroundProcessingHandlerThread!!.looper)
252+
}
253+
}
254+
}
255+
return backgroundProcessingHandler!!
256+
}
257+
}
258+
259+
internal interface ExecutorProvider {
260+
/** Returns an executor suitable for background tasks. */
261+
fun getExecutor(): ScheduledExecutorService
262+
263+
/** Returns a handler associated with the main thread looper. */
264+
fun getMainLooperHandler(): MainLooperHandler
265+
266+
/** Returns a handler associated with a background thread looper. */
267+
fun getBackgroundHandler(): Handler
237268
}

0 commit comments

Comments
 (0)