AppInspect
On this page
Safety and privacy

Security model

A tool that reads your app's traffic, storage and crashes has to be unambiguous about one thing: it must never run in the hands of a real user. This page explains why it cannot, what a debug build deliberately does expose, and where the sharp edges are.

The short version

  • Two independent layers keep the inspector out of production, and either one alone would be sufficient. The release artifact's inertness is checked by CI on every build rather than asserted in a document.
  • No network calls of its own. The library observes the traffic your app makes and sends nothing anywhere — zero telemetry, no account, no backend. That is a property of what the source can express, not a setting. See data handling.
  • Nothing gets in and nothing leaks out sideways. No component is reachable from another app, and the library never writes captured payloads, headers or traces to Logcat.
  • Debug builds are open on purpose. That is the trade, and it is configurable.

Layer 1 — the code is not in the release APK

The recommended setup puts a different artifact in your release variant:

app/build.gradle.kts
releaseImplementation(
    "io.github.appinspect:appinspect-no-op:<latest-version>"
)

appinspect-no-op contains a pass-through OkHttp interceptor and nothing else. Inspection code is absent from the release APK — not disabled, not dead-code-eliminated, absent.

It exists so that shared code compiles: your OkHttpClient builder can call .addAppInspectInterceptor() in every variant without a BuildConfig.DEBUG branch. In release, that call returns the builder unchanged.

What actually ships, in full

This is not a summary. With releaseImplementation("…:appinspect-no-op:<latest-version>") and no flags set, the following is the entire contents of what reaches your production APK — about 8 KB.

AAR entryContents
AndroidManifest.xmlA manifest and a <uses-sdk>. No <application> block, so no activity, service, receiver or provider — and no permissions.
classes.jarFour classes: the two interceptors, the extension function, and one config data class.
R.txtEmpty.
—No res/, no assets/, no jni/, no native libraries.
POMOne dependency: kotlin-stdlib.

Disassembling every method in those four classes gives their complete set of external references: OkHttp's Interceptor.Chain (only request() and proceed()), Kotlin's null-check intrinsics, Object/String, and its own config class. Not one android.* API is referenced — there is no Context, no file handle, no database, no sensor, no reflection, and no means of obtaining any of them. The whole interceptor body is return chain.proceed(chain.request()).

So in a release build with the no-op, these are not “disabled” — they are absent: network capture, response mocking, the inspector UI, SQLite storage, preferences and DataStore access, the crash handler, ANR collection, WorkManager reading, logcat capture, notifications, the shake listener, the launcher shortcut, and export. There is no code present that could do any of them.

And it is enforced by the build, not by review. A verification task opens the assembled artifact and fails if the manifest gains any component or permission, if any resource, asset or native payload appears, or if any class outside the interceptor stubs turns up. It runs as part of the library's check, and the Maven Central release task depends on it — so the artifact you would ship to production cannot be published without passing. That is the part worth telling a security reviewer: the claim is verified mechanically on the one path that matters, not asserted in a document.

Layer 2 — and if it ships anyway, it refuses to run

Dependency mistakes happen. Someone writes implementation instead of debugImplementation; a new variant inherits the wrong configuration. So the full module does not trust the build setup: it checks FLAG_DEBUGGABLE itself, and when the build is not debuggable it disables itself completely.

Concretely, in that state it:

  • never creates appinspect_storage.db on disk — it falls back to an in-memory store, so your app's storage footprint is unaffected;
  • never installs the crash handler and never starts the ANR and native-crash collector, and never writes the appinspect_internal_state bookkeeping file either;
  • never registers lifecycle callbacks, sensors, notification channels or the launcher shortcut;
  • never spawns a logcat process — the Logcat page is only reachable from an inspector that refuses to open;
  • turns the OkHttp interceptor into a pure pass-through, while captureNetworkEvent and captureCrashEvent independently re-check the gate and drop silently;
  • refuses every open() call;
  • loads no mock rules, runs no mock branch, and never writes the adb-pullable mirror file — and deletes a mirror that an earlier enabled install left behind.

That last point is worth noticing: the gate is not one check at startup. Capture, mocking and opening each re-verify it independently, so there is no single flag to flip.

How a build resolves to a tier — and how a legitimate non-debuggable staging build opts in without any of this reaching your release variant — is on builds and environments.

There is no runtime switch for production

The library cannot be turned on in a production build by a remote flag, an intent, a debug menu or a reflective call. Enabling it requires the host app to ship both allowInNonDebugBuilds = true and allowInProductionBuilds = true in the configuration compiled into the APK — two separate, deliberate opt-ins that only exist for teams with an unusual internal-distribution setup.

If you turn it on in production anyway

A library cannot defend against its owner deliberately enabling it, and the site would be dishonest if it pretended otherwise. Shipping the full library with the opt-in resource in your release build will run the inspector for real users. It is an explicit line in your own build script, reviewable in a diff — the same trust level as writing implementation instead of debugImplementation.

If you do it even briefly — a few minutes of testing against production — understand that it is everything, not a sample: capture writes real user traffic including Authorization headers to the device, the crash handler installs, the launcher shortcut publishes, notifications post, Logcat capture becomes reachable, and response mocking can serve canned data to real users. Before you try it, set allowResponseMocking = false and keep mocks.json out of src/main/assets/ — and note that a device override persisted by an earlier enabled build on the same applicationId can turn mocking on even with no mocks.json present at all. allowResponseMocking = false short-circuits ahead of that override, the file flag and every rule, which is why it is the one switch to reach for.

What the library cannot do, in any build

The two layers above are about where the inspector runs. This is a different kind of assurance: some things are not features that can be switched on, because the capability is not in the source at all. These hold in every build tier, including debug.

Looked forFound
java.net.URL, HttpURLConnection, Socket, OkHttpClient(), newCallNone. The library cannot open a connection of its own. Zero telemetry by construction rather than by policy.
DexClassLoader, System.loadLibrary, createPackageContextNone. No dynamic code loading, no native code, no reaching into other packages.
Log.*, println, System.outNone. The library never writes a line to logcat — which is why it can read the log without polluting it.
Runtime.getRuntime, ProcessBuilderOne: the logcat tail, scoped to the app's own pid and needing no permission. It is constructed only by the Logcat page, so a disabled build never spawns it, and it is absent entirely under the no-op.
Reflection over your classesNone. The only Class.forName calls name AppInspect's own modules. No annotation, classpath or ServiceLoader scanning.
Exported componentsNone. All three components in the full library are exported="false"; the no-op has none at all. There are no services or receivers anywhere, so there is no IPC surface even in debug.

The one place the library writes outside app-private storage is the mock rule mirror, and that is gated on the build tier — a build that cannot mock does not merely skip the write, it deletes a file an earlier enabled install left behind, because the external files directory survives an app upgrade.

Hardening in every build type

These apply whether the inspector is enabled or not.

  • No exported components. The inspector activity, the startup initializer and the file provider are all exported="false". Nothing is reachable from another app, with or without root.
  • No outbound traffic. The library never makes a network call. It has no server to talk to.
  • No Logcat output of captured payloads, headers or crash traces — which matters, because Logcat is readable by adb and, historically, by other tooling.
  • Notifications carry only the method, status code, host and path.
  • Export sharing is scoped. Files go through a non-exported FileProvider restricted to cacheDir/appinspect_exports/, with per-URI grants. It cannot be tricked into serving another path.

Debug builds are open, on purpose

Inside an enabled build, the inspector shows real data. Auth headers, tokens, cookies, database contents, DataStore values, decrypted EncryptedSharedPreferences, full crash stack traces and the app's own log output are all visible. That is not an oversight — a debugging tool that hides the value you are debugging is useless.

What follows from that:

  • Request headers are read off the wire, so they include credentials attached downstream of AppInspect — a host auth interceptor, an Authenticator retry, the cookie jar — not only the headers your calling code set.
  • Captured data is persisted in app-private SQLite with retention caps (300 network events, 50 crashes by default).
  • Enabled builds should go to trusted development, QA and internal-test audiences only.
  • The Logcat panel is the easiest one to underestimate: OkHttp's own HttpLoggingInterceptor prints Authorization headers straight to logcat, so a log tail can carry a token even when every other panel is clean. Its Redact toggle covers both the view and the export.
  • For a wider audience, reduce what is visible and editable with the levers on the configuration reference. showRawSensitiveValues = false masks configured sensitive values before storage, not just in the UI — and log redaction follows it by default.

Response mocking has extra gates

Mocking is the only feature that changes application behaviour rather than observing it, so it is gated more heavily than everything else. Five checks must all pass before a rule can fire — the full list, and how to work down it when a rule is not firing, is on the Mocks page. The relevant point here is that it fails closed: with no mocks.json and no device override, mocking is off.

Three things to weigh if mocking is in scope for your review:

  • Rules are not redacted. Redaction applies to captured traffic. A rule is developer-authored configuration and is stored verbatim — in the internal database, in your app's mocks.json, and in the mirror file. Real credentials must never go into a mock response body.
  • The mirror file is app-scoped external storage. It lives at <external-files>/appinspect/mocks.json so that adb pull works without run-as. It is removed when the app is uninstalled and never written on a build that cannot mock — but it is not app-private the way internal storage is. Treat it like an export.
  • A committed mocks.json belongs in src/debug/assets/. Assets are not stripped per variant the way code is, so a file under src/main/ is packaged into the release APK and is readable by anyone who unzips it — even though nothing in it can be served in production.

And if you enable the library in a non-debuggable build, set allowResponseMocking = false. A shared staging build that can rewrite live traffic is a support incident waiting to happen.

The library's own storage

What AppInspect writes to a device, how long it keeps it, and what leaves is covered in full on data handling — including the retention caps, the mock rule mirror file, export scoping and the auto-backup caveat.

The part that belongs here is what happens when the library is disabled: no database file, no internal state file, no mirror, nothing captured. And one thing worth stating plainly in a security context — SharedPreferences and host database edits made in the inspector are written straight to your app's real storage. There is no sandbox and no undo.

If you are reviewing this for an organisation

The questions that usually come up, answered in one place:

QuestionAnswer
Does it phone home?No. The library makes no network calls of its own and has no backend.
Can it run in production?Not unless two separate opt-ins are compiled into the APK, and the recommended setup does not compile the code in at all.
What data leaves the device?Only files a person explicitly shares, through the Android share sheet. See data handling.
Can another app read what it captured?No. Storage is app-private, no component is exported, and export files are served through a scoped, non-exported FileProvider.
How do we verify that ourselves?Unzip the release AAR: four classes, no manifest components, no permissions, no resources. The library's own CI fails if that changes.
What is the residual risk?Shared exports. They contain real credentials by design — that is a process control, and the QA checklist is the process.