Skip to content

Composite multi-window screenshots into a single image - #101

Open
alecramirez wants to merge 1 commit into
linkedin:mainfrom
alecramirez:alramirez/composite-multi-window-screenshot
Open

alecramirez wants to merge 1 commit into
linkedin:mainfrom
alecramirez:alramirez/composite-multi-window-screenshot

Conversation

@alecramirez

@alecramirez alecramirez commented Sep 15, 2026 •

Copy link
Copy Markdown

Problem

When Shaky is triggered while a dialog or bottom sheet is on screen, the PixelCopy fallback path captures each window as its own bitmap, and CollectDataTask writes them out as separate files. A reporter who was looking at one screen ends up filing several partial images — the activity with a blank hole where the sheet was, and the sheet alone on transparency — none of which shows what they actually saw.

Change

Add an opt-in ShakeDelegate#enableMultiWindowCompositing() that flattens those per-window bitmaps into a single screenshot.

The windows are drawn back-to-front at their on-screen positions onto a bitmap sized to their union. Each window above the base layer is preceded by the dim it casts behind itself, recreated from its FLAG_DIM_BEHIND / dimAmount, because the window manager draws that dim outside of any window's surface — PixelCopy never captures it, so the composite would otherwise look flat.

Copying only the window's region of its surface

A window reserves a margin around its frame for what it draws outside it, notably the drop shadow under a dialog or bottom sheet, so its surface is larger than the window. PixelCopy.request(Surface, Bitmap, …) copies the whole surface and scales it to fit the destination bitmap, which is sized to the root view — so a shadowed window comes back shrunk toward the centre of its own bitmap. Measured on a bottom sheet: a 1440x2870 window with a 1536x2966 surface, i.e. a 48px margin per side, arriving scaled to 0.937.

Compositing therefore passes a source rect, derived from View#getLocationInSurface, so only the window's own region is copied and the result stays 1:1. This is what the platform's own PixelCopy.request(Window, …) does internally; that overload isn't usable here because a dialog's Window isn't reachable from the root views this class enumerates.

The per-window path deliberately keeps copying the whole surface, so its output is unchanged.

Notes

  • Off by default, and only applies on top of the existing enableMultiWindowCapture(), so per-window behavior is unchanged unless you opt in. The Falcon path is untouched.
  • captureMultipleAsync() is now a thin adapter over a new private captureAllWindows(), which also hands back the ViewRootData that compositing needs for geometry. Behavior is equivalent.
  • Compositing allocates far more than any single window and runs on the main thread in the PixelCopy callback, so on failure it recycles the sources and returns null, letting Shaky degrade to its captureWithCanvas() fallback instead of crashing the host app.
  • No new files and no new Kotlin — the library is Java-only and this keeps it that way.

Testing

no composite (yields 2 images) composite-enabled (merge into 1 image)
Screenshot 2026-09-18 at 5 21 40 PM Screenshot 2026-09-18 at 5 21 59 PM

./gradlew :shaky:check passes (lint + unit tests).

Verified on device against a bottom sheet, comparing the composite to a reference screenshot of the same screen. Calibrating the transform on the activity content alone and using it to predict where the sheet should land: predicted panel top 1128px, measured 1128px; panel spans 0.0000–0.9982 of the width against the reference's 0.0000–0.9985; and the sheet's internal content scale is 0.7901 against the activity's 0.790.

@alecramirez
alecramirez force-pushed the alramirez/composite-multi-window-screenshot branch 12 times, most recently from 62af441 to bcdc73b Compare September 19, 2026 01:44
When Shaky is triggered while a dialog, bottom sheet or Compose popup is
on screen, the PixelCopy fallback captures each window as its own bitmap
and CollectDataTask writes them out as separate files: the first becomes
the screenshot, the rest become attachments. A reporter who was looking
at one screen therefore files a report carrying several partial images,
none of which shows what they actually saw.

Add an opt-in ShakeDelegate#enableMultiWindowCompositing() that flattens
those per-window bitmaps back into one screenshot. The windows are drawn
back-to-front at their on-screen positions onto a bitmap sized to their
union, and the dim a window casts behind itself is recreated from its
FLAG_DIM_BEHIND/dimAmount layout params, since the window manager draws
that dim outside of any window's surface and PixelCopy never sees it.

The flag is off by default and only applies on top of the existing
enableMultiWindowCapture(), so the per-window behavior is unchanged for
everyone who does not opt in. Compositing is a much larger allocation
than any single window, so a failure to composite recycles the captured
windows and reports failure, letting Shaky degrade to its Canvas
fallback rather than crash the host app.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@alecramirez
alecramirez force-pushed the alramirez/composite-multi-window-screenshot branch from bcdc73b to c717fd0 Compare September 19, 2026 02:08
@alecramirez
alecramirez marked this pull request as ready for review September 19, 2026 02:28
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.

1 participant