An on-device developer console for Android. Open a full inspector — network traffic, crashes, storage, feature flags, exports — right inside your debug app, by shake, floating button, or one line of code. When you want a bigger screen, the same data streams to any browser through an embedded web dashboard. Release builds compile against a no-op twin artifact, so none of this code ever ships to production, and the Gradle plugin fails the build if it would.
1.0 is out, and the public API is stable. From here, breaking changes to sdk:api need a
major version, new API a minor, everything else a patch — enforced, not just promised: every
published module carries a committed ABI baseline that fails the build on an unintended change.
See the changelog for the full policy.
1. Add the plugin and two dependencies to your app's build.gradle.kts:
plugins {
id("com.android.application")
id("io.github.devconsole-android") version "1.0.1"
}
dependencies {
debugImplementation("io.github.devconsole-android:devconsole:1.0.1")
releaseImplementation("io.github.devconsole-android:devconsole-noop:1.0.1")
}2. Open the inspector on the device. The SDK auto-initializes on debuggable builds, so this works immediately — from any button in your debug UI:
DevConsole.open(context)or hands-free, by opting into the built-in triggers — shake the device (intensity adjustable) or tap a draggable floating button:
DevConsole.initialize(
application,
DevConsoleConfig.default()
.withOpenTriggers(OpenTriggers(shakeToOpen = true, floatingButton = true)),
)That's the setup. The inspector shows crash/ANR reports, feature flags, and inspectors for SharedPreferences, SQLite, and files (read-only by default); wire your HTTP client with one line ("Wire up your network stack" below) and network, WebSocket, and MQTT traffic appears too.
3. Want a bigger screen? Start the browser dashboard — the same data live-tailing in any
browser, plus mock-rule editing and one-click HAR / Postman / bug-report exports. The easiest way:
tap Start server on the inspector's More screen, which then shows the connect URL and a QR
code. Or from code (either way, make sure your manifest has INTERNET — most apps already do):
lifecycleScope.launch { // startBrowser is a suspend function; the server never starts on its own
val result = DevConsole.startBrowser(StartRequest(bindingMode = BindingMode.LOOPBACK))
val connectUrl = (result as? StartResult.Started)?.access?.connectUrl
// e.g. http://127.0.0.1:8080/#code=B7KQ2XWZ — surface this in your debug UI
}adb forward tcp:8080 tcp:8080 # use the port from the DevConsole log linethen open the whole URL — the #code= fragment is the credential.
| Area | What it does |
|---|---|
| In-app inspector | DevConsole.open(context) shows every inspector below as an on-device screen (included with devconsole), plus a QR code for pairing the browser. Opens by shake (adjustable intensity) or draggable floating button via the opt-in DevConsoleConfig.openTriggers flags. Its More screen can also start and stop the dashboard server. |
| Network inspector | Every HTTP call with headers, bodies, and a DNS/TCP/TLS/send/wait/receive timing bar. Live-tails as traffic happens. |
| WebSocket & MQTT inspectors | Connection lifecycles and every frame, inbound and outbound. MQTT rides the Eclipse Paho adapter. |
| Mock rules | Serve canned responses for matching requests (OkHttp), toggled from the dashboard, with deterministic priority matching. |
| Request composer | Make the device issue ad-hoc HTTP requests from the dashboard. Off by default, host-allowlist confinable. |
| Crash & ANR capture | Uncaught exceptions and a main-looper watchdog with bounded all-thread dumps and breadcrumbs. Always delegates to any crash reporter you already have. |
| Push timeline | Record FCM (or any) push messages and their lifecycle: received → displayed → opened. The Firebase adapter uses reflection — no compile-time Firebase dependency. |
| State & feature flags | Snapshot host-registered state and override feature flags from the browser. |
| Data inspectors | Browse SharedPreferences, SQLite (incl. a SQL console), and app files. Read-only by default; every edit surface is opt-in. |
| Evidence tray & exports | Flag anything, attach it to a bug report bundle or Markdown/Jira/GitHub clipboard text. Export HAR, Postman Collection, or a full session ZIP. |
| Background keep-alive | Opt-in foreground service that keeps the server alive while your app is backgrounded. Manifest-only opt-in, zero SDK-declared permissions. |
Capture is category-scoped: DevConsoleConfig.withCaptureCategories(...) narrows what's recorded
(NETWORK, SOCKET, MQTT, PUSH, LOGS, CRASHES, STATE, INSPECTION, MOCKS — default
is all). Events persist in a Room database bounded by a retention policy (7 days / 100 MB by
default).
devconsole (debug) and devconsole-noop (release) expose the same public API. The no-op
twin records nothing, serves nothing, and links no server code — production safety comes from
dependency selection, not a runtime flag. The Gradle plugin
(io.github.devconsole-android) auto-wires the debug/release split if you omit the
dependencies, and verifies — declared dependencies, resolved runtime classpath, and final
APK/AAB bytes — that the full runtime never reaches a protected variant.
On debuggable builds the SDK auto-initializes from its own ContentProvider; no
Application.onCreate boilerplate needed. The server itself never auto-starts — you always
call startBrowser(...) explicitly.
DevConsole.open(context) opens the inspector from any trigger you like and returns an
InspectorOpenResult. To let the SDK open it without host code, opt into the triggers — both are
off by default, and neither ever starts the server:
DevConsoleConfig.default().withOpenTriggers(
OpenTriggers(
shakeToOpen = true,
shakeIntensity = ShakeIntensity.MEDIUM, // LIGHT | MEDIUM | FIRM
floatingButton = true,
),
)The floating button is draggable — press and move it anywhere on screen, and it stays put across Activity changes and rotation (it re-clamps into the window so it can never be stranded off-screen). It rests at 65% opacity so it doesn't hide the UI underneath, and goes fully opaque while you're touching it. A drag never counts as a tap, so moving it won't open the inspector.
Both triggers are UI-framework agnostic: the button is a plain ImageView added to the
Activity's decor view and the shake detector is a sensor listener, so an XML/Views or Java host gets
exactly the same behaviour as a Compose one — no ui-compose or ui-views dependency involved.
Java: OpenTriggers.builder().shakeToOpen(true).floatingButton(true).build(), as
views-java-app does.
Java: DevConsoleConfig.builder().openTriggers(OpenTriggers.builder().shakeToOpen(true).build()).
No code required: the inspector's More screen has Start/Stop buttons, and once running it shows the live connect URL — as text, a copy button, and a QR code to scan from another machine.
That button binds loopback by default, so the URL it shows needs adb forward. To make it bind
LAN instead — so the QR code is scannable from another machine with no forwarding — declare it on
the config, since the button issues no StartRequest of its own:
DevConsoleConfig.default().withBrowserConfig(BrowserConfig(binding = BrowserBinding.LAN))This is independent of the bindingMode you pass to startBrowser yourself; set both if you start
the server from your own UI too. Read the threat model before choosing LAN.
From code:
// Optional — auto-init already ran on debuggable builds. Call it yourself to customize:
DevConsole.initialize(application, DevConsoleConfig.default())
// startBrowser and stop are suspend functions — call them from a coroutine:
val result = DevConsole.startBrowser(StartRequest(bindingMode = BindingMode.LOOPBACK))
when (result) {
is StartResult.Started -> {
result.endpoint // host + port actually bound
result.access.connectUrl // the full credential URL — treat as a secret
}
is StartResult.PermissionRequired -> { /* LAN only: request result.permission */ }
else -> { /* NoEligibleNetwork, ServerUnavailable, ... */ }
}
DevConsole.stop(StopReason.UserRequested)Java is fully supported via async variants and builders:
DevConsole.initialize(getApplication(), DevConsoleConfig.builder().build());
StartRequest request = new StartRequest(BindingMode.LAN, new kotlin.ranges.IntRange(8080, 8099));
DevConsole.startBrowserAsync(request, result -> runOnUiThread(() -> {
if (result instanceof StartResult.Started) {
StartResult.Started started = (StartResult.Started) result;
String url = started.getAccess().getConnectUrl();
}
}));After startBrowser, filter Logcat on the DevConsole tag:
I/DevConsole: Dashboard available at: http://127.0.0.1:8080/ (access link available through the DevConsole API/launcher; binding: LOOPBACK)
The session code is deliberately absent from Logcat. The full URL — with its
#code=<session code> credential fragment — is available only from
StartResult.Started.access.connectUrl, DevConsole.accessInfo(), and the device's More screen
(as text and QR code). The port is the first free one in 8080–8099, so read it from the log
rather than assuming 8080.
- Loopback (default): run
adb forward tcp:<port> tcp:<port>, then open the connect URL. - LAN: pass
BindingMode.LANand open the URL from any device on the same network — read the threat model first; the dashboard speaks plaintext HTTP. A LAN start that finds no eligible network interface returnsStartResult.NoEligibleNetwork— it never silently falls back to loopback (or vice versa).
Open the whole URL. The #code= fragment is the credential: single-use, expires in five
minutes, creates a session immediately with no approval step. Bare http://host:port/ sits
unauthenticated forever. Issue a fresh code from the device if one lapses.
// OkHttp / Retrofit — capture + timing in one call:
val client = OkHttpClient.Builder()
.installDevConsole(DevConsole.networkRecorder())
.addInterceptor(DevConsoleMockInterceptor(DevConsole.mockEngine())) // optional, mocks
.build()
// Ktor client, any engine (captures request + response bodies, textual, up to 256 KiB each):
val ktor = HttpClient { install(DevConsoleKtorClientPlugin) { recorder = DevConsole.networkRecorder() } }
// Ktor on the OkHttp engine — only needed for DNS/TCP/TLS timing phases and mock rules, which the
// plugin above can't provide on any engine. Skip the plugin and instrument the engine instead:
val ktorOkHttp = HttpClient(OkHttp) {
engine { config { installDevConsole(DevConsole.networkRecorder()) } }
}
// WebSocket (OkHttp): inbound + outbound
val socket = client.newWebSocket(request, DevConsoleOkHttpWebSocketListener(DevConsole.socketRecorder()))
val recording = DevConsoleRecordingWebSocket.wrap(socket, DevConsole.socketRecorder())
// MQTT (Eclipse Paho):
val publisher = DevConsolePahoMqtt.install(mqttClient, DevConsole.socketRecorder())
// Push (from FirebaseMessagingService.onMessageReceived):
DevConsole.recordPush(FirebaseRemoteMessageAdapter().toPushInput(remoteMessage))
// Anything else (Cronet, Volley, custom): record directly — never throws, never blocks:
DevConsole.networkRecorder().record(requestInput, responseInput, startedAtMs, completedAtMs)One capture bound worth knowing up front: response bodies are captured up to a cap — 512 KiB on
OkHttp (chunked/unknown-length bodies via a non-blocking tee, recorded at EOF or close), 256 KiB on
Ktor. text/event-stream and known-binary bodies stay metadata-only on both, since a live SSE feed
is for all practical purposes endless. See
Chunked and streaming responses for how
the OkHttp tee works.
Java equivalents exist throughout (e.g. DevConsoleOkHttp.install(builder, recorder)). Details:
docs/NETWORK_ADAPTERS.md, docs/MQTT_CAPTURE.md,
docs/PUSH.md.
Group io.github.devconsole-android, one version for everything. Two coordinates cover a normal
integration (there is deliberately no BOM):
| Coordinate | Scope | What it is |
|---|---|---|
devconsole |
debugImplementation |
The full debug runtime: server, dashboard, capture, storage, exports, OkHttp adapters. |
devconsole-noop |
releaseImplementation |
Same API, does nothing, links nothing. |
Opt-in add-ons (each -noop twin is the matching releaseImplementation):
| Coordinate | Release twin | Adds |
|---|---|---|
devconsole-ui-compose |
(none — debugImplementation only) |
Compose launcher-panel API (the on-device inspector itself already ships inside devconsole; name this coordinate only to call the panel composables) |
devconsole-ui-views |
(none — debugImplementation only) |
DevConsolePanelView launcher for XML/Views hosts |
devconsole-network-ktor |
(none) | Ktor HttpClient capture plugin (full request + response body capture, any engine; see Network adapters) |
devconsole-socket-paho |
devconsole-socket-paho-noop |
MQTT capture via Eclipse Paho |
devconsole-push-firebase |
devconsole-push-firebase-noop |
FCM push adapter (reflection-based) |
Every other module (devconsole-core, devconsole-storage-room, …) arrives transitively — you
never name it. All modules publish sources, javadoc, and signed POMs.
Short version: nothing DevConsole needs reaches your release build, so there is nothing to
declare to Google Play and nothing for a store reviewer to ask about. The permissions live in
devconsole (a debugImplementation); devconsole-noop, which is what your release variant
compiles and ships against, declares none at all.
| Permission | Declared by | Why it exists | In your release build? |
|---|---|---|---|
ACCESS_LOCAL_NETWORK |
DevConsole (devconsole) |
Android 17 (API 37) gates local-network access. Without it a LAN-bound dashboard binds and then silently serves nobody — see LAN permission. Requested at runtime only when you start in LAN mode. | No |
ACCESS_NETWORK_STATE |
DevConsole (devconsole) |
Normal (non-runtime) permission, used only to record connectivity-change markers on the timeline. | No |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_SPECIAL_USE |
You, in src/debug |
Opt-in only. Lets the dashboard server survive your app being backgrounded — keep-alive. Omit them and you simply don't get the feature; DevConsole declares neither. | No (you put them in the debug manifest) |
POST_NOTIFICATIONS |
You, in src/debug |
Optional. Only decides whether the keep-alive notification is visible — the service runs either way. DevConsole never requests it unprompted; the inspector offers it. | No (same) |
INTERNET |
You, in src/main |
Binding the dashboard's TCP socket needs it. Not DevConsole-specific — most apps already declare it for their own networking, which is why DevConsole doesn't add it for you. | Yes — but it's yours, and almost certainly already there |
Verify it yourself on any build, rather than taking the table's word for it:
aapt2 dump permissions app/build/outputs/apk/release/app-release.apkThe Gradle plugin also enforces the split mechanically: verifyDevConsoleProtectedArtifacts fails
the build if the full runtime reaches a protected variant, checking declared dependencies, the
resolved runtime classpath, and the final APK/AAB bytes. See
Build variants and production safety.
DevConsole is a debugging tool that intentionally exposes your app's internals to a browser. The design keeps that safe by default, but you should know exactly where the edges are:
- The dashboard speaks plaintext HTTP. There is no TLS. In LAN mode, anyone who can observe
your network packets can read everything the dashboard shows — headers, tokens, bodies, exports.
Loopback +
adb forwardis the default for this reason; LAN is always an explicit opt-in. - The connect URL is a credential. Possession of a live
#code=fragment creates a session — no on-device approval step. Codes are single-use with a five-minute TTL; sessions last 30 minutes. Treat the URL like a password; the device's More screen can revoke sessions. - Redaction is an allowlist. ~25 well-known field names (plus
Bearertokens) are masked. Custom header names, signed-URL query params, and PII inside bodies pass through verbatim. See docs/SECURITY_AND_REDACTION.md. - Screenshots cannot be redacted — pixels don't have field names. Screenshot capture is off
by default and everything it produces is marked
UNREDACTED. - Editing is off by default. Preferences/database/file writes, mock editing, and capture-rule
editing are each gated per surface via
EditingCapabilities; the composer and state mutation have their ownDevConsoleConfigflags. Everything defaults to off/read-only.
The full analysis, including what a malicious network peer or co-installed app can and cannot do: docs/THREAT_MODEL.md.
Three runnable samples under samples/ cover every integration style:
| Sample | Stack | Posture |
|---|---|---|
compose-app |
Jetpack Compose | Everything unlocked: all editing capabilities, composer, screenshots, OkHttp + Ktor (CIO) network demos, MQTT + WebSocket demos, in-app inspector, shake + floating-button open triggers |
foundation-app |
Stock widgets, no UI framework | Everything at its locked-down default — the read-only contrast |
views-java-app |
Java + XML, ui-views panel |
Middle ground: mocks and capture rules editable, data read-only, async Java APIs, shake-to-open (LIGHT) + floating button — the same draggable one Compose hosts get, since it is a plain View on the decor view, not a Compose feature |
./gradlew :samples:compose-app:assembleDebugEach sample seeds preferences, a SQLite table, and files so the inspectors have real content, and includes a hazard section that triggers a real crash and a real ANR.
minSdk |
23 |
compileSdk / targetSdk |
35 |
| Android Gradle Plugin | 8.13.0 (built against; the Gradle plugin supports AGP 8.x–9.x hosts) |
| Gradle | 8.9+ (including 9.x) |
| Kotlin | 2.2.20 |
Full index: docs/README.md. Highlights:
- Threat model and safe operation — start here before LAN mode
- Getting started: Compose · XML/Kotlin · XML/Java
- Build variants and production safety
- Network adapters · MQTT capture · Push
- Data inspectors and exports · Evidence and bug reports
- Crash and ANR capture · Background keep-alive
- FAQ / troubleshooting
See CONTRIBUTING.md for build prerequisites and the test/lint/API-check
commands. CI runs the same ./gradlew tasks on every PR.



