Skip to content

Commit c1a5465

Browse files
markushiromtsngetsentry-bot
authored
Add Canvas Capture Strategy (#4777)
* WIP * refactor(replay): Use main thread handler to schedule replay capture * Remove logging in test * Format code * Changelog * Initial impl of text ignoring canvas approach * Extend options * Fix race conditions * Cleanup * Draw rects for text, utilize Bitmap.asShared * Fix processing on main thread, picture canvas size * Naive way to measure PixelCopy processing duration * Cache bitmap sampling * Simplify text drawing * Add trace sections * Default to Canvas * temp * cleanup * Add FramesTimingTracker, cleanup * Cleanup * Cleanup * Move from ApiStatus.Internal to ApiStatus.Experimental * Cleanup * Address PR feedback * Update Changelog * Fix tests * Fix Tests * Re-use bitmap, ensure bitmap creation is thread safe * Fix leaking BitmapShader, guard newer Android APIs * Cleanup * Address PR feedback * Remove unused code * Ensure shaders are removed everywhere * Enable Canvas approach for demo app * Update Changelog * Address PR comments --------- Co-authored-by: Roman Zavarnitsyn <[email protected]> Co-authored-by: Sentry Github Bot <[email protected]>
1 parent 8c2f79b commit c1a5465

File tree

14 files changed

+1556
-151
lines changed

14 files changed

+1556
-151
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@
1313
- Note that keys containing spaces are not supported.
1414
- Add experimental Sentry Android Distribution module for integrating with Sentry Build Distribution to check for and install updates ([#4804](https://github.com/getsentry/sentry-java/pull/4804))
1515
- Allow passing a different `Handler` to `SystemEventsBreadcrumbsIntegration` and `AndroidConnectionStatusProvider` so their callbacks are deliver to that handler ([#4808](https://github.com/getsentry/sentry-java/pull/4808))
16+
- Session Replay: Add new _experimental_ Canvas Capture Strategy ([#4777](https://github.com/getsentry/sentry-java/pull/4777))
17+
- A new screenshot capture strategy that uses Android's Canvas API for more accurate and reliable text and image masking
18+
- Any `.drawText()` or `.drawBitmap()` calls are replaced by rectangles, ensuring no text or images are present in the resulting output
19+
- Note: If this strategy is used, all text and images will be masked, regardless of any masking configuration
20+
- To enable this feature, set the `screenshotStrategy`, either via code:
21+
```kotlin
22+
SentryAndroid.init(context) { options ->
23+
options.sessionReplay.screenshotStrategy = ScreenshotStrategyType.CANVAS
24+
}
25+
```
26+
or AndroidManifest.xml:
27+
```xml
28+
<application>
29+
<meta-data android:name="io.sentry.session-replay.screenshot-strategy" android:value="canvas" />
30+
</application>
31+
```
1632

1733
### Fixes
1834

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.sentry.ILogger;
77
import io.sentry.InitPriority;
88
import io.sentry.ProfileLifecycle;
9+
import io.sentry.ScreenshotStrategyType;
910
import io.sentry.SentryFeedbackOptions;
1011
import io.sentry.SentryIntegrationPackageStorage;
1112
import io.sentry.SentryLevel;
@@ -111,6 +112,7 @@ final class ManifestMetadataReader {
111112
static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images";
112113

113114
static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug";
115+
static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy";
114116

115117
static final String FORCE_INIT = "io.sentry.force-init";
116118

@@ -476,6 +478,16 @@ static void applyMetadata(
476478

477479
options.getSessionReplay().setDebug(readBool(metadata, logger, REPLAYS_DEBUG, false));
478480

481+
final @Nullable String screenshotStrategyRaw =
482+
readString(metadata, logger, REPLAYS_SCREENSHOT_STRATEGY, null);
483+
if (screenshotStrategyRaw != null) {
484+
if ("canvas".equals(screenshotStrategyRaw.toLowerCase(Locale.ROOT))) {
485+
options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.CANVAS);
486+
} else {
487+
// always default to PIXEL_COPY
488+
options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.PIXEL_COPY);
489+
}
490+
}
479491
options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS));
480492

481493
final @Nullable List<String> includes = readList(metadata, logger, IN_APP_INCLUDES);

sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,4 +1835,51 @@ class ManifestMetadataReaderTest {
18351835
// Assert
18361836
assertFalse(fixture.options.feedbackOptions.isShowBranding)
18371837
}
1838+
1839+
@Test
1840+
fun `applyMetadata reads screenshot strategy canvas to options`() {
1841+
// Arrange
1842+
val bundle = bundleOf(ManifestMetadataReader.REPLAYS_SCREENSHOT_STRATEGY to "canvas")
1843+
val context = fixture.getContext(metaData = bundle)
1844+
1845+
// Act
1846+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
1847+
1848+
// Assert
1849+
assertEquals(
1850+
io.sentry.ScreenshotStrategyType.CANVAS,
1851+
fixture.options.sessionReplay.screenshotStrategy,
1852+
)
1853+
}
1854+
1855+
@Test
1856+
fun `applyMetadata reads screenshot strategy and defaults to PIXEL_COPY for unknown value`() {
1857+
// Arrange
1858+
val bundle = bundleOf(ManifestMetadataReader.REPLAYS_SCREENSHOT_STRATEGY to "unknown")
1859+
val context = fixture.getContext(metaData = bundle)
1860+
1861+
// Act
1862+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
1863+
1864+
// Assert
1865+
assertEquals(
1866+
io.sentry.ScreenshotStrategyType.PIXEL_COPY,
1867+
fixture.options.sessionReplay.screenshotStrategy,
1868+
)
1869+
}
1870+
1871+
@Test
1872+
fun `applyMetadata reads screenshot strategy and keeps default if not found`() {
1873+
// Arrange
1874+
val context = fixture.getContext()
1875+
1876+
// Act
1877+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
1878+
1879+
// Assert
1880+
assertEquals(
1881+
io.sentry.ScreenshotStrategyType.PIXEL_COPY,
1882+
fixture.options.sessionReplay.screenshotStrategy,
1883+
)
1884+
}
18381885
}

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(

0 commit comments

Comments
 (0)