Skip to content

Latest commit

 

History

76 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Lustro

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, and it shows the messages of WebSocket connections. 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-SNAPSHOT line exists today (Sonatype Central snapshots); there is no stable release yet.

With a coding agent

A coding agent can add Lustro from this README. Give it the repository, for example "Add Lustro to this app, following https://github.com/Twinsen81/Lustro". llms.txt summarizes Lustro for agents and links the rest of the docs, and context7.json tells Context7 which parts of this repository to index. To use a running Lustro from an agent or a script, see docs/AGENTS.md.

Install

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.

Quick start

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()

If the app uses WebSockets, create them with lustro.webSocketFactory(httpClient) to see their messages too: see WebSocket messages.

Add every tab before build(). start() freezes the tab registry, and addTab() after it throws IllegalStateException. start() returns a LustroStatus (ENABLED once armed, DISABLED if it cannot start) and is idempotent, as is stop().

Call start() on the main thread, as Application.onCreate() does. It adds an observer to the process lifecycle, which accepts observers only on the main thread, so from any other thread start() returns LustroStatus.DISABLED.

Three things keep that snippet out of production, in order of how much they depend on you:

Your DebugTab subclasses must live in src/debug. They are your code, so swapping :lustro for :lustro-noop cannot 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 a DebugTab subclass reachable from any source set other than src/debug (e.g. src/main or src/release). The builder call above is not flagged — it resolves to whichever facade the variant depends on. Putting the whole bootstrap under src/debug/ too (as the :sample app 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), the Lustro.builder(...) / .addTab(...) / start() calls in the snippet compile unchanged and do nothing: no socket, no capture, networkInterceptor() forwards every request, and webSocketFactory(client) returns client itself.

The runtime refuses to start in a non-debuggable build. If the real :lustro runtime ever reaches a build without android:debuggable, start() logs a WARN and returns LustroStatus.DISABLED without binding a socket — no Gradle wiring and no lint run required. An internal build that ships android:debuggable="false" but still wants the server can opt in with DebugConfig.builder().allowNonDebuggableBuilds(true).

Accessing the UI

The server is bound to loopback (127.0.0.1) on the device, so reach it from your desktop over adb port forwarding:

  1. Forward the port from your machine to the device:

    adb forward tcp:8080 tcp:8080
  2. Open http://localhost:8080 in a desktop browser.

  3. The server is token-authenticated (always on). Read the token from logcat — Lustro logs one machine-parseable line at the LustroToken tag on every successful bind:

    adb logcat -s LustroToken
    # Lustro ready endpoint=http://127.0.0.1:8080 token=<token>
  4. Authenticate the browser with that token, either by:

    • using the lustro CLI (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 an HttpOnly; SameSite=Strict cookie, then strips the fragment from the address bar.

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.

OkHttp capture setup

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.

Compressed bodies are captured decoded. OkHttp inflates a gzip response on its own only when it added Accept-Encoding itself. If your app sets that header, or compresses request bodies in an interceptor added before Lustro's, the interceptor sees the compressed bytes. Lustro inflates a body in gzip, x-gzip, or deflate before it stores it, and keeps at most DebugConfig.maxBodyCaptureBytes of the inflated body; the size the Network tab shows for it is the compressed size. Platform HttpURLConnection capture does the same. Lustro does not decode Brotli (br) or any other encoding: for such a body it keeps only the size, and the Network tab says why. To see Brotli bodies from OkHttp, add OkHttp's BrotliInterceptor (from okhttp-brotli) after Lustro's interceptor: it then decodes the body before Lustro captures it, as OkHttp does with its own gzip. Event streams are captured as they arrive, without decoding.

Leave traffic out with a capture filter. Pass a NetworkCaptureFilter as captureFilter, and Lustro asks it about each request before it captures anything. A request it returns false for is never stored, so it costs no redaction and no part of the capture budget. Use it for traffic you never inspect, such as calls to an analytics host, or for requests your app marks with a header of its own:

NetworkDebugTab.create(
    senderClient = client,
    captureFilter = NetworkCaptureFilter.of("Analytics calls, and requests marked X-No-Capture") { request ->
        request.url.toHttpUrlOrNull()?.host != "analytics.example.com" &&
            request.headers.get("X-No-Capture") == null
    },
)

The description is required, because a teammate who doesn't know about your filter needs it. Once the filter skips a request, the Network tab shows a ⚠ next to the request count, with the number skipped in its tooltip, and a click on it shows the description, how many requests the filter skipped, and how many it failed on. The counts start again when you clear the list. The poll state reports the same as captureFilter, for the CLI and agents.

The filter decides capture only: mock rules and the throttle still apply to a request it skips, as they do while capture is paused. It applies to platform HttpURLConnection capture too. It runs on the thread that makes the call, so keep it fast and thread-safe, and it sees the URL and headers before redaction. A filter that throws doesn't fail the call: the request is captured, and the first failure is logged. A marker header still goes out with the request.

To label traffic instead of leaving it out, pass a NetworkClassifier as classifier. Its labels show as category filters in the Network tab and in each transaction's categories.

WebSocket messages

The interceptor shows only the handshake of a WebSocket, as a request with the status 101, because OkHttp sends nothing else through interceptors. To see the messages, create your sockets with the factory that lustro.webSocketFactory(...) returns:

val sockets: WebSocket.Factory = lustro.webSocketFactory(httpClient)

val socket = sockets.newWebSocket(request, listener)

OkHttpClient is a WebSocket.Factory, and the returned factory wraps the one you pass. The Network tab's WebSockets view then lists each connection with its state, its close code or the exception it failed with, and a log of its events in order: each message, text or binary, sent or received, and the open, the close frames, cancel(), and the failure. A click on a message shows its payload in the viewers that bodies use, or as a hex dump for a binary one. When httpClient also carries lustro.networkInterceptor(), the connection and its handshake request link to each other. In release, the no-op artifact returns httpClient itself.

A socket from the factory behaves as one from the client. Your listener gets the same calls, with the socket that newWebSocket returned. send, close, and queueSize return what OkHttp returns, and what your listener throws reaches OkHttp. Capture adds a small, constant cost to each message on the thread that sends or receives it: it cuts, redacts, and stores the payload on a thread of its own. When that thread is behind, capture drops messages and counts them, and the view says how many. It never makes the socket wait.

A sent message is not a delivered message. send() returns true when OkHttp put the message in its queue. The log shows that return value, and it marks a message that send() refused as not sent.

Give the factory to a library that takes one. These accept a WebSocket.Factory:

Library Where the factory goes
Ktor client, OkHttp engine HttpClient(OkHttp) { engine { preconfigured = httpClient; webSocketFactory = sockets } }
Apollo Kotlin 3 and 4 ApolloClient.Builder().webSocketEngine(DefaultWebSocketEngine(sockets))
Apollo Kotlin 5 WebSocketNetworkTransport.Builder().webSocketEngine(WebSocketEngine(sockets)), passed to subscriptionNetworkTransport(...) after okHttpClient(...)
socket.io-client-java IO.Options().apply { webSocketFactory = sockets; callFactory = httpClient }
Scarlet 0.1.x OkHttpWebSocket.Factory(establisher), where your OkHttpWebSocket.ConnectionEstablisher calls sockets.newWebSocket(request, listener)

A library that takes only an OkHttpClient, or that builds its own, can't use the factory. Lustro then shows only the handshake, when that client carries the interceptor.

The hooks you already have apply to sockets. The capture filter is asked once for each socket, when the app creates it, with the request you passed to newWebSocket; a socket it skips is not listed, and neither is its handshake. The classifier labels a connection by its URL. Pause stops the recording of messages, and Clear removes the connections. A socket lives for a long time, so one that is still open is listed again with its next message, after a clear and after a pause. A close or a failure of a listed connection is recorded while capture is paused, so a connection never shows as open after it closed.

Redaction. The URL and the handshake headers go through the Redactor, as a request's do. The text of a message goes through redactBody, with no content type, so the default redactor masks a token or a password field in a JSON message. Then Redactor.redactWebSocketText gets the result, and can mask more or keep the payload out of the store:

object MyRedactor : Redactor by DefaultRedactor {
    override fun redactWebSocketText(text: String, message: WebSocketMessageInfo): String? =
        if (message.isOutgoing && text.startsWith("AUTH ")) null else text // null stores only the size
}

A binary message is stored as it arrived, because a redactor that matches on names can't read it. Override redactWebSocketBinary to change its bytes, or return null to store only its size.

Limits. A long-lived socket must not fill the heap. A payload is cut at DebugConfig.maxBodyCaptureBytes (256 KB), and the log keeps its whole size. A connection keeps its last maxWebSocketEvents events (1000), Lustro keeps maxCaptureWebSockets connections (100), and all stored payloads together stay under webSocketCaptureBudgetBytes (16 MB): past it, the connection that holds the most loses its oldest events. The view says how many events a log no longer has.

Not covered. Lustro records complete messages, as OkHttp's public API gives them. It does not show frames, fragmentation, compression, or the ping and pong frames that OkHttp sends on its own, and it records only OkHttp sockets.

Send Request

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.

Export

The Network tab's Select button adds a checkbox to each row. Export HAR saves the selected requests as a HAR 1.2 file, which browser devtools and most HTTP tools import, and Copy Markdown copies them as one Markdown document for a bug report, a pull request, or a chat. The detail's Markdown button copies one request. A filter change keeps only the selected requests that it still shows, so an export has the rows on screen. Both formats have the redacted values that the tab shows, and say when the capture cut a body short. Agents and scripts get the HAR from GET /api/v1/network/transactions/_/export or lustro net export --har FILE.

Mock rules

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.

Platform HttpURLConnection capture

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 HttpURLConnection traffic in the process. A capture filter leaves out the traffic you don't need, such as an analytics SDK's.
  • Not covered by the library's binary- or behaviour-compatibility guarantees, and may degrade across OS/SDK versions.
  • Checked in CI on API 26, 35, and 36: the capture creates the platform's internal com.android.okhttp URL handlers by reflection. Every release checked, from API 26 to 37, allows this, but Android lists these constructors as unsupported hidden APIs, so a later release can block them. An instrumented test fails on a release that blocks them, after that release is in the CI matrix. The test runs as an app that targets SDK 35, so it does not show a block that applies only to a higher target SDK.

Security model

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 via adb 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 an HttpOnly; SameSite=Strict cookie set via /api/v1/_auth. Before auth, only framework chrome is served — no tab output or captured data. The token is logged at the LustroToken tag.
  • Tabs never see the credentials. The server authenticates a request before dispatching it, and the DebugRequest a tab receives carries no Authorization or Cookie header, 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'; ...) plus X-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 inline style= attributes and <style> blocks. Every API request, whatever its method, passes an Origin / Sec-Fetch-Site check: the server's own origin is always allowed, and any other cross-origin caller must be listed in DebugConfig.allowedOrigins (other localhost ports are not trusted by default).
  • Capture-time redaction, best-effort. A Redactor masks 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 own Redactor when your traffic needs more, or a capture filter to keep a request out of capture entirely. 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. The text of a WebSocket message is redacted as a body is; a binary message is stored as it arrived (see WebSocket messages).
  • 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.allowNonDebuggableBuilds opts an internal build back in).

LAN exposure and port forwarding

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 prefer adb forward whenever 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/_auth route 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 with 403 until its origin is listed. Programmatic clients that send neither Origin nor Sec-Fetch-Site, such as curl or 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.

Custom tabs

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 from renderScript() / renderStyles() overrides the static .js / .css; returning a non-null schema() overrides the static .openapi.json.
  • Tab JS is loaded as an external script after shared.js (CSP: script-src 'self'). Use data-action attributes and event delegation — no inline onclick/<script> handlers. Submit through fetch(), not an HTML <form>: the CSP sets form-action 'none'.
  • Escape every value you render. String.escapeHtml() makes a value safe as HTML text and inside a quoted attribute; the browser-side debugEscapeHtml(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 .dc-* component library. Build on those and your tab matches the console in both themes with no extra CSS; see docs/STYLEGUIDE.md for the contract and the sample flags tab for a working example.
  • JSON routes go through handle(request); build responses with the DebugResponse factories (ok, json { ... }, text, bytes, notFound, error). For observable list routes, DebugResponse.cursorEnvelope(currentSequence, clientCursor) { /* items */ } implements the cursor envelope's reset/unchanged/delta contract — with CursorCodec for 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 its state. For a list that only grows at its end, such as a log, DebugResponse.streamEnvelope(...) implements the stream envelope: a poll gets only the entries after its cursor.
  • Change state only on POST, PUT, PATCH, or DELETE, never on GET or HEAD. The runtime rejects browser requests from other origins on every method, but for a GET or HEAD it can go only by Sec-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 a GET the 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 504 and the runtime cancels the request: request.isCancelled turns true, actions registered with request.onCancel { ... } run, and the handler's thread is interrupted. Use onCancel to abort blocking calls that ignore interrupts, such as cancelling the CancellationSignal passed to SQLiteDatabase.rawQuery or an OkHttp Call. 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.json or a dynamic schema()) are listed in /api/v1/_meta. Schema-less tabs work in the browser UI but are invisible to agents.

OpenAPI and wire protocol

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. A stream envelope { cursor, status, items?, dropped? } has the same statuses for a list that only grows at its end: its delta carries only the entries after the cursor. 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.

Troubleshooting

  • Can't connect from the browser. Run adb forward tcp:8080 tcp:8080 and confirm the app is in the foreground (the server only listens while foregrounded). Check adb logcat -s LustroToken for the actual endpoint — if you set bindFallback and 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 (or lustro open). Programmatic clients: send Authorization: Bearer <token>. Get the token from adb 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. For HttpURLConnection traffic, you must opt in with capturePlatformHttp = true.
  • A WebSocket shows only its handshake. The interceptor can't see messages. Create the socket with lustro.webSocketFactory(httpClient), or give that factory to the library that creates it: see WebSocket messages.

Modules

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

License

Apache License 2.0

About

An Android in-app debug library (browser and agent-friendly)

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages