Skip to content

win32 6, Swift Package Manager, and a toolchain refresh (6.0.0-dev.1) - #150

Merged
hpoul merged 24 commits into
mainfrom
win32-6-and-swiftpm
Aug 25, 2026
Merged

win32 6, Swift Package Manager, and a toolchain refresh (6.0.0-dev.1)#150
hpoul merged 24 commits into
mainfrom
win32-6-and-swiftpm

Conversation

@hpoul

@hpoul hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Makes the plugin installable and buildable again on the current toolchain, and adds Swift Package Manager support. Targets 6.0.0-dev.1.

The motivating case: an app that wants authenticationRequired: false to hold an OAuth refresh token could not adopt this plugin at all. Three separate blockers, each verified with a build rather than an analyze.

1. win32 pin made the plugin unresolvable

Every published 5.x pinned win32 >=2.0.0 <6.0.0, so it could not resolve alongside package_info_plus >=10.1.0, which needs win32 ^6.0.1:

biometric_storage >=5.0.0 depends on win32 >=2.0.0 <6.0.0
package_info_plus >=10.1.0 depends on win32 ^6.0.1
=> version solving failed

Lifting the pin means porting to the win32 6.0 API: TEXT() is gone in favour of String.toPcwstr()/toPwstr(), the Cred* calls return Win32Result<bool> carrying the last-error code instead of a BOOL plus GetLastError(), and the enum constants are top-level extension types. Allocations moved into an Arena.

This was never a Windows-only bug. lib/biometric_storage.dart re-exports the Windows implementation under if (dart.library.io), and Flutter generates dart_plugin_registrant.dart for every platform at once. An iOS release build of a consuming app contains:

import 'package:biometric_storage/biometric_storage.dart' as biometric_storage;
...
} else if (Platform.isWindows) {
  biometric_storage.Win32BiometricStoragePlugin.registerWith();

So a package:win32 break fails an iOS build. A federated biometric_storage_windows split would not change that — the registrant would import that package instead, on every platform. The defence is a compile: test/biometric_storage_test.dart now imports the public barrel rather than src/, so flutter test on any host compiles the win32 bindings, and CI runs the suite on Windows too. Verified by reintroducing a TEXT() call and watching the original error reappear as a test failure.

Two more bugs fell out of the rewrite. Writing an empty value reached Uint8List.toNative(), which rejects empty lists. And read() decoded the CredentialBlob.asTypedList() view after CredFree had released it — a use-after-free present in every 5.x, now fixed by copying the bytes out first.

Because that survived for years, the bindings now have runtime coverage rather than only compile coverage: test/biometric_storage_win32_test.dart exercises write/read/delete against the real credential store and runs on the Windows CI job.

The fileName: key under windows: was also a web-only key the tool never read.

2. Swift Package Manager

Flutter 3.44+ defaults to SwiftPM and falls back to CocoaPods for any plugin without a Package.swift — so adding this plugin to a migrated app silently regenerated its Podfiles. The CocoaPods registry also goes read-only on 2 December 2026.

Both are shipped: a Package.swift (with the FlutterFramework dependency the tool now expects) alongside the podspec. The sources moved to darwin/ under sharedDarwinSource: true, which removes the symlink ios/Classes used to reach into macos/Classes and the Objective-C shim — neither survives a SwiftPM target.

Verified by building an app that also depends on package_info_plus ^10.2.1: iOS and macOS both build with no Podfile generated, and the plugin's Swift symbols are in the linked binaries. Then verified again with SwiftPM disabled, so the podspec path still works.

Visible change: the macOS plugin class is BiometricStoragePlugin rather than BiometricStorageMacOSPlugin, and iOS no longer vends BiometricStoragePlugin.h. Flutter generates the registrant for both, so this only matters to someone registering the plugin by hand.

3. Staleness

Last stable was 5.0.1, roughly two years old.

  • Android: AGP 8.1.4 → 8.13.2, Kotlin 2.0.21 → 2.2.20, compileSdk 35 → 36, fragment-ktx 1.9.0, core-ktx 1.18.0, slf4j 2.0.18, kotlin-logging 8. lintOptions was removed in AGP 9, so it became lint. The plugin no longer applies the Kotlin Gradle Plugin itself — AGP 9 warns about that and future Flutter releases reject it.
  • androidx.biometric is held at 1.4.0-alpha05 deliberately. alpha06 added minCompileMinorSdk=1, which forces consumers to compileSdk 36.1. AGP 9.1 ignores that field, AGP 9.3 enforces it — so a bump to alpha07 raises every consumer's floor invisibly. alpha05 is the newest that asks only for compileSdk 35.
  • Example on AGP 9.3.2 / Gradle 9.5.0 / Kotlin 2.4.10 — ahead of the flutter create template on purpose, which is what caught the item above.
  • CI: actions/checkout@v1 → v4, a web job that set up Flutter on Windows and then did nothing → a real Windows test run, plus formatting and --fatal-infos analysis, and an assertion that no Podfile appeared. The example is a separate package, so analyze and test now run there too.
  • Dart sources reformatted with the current formatter (its own commit), logging_appenders 1.1 → 2.0 in the example, and its widget test — which had never passed, asserting a string the UI never contained — replaced with a real smoke test.

Fixes

Supersedes

  • Supersedes Update win32's constants #133 — its win32 constant update and its SwiftPM migration are both covered here. This PR shares the darwin sources through sharedDarwinSource rather than duplicating them under ios/ and macos/.
  • Supersedes Update to latest flutter #100 — "Update to latest flutter" (2023), pubspec constraints only.

Two more are already obsolete, though not because of this PR — worth closing separately:

Documentation corrections

The README presented FlutterFragmentActivity and a Theme.AppCompat theme as unconditional requirements, which is what made adoption look invasive. Both are conditions on showing a prompt:

  • withAuth returns straight to its callback when authenticationRequired is false, so attachedActivity is never read and BiometricPrompt is never constructed.
  • androidx.biometric only draws its own AppCompat dialog when isUsingFingerprintDialog() holds: SDK_INT < 28, API 28 without a fingerprint sensor, or a device on the shouldUseFingerprintForCrypto list. The README said "Android < 29".

Also documented: IosPromptInfo.saveTitle/accessTitle are not rendered on a Face ID device. Checked on an iPhone Xr running iOS 18.7.9 with a build that set the deprecated kSecUseOperationPrompt and its replacement LAContext.localizedReason to different marker strings — a screen recording of a read shows neither. They still reach the system, and Touch ID and macOS do draw them. kSecUseOperationPrompt, deprecated since iOS 14 / macOS 11, is replaced by localizedReason in this PR; the strings were invisible under Face ID before the change too.

Breaking

Requires Dart 3.10 / Flutter 3.44, which is win32 6.0.1's own floor and where SwiftPM became the default. The plugin still declares AGP 8.13.2 rather than 9.x, so it does not drag consumers onto Gradle 9.

Not addressed

Pre-existing bugs found while reviewing this PR

Deliberately not fixed here, to keep this reviewable. All six were verified against the code; a follow-up branched off this PR will pick them up. Ranked by severity:

  1. Android keeps a destroyed Activity after a configuration change. onReattachedToActivityForConfigChanges and onDetachedFromActivityForConfigChanges are both empty, so after a rotation attachedActivity still points at the destroyed Activity and BiometricPrompt gets a dead FragmentActivity. May explain ANR on Android 12 Device #142 and [Crash App]: Crash app on Samsung Note 10 Plus #143.
  2. result.error arguments reversed at BiometricStoragePlugin.kt:152 — an English sentence is delivered as PlatformException.code.
  3. Darwin init replies twice when an argument is missing: requiredArg replies with the error, then the unconditional result(true) runs anyway, so the caller sees success.
  4. Darwin canAuthenticate reports unknown LAErrors as unsupported, i.e. "plugin does not support platform", where Android now reports statusUnknown. Changing it is not cosmetic — callers treat statusUnknown as usable.
  5. forceInit is ignored on Windows and Darwin although the Dart doc promises it throws; only Android implements it.
  6. win32 read() collapses every failure to null, so a caller cannot tell "nothing stored" from "the credential store failed". Non-not-found errors are logged at warning level, so not silent.

Not published to pub.dev.

🤖 Generated with Claude Code

hpoul and others added 18 commits August 25, 2026 12:36
Dart 3.7 changed the default style and nothing here had been run through it
since, so `dart format --set-exit-if-changed` could not be added to CI without
a reformat first. This commit is that reformat and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every published 5.x pinned `win32 >=2.0.0 <6.0.0`, so the plugin could not be
resolved alongside `package_info_plus >=10.1.0`, which needs win32 ^6.0.1.
Lifting the pin means porting to the 6.0 API: `TEXT()` is gone in favour of
`String.toPcwstr()`/`toPwstr()`, the Cred* calls return `Win32Result<bool>`
carrying the last-error code rather than a BOOL plus `GetLastError()`, and the
enum constants are top-level extension types. Allocations move into an `Arena`,
which is what removes the hand-rolled free of every pointer.

This is not a Windows-only fix. `lib/biometric_storage.dart` re-exports the
Windows implementation under `if (dart.library.io)`, and Flutter generates
`dart_plugin_registrant.dart` for every platform at once — an iOS release build
of a consuming app imports the barrel and calls
`Win32BiometricStoragePlugin.registerWith()` behind a `Platform.isWindows`
check. So a win32 API break fails an iOS build. The test now imports the public
barrel instead of `src/`, which makes `flutter test` on any host compile the
bindings; reintroducing a `TEXT()` call reproduces the original error there.

Also: writing an empty value used to reach `Uint8List.toNative()`, which
rejects an empty list. A zero-length blob now gets a valid one-byte pointer.

The `fileName:` key under `windows:` is a web-only key and was never read by
the tool; dropped rather than corrected, since the barrel import is what the
registrant needs.

Requires Dart 3.10 / Flutter 3.44, which is win32 6.0.1's own floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flutter 3.44 makes SwiftPM the default for iOS and macOS, and falls back to
CocoaPods for any plugin without a Package.swift — so adding this plugin to an
app that had finished the migration silently regenerated its Podfiles. The
CocoaPods registry also goes read-only on 2 December 2026, and Flutter's advice
until then is to ship both, which is what this does: a Package.swift alongside
the podspec, with the FlutterFramework dependency the tool now expects.

SwiftPM wants the sources under `<platform>/<plugin_name>/Sources/<plugin_name>`,
which the old layout could not satisfy twice over: `ios/Classes` reached the
shared implementation through a symlink into `macos/Classes`, and mixing the
Objective-C shim with Swift in one target is not something a SwiftPM target
does. Both go away. The sources now live once in `darwin/`, shared through
`sharedDarwinSource: true`, and the registrar's messenger — a method on iOS, a
property on macOS — is the only thing behind an `#if os(...)`.

Consequences worth knowing about: the macOS plugin class is `BiometricStoragePlugin`
rather than `BiometricStorageMacOSPlugin`, and iOS no longer vends
`BiometricStoragePlugin.h`. Flutter generates the registrant for both, so this
is only visible to someone registering the plugin by hand.

The example's Xcode projects were regenerated from the current template rather
than unpicked: they carried CocoaPods build phases, an iOS 12 deployment target
and a macOS 10.14 one, all below what Flutter now supports. Its own
customisations — bundle ids, signing team, NSFaceIDUsageDescription, the
keychain-access-groups entitlements — are carried over.

Verified by building an app that also depends on package_info_plus ^10.2.1 for
iOS and macOS: both succeed, no Podfile is generated, and the plugin's Swift
symbols are in the linked binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kotlin 2.0.21 to 2.2.20, AGP 8.1.4 to 8.13.2, compileSdk 35 to 36,
androidx.biometric 1.4.0-alpha02 to alpha07, core-ktx 1.10.1 to 1.18.0,
fragment-ktx 1.6.1 to 1.9.0, slf4j 2.0.7 to 2.0.18, kotlin-logging 5 to 8.
core-ktx stops at 1.18.0 deliberately: 1.19.0 declares minCompileSdk 37, above
what AGP 9.1 supports.

Two of these are not version churn. `lintOptions` was removed in AGP 9, so the
block had to become `lint`. And the plugin no longer applies the Kotlin Gradle
Plugin itself: AGP 9 brings its own Kotlin support and Flutter warns that
plugins applying KGP will stop building, while on AGP 8 Flutter's own Gradle
plugin applies `kotlin-android` for us. Either way it arrives after this file is
evaluated, so `kotlin { }` is not available at the top level — which is also
what made `jvmToolchain` unresolvable in some consumer builds (#107). The JVM
target is configured from inside `pluginManager.withPlugin(...)` instead.

`canAuthenticate()` used to throw on any BiometricManager status code it did not
recognise, and androidx.biometric keeps adding them — Android 16 introduced
BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS (21) and the call started blowing up
(#148). Unmapped codes now report as ErrorStatusUnknown, which is what that
value is for, and log the code.

The example moves to Gradle 9.3.1 and AGP 9.1.0, which is what `flutter create`
emits today and what Java 25 requires, so the plugin is exercised against the
newest toolchain rather than the one it declares. Its `android.builtInKotlin`
stays false: AGP 9.1.0 ships KGP 2.2.10 and Flutter 3.47 requires 2.2.20.

Also drops two dead files: settings_aar.gradle, from the pre-1.0 aar workflow,
and an empty app/gradle.properties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README presented `MainActivity extends FlutterFragmentActivity` and a
`Theme.AppCompat` theme as flat requirements of using the plugin at all, which
makes adoption look far more invasive than it is — and is the reason this pass
exists, since a consumer wanting `authenticationRequired: false` needs neither.

Both are conditions on showing a prompt. `withAuth` returns straight to its
callback when `authenticationRequired` is false, so `attachedActivity` is never
read and `BiometricPrompt` is never constructed; a plain `FlutterActivity` costs
one logged error and only authenticated reads and writes.

The theme is narrower still. androidx.biometric only builds its own dialog with
`androidx.appcompat.app.AlertDialog` when `isUsingFingerprintDialog()` holds:
SDK_INT < 28, SDK_INT == 28 without a fingerprint sensor, or a device on the
`shouldUseFingerprintForCrypto` list. The README said "Android < 29"; API 29 and
up use the system BiometricPrompt and the theme does not matter.

Also: the getStorage snippet was missing its `await` and then read from a
variable it never assigned, the kotlin version to "make sure to use" was 1.4.31,
and the iOS/macOS deployment targets were three Flutter releases stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workflow had drifted: checkout@v1, flutter-action@v1, a Java 12 toolchain,
an ubuntu-22.04 matrix whose only entry excluded itself, and a `web` job that
set up Flutter on Windows and then did nothing at all.

Windows now runs the test suite rather than nothing, which is the platform whose
bindings just broke everyone else's builds. Formatting and `--fatal-infos`
analysis run alongside the tests. The iOS build asserts that no Podfile appeared,
so a regression in the Swift Package Manager support fails rather than passing
quietly with CocoaPods underneath.

macOS stays out of the build matrix for the reason the old comment gave, now
stated precisely: the example's keychain-access-groups entitlement resolves
$(AppIdentifierPrefix) from the signing team, so its Runner cannot be built
without a development certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the plugin's own `^3.10.0` and flutter_lints 6. The analyzer excludes
and the .metadata revisions are what `flutter pub get` and `flutter create`
wrote; the web platform entry is put back by hand, since regenerating only ios
and macos dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The if/else chain over `Platform.isX` had the unsupported-platform case at the
bottom of a five-branch ladder. Switching on `Platform.operatingSystem` puts
each platform's payload on one line beside its name, and the fallthrough binds
the value it reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mostly the things this pass cost time to work out and that the code does not
say: that the Windows implementation is compiled on iOS and macOS too and why
a federated split would not change that, that `fileName:` under `windows:` was
never read, how the darwin sources are shared, and why the plugin must not
apply the Kotlin Gradle Plugin.

The working-practice half is taken from the conventions in the owner's other
Flutter project, kept to what applies to a plugin: the commands, structured
edits over shell text manipulation, backgrounding slow builds, and the habit
that produced most of the above — an analyze is not a compile, and a plugin
that failed to register still builds green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`isUsingFingerprintDialog()` is `SDK_INT < 28`, not `<= 28`; the API 28 case is
the separate one where the device has no fingerprint sensor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the barrel export, naming the file with dartFileName, and splitting
Windows into a federated package each fail for a different reason, and stating
only the last of them invites the first two to be tried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carried over from the template podspec, which said 'Your Company' and
'email@example.com'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Xcode writes .swiftpm/xcode/xcuserdata beside Package.swift as soon as anyone
opens the package, and Package.resolved has nothing to pin here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ionPrompt

kSecUseOperationPrompt has been deprecated since iOS 14 / macOS 11, and warned
twice on every build. Its replacement is LAContext.localizedReason, which means
the reason travels on the context the query already carries rather than beside
it in the query dictionary.

Set on every call rather than once, because `context` may hand back a context
reused across calls when darwinTouchIDAuthenticationForceReuseContextDuration
is set. An empty or absent title is left alone: localizedReason is a non-optional
String, where assigning nil to the old query key simply removed it.

localizedReason is API_AVAILABLE(macos(10.13), ios(11.0)), below the package's
declared iOS 13 / macOS 10.15, so it needs no availability guard.

Verified by building an app for iOS and macOS: both succeed with no remaining
deprecation warnings from this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unanchored rule also matched
example/{ios,macos}/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved,
which is an app-level lockfile and the one case where committing it is right.
Neither file exists today — every dependency in the generated package graph is
a local path dependency, which SwiftPM does not record — but the rule should
not be waiting to hide the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked on an iPhone Xr running iOS 18.7.9 with a diagnostic build that set the
deprecated kSecUseOperationPrompt and the replacement LAContext.localizedReason
at the same time, to different marker strings. A screen recording of the read
shows neither: the Face ID panel is the glyph and the words "Face ID", and the
alert after a failed scan offers only "Face ID erneut versuchen" and
"Abbrechen".

So the strings were never rendered here, before the migration or after it —
worth recording, because someone customising IosPromptInfo and seeing nothing
change would otherwise reasonably suspect the plugin of dropping them. They
still reach the system, and Touch ID and macOS do draw them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
logging_appenders 1.1 to 2.0, which drops dio in favour of package:http and
takes three transitive dependencies with it. The example only uses PrintAppender
and the formatter types, so nothing in it changes.

The widget test has never passed: it is the stock plugin-template test, asserting
a Text starting with 'Running on:' that the example's UI has never contained —
zero occurrences in main.dart at any commit. Replaced with a smoke test of what
is actually on screen.

The example is a separate package, so `flutter analyze` and `flutter test` at the
repository root never reached it, which is how the test stayed broken. CI now
runs both there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upgrading the example from AGP 9.1.0 to 9.3.2 (with Gradle 9.3.1 to 9.5.0 and
KGP 2.4.0 to 2.4.10, both required by it) surfaced a regression this branch had
already introduced without noticing.

androidx.biometric 1.4.0-alpha06 added `minCompileMinorSdk=1` to its AAR
metadata, so alpha06 and alpha07 require consumers to compile against
compileSdk 36.1. AGP 9.1 does not read that field and builds happily; AGP 9.3
enforces it and fails `checkDebugAarMetadata`. Bumping alpha02 to alpha07
therefore moved every consumer's floor from compileSdk 35 to 36.1, invisibly,
for as long as they stayed on AGP 9.1.

alpha05 is the newest release that asks only for compileSdk 35. Nothing in the
plugin needs what alpha06 added: the fix for the unmapped Android 16 status code
falls back on any unrecognised value rather than naming the new constant, so it
does not depend on the constant existing.

Keeping the example ahead of the `flutter create` template is what caught this,
which is the argument for keeping it there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hpoul and others added 5 commits August 25, 2026 13:53
Two findings from review.

authenticationContext(reason:) only assigned localizedReason when the reason was
non-empty, so a call passing an empty saveTitle/accessTitle inherited the
previous call's wording — a read showing the save prompt's text. Only reachable
through a context reused via darwinTouchIDAuthenticationForceReuseContextDuration,
since a fresh context has nothing to inherit, and only when an app passes an
explicitly empty string. The key this replaced was per-query and could not carry
over. Now assigned unconditionally, which also restores the old behaviour
exactly: `kSecUseOperationPrompt` was set to the empty string in that case
rather than removed.

The win32 bindings had compile coverage on every host but no runtime coverage
anywhere, which is how a use-after-free survived in every 5.x: `read()` decoded
the `CredentialBlob.asTypedList()` view *after* `CredFree` released it. The
rewrite already copies the bytes out first, so this commit adds the test that
would have caught it, against the real credential store on the Windows CI job.
It imports `src/` rather than the barrel deliberately — the barrel reaches the
class through a conditional export whose default branch is an empty stub, and
the analyzer resolves to the stub.

Also records two ways the Gradle Kotlin hook can go quiet later: AGP's built-in
Kotlin does not register the `org.jetbrains.kotlin.android` id, so the block dies
when `android.builtInKotlin=true` becomes usable; and an app with no KGP visible
anywhere leaves our sources uncompiled with only a logged warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jvmToolchain(17) requires a JDK 17 to be discoverable, which is the constraint
#117 and #140 were both about. The plugin stopped calling it in this branch, but
the example app still did, so "builds under JDK 25" was only true where a JDK 17
also happened to be installed. jvmTarget emits 17 bytecode from whatever JDK
runs Gradle, and is what `flutter create` emits today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second review round found the one mutation the new win32 tests could not catch:
changing `namePrefix` orphans every existing user's stored credential while all
four tests stay green, because write and read both go through it. One assertion
pins it.

`.gitattributes` forces LF on the files CI formats and both platforms build.
The Windows leg of the format check passes today only because the runner's git
config happens to leave line endings alone; this makes it a guarantee, and stops
contributors on Windows with core.autocrlf=true seeing a tree that never looks
formatted.

Also corrects the age of the use-after-free. It did not ship with the initial
Windows support: 3aef1c3 decoded the blob with no CredFree in that path at all.
a9e3944 moved the CredFree call in between the asTypedList view and the decode,
and the next release was 1.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It is maintainer-facing instructions and was shipping to every consumer.

The trap: .pubignore is consulted INSTEAD OF .gitignore, and not only for its
own directory — adding a one-line version stopped every nested .gitignore being
honoured as well, which pulled seven example/build/web artifacts into the
archive. So it restates the exclusions rather than only adding one.

Measured rather than reasoned: `flutter pub publish --dry-run` goes from 151 to
150 files, and diffing the two file lists shows CLAUDE.md as the only change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lib/src/biometric_storage.dart` imported `dart:io` unconditionally, for
`Platform` alone. That library is reachable from the web implementation, so the
import marked the whole package wasm-incompatible and cost it pana's
`is:wasm-ready` tag and ten points — the only deduction it had.

The host OS now arrives through a conditional import. The io branch is still
`dart:io`'s own `Platform.operatingSystem`, so nothing changes on any platform
that has one.

Deliberately not `defaultTargetPlatform`, which needs no new files and would
have been the shorter diff. It answers a different question — the platform
Flutter is *emulating* — so an app setting `debugDefaultTargetPlatformOverride`,
routine for exercising Cupertino widgets on desktop, would have made us send iOS
arguments to an Android plugin. Not a trade worth making here.

The web branch returns 'web' rather than throwing. Every caller sits inside
MethodChannelBiometricStorage, which the web build never instantiates, so it
should be unreachable — and if some path does reach it, 'web' degrades the way
the callers already expect instead of turning a dead branch into a crash.

pana now reports every section at full marks with is:wasm-ready granted.
Verified compiling: wasm (main.dart.wasm emitted), JS, and Android for the io
branch. #145

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hpoul

hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot review this PR

Copilot AI commented Aug 25, 2026

Copy link
Copy Markdown

@copilot review this PR

CI is green across all jobs (Ubuntu, Windows, iOS build with no-Podfile assertion, Android, Linux, web). Here is a full review.


Summary

The three motivating changes are each well-executed. The win32 rewrite is correct; the SwiftPM migration is complete; the dependency and toolchain updates are conservative in the right places. A few things are worth noting.


lib/src/biometric_storage_win32.dart

read() use-after-free fix — correct. Uint8List.fromList(credential.ref.CredentialBlob.asTypedList(...)) copies the bytes out before the finally-block calls CredFree. The old code decoded them after freeing.

empty-value write() fix — correct. arena<Uint8>() gives a valid pointer with CredentialBlobSize = 0, bypassing the toNative rejection.

CRED_PERSIST_LOCAL_MACHINE — present in the old code too; fine for a device-local secure store.

delete() return type — the abstract base declares Future<bool?> while the override returns Future<bool>. The covariant return is allowed in Dart; no issue.


darwin/biometric_storage/Sources/biometric_storage/BiometricStorageImpl.swift

Pre-existing double-result call on init errors (line 97 in the shared file): requiredArg calls result(error) when an argument is missing, then the unconditional result(true) on line 97 fires anyway. Flutter logs "Reply already submitted" and discards the second call — not a crash, but a latent bug. This was there before this PR; the PR moved the file rather than changing the logic, so it is worth a separate fix but does not block this one.

canAuthenticate maps unknown codes to "ErrorUnknown"CanAuthenticateResponse.unsupported — Android maps them to "ErrorStatusUnknown"CanAuthenticateResponse.statusUnknown (the #148 fix). The iOS path was not updated: unknown codes such as biometryLockout fall through to default: result("ErrorUnknown"), which on the Dart side reads as "plugin does not support this platform". The practical impact is small (the caller should guard on success and treat everything else as "cannot use biometrics"), but the semantics differ from Android.

touchIDNotEnrolled vs biometryNotEnrolled — the same underlying raw value (-7), so the switch works even on Face ID devices. No issue.


Android

canAuthenticate unmapped-code fallback — correctly reports ErrorStatusUnknown and logs.

AGP + Kotlin constraints — the biometric_version = "1.4.0-alpha05" hold and its explanation are important; having that reasoning in the build.gradle comment is good. The pluginManager.withPlugin pattern for the JVM target is correct given the note about built-in Kotlin not registering the plugin ID.

ErrorPasscodeNotSet(-99) in the Kotlin enum is never returned by BiometricManager; it exists only so the Dart _canAuthenticateMapping entry 'ErrorPasscodeNotSet' is reachable. The actual passcode-not-set response comes from iOS/macOS. No issue.


CI (.github/workflows/dart.yml)

--fatal-infos on both the package and the example — good catch; the example's broken widget test went unnoticed without this.

No-Podfile assertion on the iOS build — clean way to guard the SwiftPM migration.

Windows job running flutter test — closes the gap between "compiles on all platforms" (Ubuntu job) and "executes on the target platform" (Windows job with biometric_storage_win32_test.dart).

macOS not in the build matrix — understandable given the signing requirement; the PR description notes it.


Everything else

lib/src/platform_os_io.dart / platform_os_web.dart — the wasm-readiness fix is clean and the comments accurately explain why returning 'web' from the web stub is safer than throwing.

CHANGELOG.md — covers all user-visible changes with issue references where relevant.

Package.swiftswift-tools-version: 5.9, correct platform floors (iOS("13.0"), macOS("10.15")), FlutterFramework dependency present.


Two items worth a follow-up (neither blocks merge):

  1. The double-result on init argument errors in BiometricStorageImpl.swift.
  2. iOS canAuthenticate returning unsupported instead of statusUnknown for unmapped codes, inconsistent with the Android Android 16+: canAuthenticate() throws "Unknown response code {21}" (BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS) #148 fix.

This branch untracked example/{ios,macos}/Podfile and their lockfiles when it
moved to Swift Package Manager, but taught no .gitignore about them. They are
still generated the moment anyone verifies the CocoaPods path still works —
which this PR's own description tells them to do — and from there they went
straight into `pub publish`, along with the r8 config the root .gitignore covers
but .pubignore did not restate, and dartdoc output nothing has ever covered.

Also corrects what the .pubignore comment claims. Nested .gitignore files ARE
still honoured; .pubignore only replaces the one in its own directory. The
artifacts the one-line version leaked were root .dart_tool output, not
example/build/web as the comment said, so it would have misled the next person
to edit it. And `/pubspec.lock` was never needed: pub does not package a
lockfile at any path.

Verified against a deliberately dirty tree — seeded Podfiles, lockfiles, a Pods
manifest, the r8 config and doc/api output — rather than the clean checkout the
first attempt measured. The archive gains none of them.

README: wincreds.h is wincred.h.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hpoul
hpoul merged commit 8ffd42f into main Aug 25, 2026
15 checks passed
@hpoul
hpoul deleted the win32-6-and-swiftpm branch August 25, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment