A browser-based, agent-friendly debugging library for Android.
Lustro embeds a small web server in your app's debug builds and serves its tools as tabs you open in a desktop browser — so you inspect on a full screen instead of a cramped on-device overlay. The built-in network inspector captures traffic and lets you mock responses, throttle connections, and replay requests. Every tab is also a JSON API, so AI agents and scripts can drive Lustro directly rather than scraping HTML.
It's built as an extensible tab platform: the network inspector ships in the box, and you add your own tabs against a stable plugin contract.
Status: early development. Pre-1.0 and not yet published — APIs and the wire protocol may change between releases. Only the
0.1.0-SNAPSHOTline exists today (Sonatype Central snapshots); there is no stable release yet.
Lustro ships as two interchangeable runtime artifacts that you split by build variant:
io.github.twinsen81:lustro— the real debug runtime (the embedded server, capture, and UI).io.github.twinsen81:lustro-noop— a release-safe no-op AAR that mirrors the same public facades with empty bodies, so your integration code compiles and runs in release with no server, no capture, and no open socket.
Both modules declare the same Gradle capability (io.github.twinsen81:lustro-runtime), so you
cannot resolve both on one configuration — you must split them by variant
(debugImplementation / releaseImplementation):
# gradle/libs.versions.toml
[versions]
lustro = "0.1.0-SNAPSHOT"
[libraries]
lustro = { group = "io.github.twinsen81", name = "lustro", version.ref = "lustro" }
lustro-noop = { group = "io.github.twinsen81", name = "lustro-noop", version.ref = "lustro" }// build.gradle.kts (app module)
dependencies {
debugImplementation(libs.lustro)
releaseImplementation(libs.lustro.noop)
}-SNAPSHOT versions resolve from the Sonatype Central snapshots repository, so add it (only
needed while Lustro is pre-release):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://central.sonatype.com/repository/maven-snapshots/")
}
}Lustro requires minSdk 26 and declares the INTERNET permission in its manifest.
Build the runtime once (typically in your Application), register tabs, wire the OkHttp
interceptor, and start it:
val client = OkHttpClient() // powers "Send Request"; carries no interceptor, so replays through
// it are not captured (see Send Request)
val lustro = Lustro.builder(application)
.addTab(NetworkDebugTab.create(senderClient = client))
.build()
val httpClient = OkHttpClient.Builder()
.addInterceptor(lustro.networkInterceptor())
.build()
lustro.start()start() freezes the tab registry; tabs registered after start() are not picked up.
start() returns a LustroStatus (ENABLED once armed, DISABLED if it cannot start) and is
idempotent, as is stop().
Three things keep that snippet out of production, in order of how much they depend on you:
Your
DebugTabsubclasses must live insrc/debug. They are your code, so swapping:lustrofor:lustro-noopcannot remove them from a release APK — whatever source set they sit in is compiled into that variant, taking the app internals they read with them. Lustro ships a published Android Lint check (LustroDebugUsageInRelease, severity ERROR) that enforces this: it flags aDebugTabsubclass reachable from any source set other thansrc/debug(e.g.src/mainorsrc/release). The builder call above is not flagged — it resolves to whichever facade the variant depends on. Putting the whole bootstrap undersrc/debug/too (as the:sampleapp does) is still the cleanest layout, because then release compiles none of it at all.
The release swap makes the API inert. With
releaseImplementation(libs.lustro.noop), theLustro.builder(...)/.addTab(...)/start()calls in the snippet compile unchanged and do nothing: no socket, no capture, andnetworkInterceptor()forwards every request.
The runtime refuses to start in a non-debuggable build. If the real
:lustroruntime ever reaches a build withoutandroid:debuggable,start()logs a WARN and returnsLustroStatus.DISABLEDwithout binding a socket — no Gradle wiring and no lint run required. An internal build that shipsandroid:debuggable="false"but still wants the server can opt in withDebugConfig.builder().allowNonDebuggableBuilds(true).
The server is bound to loopback (127.0.0.1) on the device, so reach it from your desktop over
adb port forwarding:
-
Forward the port from your machine to the device:
adb forward tcp:8080 tcp:8080
-
Open http://localhost:8080 in a desktop browser.
-
The server is token-authenticated (always on). Read the token from logcat — Lustro logs one machine-parseable line at the
LustroTokentag on every successful bind:adb logcat -s LustroToken # Lustro ready endpoint=http://127.0.0.1:8080 token=<token> -
Authenticate the browser with that token, either by:
- using the
lustroCLI (lustro open, see docs/AGENTS.md); or - appending
#lustro_token=<token>to the URL once (http://localhost:8080/#lustro_token=<token>). The page posts the token to set anHttpOnly; SameSite=Strictcookie, then strips the fragment from the address bar.
- using the
The server runs only while the app is foregrounded. When the app goes to the background the server drains in-flight requests and closes the socket; it rebinds when the app returns to the foreground.
lustro.networkInterceptor() returns an OkHttp application interceptor (despite the
"network" in the name — it is added with addInterceptor, not addNetworkInterceptor). It
feeds captured traffic into the registered NetworkDebugTab; if no network tab is registered it
is a pass-through.
OkHttpClient.Builder()
.addInterceptor(authInterceptor) // your interceptors that mutate URL/headers/body
.addInterceptor(lustro.networkInterceptor()) // add Lustro AFTER them
.build()Ordering matters. Add Lustro's interceptor after any application interceptors that rewrite the URL, headers, or body, so capture, mock matching, classification, and throttling all see the final application-level request. (As an application interceptor it does not observe OkHttp's automatic retries or redirects the way a network interceptor would — that is the intended trade-off.)
Capture doesn't hold up the call. The interceptor copies what it captures and returns; redaction, classification, and storing run on a background thread, so a request shows up in the Network tab a moment after it completes. During a burst of large bodies, once about 4 MB of captured text is waiting, calls capture on their own thread until that one catches up, which keeps memory bounded.
The Network tab's Send Request panel dispatches an arbitrary request through a configured
NetworkSender and is synchronous: the HTTP call blocks until the sender returns the final
result (within the per-request timeout), so you get a single round-trip outcome instead of having
to poll.
Pass senderClient to NetworkDebugTab.create(...) to enable it — the client is wrapped in an
OkHttpSender. When no sender is configured, the Send panel and its route are hidden. Relative
URLs resolve against DebugConfig.appServerBaseUrl (rejected when it is unset); requests aimed at
the debug server's own bind host:port are rejected.
A replay is captured only if the sender client carries the interceptor. Nothing about sending
captures on its own: the request shows up in the traffic list only when the client you passed as
senderClient has lustro.networkInterceptor() installed, which the Quick start
wiring cannot do (the sender client has to exist before lustro does). The send response's
transactionId field is currently always null either way, so poll GET transactions rather
than following it.
The panel reports only the status and outcome, so the sender reads at most
DebugConfig.maxBodyCaptureBytes of the response body and then closes the response: a large
download or an endless stream cannot exhaust the app's heap. A send still running when the
per-request timeout answers 504 has its call cancelled right after.
The Network tab's Mock Rules panel short-circuits matching requests with a synthetic
response: the interceptor answers from the rule and the request never leaves the device.
urlPattern is a substring match, or a regular expression when prefixed with regex:.
Rules live in the app, not in the browser. They are kept in memory unless you pass a
MockRuleStorage to NetworkDebugTab.create(...), so without one they are gone when the process
dies:
NetworkDebugTab.create(
senderClient = client,
mockRuleStorage = SharedPreferencesMockRuleStorage(
context.getSharedPreferences("my_app_mocks", Context.MODE_PRIVATE),
),
)A rule must be one the interceptor can serve. Its statusCode has to be within 100–599, its
responseHeaders have to be header names and values OkHttp accepts, and a Content-Type among
them has to parse as a media type. A rule that isn't is rejected with an enveloped 400 naming
the offending field, and one already stored is dropped when it is loaded — an unservable rule
would otherwise throw inside your own HTTP call, on every request it matched.
OkHttp capture is the default and needs no opt-in. To also capture platform
HttpURLConnection traffic, opt in explicitly:
@OptIn(ExperimentalPlatformCapture::class)
val tab = NetworkDebugTab.create(senderClient = client, capturePlatformHttp = true)Caveats — this path is gated by @ExperimentalPlatformCapture because it relies on a non-public
platform detail (a process-global URL stream handler):
- Best-effort and fail-open: if it cannot install, capture is simply skipped; your app keeps working.
- Process-wide: it installs a global handler once, affecting all
HttpURLConnectiontraffic in the process. - Not covered by the library's binary- or behaviour-compatibility guarantees, and may degrade across OS/SDK versions.
Lustro deliberately surfaces app internals, so its defaults are conservative. See SECURITY.md for the full threat model.
- Loopback by default. The server binds to
127.0.0.1; it is reachable only from the device itself (and your desktop viaadb forward). - Token auth, always on. A 256-bit token is generated on first run and stored in private
debug preferences. Programmatic clients send
Authorization: Bearer <token>; browsers use anHttpOnly; SameSite=Strictcookie set via/api/v1/_auth. Before auth, only framework chrome is served — no tab output or captured data. The token is logged at theLustroTokentag. - Tabs never see the credentials. The server authenticates a request before dispatching it,
and the
DebugRequesta tab receives carries noAuthorizationorCookieheader, so a tab that logs or echoes its request can't leak the token. - Content Security Policy. Chrome and tab views ship a CSP (
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; ...) plusX-Content-Type-Options: nosniff. Scripts are'self'-only — no inline scripts, so tab JS loads as an external same-origin resource and there are no inline handlers; styles allow'unsafe-inline'so tabs can use inlinestyle=attributes and<style>blocks. Every API request, whatever its method, passes an Origin /Sec-Fetch-Sitecheck: the server's own origin is always allowed, and any other cross-origin caller must be listed inDebugConfig.allowedOrigins(other localhost ports are not trusted by default). - Capture-time redaction, best-effort. A
Redactormasks sensitive headers, URL/query params, and JSON/form body fields before anything is stored, so what it masks never reaches the API, UI, or fixtures. It matches on names, so it cannot find a secret that isn't keyed by a name it recognizes: see SECURITY.md for the known gaps, and pass your ownRedactorwhen your traffic needs more. A JSON body with no sensitive field in it is stored exactly as it arrived, so what you inspect and copy is what was on the wire. - Nothing persisted to disk except mock rules. Captured traffic lives only in a bounded
in-memory ring buffer and is lost when the process dies; the sole persisted state is your mock
rules, and only when you give the tab a
MockRuleStorage(see Mock rules). The browser keeps no copy of them. - Release builds are inert. Release variants depend on
:lustro-noop, whose runtime bodies are empty — no server ships to production. As a backstop for a misconfigured dependency graph, the real runtime also refuses to start in a build that is not marked debuggable (DebugConfig.allowNonDebuggableBuildsopts an internal build back in).
The default workflow is loopback + adb forward (see Accessing the UI); it
needs no LAN exposure.
To reach the server from another machine on the network, opt in by binding all interfaces:
val lustro = Lustro.builder(application)
.config(DebugConfig.builder().bindAddress("0.0.0.0").build())
.addTab(NetworkDebugTab.create(senderClient = client))
.build()Risk:
bindAddress = "0.0.0.0"exposes the debug server (and your app's captured traffic) to everyone on the same network. Token auth still applies, but you lose the loopback boundary. Use it only on trusted networks, and preferadb forwardwhenever you can.
Browser login over LAN: add the browser's origin to
DebugConfig.allowedOrigins, e.g.allowedOrigins(listOf("http://192.168.1.42:8080"))for the address the browser shows. The/api/v1/_authroute is origin-checked like every other API route, and only a loopback host on the listening port counts as the server's own origin, so a page loaded over LAN is rejected with403until its origin is listed. Programmatic clients that send neitherOriginnorSec-Fetch-Site, such ascurlor the CLI, are unaffected.
A network security config is not needed for any of this: it governs the connections your app
makes, not the socket Lustro listens on. Lustro serves plain HTTP and needs no
cleartextTrafficPermitted entry and no usesCleartextTraffic in either workflow.
DebugTab is the only public extension point. Required: id (must match
[a-z][a-z0-9-]{0,30}, validated at registration), title, and icon. Everything else has a
default — including handle(request) for JSON routes and renderContent() for optional HTML
(API-only tabs are valid).
class FlagsTab(private val flags: FeatureFlagRepository) : DebugTab() {
override val id = "flags"
override val title = "Feature Flags"
override val icon = "🚩"
override fun renderContent() = """<div id="flags-list"></div>"""
// request.path is the remainder after /api/v1/flags/; null -> enveloped 404,
// anything thrown, even TODO(), -> enveloped 500 (it never escapes into your app).
override fun handle(request: DebugRequest): DebugResponse? = when (request.path) {
"list" -> DebugResponse.ok(flags.toJson())
else -> null
}
}Register it alongside the network tab:
Lustro.builder(application)
.addTab(NetworkDebugTab.create(senderClient = client))
.addTab(FlagsTab(flagsRepo))
.build()Asset and rendering conventions:
- Static assets live at
assets/lustro/<id>.{js,css,openapi.json}and are resolved by id at runtime. Returning non-empty strings fromrenderScript()/renderStyles()overrides the static.js/.css; returning a non-nullschema()overrides the static.openapi.json. - Tab JS is loaded as an external script after
shared.js(CSP:script-src 'self'). Usedata-actionattributes and event delegation — no inlineonclick/<script>handlers. Submit throughfetch(), not an HTML<form>: the CSP setsform-action 'none'. - Escape every value you render.
String.escapeHtml()makes a value safe as HTML text and inside a quoted attribute; the browser-sidedebugEscapeHtml(text)does the same in tab JS. Anything the app stores or a request carries is untrusted input to your tab. - Styling is free. Every tab page loads
shared.css— the console's design system: design tokens (surfaces, text ramp, semantic method/status/level/category palettes; dark + light themes) plus a documented component library (.dc-*and the shared.debug-*classes). Build on those and your tab matches the console in both themes with no extra CSS; seedocs/STYLEGUIDE.mdfor the contract and the sample flags tab for a working example. - JSON routes go through
handle(request); build responses with theDebugResponsefactories (ok,json { ... },text,bytes,notFound,error). For observable list routes,DebugResponse.cursorEnvelope(currentSequence, clientCursor) { /* items */ }implements the cursor envelope'sreset/unchanged/deltacontract — withCursorCodecfor the opaque tokens — so tabs don't hand-roll it. Advance the sequence only when the list changes, since each advance re-sends the whole list; other observable values go in itsstate. - Change state only on
POST,PUT,PATCH, orDELETE, never onGETorHEAD. The runtime rejects browser requests from other origins on every method, but for aGETorHEADit can go only bySec-Fetch-Site, which browsers send only to loopback and HTTPS addresses, and older ones not at all. Without that header, an<img>on a page from another port of the same host sends aGETthe runtime can't tell from the console's own, and it carries the console's cookie, since cookies aren't isolated by port. handle()runs off the main thread and calls may be concurrent — keep mutable tab state thread-safe. Blocking I/O is fine; the runtime enforces a per-request timeout.- When a request times out, the client gets a
504and the runtime cancels the request:request.isCancelledturnstrue, actions registered withrequest.onCancel { ... }run, and the handler's thread is interrupted. UseonCancelto abort blocking calls that ignore interrupts, such as cancelling theCancellationSignalpassed toSQLiteDatabase.rawQueryor an OkHttpCall. A handler that ignores cancellation keeps its concurrency slot until it returns. - Ship a schema to be agent-discoverable. Only tabs that expose a schema (a static
assets/lustro/<id>.openapi.jsonor a dynamicschema()) are listed in/api/v1/_meta. Schema-less tabs work in the browser UI but are invisible to agents.
Every tab is a JSON API under /api/v1/. Framework routes:
GET /api/v1/_meta— library/protocol versions and the schema-exposing tabs.GET /api/v1/_schema— JSON Schema for the shared envelopes.GET /api/v1/<id>/_schema— a tab's OpenAPI document.
Shared shapes: a uniform error envelope { error, message, code?, field?, hint? }, a
list pagination envelope { items, nextCursor }, and a live-polling cursor envelope
{ cursor, status, items? } where status is delta / unchanged / reset (unknown values →
reset) and the cursor advances when the route's list changes. The schemas and the SemVer
policy live in wire-protocol/v1/; the Network tab's contract is
lustro/src/main/assets/lustro/network.openapi.json.
For driving Lustro from agents, scripts, or the lustro CLI, see
docs/AGENTS.md.
- Can't connect from the browser. Run
adb forward tcp:8080 tcp:8080and confirm the app is in the foreground (the server only listens while foregrounded). Checkadb logcat -s LustroTokenfor the actual endpoint — if you setbindFallbackand the configured port was taken, the server is on an OS-assigned port that the log line reports. 401 unauthorized. The request is missing a valid token. Browsers: open via#lustro_token=<token>once (orlustro open). Programmatic clients: sendAuthorization: Bearer <token>. Get the token fromadb logcat -s LustroToken.- Nothing is captured. Make sure you added
lustro.networkInterceptor()to the client that actually makes the calls, after any URL/header/body-mutating interceptors. Check that capture isn't paused in the Network tab. ForHttpURLConnectiontraffic, you must opt in withcapturePlatformHttp = true.
| Module | Coordinates | What it is |
|---|---|---|
:lustro |
io.github.twinsen81:lustro |
Debug runtime AAR: embedded server, capture, built-in Network tab, mock storage, OkHttp adapters. |
:lustro-noop |
io.github.twinsen81:lustro-noop |
Release-safe no-op AAR mirroring :lustro's public facades with empty bodies. |
:lustro-api |
io.github.twinsen81:lustro-api |
Pure-Kotlin public SPI (DebugTab, DebugRequest/DebugResponse, Headers, MediaType, network seams). |
lustro-cli/ |
lustro-cli (PyPI) |
Python CLI that wraps the HTTP API, installing a lustro command; published alongside each release. |
See also: CONTRIBUTING.md · SECURITY.md · CHANGELOG.md · DECISIONS.md · docs/AGENTS.md