Skip to content

Conversation

@stuartmorgan-g
Copy link
Collaborator

@stuartmorgan-g stuartmorgan-g commented Jun 20, 2025

Rewrites the iOS/macOS implementation in Swift. This is an in-place rewrite with minimal changes, to minimize the chances of breakage, and to simplify review. Future refactorings can improve the Swift structure (e.g., fully adopting thread enforcement).

Fixes flutter/flutter#119104

Pre-Review Checklist

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. 2 3

Copy link
Collaborator Author

@stuartmorgan-g stuartmorgan-g left a comment

Choose a reason for hiding this comment

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

I did this conversion manually (as a way to practice Swift); no auto-conversion or AI tools involved. I deliberately did it in-place, pretty much line by line, to simplify side-by-side review. I've left notes about changes that aren't just direct language conversion below.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This can mostly be compared side-by-side to FLALocalAuthPlugin_Test.h

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This should be renamed in a follow-up; I didn't rename it here because there are enough changes that git would probably not show it as a diff, making review harder.

// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import LocalAuthentication
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This should always have been here, but transitive includes in Obj-C masked the missing include.


/// A context factory that returns preset contexts.
final class StubAuthContextFactory: NSObject, FLADAuthContextFactory {
final class StubAuthContextFactory: AuthContextFactory {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I removed NSObject from all the test implementations since the protocols aren't Obj-C now, so it was just cruft.

var contexts: [FLADAuthContext]
init(contexts: [FLADAuthContext]) {
var contexts: [AuthContext]
init(contexts: [AuthContext]) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

All the Pigeon-generated type names changed because there's no more prefixing.


/// A flutter plugin for local authentication.
// TODO(stuartmorgan): Remove the @unchecked Sendable, and convert to strict thread enforcement.
// This was added to minimize the changes while converting from Swift to Objective-C.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was unfortunate, but it was the best I could come up with to fix all the warnings without significant restructuring. Without this, the places where we dispatch from a potentially-background-thread-callback back to a main thread call to a method on self triggers warnings about self not being Sendable.

There are ways to fix that with more static methods, passing more state around instead of using self, etc., but they would require refactoring that I didn't want to combine with the language conversion as it would have made side-by-side review nearly impossible.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think let's keep @unchecked Sendable for now. I plan to look closer into Swift concurrency later this year

self.handleError(authError, options: options, strings: strings, completion: completion)
} else {
// This should not happen according to docs, but if it ever does the plugin should still
// fire the completion.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This branch is new. In Obj-C, we just assumed the docs weren't lying about the error being set if it returned false, but that's not type safe in Swift, and since docs have lied about nullability before I wanted a fallback instead of a crash from force-unwrapping.

animated: true,
completion: nil)
} else {
// TODO(stuartmorgan): Create a new error code for failure to show UI, and return it here.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This branch is new. We were failing to handle the case where there is no view before, so I added minimal handling for now.

self.handleResult(succeeded: false, completion: completion)
}
} else {
alert.runModal()
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This branch is new. We were failing to handle the case where there is no view before.

We could fail like on iOS (see below), but since macOS has a simple way to handle showing an alert without a view I went with that option.

} else {
// The Obj-C declaration of evaluatePolicy defines the callback type as NSError*, but the
// Swift version is (any Error)?, so provide a fallback in case somehow the type is not
// NSError.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This branch is new, for the reason described here.

@stuartmorgan-g

This comment was marked as resolved.

@stuartmorgan-g stuartmorgan-g added the triage-ios Should be looked at in iOS triage label Jun 23, 2025
var localizedFallbackTitle: String?

func canEvaluatePolicy(_ policy: LAPolicy) throws {
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

curious if this also conforms to the protocol:

func canEvaluatePolicy(_ policy: LAPolicy, error: inout NSError?) -> Bool

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

XCTAssertNil(error)
switch resultDetails {
case .success(let successDetails):
XCTAssertEqual(successDetails.result, AuthResult.success)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: XCTAssertEqual(successDetails.result, .success)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done throughout.

XCTAssertEqual(result!.count, 1)
XCTAssertEqual(result![0].value, FLADAuthBiometric.face)
XCTAssertNil(error)
do {
Copy link
Contributor

Choose a reason for hiding this comment

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

here and other tests - we can mark the function signature as throws, and remove this do/catch block

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done throughout.

return AuthStrings(
reason: "a reason", lockOut: "locked out", goToSettingsButton: "Go To Settings",
goToSettingsDescription: "Settings", cancelButton: "Cancel",
localizedFallbackTitle: localizedFallbackTitle)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: separate lines for each items

(https://google.github.io/swift/ -> Line-Wrapping -> "Comma-delimited lists are only laid out in one direction: horizontally or vertically. ")

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done. I forgot swift-format annoyingly doesn't handle this.

#endif

/// A default context factory that wraps standard LAContext allocation.
struct DefaultAuthContextFactory: AuthContextFactory {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: use class unless we need value semantics of struct (since struct comes with overhead)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Contributor

@hellohuanlin hellohuanlin Jun 26, 2025

Choose a reason for hiding this comment

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

What's the overhead?

Value types increase both build time and binary size. Though Apple has improved it over the years, so it shouldn't be our key consideration.

The key consideration is whether we want value or reference semantics (which should be the "control identity" in the link you provided)

For example, factories in general should be reference types, because they often share state, so we don't want multiple factories. This particular factory is stateless, so not the end of the world, but still, semantically we don't want multiple factories when passing it around.

The next example is more interesting:

struct DefaultAlertController {
    var controller: UIAlertController
}

If we use value type like here, imagine this code:

let c1 = DefaultAlertController(...)
let c2 = c1

Then we have 2 separate values (c1 and c2), but both pointing to the same underlying UIAlertController, which is conceptually misleading (it looks like you have created 2 distinct controllers, but you haven't).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Makes sense! Changed all of them.

) {
if success {
handleResult(succeeded: true, completion: completion)
} else {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: return in success to reduce indentation?

if success {
  handle...
  return
}
...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

LAError.biometryLockout,
LAError.userFallback,
LAError.passcodeNotSet,
LAError.authenticationFailed:
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: can infer these (e.g. case .biometryNotAvailable)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

completion(
.success(
AuthResultDetails(
result: succeeded ? AuthResult.success : AuthResult.failure,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: infer type

Copy link
Contributor

Choose a reason for hiding this comment

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

and below

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
)
@MainActor
Copy link
Contributor

Choose a reason for hiding this comment

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

we can mark the whole protocol @MainActor so no need each function. (same below)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is problematic until we annotate both Pigeon-generated methods and the FlutterPlugin registration methods as @MainActor, because it cascades through other code that ends up in places like the creation of the default implementations in register(with:), which aren't annotated @MainActor even though in practice they are.

It's much easier for now to annotate the specific methods.

/// instances in unit tests.
protocol AuthAlertController {
@MainActor
func addAction(_ action: UIAlertAction)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: add a blank line between each members (same below)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

Copy link
Collaborator Author

@stuartmorgan-g stuartmorgan-g left a comment

Choose a reason for hiding this comment

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

Thanks for the Swift tips!

var localizedFallbackTitle: String?

func canEvaluatePolicy(_ policy: LAPolicy) throws {
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

XCTAssertNil(error)
switch resultDetails {
case .success(let successDetails):
XCTAssertEqual(successDetails.result, AuthResult.success)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done throughout.

XCTAssertEqual(result!.count, 1)
XCTAssertEqual(result![0].value, FLADAuthBiometric.face)
XCTAssertNil(error)
do {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done throughout.

return AuthStrings(
reason: "a reason", lockOut: "locked out", goToSettingsButton: "Go To Settings",
goToSettingsDescription: "Settings", cancelButton: "Cancel",
localizedFallbackTitle: localizedFallbackTitle)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done. I forgot swift-format annoyingly doesn't handle this.

#endif

/// A default context factory that wraps standard LAContext allocation.
struct DefaultAuthContextFactory: AuthContextFactory {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Makes sense! Changed all of them.

completion(
.success(
AuthResultDetails(
result: succeeded ? AuthResult.success : AuthResult.failure,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
)
@MainActor
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is problematic until we annotate both Pigeon-generated methods and the FlutterPlugin registration methods as @MainActor, because it cascades through other code that ends up in places like the creation of the default implementations in register(with:), which aren't annotated @MainActor even though in practice they are.

It's much easier for now to annotate the specific methods.

/// instances in unit tests.
protocol AuthAlertController {
@MainActor
func addAction(_ action: UIAlertAction)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done


#if os(iOS)
/// A default alert controller that wraps UIAlertController.
struct DefaultAlertController: AuthAlertController {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

/// A default alert controller that wraps UIAlertController.
struct DefaultAlertController: AuthAlertController {
/// The wrapped alert controller.
var controller: UIAlertController
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

#endif

/// A default context factory that wraps standard LAContext allocation.
class DefaultAuthContextFactory: AuthContextFactory {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: these classes can all be final

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

// MARK: -

/// A data container for sticky auth state.
struct StickyAuthState {
Copy link
Contributor

Choose a reason for hiding this comment

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

thanks. not straightforward but interesting name

policy,
localizedReason: strings.reason
) { (success: Bool, error: Error?) in
DispatchQueue.main.async { [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you have to put [weak self] in outer closure context.evaluatePolicy, otherwise it would remain strong when outer closure is still alive.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Makes sense; done.

#elseif os(iOS)
// TODO(stuartmorgan): Get the view controller from the view provider once it's possible.
// See https://github.com/flutter/flutter/issues/104117.
if let controller = UIApplication.shared.delegate?.window??.rootViewController {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this is a good use case for guard let

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Converted to guard.

strings: AuthStrings,
completion: @escaping (Result<AuthResultDetails, Error>) -> Void
) {
var result = AuthResult.errorNotAvailable
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: can be let result: AuthResult (prefer immutability). This will fail to compile if any of the case below forget to set the result.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done. I didn't know Swift could do this to; I'm a big fan of the pattern in Dart.

/// Protocol for interacting with LAContext instances, abstracted to allow using mock/fake instances
/// in unit tests.
protocol AuthContext {
var localizedFallbackTitle: String? { get set }
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: blank line between each members. Also wanna add doc comments?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

@stuartmorgan-g stuartmorgan-g added the autosubmit Merge PR when tree becomes green via auto submit App label Jul 2, 2025
@auto-submit auto-submit bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Jul 2, 2025
@auto-submit
Copy link
Contributor

auto-submit bot commented Jul 2, 2025

autosubmit label was removed for flutter/packages/9459, because - The status or check suite Linux_android android_build_all_packages stable has failed. Please fix the issues identified (or deflake) before re-applying this label.

@stuartmorgan-g stuartmorgan-g added the autosubmit Merge PR when tree becomes green via auto submit App label Jul 2, 2025
@auto-submit auto-submit bot merged commit fe5b25e into flutter:main Jul 2, 2025
78 checks passed
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request Jul 3, 2025
github-merge-queue bot pushed a commit to flutter/flutter that referenced this pull request Jul 3, 2025
mboetger pushed a commit to mboetger/flutter that referenced this pull request Jul 21, 2025
azatech pushed a commit to azatech/flutter that referenced this pull request Jul 28, 2025
vashworth pushed a commit to vashworth/packages that referenced this pull request Jul 30, 2025
Rewrites the iOS/macOS implementation in Swift. This is an in-place rewrite with minimal changes, to minimize the chances of breakage, and to simplify review. Future refactorings can improve the Swift structure (e.g., fully adopting thread enforcement).

Fixes flutter/flutter#119104

## Pre-Review Checklist

[^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
lucaantonelli pushed a commit to lucaantonelli/flutter that referenced this pull request Nov 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autosubmit Merge PR when tree becomes green via auto submit App p: local_auth platform-ios platform-macos triage-ios Should be looked at in iOS triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[local_auth] iOS Swift migration

2 participants