Skip to content

refactor(android): UI-thread-safe view capture with hardened bitmap lifecycle#628

Merged
gre merged 1 commit intomasterfrom
fix/android-subtree-opacity
May 1, 2026
Merged

refactor(android): UI-thread-safe view capture with hardened bitmap lifecycle#628
gre merged 1 commit intomasterfrom
fix/android-subtree-opacity

Conversation

@gre
Copy link
Copy Markdown
Owner

@gre gre commented Apr 29, 2026

Summary

This PR started as an attempt to fix the Android nested-opacity capture artifact (see #616 discussion) by forcing translucent ViewGroups to LAYER_TYPE_SOFTWARE during capture. In practice that did not produce a visible fix — the captured output still shows the same nested-opacity blending as master. So this PR does not ship the rendering correctness fix it set out to.

What it does ship is a meaningful hardening of the Android capture path. No visible regression, no behavior change for users on the success path, but the failure modes around UI-thread access, timeouts, and bitmap recycling are now well-defined. Worth shipping as a standalone improvement; the nested-opacity work continues elsewhere.

What's actually in this PR

UI-thread handoff (runOnUiThreadBlocking, CancellableUiTask)

  • view.draw() now runs on the UI thread instead of the capture executor — this is the thread that owns the view tree, so setLayerType / getLayerType / draw traversal are no longer crossing thread boundaries.
  • Posts to Handler(mainLooper) and waits via CountDownLatch. Already-on-UI-thread fast path, interrupt-safe wait (loops until counted down, re-interrupts on exit).
  • Hard 5s timeout (UiThreadBlockTimeoutException, sized to match the existing SURFACE_VIEW_READ_PIXELS_TIMEOUT) so a stuck main thread can't hang the capture forever.

Cancellation + bitmap lifecycle hardening

  • State machine (STATE_QUEUED/RUNNING/DONE) with CAS between caller (timeout) and runnable (entry) so exactly one side owns cleanup. On timeout the caller calls removeCallbacks, flips a cancelled flag, and CAS-promotes still-queued tasks to DONE.
  • Bitmap recycling split between caller and UI runnable via AtomicReference<Bitmap> + getAndSet(null) — prevents the canvas-backing bitmap from being returned to the pool while view.draw() may still be drawing into it under timeout races.
  • handler.post() return value checked; failure path signals uiTaskFinished so the caller's finally can recycle immediately.
  • Throwable thrown inside the UI runnable is captured and rethrown on the caller thread, so it surfaces as promise.reject(...) instead of crashing the UI thread.

Layer-marking attempt (kept but inert in practice)

  • markSubtreeAlphaLayers walks the tree and forces LAYER_TYPE_SOFTWARE on translucent ViewGroups with childCount > 1, hasOverlappingRendering(), and LAYER_TYPE_NONE; restored in finally.
  • Theoretically this should make Android composite each subtree opaquely and apply parent alpha once, but empirically the captured output is unchanged. Kept in the PR because it's harmless (live UI is restored, no perf regression observed) and because a follow-up may need it as scaffolding — open to ripping it out before merge if preferred.

Misc

  • executeImpl reject message falls back to ex.toString() when getMessage() is null.

Diff: 1 file, +325/-79 lines.

Test plan

  • DL the APK from CI, install on device
  • Open the example app → "🔴 RENDERING CORRECTNESS" → "Rendering test card"
  • Confirm no regression vs master on every cell: borderRadius + overflow:hidden, transforms, z-index, scrolled ScrollView, padding+bg+border, Skia comparator
  • Confirm the nested opacity (parent 0.5) cell renders the same as master (this PR does not change that output)
  • Stress: trigger captures rapidly / on a slow device to exercise the timeout / pool / cancellation paths

🤖 Generated with Claude Code

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes incorrect alpha compositing in Android view captures when a translucent parent contains overlapping children, by temporarily forcing translucent ViewGroups to render into software layers during view.draw() and then restoring original layer types.

Changes:

  • Add a subtree walk to detect translucent ViewGroups (0 < alpha < 1) and switch them to LAYER_TYPE_SOFTWARE prior to capture.
  • Restore original layer types in a finally block to avoid leaving the live UI in a modified state.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from 073f508 to e66871c Compare April 30, 2026 07:21
@gre gre requested a review from Copilot April 30, 2026 07:30
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes the Android “nested opacity” capture artifact by temporarily forcing translucent ViewGroups to render into software layers during view.draw() capture, then restoring the original layer types afterward.

Changes:

  • Adds a subtree walk to switch translucent ViewGroups with LAYER_TYPE_NONE to LAYER_TYPE_SOFTWARE before capture.
  • Introduces a runOnUiThreadBlocking helper to ensure setLayerType mutations occur on the main thread.
  • Restores original layer types in a finally block after drawing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from e66871c to 50f740e Compare April 30, 2026 07:39
@gre gre requested a review from Copilot April 30, 2026 07:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR targets an Android-only nested-opacity capture artifact by forcing translucent ViewGroup subtrees to be composited as software layers during view.draw(...), then restoring original layer types after capture.

Changes:

  • Add a traversal that detects translucent ViewGroups and temporarily sets them to LAYER_TYPE_SOFTWARE for capture.
  • Add a small UI-thread blocking helper to safely mutate view layer types from the capture thread.
  • Restore original layer types in a finally block after drawing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from 50f740e to 2540d95 Compare April 30, 2026 07:45
@gre gre requested a review from Copilot April 30, 2026 07:45
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes incorrect alpha compositing for Android view captures when a translucent parent ViewGroup contains overlapping children, by forcing translucent ViewGroups to composite as a single software layer during view.draw() and restoring original layer types afterward.

Changes:

  • Add a subtree walk to temporarily switch translucent ViewGroups from LAYER_TYPE_NONE to LAYER_TYPE_SOFTWARE during capture.
  • Run layer-type mutations + view.draw() + restore as a single UI-thread task with a bounded blocking wait helper.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from 2540d95 to 781693b Compare April 30, 2026 07:51
@gre gre requested a review from Copilot April 30, 2026 07:51
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes an Android screenshot rendering issue where a translucent parent ViewGroup with overlapping children is composited incorrectly during capture, causing “nested opacity” artifacts in PNG output.

Changes:

  • Temporarily forces certain translucent ViewGroups to LAYER_TYPE_SOFTWARE during capture to ensure subtree composition happens before applying parent alpha.
  • Runs mark → draw → restore as a single UI-thread task with a blocking wait + timeout from the capture executor thread.
  • Improves capture failure reporting by including the underlying exception in the Promise rejection.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from 781693b to d6eadc5 Compare April 30, 2026 08:03
@gre gre requested a review from Copilot April 30, 2026 08:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes Android screenshot rendering for nested translucency by temporarily forcing certain translucent ViewGroup subtrees to render as software layers during capture, ensuring correct alpha compositing while preserving view.draw() behavior (e.g., clipping/overflow).

Changes:

  • Add UI-thread atomic “mark translucent subtrees → draw → restore” logic to correct nested-opacity blending during capture.
  • Introduce a UI-thread blocking helper with timeout/cancellation semantics for safe view mutations during capture.
  • Improve snapshot failure reporting (include underlying exception detail) and ensure bitmap recycling happens in a finally.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from d6eadc5 to 073f673 Compare April 30, 2026 08:11
@gre gre requested a review from Copilot April 30, 2026 08:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses an Android capture rendering correctness issue where nested opacity on ViewGroups with overlapping children is composited incorrectly in software-canvas captures, by temporarily forcing affected subtrees to render as software layers during capture and then restoring the original state.

Changes:

  • Add a UI-thread mark/draw/restore flow that switches qualifying translucent ViewGroups to LAYER_TYPE_SOFTWARE during view.draw().
  • Introduce a blocking UI-thread helper with timeout/cancellation to avoid indefinite hangs.
  • Improve snapshot failure rejection by including exception details and the throwable.
Comments suppressed due to low confidence (1)

android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java:490

  • The body of this try block isn’t indented, which makes the new capture logic harder to read and review. Please reformat/indent the code inside the try so it matches the rest of this file’s style.
        try {

        final Paint paint = new Paint();
        paint.setAntiAlias(true);
        paint.setFilterBitmap(true);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java Outdated
@gre gre force-pushed the fix/android-subtree-opacity branch from 073f673 to d8116e8 Compare April 30, 2026 08:19
@gre gre requested a review from Copilot April 30, 2026 08:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adjusts Android view capture to fix nested-opacity blending issues by forcing certain translucent ViewGroup subtrees to render via software layers during snapshot capture, while adding UI-thread blocking/timeout safeguards.

Changes:

  • Adds subtree traversal to temporarily set qualifying translucent ViewGroups to LAYER_TYPE_SOFTWARE during view.draw(), then restores layer types.
  • Introduces a runOnUiThreadBlocking() helper with a hard timeout to avoid indefinite hangs when posting capture work to the main thread.
  • Improves snapshot failure reporting by including exception details and passing the throwable to promise.reject.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
Comment thread android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
@gre
Copy link
Copy Markdown
Owner Author

gre commented Apr 30, 2026

I'll need to manually test it

@gre gre changed the title fix(android): composite translucent ViewGroups as a layer during capture fix(android): correct nested-opacity capture with UI-thread-safe draw and bitmap lifecycle Apr 30, 2026
@gre gre changed the title fix(android): correct nested-opacity capture with UI-thread-safe draw and bitmap lifecycle refactor(android): UI-thread-safe view capture with hardened bitmap lifecycle May 1, 2026
…ifecycle

Move setLayerType + view.draw() onto the UI thread, with a hard timeout,
cancellation, and CAS-based bitmap pool handoff so canvas-backing
bitmaps can't be recycled mid-draw. No visible behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gre gre force-pushed the fix/android-subtree-opacity branch from 247542e to f8069c4 Compare May 1, 2026 11:04
@gre gre marked this pull request as ready for review May 1, 2026 11:16
@gre gre merged commit 5831e8f into master May 1, 2026
7 checks passed
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