Symptom
A security review of an Android Capacitor app filed a submission blocker: "WebView DevTools are enabled in release builds, so chrome://inspect can attach to a personal-finance app in production." The evidence looked airtight — the Gradle file set the flag true even for release, and the release script printed an unconditional warning saying exactly that:
// build.gradle
defaultConfig { buildConfigField 'boolean', 'WEBVIEW_DEVTOOLS', 'true' }
release { buildConfigField 'boolean', 'WEBVIEW_DEVTOOLS',
(project.findProperty('ffWebviewDevtools') ?: 'true') }The runtime posture was the opposite. Release builds already had devtools OFF. The finding was a false blocker: nothing was ever exposed.
Cause
BuildConfig.WEBVIEW_DEVTOOLS was read but inert — one reader, zero effect — because a framework call downstream unconditionally reassigns the same global:
MainActivity.onCreatecalledWebView.setWebContentsDebuggingEnabled(BuildConfig.WEBVIEW_DEVTOOLS)at the top, beforesuper.onCreate().super.onCreate()->BridgeActivity.onCreate->load()-> theBridgeconstructor ->Bridge.java:618:WebView.setWebContentsDebuggingEnabled(this.config.isWebContentsDebuggingEnabled())— unconditional, no null check, no "only if unset".CapConfigdefaults that value toisDebug(ApplicationInfo.FLAG_DEBUGGABLE) whenandroid.webContentsDebuggingEnabledis absent fromcapacitor.config.json— and it was absent ("android": {}).
So the real control was FLAG_DEBUGGABLE all along. The app's own flag never decided anything, in either direction. The debug-APK remote-inspection workflow that everyone believed the flag enabled was also actually running on FLAG_DEBUGGABLE.
Why the wrong read is the natural one
Every cheap check agreed with the wrong conclusion: the declaration said true, the build script's own warning text said "ENABLED", and no test covered it. The only way to see it is to follow write ordering to the last writer — which requires reading vendored framework source under node_modules, not the app's own repo.
Procedure
- For any flag that feeds a process-global setter (debugging, logging level, TLS verification, strict mode,
setWebContentsDebuggingEnabled), grep the setter, not the flag.grep -rn 'setWebContentsDebuggingEnabled' node_modules/@capacitor/found it immediately. - Ask who else assigns this value, and in what order relative to your assignment. Framework lifecycle hooks (
onCreate,load, plugin init) frequently run after yours. - If nothing reads your flag, say so plainly — "the field is decorative" is a different and more important finding than "the field has the wrong default", because the real posture is whatever the framework defaults to.
- Prove ordering by compiling and disassembling, not by reasoning.
javap -con the relevant method shows the sequence unambiguously:
invokespecial BridgeActivity.load() # framework's call happens here
iconst_0 # our value, release
invokestatic WebView.setWebContentsDebuggingEnabledThat ordering is the proof the flag is now authoritative. Reasoning about Java lifecycle order in prose is exactly how the original bug survived.
Fix shape
Become the last writer, don't race:
@Override
protected void load() {
super.load(); // framework's unconditional call runs first
WebView.setWebContentsDebuggingEnabled(BuildConfig.WEBVIEW_DEVTOOLS);
}Note the tempting alternative is worse: handing the Bridge a hand-built CapConfig drops config the file supplied, because CapConfig.Builder builds from scratch rather than from capacitor.config.json — it would have silently dropped the app's SocialLogin plugin config.
Also worth doing while there: make release actively assert false rather than omit the call (an if (flag) constant-folds away entirely at false, so the hardening only exists if you always call the setter), give debug its own explicit build type instead of relying on a shared default, and make a malformed flag value fail the build rather than splice garbage into generated Java.
Generalization
A default value is a claim about configuration, never about runtime state. Before filing a finding whose whole evidence is a default, establish that the value survives to the point of use. This is the same family as "a check whose clean result and broken result produce the same string will read green forever" — the cheap read and the correct read disagree while looking alike.