codeview
Generate an interactive HTML report of every @Preview composable in your
Android module — with click-to-IDE deep-links per composable. Hover any
rectangle in the rendered preview to see its name and source line; click it to open
that exact line in IntelliJ, Android Studio, or VS Code.
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.
Walks Compose's CompositionData via the same API Layout Inspector uses.
Fast Robolectric (no PNG) or full instrumented (with real bitmaps).
JSON inlined in <script>, PNGs alongside. Works from file://.
Per-overlay idea:// or vscode:// URLs. One click, one line.
Search the report for what users see, not what the source says. R.string-resolved.
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:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
Then apply the plugin in your Android module's 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:
<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.
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.
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:
./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.
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
@Previewfunction name (e.g.HomeScreenPreview), - every slot-tree node name (e.g.
Text,Button,Column), - every visible string from the semantics tree — after
R.stringlookups, so a search for "welcome back" finds a preview whose source readsText(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:
@Preview(showBackground = true)
@Composable
fun HomeScreenPreview() { HomeScreen() }
codeview generates a test class like this (instrumented mode shown):
// 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:
-
The unit-test APK uses your main
AndroidManifest.xml, not the merged unit-test one. AGP packagesapk-for-local-test.ap_from the main source set; entries you put undersrc/test/AndroidManifest.xmlnever reach Robolectric'sPackageManager. That's whytestActivityClassmust point at an activity already registered in main. -
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 matchingMAIN/LAUNCHERintent filter. Hence the manifest snippet above. -
LocalInspectionTablesalone doesn't populate the slot table. You also need to addcurrentComposer.compositionDatato the set inside the composition itself — that's the same pattern Layout Inspector uses internally. codeview wraps content in Compose's officialInspectablehelper to handle this. -
LocalInspectionTablesdoesn't propagate into subcompositions.LazyVerticalGrid,LazyRow,SubcomposeLayout, etc. each create a separate composition that does not register with ourInspectable'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 thatmapTreemissed. These boxes don't carry source-line info (semantics doesn't record it), so they render as the gray non-clickable variant. -
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 throwExpected 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. -
An unhandled throw inside a composable cancels the Compose
Recomposer. Compose forbidstry/catcharound composable invocations, so we can't recover in-line. Once the Recomposer is cancelled, every subsequent preview in the batch fails withIllegalStateException: No compose hierarchies found in the app. The escape hatch is theexcludePreviewsDSL — skip previews that fundamentally can't render in the test environment (most commonly composables callinghiltViewModel()against a plainComponentActivityhost) so the cascade never starts. -
AGP uninstalls the app after
connected*AndroidTest. Anything written to the app'sexternalCacheDiris gone before the host canadb pullit. The instrumented helper republishes sidecars to/sdcard/codeview-sidecars/from inside the test process, usingUiAutomation.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. -
In Compose 1.11+,
Group.locationis no longer populated. The new way to read source positions is themapTreeAPI with aSourceContextcallback parameter. codeview uses that path; older versions usingGroup.locationwould silently returnnullfor every composable.
Limitations
-
Unit mode emits no PNGs.
captureToImage()times out under Robolectric's headless graphics mode. UsetestMode = "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
sourceInformationrecords — typically the call site of each composable. Lambda contents (e.g. insideText("foo")) resolve to theText(...)call site, not the string literal. -
Only JetBrains
idea://and VS Codevscode://URL schemes are supported. Browser must register the protocol handler. -
androidx.compose.ui.tooling.datais@UiToolingDataApi(experimental). Bumping Compose may require codeview to follow. -
@PreviewParametervariants render as separate sequenced entries (_0,_1, …). -
Top-level
private @Previewfunctions are skipped. Kotlinprivateat file scope is unreachable from generated test files, and@Composablefunctions can't be invoked via reflection. Codeview emits a Gradle warning listing every skipped FQN — change them tointernal(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
.ktsource. Subsequent runs skip previews whose hash matches the previous run, so the device avoids thecaptureToImageroundtrip. 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-tasksto force a full re-render. -
Batched rendering (v1.3+). Codeview generates a single
CodeviewBatchTestwith 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 arenderErrorfield (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 aHiltTestActivityhost with@HiltAndroidTestplumbing; codeview only generates plainrunAndroidComposeUiTest(activityClass = ...)blocks. For now, list such previews inexcludePreviews. Native Hilt support is on the roadmap.
Troubleshooting
-
pullCodeviewSidecars{Variant}fails with "No sentinel__codeview_published__on device" — the instrumented test process never reachedCodeviewRuntime.runBatch. Check the AGP-captured logcat underapp/build/outputs/androidTest-results/connected/<variant>/<device>/logcat-*.txtfor the underlying crash. The pull task refuses to publish a report from stale sidecars on purpose. -
NoClassDefFoundError: ActivityInvokerwhen the test launches —androidx.test:monitorisn'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-nullrenderErrorin batch order and add it toexcludePreviews. Most common offender: composables callinghiltViewModel(). -
"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.