Device frames

September 9, 2026 · View on GitHub

Composite a rendered @Preview into a real device bezel — a round Wear watch or a phone, with the hardware buttons (crown / side button, power + volume) baked into the artwork — so a capture looks like the device, the way Android Studio's "device frame" does. Useful for marketing shots and for making a Wear/phone preview read as the physical product.

This is a post-capture step: the base PNG is rendered exactly as before, then a framed sibling is written alongside it. It never changes or replaces the base capture, and a fetch/compose failure is swallowed (logged) so it can't break a render.

A Compose preview composited into the round Wear device-art bezel

A sample preview rendered through the pipeline (-PcomposePreview.deviceFrame.device=wear_round), composited into Google's CC-BY wear_round bezel — strap and crown come from the frame art.

Turning it on

Off by default. Enable per-invocation with a Gradle property:

./gradlew :app:composePreviewRenderAll -PcomposePreview.deviceFrame.device=auto

auto picks a frame from each preview's device class:

@Preview(device = …) classFrame
round Wear (*_round, isround=true)wear_round
square Wear (wearos_square)wear_square
phones (Pixel ids, spec:…, width/height)pixel_5 (generic phone)
tablet / TV / automotive / XR / desktop(un-framed)

Force one frame for every preview by naming a Device Art Generator id instead of auto:

-PcomposePreview.deviceFrame.device=wear_round

Output

For a framed preview, under <render-dir>/../data/<previewId>/:

  • deviceframe_<artId>.png — the framed image.
  • deviceframe.json{ device, artId, path, mediaType, attribution }.
  • deviceframe-attribution.txt — the CC-BY attribution (see below).

Advanced (system properties)

The Gradle plugin forwards -Dcomposeai.deviceframe.device=… and -Dcomposeai.deviceframe.cacheDir (the prefetch cache it just filled) to the renderer. Two more system properties tune the composite (no Gradle-property wrapper yet — pass with -D… via org.gradle.jvmargs or a renderer arg):

PropertyDefaultMeaning
composeai.deviceframe.shadowtruefalse drops the drop-shadow layer.
composeai.deviceframe.glaretruefalse drops the screen-glare layer.

The on-disk cache lives at ${java.io.tmpdir}/compose-preview-device-art/<artId>/port_<resource>.png.

The Gradle plugin prefetches the needed layers (Ktor/OkHttp) into the cache before launching renderers, so the render itself does no network IO. Layers are downloaded once and reused across runs; pre-seed cacheDir with <artId>/port_<resource>.png files for fully offline / CI runs.

How it works

  • DeviceArtCatalog — frame geometry (screen rectangle, corner radius, layers, notch) transcribed from Google's device-art-generator.js.
  • DeviceFrameCompositor — pure java.awt (BufferedImage/Graphics2D) compositor reproducing the generator's layering (shadow → back → screen, anti-aliased rounded-rect/circle clip → notch redraw → glare). Runs unchanged on the Robolectric host JVM and the Desktop renderer.
  • DeviceArtPrefetch — downloads the bezel layers into the on-disk cache before the render runs, via OkHttp (synchronous), in the Gradle daemon JVM. It lives in the plugin, not the renderer, on purpose: an HTTP client can't sit on the render subprocess classpath without skewing Compose's kotlinx-coroutines (runBlockingK$default NoSuchMethodError — see RENDERER_COMPATIBILITY.md). OkHttp (not Ktor) because Ktor 3.x needs coroutines ≥ 1.10 while the Gradle daemon ships an older one.
  • CachedDeviceArtSource — the renderer-side layer source. Reads the prefetched cache only (no HTTP libs on the render classpath); a cache miss degrades to "no frame".
  • DeviceFrameDataProducer — resolves the frame, fetches layers, composites, writes the artifacts. Called from the post-capture block in RobolectricRenderTest (Android) and DesktopRendererMain (Desktop), mirroring the display-filter step.

Licensing / attribution

The bezel artwork is Google's Device Art Generator output, licensed CC BY 3.0. Every framed image ships a deviceframe-attribution.txt carrying the required attribution; keep it with the image when you redistribute. The frame PNGs are not committed to this repo — they're fetched on demand and cached — except one small wear_round frame used as a test fixture.

Only wear_round and wear_square exist in the generator (no Pixel Watch / modern Wear). For a realistic modern watch, see the 3D device-model render (separate feature).