android: log through android.util.Log, and unbreak release builds - #157
Conversation
`consumerProguardFiles 'proguard.pro'` has been declared since the file was
added, but proguard.pro was zero bytes. The mechanism was wired up and never
filled in.
kotlin-logging names every backend it can bind to, so R8 walks references to
`ch.qos.logback.*` regardless of whether logback is on the classpath. This
package ships no provider, so it is not, and R8 refuses to finish:
Missing class ch.qos.logback.classic.Logger (referenced from: ...)
Execution failed for task ':app:minifyReleaseWithR8'
> Compilation failed to complete
Minification only runs in release, so nothing anyone does while developing
reaches it — `flutter run`, `flutter test` and an emulator check all build
debug or profile. The first thing to hit it is an attempt to ship to Play.
-dontwarn rather than -keep: keeping them would ask R8 to preserve classes
that do not exist. Nothing reaches those references at runtime either; they
are alternative backends kotlin-logging picks between, and with none present
it falls back to a no-op.
Consumer rules travel inside the AAR and R8 applies them on its own, so this
is enough on its own to unblock an app on 6.0.0-dev.4. The next commit removes
the dependencies that make the rules necessary in the first place.
Verified by building the example app with `flutter build appbundle --release`
after removing the logging stack the example itself carries — without which
logback is present and the failure cannot reproduce. Red before this commit
with exactly the four missing classes above, green after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
slf4j-api and kotlin-logging are both facades, and this package shipped
neither with a provider. SLF4J 2.x with no provider reports `No SLF4J
providers were found` once and then discards every call, so the plugin's
Android logging has produced no output at all unless a consuming app happened
to bring a binding of its own. Two dependencies, and the feature they exist
for did not work.
They were not free, either: kotlin-logging names every backend it can bind
to, which is what made R8 fail on missing `ch.qos.logback.*` classes in every
consumer's release build. The previous commit suppresses that; this removes
the cause.
PluginLog keeps the call shape kotlin-logging had — `logger.debug { "..." }`
and `logger.error(e) { "..." }` — so all 35 call sites across the three files
compile untouched and the change is an import and a factory line, three times.
It is worth wrapping android.util.Log rather than calling it directly because
the members are `inline`: the lambda runs only after the level is known to be
enabled, so `logger.debug { "expensive $x" }` builds no string when debug is
off. `Log.d(TAG, "expensive $x")` would build it and throw it away.
Two decisions worth recording.
The tag is one constant, `BiometricStorage`, not derived from the class name.
minSdk is 23, and below API 24 Log.isLoggable throws IllegalArgumentException
for a tag over 23 characters; the derived name for the largest file here would
be `BiometricStoragePluginKt`, which is 24. A truncated tag would also leave
nobody able to guess what to hand to setprop. One tag means one command turns
the whole plugin on:
adb shell setprop log.tag.BiometricStorage VERBOSE
The default level is `BuildConfig.DEBUG || Log.isLoggable(TAG, level)`.
Log.isLoggable alone answers false below INFO, which would leave trace and
debug — most of the call sites — silent until somebody ran setprop, and silent
by default is the behaviour being fixed. A debug build now logs everything,
which is what the example app's logback.xml used to configure; a release build
stays quiet unless asked. BuildConfig needs buildFeatures.buildConfig, since
AGP 8 stopped generating it for library modules by default.
Verified with `flutter build appbundle --release` in example/, with the
example's own logging stack removed so that nothing supplies a provider:
minifyReleaseWithR8 completes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CI job that would have caught the release-build failure already existed: the example app has `minifyEnabled true`, and the workflow builds `appbundle`, so R8 has been running on every push. It stayed green because the example was not a realistic consumer. It carried slf4j-api, logback-android and kotlin-logging of its own, plus an assets/logback.xml wiring logback's LogcatAppender at TRACE. That is a provider, and it did two things. It supplied the very `ch.qos.logback.*` classes R8 was failing to find, so minifyReleaseWithR8 completed here while breaking in every real app. And it made the plugin's logging appear to work during development, which is presumably why nobody noticed that consumers were getting nothing. So the durable half of this fix is not a new CI job, it is deleting a dependency: with the example supplying no provider, the existing appbundle build fails exactly the way a consumer's does. Confirmed by reverting the plugin-side fix on top of this commit and watching it go red with the four missing logback classes, then green again. The onCreate override went with it — all it did was log — and MainActivity keeps FlutterFragmentActivity, which is load-bearing for anything that shows a prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default PluginLog picked — everything in a debug build, `Log.isLoggable`
in a release one — suits reading logs off a device with `setprop`. It does not
suit an app that collects its own, and those are the apps most likely to want
these records. authpass is the case in point: it binds slf4j to
`uk.uuid.slf4j:slf4j-android` with `level=VERBOSE`, deliberately, in every
build type. Moving this plugin off slf4j would have silently cost it the
plugin's logs in release.
BiometricStorageLogging adds two optional knobs, both null by default, so
nothing changes for an app that ignores them.
`level` overrides the automatic default. Set it to Log.VERBOSE to keep verbose
logging in a release build without depending on a device property.
`sink` takes every record instead of android.util.Log — slf4j, Timber, a file
appender, a crash reporter. The Throwable is passed alongside the message
rather than flattened into it, so an implementation can hand the real
exception to whatever it reports to, and the priority is an android.util.Log
constant so it maps onto most frameworks directly.
Installing a sink also turns every level on unless `level` says otherwise. A
sink is an explicit statement that something wants these records, and the
automatic default describes logcat rather than somebody else's framework;
without this rule a sink installed in a release build would silently receive
almost nothing.
The level is still decided before the message lambda runs, so the `inline`
short-circuit survives: a filtered-out `logger.debug { "expensive $x" }`
builds no string whether or not a sink is installed. `write` reads the
volatile sink once rather than re-reading after `isLoggable`, so a sink
installed or cleared by another thread mid-call falls back to logcat instead
of dropping the record.
Verified on a minified release APK of the example, where the plugin is
otherwise silent. With a sink installed, every record arrives at it, tagged by
the sink rather than by the plugin — so they are diverted, not duplicated —
and verbose records arrive with no `level` set, confirming the rule above. The
example itself is left clean: it is the R8 canary, and adding a logging
framework back to it is exactly what hid the bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Added a fourth commit, The motivation turned out to be concrete rather than hypothetical. authpass binds slf4j to // keep verbose logging in a release build, no setprop needed
BiometricStorageLogging.level = Log.VERBOSE
// or hand every record to your own framework
BiometricStorageLogging.sink =
BiometricStorageLogging.Sink { priority, tag, message, throwable ->
// slf4j, Timber, a file appender, a crash reporter
}
Installing a sink also turns every level on unless The Verified on a minified release APK of the example, where the plugin is otherwise silent: Records arrive tagged by the sink and not by the plugin, so they are diverted rather than duplicated; and they arrive at VERBOSE with no The example app itself is left clean — it is the R8 canary, and adding a logging framework back to it is exactly what hid this bug. The wiring above was a throwaway edit for the test. Usage is documented in the README's new "Logging" section instead. |
They were added earlier in this branch to unblock 6.0.0-dev.4 on its own, and removing the dependencies made them dead: with slf4j and kotlin-logging gone, this plugin references nothing R8 cannot find. Verified by building the example app for release with proguard.pro emptied, and now with it deleted and `consumerProguardFiles` removed — minifyReleaseWithR8 completes either way. Keeping them would not have been free. Consumer rules travel inside the AAR and R8 applies them to the consuming app's entire run, so `-dontwarn org.slf4j.**` shipped from here would also silence missing-class warnings for slf4j usage in the app's own code. That is this package quietly making a decision on behalf of apps it knows nothing about. `consumerProguardFiles` goes too rather than pointing at an empty file. An empty rules file reads as "considered, nothing needed", which is exactly the wrong signal: the previous one had never been filled in, and that is how the R8 break shipped. Declare it again when there is a rule to put in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Dropped the consumer rules in Verified: I left commits The CHANGELOG's "fixed at both ends" line is updated accordingly — it now just says the dependencies are gone, and tells anyone who added Correction on the formattingI was wrong earlier: What I measured the first time was an artifact of my own environment. I ran Confirmed by moving
CI always runs |
There was a problem hiding this comment.
Pull request overview
This PR fixes Android release build failures caused by missing SLF4J backends during R8 minification, and replaces the plugin’s Android logging with a android.util.Log-based implementation that can be forwarded into an app’s preferred logging system.
Changes:
- Replace
slf4j-api/kotlin-loggingusage with an internalPluginLogwrapper overandroid.util.Log, plus a publicBiometricStorageLogginghook for level/sink configuration. - Remove logging backend dependencies from the example app (and delete its
logback.xml) so CI’s existing releaseappbundlebuild actually exercises the “real consumer” case. - Bump version to
6.0.0-dev.5and document the new Android logging behavior in README/CHANGELOG.
Reviewed changes
Copilot reviewed 14 out of 16 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents Android logging defaults and how to install a sink/level override. |
| pubspec.yaml | Version bump to 6.0.0-dev.5. |
| CHANGELOG.md | Release notes for the Android R8 fix and the new logging behavior/hooks. |
| android/build.gradle | Removes logging deps, enables buildConfig, and removes unused consumer ProGuard wiring. |
| android/src/main/kotlin/design/codeux/biometric_storage/PluginLog.kt | New internal logging wrapper over android.util.Log with lazy message construction. |
| android/src/main/kotlin/design/codeux/biometric_storage/BiometricStorageLogging.kt | New public Android logging configuration API (level + sink). |
| android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt | Switches logger factory to PluginLog. |
| android/src/main/kotlin/design/codeux/biometric_storage/BiometricStorageFile.kt | Switches logger factory to PluginLog. |
| android/src/main/kotlin/design/codeux/biometric_storage/CryptographyManager.kt | Switches logger factory to PluginLog. |
| example/android/app/build.gradle | Removes SLF4J/logback/kotlin-logging dependencies and documents why. |
| example/android/app/src/main/assets/logback.xml | Removes the example’s logback configuration. |
| example/android/app/src/main/kotlin/design/codeux/biometric_storage_example/MainActivity.kt | Removes kotlin-logging usage and clarifies why FlutterFragmentActivity is required. |
| example/.gitignore | Ignores Kotlin’s per-build .kotlin directory. |
| example/pubspec.lock | Updates path dependency version to 6.0.0-dev.5. |
| .github/workflows/dart.yml | Adds clarification that the release appbundle job is the R8 guardrail. |
| android/proguard.pro | Removed in the PR branch (no longer present). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| import android.util.Log | ||
| import design.codeux.biometric_storage.BiometricStorageLogging | ||
|
|
||
| BiometricStorageLogging.sink = | ||
| BiometricStorageLogging.Sink { priority, tag, message, throwable -> | ||
| val log = LoggerFactory.getLogger(tag) |
The snippet lists its imports, so a reader is entitled to assume the list is complete — and it was not: `LoggerFactory` had none, so the example does not compile when pasted. Caught in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Fixed the Copilot finding in That was the only actionable item in the review; the rest was a summary of the diff. |
android/build.gradledeclaredslf4j-apiandkotlin-logging-jvm. Both arefacades, and there was no provider — no
slf4j-android, nologback-android,no
slf4j-simple. SLF4J 2.x with no provider reportsNo SLF4J providers were foundonce and discards every call after that, so the plugin's Androidlogging has produced no output at all unless a consuming app happened to ship
a binding of its own.
It also broke release builds.
kotlin-loggingnames every backend it can bindto, so R8 walked references to
ch.qos.logback.*classes that were genuinelymissing and refused to complete:
Minification runs in release and nowhere else, so every app that added
6.0.0-dev.4 hit this at the last possible moment — on the way to Play.
The CI job you'd expect to catch this already existed
This is the part worth reading, because it changes what the durable fix is.
.github/workflows/dart.ymlalready buildsappbundle, and the example appalready has
minifyEnabled true. R8 has been running in CI on every push.It stayed green because the example app was not a realistic consumer — it
carried its own
slf4j-api,logback-androidandkotlin-logging, plus anassets/logback.xmlwiring logback'sLogcatAppenderat TRACE.That provider did two things. It supplied the very
ch.qos.logback.*classesR8 could not find, so the build completed here while breaking everywhere else.
And it made the plugin's logging appear to work during development, which is
presumably why nobody noticed consumers were getting nothing.
So the fix isn't a new CI job, it's deleting a dependency from the example. With
no provider there, the existing appbundle build now fails exactly the way a
consumer's does.
The three commits
android: fill in the consumer proguard rules—consumerProguardFiles 'proguard.pro'was already declared andproguard.prowas zero bytes; themechanism was wired up and never filled in.
-dontwarnrather than-keep,since keeping them would ask R8 to preserve classes that do not exist. This
commit stands alone as the unblock.
android: log through android.util.Log instead of slf4j— a drop-inPluginLogobject, so all 35 call sites compile untouched and the change isan import and a factory line, three times. Both dependencies removed.
example: stop shipping a logging backend— the durable half.The two decisions you asked me to record
Tag: one constant,
BiometricStorage— not derived from the class name.minSdkis 23, and below API 24Log.isLoggablethrowsIllegalArgumentExceptionfor a tag over 23 characters. The derived name forthe largest file here would be
BiometricStoragePluginKt, which is 24. Atruncated tag would also be unguessable for
setprop; one tag means one commandturns the whole plugin on.
Level:
BuildConfig.DEBUG || Log.isLoggable(TAG, level)—isLoggablealone answers false below INFO, which would leave
traceanddebug(most ofthe call sites) silent until someone ran
setprop, and silent by default isprecisely the behavior being fixed. Debug builds now log everything, matching
what the example's
logback.xmlused to configure; release stays quiet unlessasked. Needs
buildFeatures.buildConfig, since AGP 8 stopped generatingBuildConfigfor library modules by default.Verification
Red before, green after — the failure was made reachable first:
appbundle --releaseappbundle --releaseappbundle --releaseappbundle --releaseLogging confirmed on an emulator, which is the claim that has never been true
before. Debug build, no provider on the classpath:
And on a minified release APK: zero plugin lines by default, then after
adb shell setprop log.tag.BiometricStorage VERBOSE, the same lines appear —which also shows R8 did not strip
PluginLog.Also
flutter test(9 passing, including the win32 guard),flutter analyze --fatal-infosclean in both packages,cd example && flutter test.Two things for you to decide
The consumer rules are now redundant, and that last table row is the
evidence — with the dependencies gone, the release build passes with
proguard.proempty. I kept them because they're the standalone unblock andthey'd also cover a consumer who brings
kotlin-loggingthemselves. Butconsumer rules apply to the consuming app's entire R8 run, so
-dontwarn org.slf4j.**shipping inside our AAR would silence missing-classwarnings in their code too. Say the word and I'll drop the file.
dart format --set-exit-if-changedis already failing onmain— four.dartfiles this branch never touches (biometric_storage.dartis 93 linesof drift), from a newer formatter in Dart 3.13. CI's "Verify formatting" step
will be red on this PR for that reason alone. I left it per CLAUDE.md's "do not
fix unrelated existing violations"; happy to do it as its own PR.
🤖 Generated with Claude Code