Overview

Compose previews are great for designers but lossy for developers — when you see a rendered screen, there's no way to jump from a button you can see to the Button(...) call that produced it. Layout Inspector solves this on a running device, but most preview review happens against a static screenshot in a PR comment or design doc.

codeview bridges that gap. It generates a self-contained index.html with every preview screenshotted, every composable overlaid, and every overlay linked to the matching source line. No runtime dependency, no custom annotations — just point it at your existing @Preview functions.

Slot-tree extraction

Walks Compose's CompositionData via the same API Layout Inspector uses.

Two test modes

Fast Robolectric (no PNG) or full instrumented (with real bitmaps).

Self-contained HTML

JSON inlined in <script>, PNGs alongside. Works from file://.

IDE deep-links

Per-overlay idea:// or vscode:// URLs. One click, one line.

Searchable rendered text

Search the report for what users see, not what the source says. R.string-resolved.

Auto-opens the report

The HTML pops in your browser on success. Light/dark themed via prefers-color-scheme.

Installation

Add Maven Central to your plugin repositories in settings.gradle.kts:

settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

Then apply the plugin in your Android module's build.gradle.kts:

build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.plugin.compose")
    id("com.rohittp.plugables.codeview") version "1.2.0"
}

Requires AGP 9.0+

Generated tests are wired via the AGP HasHostTests / HasDeviceTests APIs (Android Gradle Plugin 9.0+). For older AGP, pin codeview to a future maintenance branch.

Manifest entry

codeview launches an Activity to host the preview render. AGP packages the main manifest into the unit-test APK (test-only manifest entries are ignored), and Robolectric (since PR #4736) refuses to resolve activities without a matching MAIN/LAUNCHER intent filter — even when the launch intent names the activity explicitly. So you need a host activity in your main manifest with a launcher filter that does not set its own content in onCreate(). The simplest option is to declare androidx.activity.ComponentActivity directly:

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application>
        <!-- Your real launcher activity -->
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- Hosting activity for codeview test renders -->
        <activity
            android:name="androidx.activity.ComponentActivity"
            android:exported="true"
            tools:replace="android:exported">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Mode 1 — Unit (Robolectric, default)

Fastest setup. Generated tests run on the JVM via Robolectric, walk the slot tree, and emit JSON sidecars. The HTML report renders all overlays, but the <img> slot is empty — Robolectric's WindowCapture.forceRedraw times out under headless graphics, so codeview catches the timeout and continues with metadata only.

build.gradle.kts
android {
    testOptions {
        unitTests {
            isIncludeAndroidResources = true
            all { it.useJUnit() }
        }
    }
}

dependencies {
    testImplementation(platform("androidx.compose:compose-bom:2026.04.01"))
    testImplementation("androidx.compose.ui:ui-test-junit4-android")
    testImplementation("androidx.compose.ui:ui-test-manifest")
    testImplementation("androidx.compose.ui:ui-tooling")
    testImplementation("androidx.compose.ui:ui-tooling-data")
    testImplementation("androidx.test.ext:junit:1.3.0")
    testImplementation("org.robolectric:robolectric:4.16.1")
    testImplementation("junit:junit:4.13.2")
}

codeview {
    ideScheme.set("idea")  // or "vscode"
    testActivityClass.set("androidx.activity.ComponentActivity")
    // testMode defaults to "unit"
}

Mode 2 — Instrumented (real device, real PNGs)

Renders previews on an emulator or connected device, capturing real RGBA bitmaps. Sidecars are written to the app's externalCacheDir, republished to /sdcard/codeview-sidecars/ via UiAutomation.executeShellCommand (so they survive AGP's post-test app uninstall), then pulled back to the host via adb pull.

build.gradle.kts
android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}

composeCompiler {
    includeSourceInformation = true
}

dependencies {
    androidTestImplementation(platform("androidx.compose:compose-bom:2026.04.01"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4-android")
    androidTestImplementation("androidx.compose.ui:ui-tooling")
    androidTestImplementation("androidx.compose.ui:ui-tooling-data")
    androidTestImplementation("androidx.test:core:1.7.0")
    androidTestImplementation("androidx.test:runner:1.7.0")
    androidTestImplementation("androidx.test:rules:1.7.0")
    androidTestImplementation("androidx.test:monitor:1.7.2")  // see note below
    androidTestImplementation("androidx.test.ext:junit:1.3.0")
    androidTestImplementation("junit:junit:4.13.2")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

codeview {
    ideScheme.set("idea")
    testActivityClass.set("androidx.activity.ComponentActivity")
    testMode.set("instrumented")
}

Note on androidx.test:monitor

ActivityScenario (used internally by runAndroidComposeUiTest) needs androidx.test.internal.platform.app.ActivityInvoker, which lives in androidx.test:monitor. Normally pulled transitively by :core/:runner. AGP 9.x with the test orchestrator (testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" }) strips monitor's classes out of the test APK on the assumption orchestrator provides them — which only holds for orchestrator-managed runs, not for codeview's plain AndroidJUnitRunner execution. Symptom: NoClassDefFoundError: ActivityInvoker at activity launch. If you hit this, either drop orchestrator for the codeview run, or stop AGP from filtering monitor by depending on it as a flat jar (androidTestImplementation(files("libs/monitor-X.Y.Z-classes.jar"))).

Then run the report — codeview will install + run on whichever emulator adb devices shows:

terminal
./gradlew :app:codeviewReportDebug

How It Works

codeview registers a generation task that scans your Kotlin sources for @Preview functions and writes a JUnit test class per preview into the appropriate test source set. Each generated test renders the preview, walks Compose's slot tree to extract every composable's bounds and source location, captures a bitmap, and writes a JSON sidecar. A second task aggregates the sidecars + PNGs into a single self-contained index.html.

Discover @Preview functions
Generate JUnit test per preview
Render Compose UI test
Walk slot tree → JSON
Assemble HTML report

The slot-tree walk uses androidx.compose.ui.tooling.data.mapTree, the same API Layout Inspector uses internally. Source positions come from Compose's sourceInformation markers (default-on for debug builds); codeview does not author its own compiler plugin. Composable groups whose source file resolves to a path under your sourceDirs become clickable overlays; Compose-internal call sites (BasicText.kt, Layout.kt, etc.) are filtered out so the report stays focused on your code.

Alongside the slot tree, codeview also walks the Compose semantics tree and ships every visible text string in the sidecar — Text content, TextField editable text, contentDescription. By the time these strings hit the JSON they have already been through R.string resolution, lambda evaluation, and any other runtime substitution, so the search box matches what your users actually see, not what the source file says.

The HTML Report

The generated index.html is a self-contained single-page app — JSON is inlined in a <script type="application/json"> tag, PNGs sit alongside under previews/. Open it from anywhere; no server required.

Home — preview grid

The landing page shows every @Preview as a card with its rendered screenshot, name, and node count. Cards are responsive, clickable, and the search box at the top filters them in real time. Search matches against:

  • the @Preview function name (e.g. HomeScreenPreview),
  • every slot-tree node name (e.g. Text, Button, Column),
  • every visible string from the semantics tree — after R.string lookups, so a search for "welcome back" finds a preview whose source reads Text(stringResource(R.string.welcome)).

When the query matches a rendered string, the matching card surfaces a short excerpt of that text with the matched substring highlighted.

Detail — single preview with sidebar

Clicking a card navigates to #preview-id and shows the screenshot. A 240px sidebar lists every other preview by name (one click to switch), and a ← All previews link in the top bar returns to the grid. Browser back/forward work; URLs are shareable.

Zoom controls

A toolbar above each detail screenshot offers Fit (default — scales the image to fit the viewport, capped at 1×), 1:1, + and buttons, with the current scale shown as a percentage. The viewport scrolls when zoomed past fit so you can pan around large screenshots. Bounding-box overlays scale with the image so they stay aligned at any zoom level.

Hover — line-of-source tooltip

Bounding boxes are invisible by default and outlined on hover. Two visual styles:

  • Source-backed overlays (your composables, with file:line attribution from Compose's sourceInformation) are blue, clickable anchors that deep-link into your IDE at the exact line.
  • Library/internal overlays (Material, Foundation, Compose internals — these don't carry source attribution in their compiled bytecode) are gray, hover-only, non-clickable. They still appear so you can inspect the full layout structure of the rendered preview.

Hovering shows a tooltip with the composable name, the file:line label, and (for source-backed overlays) the actual line of Kotlin from your project — no IDE jump needed to see the call. Overlapping overlays are sorted smallest-on-top so the most specific composable wins hover.

Auto-open

On a successful codeviewReport run, the plugin shells out to open (macOS), xdg-open (Linux), or cmd /c start (Windows) to load the report in your default browser. Disable with codeview { openOnComplete.set(false) } in CI or other headless contexts.

Configuration

The codeview DSL block exposes these properties:

Property Type Default Description
sourceDirs ConfigurableFileCollection src/main/kotlin, src/main/java Directories scanned for *.kt files containing @Preview functions. Defaults cover the standard Android Studio layout (Kotlin under src/main/java); override if your previews live elsewhere.
outputDir DirectoryProperty build/reports/codeview Where the assembled HTML report is written. Each variant gets a subdirectory.
ideScheme Property<String> "idea" URL scheme for source links. "idea" for JetBrains IDEs, "vscode" for VS Code.
testActivityClass Property<String> FQN of an Activity registered in your main manifest with a MAIN/LAUNCHER filter. Required.
testMode Property<String> "unit" Either "unit" (Robolectric, no PNG) or "instrumented" (device, with PNG).
openOnComplete Property<Boolean> true If true, the generated index.html is opened in the default browser when the report task finishes. Set to false in CI.
excludePreviews ListProperty<String> empty Display names of @Preview functions to skip entirely. Excluded previews are filtered at source-parse time, never enter the registry, never execute, and never appear in the report. Use this for previews that fundamentally can't render in the test environment — e.g. composables that call hiltViewModel() against the plain ComponentActivity host (the throw would otherwise cancel the Compose Recomposer and cascade-fail every later preview in the batch).

Excluding problematic previews

Add display names (the @Preview function's simple name) to excludePreviews:

codeview {
    testMode.set("instrumented")
    testActivityClass.set("androidx.activity.ComponentActivity")
    excludePreviews.add("HomeScreenPreview")
    excludePreviews.add("OnboardingScreenPreview")
}

How to find the names of failing previews: open the report and look for cards with a red render failed badge — the failures cascade in batch order, so the first failing one is usually the cause.

Gradle Tasks

codeview registers these tasks per Android variant:

Task Purpose
generateCodeviewPreviewTests Discover @Previews and emit one JUnit test class per preview, plus a runtime helper. Writes preview-index.json.
pullCodeviewSidecars{Variant} (Instrumented mode only) adb pulls sidecars from /sdcard/codeview-sidecars after connected{Variant}AndroidTest.
codeviewReport{Variant} Joins sidecars + PNGs + the preview index, renders the HTML report. Logs the absolute file:// path on success.
codeviewReport Aggregates all variants' reports.

Each codeviewReport{Variant} depends transitively on the right test task — running ./gradlew :app:codeviewReportDebug alone will build, run the tests, pull sidecars (instrumented mode), and write the HTML in one shot.

Generated Test Example

Given a sample preview in your code:

kotlin
@Preview(showBackground = true)
@Composable
fun HomeScreenPreview() { HomeScreen() }

codeview generates a test class like this (instrumented mode shown):

generated kotlin
// AUTO-GENERATED by codeview. Do not edit.
@file:OptIn(ExperimentalTestApi::class)
package com.rohittp.plugables.codeview.generated

import androidx.compose.ui.test.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class Codeview_HomeScreenPreview_0Test {

    @Test
    fun render() = runAndroidComposeUiTest(
        activityClass = ComponentActivity::class.java
    ) {
        val ctx = InstrumentationRegistry.getInstrumentation().targetContext
        val outputDir = File(ctx.externalCacheDir, "codeview").apply { mkdirs() }
        CodeviewRuntime.renderAndCapture(
            uiTest = this,
            outputDir = outputDir,
            previewId = "Codeview_HomeScreenPreview_0",
            previewFqn = "com.example.HomeScreenPreview",
            previewDisplayName = "HomeScreenPreview",
            previewSourceFile = "/abs/path/HomeScreen.kt",
            previewSourceLine = 19,
        ) { com.example.HomeScreenPreview() }
    }
}

The shared CodeviewRuntime helper (also generated, into the same package) wraps the content in Compose's Inspectable, walks the resulting slot tree via mapTree, captures the bitmap, and writes a per-preview JSON sidecar.

Why It's Built This Way

Several non-obvious things forced the current shape of the plugin. They're worth knowing if you ever need to debug a setup or extend the plugin:

  1. The unit-test APK uses your main AndroidManifest.xml, not the merged unit-test one. AGP packages apk-for-local-test.ap_ from the main source set; entries you put under src/test/AndroidManifest.xml never reach Robolectric's PackageManager. That's why testActivityClass must point at an activity already registered in main.
  2. Robolectric PR #4736 enforces strict activity resolution. Even with an explicit component (cmp=...) in the launch intent, it requires the activity at that component to have a matching MAIN/LAUNCHER intent filter. Hence the manifest snippet above.
  3. LocalInspectionTables alone doesn't populate the slot table. You also need to add currentComposer.compositionData to the set inside the composition itself — that's the same pattern Layout Inspector uses internally. codeview wraps content in Compose's official Inspectable helper to handle this.
  4. LocalInspectionTables doesn't propagate into subcompositions. LazyVerticalGrid, LazyRow, SubcomposeLayout, etc. each create a separate composition that does not register with our Inspectable's tables — the slot-tree walk would then only see one item per lazy container. Codeview compensates with a secondary walk of the unmerged semantics tree (onAllNodes(isRoot(), useUnmergedTree = true)) that does see every laid-out child, and emits a synthetic bounding box per item that mapTree missed. These boxes don't carry source-line info (semantics doesn't record it), so they render as the gray non-clickable variant.
  5. Dialog/Popup/BottomSheet previews mount a second window. The activity's main compose root ends up at 0×0 because content lives in the dialog's window. onRoot() would throw Expected exactly '1' node but found '2'. The runtime instead enumerates all roots and picks the largest by area, which targets the window with the actual content.
  6. An unhandled throw inside a composable cancels the Compose Recomposer. Compose forbids try/catch around composable invocations, so we can't recover in-line. Once the Recomposer is cancelled, every subsequent preview in the batch fails with IllegalStateException: No compose hierarchies found in the app. The escape hatch is the excludePreviews DSL — skip previews that fundamentally can't render in the test environment (most commonly composables calling hiltViewModel() against a plain ComponentActivity host) so the cascade never starts.
  7. AGP uninstalls the app after connected*AndroidTest. Anything written to the app's externalCacheDir is gone before the host can adb pull it. The instrumented helper republishes sidecars to /sdcard/codeview-sidecars/ from inside the test process, using UiAutomation.executeShellCommand (uid 2000, has write access to /sdcard). It also drops a __codeview_published__ sentinel so the host pull task can tell this run's output from a previous run's leftovers — if the sentinel is missing, the pull task fails the build instead of silently re-publishing stale data.
  8. In Compose 1.11+, Group.location is no longer populated. The new way to read source positions is the mapTree API with a SourceContext callback parameter. codeview uses that path; older versions using Group.location would silently return null for every composable.

Limitations

  • Unit mode emits no PNGs. captureToImage() times out under Robolectric's headless graphics mode. Use testMode = "instrumented" if you need bitmaps. The slot-tree data is still extracted in unit mode, so overlays and source links work — only the screenshot is missing.
  • Source-line precision stops at what Compose's sourceInformation records — typically the call site of each composable. Lambda contents (e.g. inside Text("foo")) resolve to the Text(...) call site, not the string literal.
  • Only JetBrains idea:// and VS Code vscode:// URL schemes are supported. Browser must register the protocol handler.
  • androidx.compose.ui.tooling.data is @UiToolingDataApi (experimental). Bumping Compose may require codeview to follow.
  • @PreviewParameter variants render as separate sequenced entries (_0, _1, …).
  • Top-level private @Preview functions are skipped. Kotlin private at file scope is unreachable from generated test files, and @Composable functions can't be invoked via reflection. Codeview emits a Gradle warning listing every skipped FQN — change them to internal (or drop the modifier) to include them in the report.
  • Incremental rendering is per-file (v1.2+). Each sidecar JSON stores a SHA-256 of its .kt source. Subsequent runs skip previews whose hash matches the previous run, so the device avoids the captureToImage roundtrip. Editing any preview in a file re-renders all previews in that file; cross-file dependencies (themes, shared composables) aren't tracked — run ./gradlew :app:codeviewReportDebug --rerun-tasks to force a full re-render.
  • Batched rendering (v1.3+). Codeview generates a single CodeviewBatchTest with one @Test fun renderAll() that loops over every preview in the module. The Activity launches once, the Compose runtime initialises once, and unchanged previews are filtered inside the loop. Trade-off: JUnit reports show one test row instead of N. A render exception in any preview is caught — its sidecar gets a renderError field (schema 3) and the report card renders the failure; other previews in the batch keep going.
  • Instrumented mode requires a device or emulator at run time; CI must provision one (managed devices, GMD, or a separate emulator step). When multiple devices are attached, codeview targets the first emulator it finds.
  • Hilt-aware test activity not yet supported. Composables that call hiltViewModel() need a HiltTestActivity host with @HiltAndroidTest plumbing; codeview only generates plain runAndroidComposeUiTest(activityClass = ...) blocks. For now, list such previews in excludePreviews. Native Hilt support is on the roadmap.

Troubleshooting

  • pullCodeviewSidecars{Variant} fails with "No sentinel __codeview_published__ on device" — the instrumented test process never reached CodeviewRuntime.runBatch. Check the AGP-captured logcat under app/build/outputs/androidTest-results/connected/<variant>/<device>/logcat-*.txt for the underlying crash. The pull task refuses to publish a report from stale sidecars on purpose.
  • NoClassDefFoundError: ActivityInvoker when the test launches — androidx.test:monitor isn't landing in the test APK. See the note in Mode 2 — Instrumented.
  • Most/all previews end up with "No compose hierarchies found in the app" — one preview earlier in the batch threw inside its composable and cancelled the Compose Recomposer; the rest are collateral damage. Find the first preview with a non-null renderError in batch order and add it to excludePreviews. Most common offender: composables calling hiltViewModel().
  • "Preview rendered at 0×0" in renderError — the preview itself produced no laid-out content. Either wrap it in a sized container (Box(Modifier.size(360.dp, 640.dp)) { … }) or pin the size on the annotation (@Preview(widthDp = 360, heightDp = 640)).
  • Bounding boxes for lazy-grid items are missing — codeview captures these via a secondary semantic-tree walk, which only emits if the items are actually laid out at render time. If your preview is meaningfully smaller than the grid's content, set the grid's height explicitly so all rows fit.

Future work

Auto-discovery of an existing MAIN/LAUNCHER activity (so testActivityClass becomes optional), androidx.test.services.storage to replace the /sdcard republish hack, runtime IDE-scheme switcher in the report UI, native Hilt-aware test activity, and multi-module aggregation are all on the roadmap.