Skip to content

fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll (Rubber animation) - #739

Merged
abelonogov-ld merged 4 commits into
mainfrom
andrey/anchor-geometry
Aug 19, 2026
Merged

fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll (Rubber animation)#739
abelonogov-ld merged 4 commits into
mainfrom
andrey/anchor-geometry

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Session replay masks slipped off content while the screen was moving. Two independent causes, one per commit.

Frame alignment. Mask geometry was read in a Choreographer frame callback, which runs in the animation phase before that frame's measure/layout/draw, so it described the previous traversal; PixelCopy then returned whatever buffer the surface held, usually older still. Nothing guaranteed the captured pixels fell between the before and after mask passes, so the convex hull spanning them could be covering the wrong interval.

FrameSynchronizer now reads geometry from inside the draw pass that produces a frame (ViewTreeObserver.OnDrawListener) and waits for that frame to finish rendering (Window.addOnFrameMetricsAvailableListener, API 24+) before the caller copies pixels. The listener is registered before the draw so the frame being waited for is the frame that was sampled. Pixels are now never older than the before pass nor newer than the after pass, which is the bracket the hull assumes.

Stretch overscroll. Android 12+ rubber-band overscroll is a RenderEffect recorded into the scrollable's display list and applied by the RenderThread. No view moves, no layout changes, and Compose's boundsInWindow is equally unaware, so this one is not a timing problem at all — the geometry faithfully describes an undistorted layout that was never rendered, and frame alignment can't help.

StretchOverscroll asks the container's EdgeEffects how hard they are being pulled and converts that to pixels with the same curve the framework uses (EdgeEffect.dampStretchVector, scaled by the container size, capped at 3.2% of it) plus 25% slack. MaskCollector grows everything the stretched container's subtree emitted — rect and transformed quad alike — outward from each mask's own center. Growth is symmetric per axis because nothing in the tree reveals which way content slid. Compose keeps its effects in the modifier chain rather than on a view, so ComposeStretchOverscroll follows LayoutInfo.getModifierInfo() by class name to the same EdgeEffectWrapper; the shapes were checked against foundation 1.7.0 and 1.11.2, covering both the draw-modifier and node forms.

Inflation logs at debug (Stretch overscroll: grew N mask(s) by (dx, dy)px) so a slip can be attributed to detection or to amount.

Known limits

  • Framework scrollables (ScrollView, ListView) keep their edge effects in non-SDK fields that reflection is blocked from on modern Android, so they get no inflation. RecyclerView, ViewPager2, NestedScrollView, Compose and app-defined containers are all covered.
  • Masks cover slightly more than the content during an overscroll, deliberately.
  • Frame alignment degrades gracefully: an idle tree that never draws falls back to reading it directly and skips the frame wait, and below API 24 only the wait is skipped.

Test plan

  • :lib:testDebugUnitTest green, including 25 new tests: draw anchoring, the register-before-draw ordering, both fallbacks, sampling failures kept out of the draw pass, displacement against the framework's curve, per-axis attribution, inflation geometry, and that only the stretched subtree grows.
  • :lib:lintDebug reports no new findings (five pre-existing NewApi errors elsewhere remain).
  • Verified on device: masks stay on content through rubber-band scrolling.

Note

Overview
Fixes session replay masks slipping off content during scroll and animation by aligning mask sampling with rendered frames and inflating masks for stretch overscroll.

Frame alignment: ImageCaptureService replaces raw Choreographer callbacks with FrameSynchronizer, which samples mask geometry inside OnDrawListener (not pre-draw animation phase) and waits for frame completion via OnFrameMetricsAvailableListener before pixel copy. Before/after mask passes now bracket the captured frame for MaskApplier.mergeMasksMap's convex hull.

Stretch overscroll: On API 31+, StretchOverscroll and ComposeStretchOverscroll read EdgeEffect pull distance (reflection / Compose modifier chain) and map it through the framework stretch curve with slack. Mask.inflate and MaskCollector grow only masks from the stretched subtree (identity snapshots survive culling). This covers RenderThread distortion the view tree cannot describe.

Adds unit tests for synchronizer ordering, inflation geometry, and displacement math.

Reviewed by Cursor Bugbot for commit c26c62a. Bugbot is set up for automated code reviews on this repo. Configure here.

abelonogov-ld and others added 2 commits August 19, 2026 10:54
Android 12+ stretch overscroll distorts a scrollable's pixels via a
RenderEffect on the RenderThread while the view tree and Compose semantics
keep reporting the undistorted layout, so masks slipped off content during
rubber-band scrolling no matter how precisely geometry was sampled.

Read how hard the container's edge effects are being pulled and grow the
masks its subtree emitted by the same amount the framework displaces
content, for both native containers and Compose scrollables.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner August 19, 2026 22:04

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dfd2dc8. Configure here.

abelonogov-ld and others added 2 commits August 19, 2026 15:18
Stretch inflation marked the start of a container's subtree with an index
into the mask list, but occlusion culling runs at every view of that
subtree and deletes masks collected before the container was entered,
sliding the subtree's own masks to lower indices. Everything below the
stale index then went uninflated, so a scrollable overlapping earlier
content lost its inflation exactly when it was needed.

Remember the masks present before descending instead, and grow the ones
that weren't.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld abelonogov-ld changed the title fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll (Rubber animation) Aug 19, 2026
@abelonogov-ld
abelonogov-ld merged commit b2f151a into main Aug 19, 2026
27 checks passed
@abelonogov-ld
abelonogov-ld deleted the andrey/anchor-geometry branch August 19, 2026 23:14
abelonogov-ld added a commit that referenced this pull request Aug 19, 2026
* main:
  fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll (Rubber animation) (#739)
  fix: remediate dependency vulnerabilities across npm, go, pip, and rubygems (#732)
  chore: release main (#735)
  fix(go): Honor unsampled parent in span-kind sampler (#722)
abelonogov-ld pushed a commit that referenced this pull request Aug 19, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>launchdarkly-observability-android: 0.66.1</summary>

##
[0.66.1](launchdarkly-observability-android-0.66.0...launchdarkly-observability-android-0.66.1)
(2026-08-19)


### Bug Fixes

* **android:** anchor session replay masks to the rendered frame and
cover stretch overscroll (Rubber animation)
([#739](#739))
([b2f151a](b2f151a))
</details>

<details><summary>session-replay-react-native: 0.22.2</summary>

##
[0.22.2](session-replay-react-native-0.22.1...session-replay-react-native-0.22.2)
(2026-08-19)


### Bug Fixes

* remediate dependency vulnerabilities across npm, go, pip, and rubygems
([#732](#732))
([9cbe8d5](9cbe8d5))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Release Please bumps **launchdarkly-observability-android** to
**0.66.1** and **@launchdarkly/session-replay-react-native** to
**0.22.2**, updating the release manifest, package metadata, and
changelogs.
> 
> **0.66.1** documents a session replay fix: privacy masks anchor to the
rendered frame and cover stretch overscroll (“rubber band”) animation
([#739](#739)).
**0.22.2** documents dependency vulnerability remediation across the
repo
([#732](#732)).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7bf0a3e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
abelonogov-ld added a commit that referenced this pull request Aug 20, 2026
* main:
  chore: release main (#741)
  feat(react-native): fix lagging masks and imageQuality option (#734)
  chore: release main (#736)
  fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll (Rubber animation) (#739)
  fix: remediate dependency vulnerabilities across npm, go, pip, and rubygems (#732)
  chore: release main (#735)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants