Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/dart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ jobs:
os: windows-latest
- buildcommand: linux
os: ubuntu-latest
# Release, and that is the whole point: the example app has
# `minifyEnabled true`, so this is the only job where R8 runs.
# Everything else — flutter run, flutter test, an emulator check —
# builds debug or profile, where minification never happens. A
# dependency that references classes it does not ship fails here and
# nowhere else. It only works as a guard for as long as the example
# app stays a realistic consumer; see example/android/app/build.gradle.
- buildcommand: appbundle
os: ubuntu-latest
- buildcommand: web
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,48 @@
## 6.0.0-dev.5

* android: fix `minifyReleaseWithR8` failing in every app that ships a release
build. The plugin depended on `kotlin-logging`, which names every backend it
can bind to, so R8 walked references to `ch.qos.logback.*` classes that were
genuinely absent and refused to complete:

Missing class ch.qos.logback.classic.Logger (referenced from: ...)
Execution failed for task ':app:minifyReleaseWithR8'

Minification runs in release and nowhere else, 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, which is the worst possible moment. Present in 6.0.0-dev.4.

Fixed by removing the dependencies, so nothing needs suppressing. If you
added `-dontwarn ch.qos.logback.**` to your own `proguard-rules.pro` to get
6.0.0-dev.4 to build, you can drop it.
* android: the plugin's logging now actually produces output. `slf4j-api` and
`kotlin-logging` are both facades and neither had a provider, so SLF4J
reported `No SLF4J providers were found` once and discarded every call after
that. Unless a consuming app happened to ship a binding of its own, this
package has logged nothing at all on Android. It now writes to
`android.util.Log` under the tag `BiometricStorage`, and both dependencies
are dropped.

Debug builds log everything. Release builds stay silent until asked:

adb shell setprop log.tag.BiometricStorage VERBOSE
* android: `BiometricStorageLogging` lets an app choose the level and the
destination. `level` overrides the default above — set it to `Log.VERBOSE` to
keep verbose logging in a release build without depending on a device
property. `sink` hands every record to your own logging framework instead of
`android.util.Log`, with the `Throwable` passed separately rather than
flattened into the message, so you can report the real exception:

BiometricStorageLogging.sink =
BiometricStorageLogging.Sink { priority, tag, message, throwable ->
// forward to slf4j, Timber, a file appender, a crash reporter
}

Installing a sink turns every level on unless `level` says otherwise. Both
are optional and the default is unchanged. See the README for the full
slf4j example.

## 6.0.0-dev.4

* `BiometricStorageException` carries a `code`. It previously held only a
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,51 @@ the following applies to it:
</resources>
```

##### Logging

The plugin writes to `android.util.Log` under the tag `BiometricStorage`. A
debug build logs everything; a release build logs nothing below INFO until you
ask for it:

```
adb shell setprop log.tag.BiometricStorage VERBOSE
```

That suits reading logs off a device. If your app collects its own — a file
appender, a crash reporter, slf4j, Timber — install a sink instead, from
`Application.onCreate` or your `FlutterActivity`, before the first call into
the plugin:

```kotlin
import android.util.Log
import design.codeux.biometric_storage.BiometricStorageLogging

BiometricStorageLogging.sink =
BiometricStorageLogging.Sink { priority, tag, message, throwable ->
val log = LoggerFactory.getLogger(tag)
Comment on lines +94 to +100
when (priority) {
Log.VERBOSE -> log.trace(message, throwable)
Log.DEBUG -> log.debug(message, throwable)
Log.INFO -> log.info(message, throwable)
Log.WARN -> log.warn(message, throwable)
else -> log.error(message, throwable)
}
}
```

Installing a sink turns every level on, since it says something wants the
records. To decide the level yourself — including keeping verbose logging in a
release build without touching a device property — set it explicitly:

```kotlin
BiometricStorageLogging.level = Log.VERBOSE
```

`throwable` is passed separately rather than flattened into `message`, so you
can hand the real exception to whatever you report to. The sink is called on
whichever thread produced the record, so it must be safe to call from the main
thread and from a background executor.

##### Resources

* https://developer.android.com/topic/security/data
Expand Down
17 changes: 14 additions & 3 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,20 @@ android {
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
// No consumerProguardFiles: this plugin references nothing R8 cannot find,
// so it has no rules to hand its consumers. Declaring the mechanism against
// an empty file is what the previous state was, and an empty rules file
// reads as "considered, nothing needed" when in fact it had never been
// filled in. Add it back with the rule when there is a rule.
defaultConfig {
minSdkVersion 23
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'proguard.pro'
}
buildFeatures {
// PluginLog reads BuildConfig.DEBUG to decide whether debug and trace
// logging is on by default. AGP 8 stopped generating BuildConfig for
// library modules unless asked.
buildConfig true
}
lint {
disable 'InvalidPackage'
Expand Down Expand Up @@ -78,7 +88,8 @@ dependencies {
api "androidx.core:core-ktx:1.18.0"
api "androidx.fragment:fragment-ktx:1.9.0"

implementation "org.slf4j:slf4j-api:2.0.18"
// No logging framework: see PluginLog.kt. slf4j-api and kotlin-logging were
// both here, both facades, and neither had a provider — so every call was
// discarded, and R8 failed on the logback classes kotlin-logging names.
implementation "androidx.biometric:biometric:$biometric_version"
implementation "io.github.oshai:kotlin-logging-jvm:8.0.4"
}
Empty file removed android/proguard.pro
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyProperties
import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.File
import java.io.IOException
import javax.crypto.Cipher
import kotlin.time.Duration

private val logger = KotlinLogging.logger {}
private val logger = PluginLog

data class InitOptions(
val androidAuthenticationValidityDuration: Duration? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package design.codeux.biometric_storage

import android.util.Log

/**
* Controls what this plugin logs on Android, and where those logs go.
*
* Everything here is optional. Left alone, the plugin writes to
* [android.util.Log] under the tag [TAG]: everything in a debug build, and in a
* release build only what `Log.isLoggable` allows, which means nothing below
* INFO until somebody asks for it:
*
* adb shell setprop log.tag.BiometricStorage VERBOSE
*
* That default suits an app that reads logs off a device. It does not suit an
* app that collects its own — hence [level] and [sink].
*
* Set these before the first call into the plugin, from `Application.onCreate`
* or your `FlutterActivity`. Both are read on every log call, so a later change
* takes effect, but records emitted before the change are already gone.
*/
object BiometricStorageLogging {

/**
* Where log records go when one is installed as [sink].
*
* `priority` is an [android.util.Log] constant — [Log.VERBOSE] through
* [Log.ERROR] — so it maps onto most frameworks directly. `message` is
* already built; `throwable` is passed separately rather than flattened
* into the message, so an implementation can hand the real exception to
* whatever it reports to.
*
* Called on whichever thread produced the record, which for this plugin is
* the main thread or its background executor. An implementation must be
* safe to call from either.
*/
fun interface Sink {
fun log(priority: Int, tag: String, message: String, throwable: Throwable?)
}

/**
* The tag the plugin logs under, and the one `setprop` expects.
*
* One tag for the whole plugin rather than one per class. minSdk is 23, and
* below API 24 [Log.isLoggable] throws `IllegalArgumentException` for a tag
* over 23 characters — a name derived from the largest source file here
* would be `BiometricStoragePluginKt`, which is 24. A truncated tag would
* also leave nobody able to guess what to hand to `setprop`.
*/
const val TAG = "BiometricStorage"

/**
* The lowest [android.util.Log] priority to emit, or `null` to decide
* automatically.
*
* Automatic — the default — means everything in a debug build, and in a
* release build whatever `Log.isLoggable` permits. Setting this to
* [Log.VERBOSE] makes the plugin log everything regardless of build type,
* which is what an app wants if it captures its own logs and does not want
* to depend on a device property being set.
*/
@JvmStatic
@Volatile
var level: Int? = null

/**
* Receives every record the plugin emits, instead of [android.util.Log].
*
* This is the hook for an app that already has a logging framework — slf4j,
* Timber, a file appender, a crash reporter. Forwarding to slf4j, for
* example:
*
* BiometricStorageLogging.sink =
* BiometricStorageLogging.Sink { priority, tag, message, throwable ->
* val log = LoggerFactory.getLogger(tag)
* when (priority) {
* Log.VERBOSE -> log.trace(message, throwable)
* Log.DEBUG -> log.debug(message, throwable)
* Log.INFO -> log.info(message, throwable)
* Log.WARN -> log.warn(message, throwable)
* else -> log.error(message, throwable)
* }
* }
*
* 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 the built-in logcat
* destination rather than this one. Set [level] as well to filter here
* instead of in the framework.
*/
@JvmStatic
@Volatile
var sink: Sink? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@ import io.flutter.embedding.engine.plugins.activity.*
import io.flutter.plugin.common.*
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.PrintWriter
import java.io.StringWriter
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import javax.crypto.Cipher
import kotlin.time.Duration.Companion.seconds

private val logger = KotlinLogging.logger {}
private val logger = PluginLog

enum class CipherMode {
Encrypt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ package design.codeux.biometric_storage

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.File
import java.nio.charset.Charset
import java.security.KeyStore
Expand All @@ -30,7 +29,7 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

private val logger = KotlinLogging.logger {}
private val logger = PluginLog

interface CryptographyManager {

Expand Down
104 changes: 104 additions & 0 deletions android/src/main/kotlin/design/codeux/biometric_storage/PluginLog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package design.codeux.biometric_storage

import android.util.Log

/**
* The plugin's logging, on top of [android.util.Log].
*
* This deliberately replaces `kotlin-logging` and `slf4j-api`. Both are
* facades, and this package shipped neither with a provider, so every call was
* discarded — SLF4J 2.x with no provider reports `No SLF4J providers were
* found` once and then no-ops. Worse, `kotlin-logging` names every backend it
* can bind to, which left R8 walking references to `ch.qos.logback.*` classes
* that were genuinely absent and failing `minifyReleaseWithR8` in every
* consumer's release build.
*
* The call shape is kept identical to `kotlin-logging` so the call sites did
* not have to change: `logger.debug { "..." }` and `logger.error(e) { "..." }`.
*
* The members are `inline`, and that is the point of the wrapper rather than
* calling [Log] directly: the lambda is only invoked once the level has been
* found to be enabled, so `logger.debug { "expensive $x" }` builds no string
* when debug logging is off. A bare `Log.d(TAG, "expensive $x")` would build it
* every time and then throw it away. That still holds with a
* [BiometricStorageLogging.Sink] installed,
* which is why the level is decided here rather than left to the framework on
* the other side of it.
*
* This is the internal half. [BiometricStorageLogging] is what a consuming app
* sees, and is where the level and the destination are configured.
*/
internal object PluginLog {

/** @see BiometricStorageLogging.TAG */
const val TAG = BiometricStorageLogging.TAG

inline fun trace(t: Throwable? = null, message: () -> String) =
log(Log.VERBOSE, t, message)

inline fun debug(t: Throwable? = null, message: () -> String) =
log(Log.DEBUG, t, message)

inline fun info(t: Throwable? = null, message: () -> String) =
log(Log.INFO, t, message)

inline fun warn(t: Throwable? = null, message: () -> String) =
log(Log.WARN, t, message)

inline fun error(t: Throwable? = null, message: () -> String) =
log(Log.ERROR, t, message)

@PublishedApi
internal inline fun log(level: Int, t: Throwable?, message: () -> String) {
if (!isLoggable(level)) {
return
}
write(level, message(), t)
}

/**
* An explicit [BiometricStorageLogging.level] wins. Otherwise a sink is
* taken as a statement that something wants every record, since the
* automatic default below describes logcat rather than somebody else's
* framework.
*
* That default exists because [Log.isLoggable] answers `false` below INFO
* unless a `log.tag` property says otherwise, which would leave `trace` and
* `debug` — most of the call sites here — silent. Silent by default is the
* behaviour this class was written to fix, so a debug build logs
* everything and a release build stays quiet until somebody asks.
*/
@PublishedApi
internal fun isLoggable(level: Int): Boolean {
BiometricStorageLogging.level?.let {
return level >= it
}
if (BiometricStorageLogging.sink != null) {
return true
}
return BuildConfig.DEBUG || Log.isLoggable(TAG, level)
}

/**
* Read [BiometricStorageLogging.sink] once: it is volatile, and reading it
* again after [isLoggable] could see a different value if another thread
* installed or cleared it in between. Falling back to logcat is better than
* dropping the record.
*/
@PublishedApi
internal fun write(level: Int, text: String, t: Throwable?) {
BiometricStorageLogging.sink?.let {
it.log(level, TAG, text, t)
return
}
Log.println(
level,
TAG,
if (t == null) {
text
} else {
"$text\n${Log.getStackTraceString(t)}"
}
)
}
}
Loading
Loading