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:
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 entry | Contents |
|---|---|
AndroidManifest.xml | A manifest and a <uses-sdk>. No <application> block, so no activity, service, receiver or provider — and no permissions. |
classes.jar | Four classes: the two interceptors, the extension function, and one config data class. |
R.txt | Empty. |
| — | No res/, no assets/, no jni/, no native libraries. |
| POM | One 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.dbon 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_statebookkeeping file either; - never registers lifecycle callbacks, sensors, notification channels or the launcher shortcut;
- never spawns a
logcatprocess — the Logcat page is only reachable from an inspector that refuses to open; - turns the OkHttp interceptor into a pure pass-through, while
captureNetworkEventandcaptureCrashEventindependently 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 for | Found |
|---|---|
java.net.URL, HttpURLConnection, Socket, OkHttpClient(), newCall | None. The library cannot open a connection of its own. Zero telemetry by construction rather than by policy. |
DexClassLoader, System.loadLibrary, createPackageContext | None. No dynamic code loading, no native code, no reaching into other packages. |
Log.*, println, System.out | None. The library never writes a line to logcat — which is why it can read the log without polluting it. |
Runtime.getRuntime, ProcessBuilder | One: 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 classes | None. The only Class.forName calls name AppInspect's own modules. No annotation, classpath or ServiceLoader scanning. |
| Exported components | None. 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
FileProviderrestricted tocacheDir/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
Authenticatorretry, 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
HttpLoggingInterceptorprintsAuthorizationheaders 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 = falsemasks 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.jsonso thatadb pullworks withoutrun-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.jsonbelongs insrc/debug/assets/. Assets are not stripped per variant the way code is, so a file undersrc/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:
| Question | Answer |
|---|---|
| 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. |