feat(replay): Capture SurfaceView content (experimental) (#5333)
* feat(replay): Capture SurfaceView content (experimental) SurfaceView (used by Unity, video players, maps, and similar) renders to a separate Surface that is composited by SurfaceFlinger outside of the View hierarchy. PixelCopy.request(window, ...) only captures the Window surface, so SurfaceView regions appeared as transparent/black holes in Session Replay recordings. When the experimental option options.sessionReplay.isCaptureSurfaceViews is enabled, each visible SurfaceView is now captured separately via PixelCopy.request(surfaceView, ...) and composited onto the screenshot using PorterDuff.DST_OVER, so the SurfaceView content draws behind the Window content (which has transparent holes where the SurfaceViews are). Because SurfaceView redraws do not trigger ViewTreeObserver.OnDrawListener, the recorder bypasses the contentChanged guard when SurfaceViews are present, so subsequent frames are re-captured at the configured frame rate instead of reusing the last screenshot. The option defaults to false to preserve existing behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(replay): Cover SurfaceView capture paths Add unit tests for the new SurfaceView capture support and extract a compositeSurfaceViewInto helper so the drawing contract can be verified with hand-built bitmaps (Robolectric's ShadowPixelCopy cannot produce meaningful SurfaceView pixels because there is no real GL producer). The tests cover: - ViewHierarchyNode.fromView returns SurfaceViewHierarchyNode vs. generic - View.traverse collects SurfaceView nodes when a list is supplied, not when it is null, and skips invisible SurfaceViews - PixelCopyStrategy leaves hasSurfaceViews false when the option is off - PixelCopyStrategy flags hasSurfaceViews true when the option is on - PixelCopyStrategy completes gracefully when a SurfaceView has no valid surface (the common Robolectric case) - compositeSurfaceViewInto fills transparent holes behind existing window content via DST_OVER, and respects both window offset and scale factors Also fixes a latent NPE in captureSurfaceViews when SurfaceHolder.surface is null (not just invalid) — happens before the surface is created. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * formatting * api dump * docs(changelog): Move SurfaceView entry to Unreleased Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(replay): Wire capture-surface-views option through ManifestMetadataReader Allow enabling the experimental SurfaceView capture in Session Replay via the manifest meta-data `io.sentry.session-replay.capture-surface-views`, so users relying on auto-init don't need to switch to manual SentryAndroid.init just to flip the flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ref(replay): Inline captureSurfaceViewsEnabled local Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ref(replay): Address review comments - Drop the dedicated hasSurfaceViews flag and ScreenshotStrategy hook; PixelCopyStrategy now signals \"capture again next tick\" via a markContentChanged callback that re-arms the recorder's existing contentChanged gate. One source of truth instead of two booleans. - Inline the trivial submitMaskingAndCallback helper at its single call site. - Bail early in the SurfaceView PixelCopy callback if the strategy has been closed mid-flight, mirroring the Window-capture callback. - Document on SentryReplayOptions.captureSurfaceViews and in CHANGELOG that masking granularity is at the SurfaceView level only — content rendered inside a SurfaceView is opaque to the View masking system. - Simplify ViewsTest: build the test view tree inline instead of via a custom Activity subclass, idle the looper after setContentView. - Drop ViewHierarchyNodeTest — its type-dispatch coverage is implicit in ViewsTest, which only counts non-zero results when SurfaceView instances are correctly mapped to SurfaceViewHierarchyNode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * tweaks * fix(replay): Detect window size changes on activities with configChanges Activities that declare android:configChanges="orientation|screenSize|..." (e.g. Unity, fullscreen video players) keep the same root view across rotations, so onRootViewsChanged never fires and determineWindowSize was never re-invoked. The recording bitmap stayed at the pre-rotation size, the rotated window content rendered into wrong-dim bitmaps, and SurfaceView captures composited at stale coordinates. Attach an OnLayoutChangeListener to each tracked root so a same-root resize triggers determineWindowSize. The existing size-comparison guard (both width and height must differ) keeps IME/adjustResize relayouts from causing spurious reconfigurations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(replay): Avoid windowLocation race and bitmap leak in SurfaceView capture Address two issues flagged by review: 1. windowLocation race — root.getLocationOnScreen(windowLocation) ran on the main thread, but compositeSurfaceViewsAndMask read windowLocation[0]/[1] later from the executor thread. If a new capture cycle started before the compositor ran, the field was overwritten and SurfaceView pixels would composite at the wrong offset. Snapshot into locals (windowX/windowY) at capture time and pass them through, matching the existing svLocation → capturedX/capturedY pattern. 2. Bitmap leak when isClosed in SurfaceView callback — when the strategy closed mid-capture, the path recycled the in-flight svBitmap but skipped onCaptureComplete(), so remaining never reached zero and any sibling bitmaps already stored in captures[] leaked until GC. Now still drive the completion latch on the closed path, and have the compositor's early-return path recycle leftover captures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(replay): Reconfig on single-dim resizes and recycle SurfaceView bitmap on throw Two review-bot findings: 1. determineWindowSize used && to compare new vs last-known dimensions, so single-dimension resizes (split-screen drag, partial multi-window adjustments, foldable transitions where only one dim shifts) were silently dropped — onWindowSizeChanged only fired when both width AND height differed. The new layout listener already detects single-dim changes with ||, but then delegated to a function that AND'd them away. Switch the existing checks to || so any size delta reconfigs the recorder, matching the listener's intent. 2. In captureSurfaceViews, if PixelCopy.request or getLocationOnScreen threw after svBitmap was allocated, the catch path logged the error but never recycled the bitmap, leaking it until GC. Track the bitmap in a nullable local that the catch block recycles, and clear it after PixelCopy.request returns successfully so ownership transfers to the async callback without double-recycling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(replay): Ignore layout changes on non-latest root in WindowRecorder rootViews is a stack of windows (dialogs, popups, IME). The recorder binds to the topmost root, so a background activity resizing underneath a dialog must not reconfigure the recorder — we'd otherwise allocate a bitmap sized to the activity while still recording the dialog. The latest root's correct dimensions are already picked up via determineWindowSize in the onRootViewsChanged remove path when the overlaying window dismisses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R
Roman Zavarnitsyn committed
7ce4e911688f63d921a37f085dba629a097d9680
Parent: d446e68
Committed by GitHub <noreply@github.com>
on 5/6/2026, 4:59:18 PM