fix(android): anchor session replay masks to the rendered frame and cover stretch overscroll (Rubber animation) - #739
Merged
Conversation
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ 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.
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>
Vadman97
approved these changes
Aug 19, 2026
Merged
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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
Choreographerframe callback, which runs in the animation phase before that frame's measure/layout/draw, so it described the previous traversal;PixelCopythen 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.FrameSynchronizernow 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
RenderEffectrecorded into the scrollable's display list and applied by the RenderThread. No view moves, no layout changes, and Compose'sboundsInWindowis 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.StretchOverscrollasks the container'sEdgeEffects 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.MaskCollectorgrows 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, soComposeStretchOverscrollfollowsLayoutInfo.getModifierInfo()by class name to the sameEdgeEffectWrapper; 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
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.Test plan
:lib:testDebugUnitTestgreen, 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:lintDebugreports no new findings (five pre-existingNewApierrors elsewhere remain).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:
ImageCaptureServicereplaces rawChoreographercallbacks withFrameSynchronizer, which samples mask geometry insideOnDrawListener(not pre-draw animation phase) and waits for frame completion viaOnFrameMetricsAvailableListenerbefore pixel copy. Before/after mask passes now bracket the captured frame forMaskApplier.mergeMasksMap's convex hull.Stretch overscroll: On API 31+,
StretchOverscrollandComposeStretchOverscrollreadEdgeEffectpull distance (reflection / Compose modifier chain) and map it through the framework stretch curve with slack.Mask.inflateandMaskCollectorgrow 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.