Skip to content

android: log through android.util.Log, and unbreak release builds - #157

Merged
hpoul merged 6 commits into
mainfrom
android-logging-drop-slf4j
Aug 25, 2026
Merged

android: log through android.util.Log, and unbreak release builds#157
hpoul merged 6 commits into
mainfrom
android-logging-drop-slf4j

Conversation

@hpoul

@hpoul hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

android/build.gradle declared slf4j-api and kotlin-logging-jvm. Both are
facades, and there was no provider — no slf4j-android, no logback-android,
no slf4j-simple. SLF4J 2.x with no provider reports No SLF4J providers were found once and discards every call after that, so the plugin's Android
logging has produced no output at all
unless a consuming app happened to ship
a binding of its own.

It also broke release builds. kotlin-logging names every backend it can bind
to, so R8 walked references to ch.qos.logback.* classes that were genuinely
missing and refused to complete:

Missing class ch.qos.logback.classic.Logger (referenced from: ...)
Execution failed for task ':app:minifyReleaseWithR8'
> Compilation failed 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.yml already builds appbundle, and the example app
already 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-android and kotlin-logging, plus an
assets/logback.xml wiring logback's LogcatAppender at TRACE.

That provider did two things. It supplied the very ch.qos.logback.* classes
R8 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

  1. android: fill in the consumer proguard rulesconsumerProguardFiles 'proguard.pro' was already declared and proguard.pro was zero bytes; the
    mechanism was wired up and never filled in. -dontwarn rather than -keep,
    since keeping them would ask R8 to preserve classes that do not exist. This
    commit stands alone as the unblock.
  2. android: log through android.util.Log instead of slf4j — a drop-in
    PluginLog object, so all 35 call sites compile untouched and the change is
    an import and a factory line, three times. Both dependencies removed.
  3. 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.
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 be unguessable for setprop; one tag means one command
turns the whole plugin on.

Level: BuildConfig.DEBUG || Log.isLoggable(TAG, level)isLoggable
alone answers false below INFO, which would leave trace and debug (most of
the call sites) silent until someone ran setprop, and silent by default is
precisely the behavior being fixed. Debug builds now log everything, matching
what the example's logback.xml used to configure; release stays quiet unless
asked. Needs buildFeatures.buildConfig, since AGP 8 stopped generating
BuildConfig for library modules by default.

Verification

Red before, green after — the failure was made reachable first:

build proguard.pro plugin deps example provider result
appbundle --release empty slf4j + kotlin-logging none fails, the 4 missing logback classes
appbundle --release rules slf4j + kotlin-logging none passes
appbundle --release rules none none passes
appbundle --release empty none none passes

Logging confirmed on an emulator, which is the claim that has never been true
before. Debug build, no provider on the classpath:

V BiometricStorage: onMethodCall(canAuthenticate)
V BiometricStorage: Initialized BiometricStorageFile(masterKeyName='default_unauthenticated_master_key', ...) with InitOptions(...)

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-infos clean 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.pro empty. I kept them because they're the standalone unblock and
they'd also cover a consumer who brings kotlin-logging themselves. But
consumer rules apply to the consuming app's entire R8 run, so
-dontwarn org.slf4j.** shipping inside our AAR would silence missing-class
warnings in their code too. Say the word and I'll drop the file.

dart format --set-exit-if-changed is already failing on main — four
.dart files this branch never touches (biometric_storage.dart is 93 lines
of 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

hpoul and others added 4 commits August 25, 2026 23:56
`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>
@hpoul

hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Added a fourth commit, 179f150, adding BiometricStorageLogging — a settable level and a hook for a custom logging framework. Both optional and null by default, so nothing changes for an app that ignores them.

The motivation turned out to be concrete rather than hypothetical. authpass binds slf4j to uk.uuid.slf4j:slf4j-android with level=VERBOSE in config.properties, deliberately, in every build type — so it is one of the few consumers whose plugin logs actually worked, and moving off slf4j would have silently cost it those logs in release builds. (It has no logback: the logback.xml in its assets is dead, with no dependency to read it.)

// 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
    }

throwable is passed alongside message rather than flattened into it, so an implementation can report the real exception; 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 the records, and the automatic default describes logcat rather than somebody else's framework. Without that rule, a sink installed in a release build would silently receive almost nothing.

The inline short-circuit survives: the level is still decided before the message lambda runs, so a filtered-out logger.debug { "expensive $x" } builds no string whether or not a sink is installed.

Verified on a minified release APK of the example, where the plugin is otherwise silent:

D SinkProof: [BiometricStorage] Attached to new activity.
V SinkProof: [BiometricStorage] onMethodCall(canAuthenticate)

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 level set, confirming the sink rule. R8 keeps the API and the SAM conversion.

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>
@hpoul

hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped the consumer rules in 3fd53e9android/proguard.pro is deleted and consumerProguardFiles is gone with it, rather than left 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 this shipped in the first place.

Verified: flutter build appbundle --release in example/ still completes minifyReleaseWithR8 with the file deleted.

I left commits 3fbdd13 (adds the rules) and 3fd53e9 (removes them) as a pair rather than rewriting history, since this PR already has review comments pointing at commits. The add/remove churn is deliberate and the messages explain it: 3fbdd13 was the standalone unblock for anyone on 6.0.0-dev.4, and it stopped being load-bearing once the dependencies went. Happy to squash if you would rather the PR not contain the round trip.

The CHANGELOG's "fixed at both ends" line is updated accordingly — it now just says the dependencies are gone, and tells anyone who added -dontwarn ch.qos.logback.** to their own proguard-rules.pro that they can drop it.

Correction on the formatting

I was wrong earlier: dart format is not failing, and there is nothing to fix. CI's step passes.

What I measured the first time was an artifact of my own environment. I ran dart format in a fresh worktree before any flutter pub get had populated .dart_tool/, and without package_config.json the formatter cannot resolve the package's language version, so it falls back to the latest and reports four files as needing changes.

Confirmed by moving package_config.json aside and back:

.dart_tool/package_config.json result
absent Formatted 12 files (4 changed), exit 1
present (languageVersion 3.10) Formatted 12 files (0 changed), exit 0

CI always runs flutter pub get before the format step, so it sees exit 0. The repo is correctly formatted and I have not touched any .dart file. Sorry for the noise — no separate formatting PR is needed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-logging usage with an internal PluginLog wrapper over android.util.Log, plus a public BiometricStorageLogging hook for level/sink configuration.
  • Remove logging backend dependencies from the example app (and delete its logback.xml) so CI’s existing release appbundle build actually exercises the “real consumer” case.
  • Bump version to 6.0.0-dev.5 and 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.

Comment thread README.md
Comment on lines +94 to +99
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>
@hpoul

hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the Copilot finding in 7d5ddd7 — the slf4j snippet lists its imports, so a reader is entitled to assume the list is complete, and LoggerFactory had none. Added import org.slf4j.LoggerFactory.

That was the only actionable item in the review; the rest was a summary of the diff.

@hpoul
hpoul merged commit 6860447 into main Aug 25, 2026
15 checks passed
@hpoul
hpoul deleted the android-logging-drop-slf4j branch August 26, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants