SIGN IN SIGN UP

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