From fccac91fed6782c617cac0e85c3f342716f43909 Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 25 Aug 2026 16:55:23 +0200 Subject: [PATCH 1/6] android: keep the attached activity in step with configuration changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onReattachedToActivityForConfigChanges and onDetachedFromActivityForConfigChanges were both empty bodies. ActivityAware's own documentation is explicit about what that costs: "By the end of this method, the Activity ... is no longer valid. Any references ... should be cleared", and the reattach binding "includes a reference to the new instance of the Activity". So after a rotation `attachedActivity` went on pointing at the destroyed Activity, and every authenticated read or write handed BiometricPrompt a dead FragmentActivity. Worth checking against #142 (ANR) and #143 (crash) — both are reports of the prompt misbehaving on real devices, and both predate any of this. Also: the storage-not-initialized error passed its message as the error *code*. `error(code, message, details)`, and the code is what callers match on — Dart's own _transformErrors branches on `code.startsWith('AuthError:')`. It is "NotInitialized" now, with the sentence as the message. Verified by building the example for Android. Co-Authored-By: Claude Opus 5 --- .../biometric_storage/BiometricStoragePlugin.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt index aa61c89..f9cfb4b 100644 --- a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt +++ b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt @@ -149,7 +149,13 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { val name = getName() storageFiles[name]?.apply(cb) ?: run { logger.warn { "User tried to access storage '$name', before initialization" } - result.error("Storage $name was not initialized.", null, null) + // error(code, message, details) — the sentence used to be + // passed as the code, which is the field callers match on. + result.error( + "NotInitialized", + "Storage $name was not initialized.", + null + ) return } } @@ -436,6 +442,8 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + logger.debug { "Reattached to activity after a configuration change." } + updateAttachedActivity(binding.activity) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -452,6 +460,12 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { } override fun onDetachedFromActivityForConfigChanges() { + logger.trace { "onDetachedFromActivityForConfigChanges" } + // ActivityAware's contract: "By the end of this method, the Activity ... + // is no longer valid. Any references ... should be cleared." Holding on + // to it meant every authenticated read or write after a rotation handed + // BiometricPrompt a destroyed FragmentActivity. + attachedActivity = null } } From ce6a8ce58a9c80d7703330ecef5dfd685e32f506 Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 25 Aug 2026 16:55:23 +0200 Subject: [PATCH 2/6] darwin: reply once from init, and stop reporting lockout as unsupported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things in the shared implementation, none of them new. `init` replied twice on a bad argument. The requiredArg helper replies with the error and returns, then the unconditional result(true) ran anyway. Flutter discards the second reply and logs "Reply already submitted", so the caller was told init had succeeded when it had not. The success reply now sits on the success path. forceInit was never implemented here — the flag crossed the channel and was ignored, while the Dart API documents that it throws if the store was already created in this runtime. Mirrors Android now, including the case it gets right: without forceInit a repeat init is a no-op that reports false. canAuthenticate mapped every unrecognised LAError to "ErrorUnknown", which the Dart side reads as CanAuthenticateResponse.unsupported — documented as "Plugin does not support platform". So a biometryLockout, which is recoverable and means the user should try again later, told callers the plugin did not work on iOS at all. It reports ErrorStatusUnknown now, the same as Android does for a code it does not recognise, and logs the raw value. That last one is a behaviour change, not just a relabelling: callers commonly treat statusUnknown as usable and will now attempt authentication where they previously gave up. That is the intended direction — failing at the prompt with a real reason beats disabling the feature outright — but it is why this is called out as breaking in the changelog. Verified by building the example for iOS, and by confirming the new strings are in the linked binary rather than trusting a quiet build. Co-Authored-By: Claude Opus 5 --- .../BiometricStorageImpl.swift | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift b/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift index 67e2b4c..aecd140 100644 --- a/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift +++ b/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift @@ -89,12 +89,32 @@ class BiometricStorageImpl { canAuthenticate(options: initOptions, result: result) } } else if ("init" == call.method) { - requiredArg("name") { name in - requiredArg("options") { options in + requiredArg("name") { (name: String) in + requiredArg("options") { (options: [String: Any]) in + // The success reply belongs on the success path. It used to sit after + // this block and ran unconditionally, so a missing or mistyped + // argument replied with the error and then replied `true` as well — + // Flutter discards the second reply and logs "Reply already + // submitted", leaving the caller believing init had succeeded. + if stores[name] != nil { + // Mirrors Android: forceInit means "assert this was not already + // initialised", and without it a repeat init is a no-op reporting + // false. Neither was implemented here at all before. + let args = call.arguments as? [String: Any] + if args?["forceInit"] as? Bool == true { + result(storageError( + code: "AlreadyInitialized", + message: "A storage file with the name '\(name)' was already initialized.", + details: nil)) + } else { + result(false) + } + return + } stores[name] = BiometricStorageFile(name: name, initOptions: InitOptions(params: options), storageError: storageError) + result(true) } } - result(true) } else if ("dispose" == call.method) { // nothing to dispose result(true) @@ -156,7 +176,14 @@ class BiometricStorageImpl { break; case .invalidContext: fallthrough default: - result("ErrorUnknown") + // Not "ErrorUnknown": the Dart side maps that to + // CanAuthenticateResponse.unsupported, whose own doc reads "Plugin does + // not support platform", so a biometryLockout — too many failed attempts, + // and recoverable — told callers the plugin did not work on iOS at all. + // ErrorStatusUnknown is what Android reports for a code it does not + // recognise, and it says what is actually true: we cannot tell. + NSLog("Unmapped LAError \(laError.code.rawValue), reporting status unknown"); + result("ErrorStatusUnknown") break; } } From 1aacf7c1857757baac65c800dcda04c357cd78dc Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 25 Aug 2026 16:55:35 +0200 Subject: [PATCH 3/6] windows/web: honour forceInit, and stop hiding credential-store failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getStorage accepted forceInit on both and dropped it, so the documented "will throw an exception if the store was already created in this runtime" held on Android alone. Neither platform has a native side keeping per-store handles, so each tracks the names it has handed out. win32 read() also returned null for every CredRead failure, not just ERROR_NOT_FOUND. null is the documented answer to "nothing stored there", so a credential store that was failing was indistinguishable from an empty one — it read as silent data loss. Genuine errors throw BiometricStorageException now; not-found still returns null. The warning-level log was already there, which is why this was lossy rather than silent. The win32 suite covers the forceInit path; it runs on the Windows CI job. Co-Authored-By: Claude Opus 5 --- lib/src/biometric_storage_web.dart | 9 +++++++++ lib/src/biometric_storage_win32.dart | 20 ++++++++++++++++++++ test/biometric_storage_win32_test.dart | 15 +++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/lib/src/biometric_storage_web.dart b/lib/src/biometric_storage_web.dart index 29d2e5c..0d167bc 100644 --- a/lib/src/biometric_storage_web.dart +++ b/lib/src/biometric_storage_web.dart @@ -10,6 +10,10 @@ class BiometricStoragePluginWeb extends BiometricStorage { static const namePrefix = 'design.codeux.authpass.'; + /// See the win32 implementation: [forceInit] has to be tracked here, because + /// there is no native side holding a per-store handle. + final _initialized = {}; + static void registerWith(Registrar registrar) { BiometricStorage.instance = BiometricStoragePluginWeb(); } @@ -26,6 +30,11 @@ class BiometricStoragePluginWeb extends BiometricStorage { bool forceInit = false, PromptInfo promptInfo = PromptInfo.defaultValues, }) async { + if (!_initialized.add(name) && forceInit) { + throw BiometricStorageException( + "A storage file with the name '$name' was already initialized.", + ); + } return BiometricStorageFile(this, namePrefix + name, promptInfo); } diff --git a/lib/src/biometric_storage_win32.dart b/lib/src/biometric_storage_win32.dart index 07a095c..88e918f 100644 --- a/lib/src/biometric_storage_win32.dart +++ b/lib/src/biometric_storage_win32.dart @@ -17,6 +17,11 @@ class Win32BiometricStoragePlugin extends BiometricStorage { static const _userName = 'flutter.biometric_storage'; + /// Names handed out by [getStorage] in this runtime, so that [forceInit] can + /// mean what the API documents. Windows has no per-store native handle to + /// hang this off, unlike the method-channel platforms. + final _initialized = {}; + /// Registers this class as the default instance of [BiometricStorage]. static void registerWith() { BiometricStorage.instance = Win32BiometricStoragePlugin(); @@ -36,6 +41,13 @@ class Win32BiometricStoragePlugin extends BiometricStorage { bool forceInit = false, PromptInfo promptInfo = PromptInfo.defaultValues, }) async { + // forceInit was accepted and dropped here, so the documented "will throw if + // the store was already created in this runtime" held on Android alone. + if (!_initialized.add(name) && forceInit) { + throw BiometricStorageException( + "A storage file with the name '$name' was already initialized.", + ); + } return BiometricStorageFile(this, namePrefix + name, promptInfo); } @@ -69,6 +81,14 @@ class Win32BiometricStoragePlugin extends BiometricStorage { ); if (!result.value) { _logFailure('reading', name, result.error); + // `null` is the documented answer to "no value stored". Reporting a + // credential-store failure the same way left a caller unable to tell + // an empty store from a broken one, so it read as data loss. + if (result.error != ERROR_NOT_FOUND) { + throw BiometricStorageException( + 'Error reading credential $name: ${result.error}', + ); + } return null; } final credential = credentialPointer.value; diff --git a/test/biometric_storage_win32_test.dart b/test/biometric_storage_win32_test.dart index f5a675f..92afbb1 100644 --- a/test/biometric_storage_win32_test.dart +++ b/test/biometric_storage_win32_test.dart @@ -66,6 +66,21 @@ void main() { expect(file.name, 'design.codeux.authpass.example'); }); + test('forceInit rejects a second getStorage for the same name', () async { + final name = 'test_force_${DateTime.now().microsecondsSinceEpoch}'; + + // The first call establishes it; a plain second call is a no-op. Only + // forceInit turns "already initialized" into an error, which is what the + // API documents and what Android has always done. + await plugin.getStorage(name); + await plugin.getStorage(name); + + expect( + () => plugin.getStorage(name, forceInit: true), + throwsA(isA()), + ); + }); + test('reading an unknown name returns null rather than throwing', () async { final file = await plugin.getStorage( 'test_absent_${DateTime.now().microsecondsSinceEpoch}', From 7a1a0d03dfd20549ea3677ec2318e46be5c0f6e2 Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 25 Aug 2026 16:55:35 +0200 Subject: [PATCH 4/6] release 6.0.0-dev.2 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e11543..82a3d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 6.0.0-dev.2 + +Six pre-existing bugs, all found while reviewing 6.0.0-dev.1 and none introduced +by it. + +* android: the plugin kept using a destroyed `Activity` after a configuration + change. `onReattachedToActivityForConfigChanges` and + `onDetachedFromActivityForConfigChanges` were both empty, so after a rotation + every authenticated read or write handed `BiometricPrompt` a dead + `FragmentActivity`. `ActivityAware` documents that the old reference must be + cleared and the new binding adopted; now both happen. +* android: a storage-not-initialized error delivered its message as the + `PlatformException.code` — the field callers match on. The code is now + `NotInitialized`. +* iOS/macOS: `init` replied twice when an argument was missing or mistyped, so + the caller saw the error and then a success. The success reply now only + happens on the success path. +* **Breaking**: iOS/macOS `canAuthenticate()` reports an unrecognised `LAError` + as `CanAuthenticateResponse.statusUnknown` rather than `unsupported`, matching + Android. `unsupported` means "the plugin does not support this platform", so a + recoverable `biometryLockout` was telling callers to give up entirely. Callers + that treat `statusUnknown` as usable will now attempt authentication where + they previously did not. +* **Breaking**: `forceInit` now does what it documents on Windows, web, iOS and + macOS. It was implemented on Android only; the others accepted the flag and + dropped it. +* **Breaking**: windows `read()` throws `BiometricStorageException` when the + credential store fails, instead of returning `null`. `null` still means "no + value stored" — previously the two were indistinguishable, so a failure read + as data loss. + ## 6.0.0-dev.1 **Breaking**: requires Dart 3.10 / Flutter 3.44 or newer. diff --git a/example/pubspec.lock b/example/pubspec.lock index e31117b..de1e86a 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -15,7 +15,7 @@ packages: path: ".." relative: true source: path - version: "6.0.0-dev.1" + version: "6.0.0-dev.2" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4a0b737..6224b9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: biometric_storage description: | Secure Storage: Encrypted data store optionally secured by biometric lock with support for iOS, Android, MacOS. Partial support for Linux, Windows and web (localStorage). -version: 6.0.0-dev.1 +version: 6.0.0-dev.2 homepage: https://github.com/authpass/biometric_storage/ environment: From e6f7128d1392d0e036a332bfab6c6618b26b62f9 Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 25 Aug 2026 17:13:30 +0200 Subject: [PATCH 5/6] close the gaps review found around the six fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these are defects in the fixes themselves; they are places where the fixes stopped short of their own reasoning. canAuthenticate: the nil-error branch still answered "ErrorUnknown", which is the exact mapping the commit before this one rejected — "cannot tell" is not "this plugin does not work here". And biometryLockout, the one unmapped code that is actually common, now has its own arm, so the log in the default arm keeps meaning "something we have not seen" instead of firing on the ordinary case. forceInit raised PlatformException(code: 'AlreadyInitialized') on the method-channel platforms and BiometricStorageException on the two implemented in Dart, so a caller needed two catch clauses for one failure. The channel path translates now; every platform throws BiometricStorageException. win32 delete() still collapsed a failing credential store into `false`, which is its answer for "there was nothing to delete" — the same conflation this branch just removed from read(). The changelog was also wrong by omission in a way that mattered more than any of the above: moving the darwin init reply onto the success path also stopped a repeat getStorage() rebuilding the store. That used to discard the cached LAContext, so any darwinTouchIDAuthenticationForceReuseContextDuration in flight, and silently adopt the second call's options. First call wins now, as on Android. That is a behaviour change existing iOS users can hit without touching forceInit, and it now has its own entry. The forceInit entry also says that Linux still ignores the flag, rather than leaving "Windows, web, iOS and macOS" to be read as "everywhere". Verified: analyze, format and tests clean; iOS build with ErrorStatusUnknown confirmed in the linked binary. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 24 +++++++++++++++---- .../BiometricStorageImpl.swift | 14 ++++++++++- lib/src/biometric_storage.dart | 18 ++++++++++++++ lib/src/biometric_storage_win32.dart | 7 ++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a3d33..2ec27bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,13 +21,29 @@ by it. recoverable `biometryLockout` was telling callers to give up entirely. Callers that treat `statusUnknown` as usable will now attempt authentication where they previously did not. +* **Breaking**: iOS/macOS, a repeat `getStorage()` for a name that is already + open no longer rebuilds the store. It used to replace it outright, which threw + away the cached `LAContext` — so any + `darwinTouchIDAuthenticationForceReuseContextDuration` in progress — and + quietly adopted whatever options the second call passed. The first call now + wins, as on Android. If you relied on re-initializing to change options, close + the old store first; passing different options to a repeat call has never been + reported back to Dart and now definitively does nothing. * **Breaking**: `forceInit` now does what it documents on Windows, web, iOS and macOS. It was implemented on Android only; the others accepted the flag and - dropped it. -* **Breaking**: windows `read()` throws `BiometricStorageException` when the - credential store fails, instead of returning `null`. `null` still means "no - value stored" — previously the two were indistinguishable, so a failure read + dropped it. It throws `BiometricStorageException` on every platform — the + `PlatformException(code: 'AlreadyInitialized')` that the method-channel + platforms raise is translated, so one catch clause covers all of them. + **Linux still ignores the flag**: its `init` keeps no per-store state at all, + so implementing it there is a separate change. +* **Breaking**: windows `read()` and `delete()` throw `BiometricStorageException` + when the credential store fails, instead of returning `null` and `false`. + Those values still mean "no value stored" and "there was nothing to delete" — + previously they doubled as the answer for a failing store, so a failure read as data loss. +* iOS/macOS: `canAuthenticate()` names `biometryLockout` explicitly rather than + letting it fall through the unmapped-code path, and the nil-error branch + reports `statusUnknown` too rather than `unsupported`. ## 6.0.0-dev.1 diff --git a/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift b/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift index aecd140..53390a2 100644 --- a/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift +++ b/darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift @@ -159,7 +159,9 @@ class BiometricStorageImpl { return } guard let err = error else { - result("ErrorUnknown") + // Same reasoning as the default arm below: "cannot tell" is not + // "this plugin does not work on this platform". + result("ErrorStatusUnknown") return } let laError = LAError(_nsError: err) @@ -174,6 +176,16 @@ class BiometricStorageImpl { case .touchIDNotEnrolled: result("ErrorNoBiometricEnrolled") break; + case .biometryLockout: + // Recoverable — too many failed attempts, and the user can retry later or + // fall back to the device credential. Named explicitly rather than left + // to the default arm so that the "unmapped" log there keeps meaning + // "something we have not seen", instead of firing on the common case. + // androidx's BiometricManager has no lockout status at all, so Android + // callers already get Success here and discover the lockout at the + // prompt; reporting "cannot tell, try it" converges the two platforms. + result("ErrorStatusUnknown") + break; case .invalidContext: fallthrough default: // Not "ErrorUnknown": the Dart side maps that to diff --git a/lib/src/biometric_storage.dart b/lib/src/biometric_storage.dart index 9571fe7..3b59fa1 100644 --- a/lib/src/biometric_storage.dart +++ b/lib/src/biometric_storage.dart @@ -403,6 +403,24 @@ class MethodChannelBiometricStorage extends BiometricStorage { }); _logger.finest('getting storage. was created: $result'); return BiometricStorageFile(this, name, promptInfo); + } on PlatformException catch (e, stackTrace) { + // The platforms implemented in Dart — Windows and web — raise this + // themselves, so without translating here the same failure would reach + // callers as two different types and need two catch clauses. + if (e.code == 'AlreadyInitialized') { + _logger.warning( + 'Storage $name was already initialized.', + e, + stackTrace, + ); + throw BiometricStorageException(e.message ?? e.code); + } + _logger.warning( + 'Error while initializing biometric storage.', + e, + stackTrace, + ); + rethrow; } catch (e, stackTrace) { _logger.warning( 'Error while initializing biometric storage.', diff --git a/lib/src/biometric_storage_win32.dart b/lib/src/biometric_storage_win32.dart index 88e918f..f5f2d20 100644 --- a/lib/src/biometric_storage_win32.dart +++ b/lib/src/biometric_storage_win32.dart @@ -63,6 +63,13 @@ class Win32BiometricStoragePlugin extends BiometricStorage { ); if (!result.value) { _logFailure('deleting', name, result.error); + // Same distinction read() makes: `false` means there was nothing to + // delete, so a store that is failing must not borrow that answer. + if (result.error != ERROR_NOT_FOUND) { + throw BiometricStorageException( + 'Error deleting credential $name: ${result.error}', + ); + } return false; } return true; From 7af63468b2f2fcb49ed6638be4a5586e1c59873f Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Tue, 25 Aug 2026 17:32:02 +0200 Subject: [PATCH 6/6] android: fail instead of hanging when the activity cannot host a prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Being attached to an activity is not the same as being able to show a dialog. androidx's BiometricPrompt refuses to start after onSaveInstanceState and returns *without invoking any callback*, so the pending Flutter result never completes — not an error the caller can catch, a permanent silent hang. The emulator A/B for the configuration-change fix caught it in the act: E/BiometricPromptCompat: Unable to start authentication. Called after onSaveInstanceState(). logged at the exact moment the read stopped, and absent from the passing run. That guard is reachable whenever the app is backgrounded, with a perfectly valid activity reference and no configuration change involved — so the lifecycle fix earlier on this branch does not cover it, and neither does the existing `attachedActivity ?: return` guard, since the reference is non-null. isFinishing, isDestroyed and isStateSaved are checked before the prompt is built, and the caller gets an AuthException it can act on. Also from review: The changelog told upgraders to "close the old store first" when they wanted different options. There is no close — nothing in the Dart API disposes a store, though the darwin side has a handler waiting for one. Replaced with advice that can actually be followed. The AlreadyInitialized translation now uses Error.throwWithStackTrace, so the frames showing which call re-initialized the store survive instead of the trace restarting at getStorage. Verified: analyze, format and tests clean; Android build. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++++++++-- .../BiometricStoragePlugin.kt | 28 +++++++++++++++++++ lib/src/biometric_storage.dart | 7 ++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec27bd..1c7d89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Six pre-existing bugs, all found while reviewing 6.0.0-dev.1 and none introduced by it. +* android: an authentication requested while the activity cannot host a dialog + no longer hangs forever. `androidx.biometric` refuses to start after + `onSaveInstanceState` — logging `Unable to start authentication` — and returns + without invoking any callback, so the pending Flutter result never completed. + The state is checked up front and reported as + `AuthException(AuthExceptionCode.unknown)` instead. Reachable whenever the app + is backgrounded, independently of the configuration-change bug below. * android: the plugin kept using a destroyed `Activity` after a configuration change. `onReattachedToActivityForConfigChanges` and `onDetachedFromActivityForConfigChanges` were both empty, so after a rotation @@ -26,9 +33,11 @@ by it. away the cached `LAContext` — so any `darwinTouchIDAuthenticationForceReuseContextDuration` in progress — and quietly adopted whatever options the second call passed. The first call now - wins, as on Android. If you relied on re-initializing to change options, close - the old store first; passing different options to a repeat call has never been - reported back to Dart and now definitively does nothing. + wins, as on Android. If you relied on re-initializing to change options, give + each set of options its own store name, or restart — there is no API to close + a store, so none can be reopened differently within a run. Passing different + options to a repeat call was never reported back to Dart, and now definitively + does nothing. * **Breaking**: `forceInit` now does what it documents on Windows, web, iOS and macOS. It was implemented on Android only; the others accepted the flag and dropped it. It throws `BiometricStorageException` on every platform — the diff --git a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt index f9cfb4b..6f7dea7 100644 --- a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt +++ b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt @@ -377,6 +377,34 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { ) ) } + // Being attached is not the same as being able to show a dialog. + // androidx's BiometricPrompt refuses to start after onSaveInstanceState + // and — this is the part that hurts — returns without invoking any + // callback, logging "Unable to start authentication. Called after + // onSaveInstanceState()". The pending Flutter result would then never + // complete: not an error the caller can catch, a permanent silent hang. + // Reachable whenever the app is backgrounded, independently of whether + // the activity reference itself is stale. + if (activity.isFinishing || + activity.isDestroyed || + activity.supportFragmentManager.isStateSaved + ) { + return run { + logger.error { + "Activity cannot host a prompt right now " + + "(finishing=${activity.isFinishing} " + + "destroyed=${activity.isDestroyed} " + + "stateSaved=${activity.supportFragmentManager.isStateSaved})." + } + onError( + AuthenticationErrorInfo( + AuthenticationError.Failed, + "Activity is not in a state where an authentication " + + "prompt can be shown." + ) + ) + } + } val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { diff --git a/lib/src/biometric_storage.dart b/lib/src/biometric_storage.dart index 3b59fa1..b837b0a 100644 --- a/lib/src/biometric_storage.dart +++ b/lib/src/biometric_storage.dart @@ -413,7 +413,12 @@ class MethodChannelBiometricStorage extends BiometricStorage { e, stackTrace, ); - throw BiometricStorageException(e.message ?? e.code); + // Not a plain `throw`: that would restart the trace here and lose the + // frames showing which call re-initialized the store. + Error.throwWithStackTrace( + BiometricStorageException(e.message ?? e.code), + stackTrace, + ); } _logger.warning( 'Error while initializing biometric storage.',