Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b53743a
Bind to local platform interface package
stuartmorgan-g Aug 26, 2025
dff4dec
Add structured exception class
stuartmorgan-g Aug 26, 2025
0f54e0f
Update Windows
stuartmorgan-g Aug 26, 2025
9f9c148
Clarify platform interface docs
stuartmorgan-g Sep 3, 2025
a623537
Android implementation
stuartmorgan-g Aug 27, 2025
8ce45dc
Convert iOS
stuartmorgan-g Sep 3, 2025
6b44729
Documentation updates
stuartmorgan-g Sep 4, 2025
3894214
Remove useErrorDialogs and all related handling, update README
stuartmorgan-g Sep 8, 2025
b7cbe24
More changelog and version updates
stuartmorgan-g Sep 9, 2025
4897463
Rename Android biometricHint
stuartmorgan-g Sep 9, 2025
7362182
Throw structured exception from getEnrolledBiometrics
stuartmorgan-g Sep 9, 2025
a9d6b2f
Merge branch 'main' into local-auth-structured-errors
stuartmorgan-g Sep 9, 2025
359be0c
Apply Gemini error formatting suggestions in app-facing package
stuartmorgan-g Sep 11, 2025
5d23e5c
autoformat Gemini changes
stuartmorgan-g Sep 11, 2025
4f8fb80
Merge branch 'main' into local-auth-structured-errors
stuartmorgan-g Sep 11, 2025
3125501
Merge branch 'main' into local-auth-structured-errors
stuartmorgan-g Sep 16, 2025
d6dc783
Typo fixes
stuartmorgan-g Sep 16, 2025
11d257d
Merge branch 'main' into local-auth-structured-errors
stuartmorgan-g Sep 17, 2025
f11d2be
Update for adjustment to auth_options
stuartmorgan-g Sep 24, 2025
4e99fdd
Sync with landed version of platform interface
stuartmorgan-g Sep 30, 2025
fbd24bf
Merge branch 'main' into local-auth-structured-errors
stuartmorgan-g Sep 30, 2025
859b046
iOS README update
stuartmorgan-g Sep 30, 2025
3955e48
Revert app-facing package
stuartmorgan-g Sep 30, 2025
bb45330
Update interface dependency
stuartmorgan-g Sep 30, 2025
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 packages/local_auth/local_auth_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2.0.0

* **BREAKING CHANGES:**
* Switches to `LocalAuthException` for error reporting.
* Removes support for `useErrorDialogs`.
* Renames `biometricHint` to `signInHint` to reflect its usage.

## 1.0.53

* Removes obsolete code related to supporting SDK <24.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,19 @@

import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Application;
import android.content.Context;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import io.flutter.plugins.localauth.Messages.AuthResult;
import io.flutter.plugins.localauth.Messages.AuthResultCode;
import java.util.concurrent.Executor;

/**
Expand All @@ -38,14 +31,12 @@ class AuthenticationHelper extends BiometricPrompt.AuthenticationCallback
/** The callback that handles the result of this authentication process. */
interface AuthCompletionHandler {
/** Called when authentication attempt is complete. */
void complete(Messages.AuthResult authResult);
void complete(AuthResult authResult);
}

// This is null when not using v2 embedding;
private final Lifecycle lifecycle;
private final FragmentActivity activity;
private final AuthCompletionHandler completionHandler;
private final boolean useErrorDialogs;
private final Messages.AuthStrings strings;
private final BiometricPrompt.PromptInfo promptInfo;
private final boolean isAuthSticky;
Expand All @@ -65,14 +56,13 @@ interface AuthCompletionHandler {
this.completionHandler = completionHandler;
this.strings = strings;
this.isAuthSticky = options.getSticky();
this.useErrorDialogs = options.getUseErrorDialgs();
this.uiThreadExecutor = new UiThreadExecutor();

BiometricPrompt.PromptInfo.Builder promptBuilder =
new BiometricPrompt.PromptInfo.Builder()
.setDescription(strings.getReason())
.setTitle(strings.getSignInTitle())
.setSubtitle(strings.getBiometricHint())
.setSubtitle(strings.getSignInHint())
.setConfirmationRequired(options.getSensitiveTransaction());

int allowedAuthenticators =
Expand Down Expand Up @@ -120,58 +110,69 @@ private void stop() {
@SuppressLint("SwitchIntDef")
@Override
public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
AuthResultCode code;
switch (errorCode) {
case BiometricPrompt.ERROR_USER_CANCELED:
code = AuthResultCode.USER_CANCELED;
break;
case BiometricPrompt.ERROR_NEGATIVE_BUTTON:
code = AuthResultCode.NEGATIVE_BUTTON;
break;
case BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL:
if (useErrorDialogs) {
showGoToSettingsDialog(
strings.getDeviceCredentialsRequiredTitle(),
strings.getDeviceCredentialsSetupDescription());
return;
}
completionHandler.complete(Messages.AuthResult.ERROR_NOT_AVAILABLE);
code = AuthResultCode.NO_CREDENTIALS;
break;
case BiometricPrompt.ERROR_NO_SPACE:
case BiometricPrompt.ERROR_NO_BIOMETRICS:
if (useErrorDialogs) {
showGoToSettingsDialog(
strings.getBiometricRequiredTitle(), strings.getGoToSettingsDescription());
return;
}
completionHandler.complete(Messages.AuthResult.ERROR_NOT_ENROLLED);
code = AuthResultCode.NOT_ENROLLED;
break;
case BiometricPrompt.ERROR_HW_UNAVAILABLE:
code = AuthResultCode.HARDWARE_UNAVAILABLE;
break;
case BiometricPrompt.ERROR_HW_NOT_PRESENT:
completionHandler.complete(Messages.AuthResult.ERROR_NOT_AVAILABLE);
code = AuthResultCode.NO_HARDWARE;
break;
case BiometricPrompt.ERROR_LOCKOUT:
completionHandler.complete(Messages.AuthResult.ERROR_LOCKED_OUT_TEMPORARILY);
code = AuthResultCode.LOCKED_OUT_TEMPORARILY;
break;
case BiometricPrompt.ERROR_LOCKOUT_PERMANENT:
completionHandler.complete(Messages.AuthResult.ERROR_LOCKED_OUT_PERMANENTLY);
code = AuthResultCode.LOCKED_OUT_PERMANENTLY;
break;
case BiometricPrompt.ERROR_CANCELED:
// If we are doing sticky auth and the activity has been paused,
// ignore this error. We will start listening again when resumed.
if (activityPaused && isAuthSticky) {
return;
} else {
completionHandler.complete(Messages.AuthResult.FAILURE);
}
code = AuthResultCode.SYSTEM_CANCELED;
break;
case BiometricPrompt.ERROR_TIMEOUT:
code = AuthResultCode.TIMEOUT;
break;
case BiometricPrompt.ERROR_NO_SPACE:
code = AuthResultCode.NO_SPACE;
break;
case BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED:
code = AuthResultCode.SECURITY_UPDATE_REQUIRED;
break;
default:
completionHandler.complete(Messages.AuthResult.FAILURE);
code = AuthResultCode.UNKNOWN_ERROR;
break;
}
completionHandler.complete(
new AuthResult.Builder().setCode(code).setErrorMessage(errString.toString()).build());
stop();
}

@Override
public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
completionHandler.complete(Messages.AuthResult.SUCCESS);
completionHandler.complete(new AuthResult.Builder().setCode(AuthResultCode.SUCCESS).build());
stop();
}

@Override
public void onAuthenticationFailed() {}
public void onAuthenticationFailed() {
// No-op; this is called for incremental failures. Wait for a final
// resolution via the success or error callbacks.
}

/**
* If the activity is paused, we keep track because biometric dialog simply returns "User
Expand Down Expand Up @@ -205,34 +206,6 @@ public void onResume(@NonNull LifecycleOwner owner) {
onActivityResumed(null);
}

// Suppress inflateParams lint because dialogs do not need to attach to a parent view.
@SuppressLint("InflateParams")
private void showGoToSettingsDialog(String title, String descriptionText) {
View view = LayoutInflater.from(activity).inflate(R.layout.go_to_setting, null, false);
TextView message = view.findViewById(R.id.fingerprint_required);
TextView description = view.findViewById(R.id.go_to_setting_description);
message.setText(title);
description.setText(descriptionText);
Context context = new ContextThemeWrapper(activity, R.style.AlertDialogCustom);
OnClickListener goToSettingHandler =
(dialog, which) -> {
completionHandler.complete(Messages.AuthResult.FAILURE);
stop();
activity.startActivity(new Intent(Settings.ACTION_SECURITY_SETTINGS));
};
OnClickListener cancelHandler =
(dialog, which) -> {
completionHandler.complete(Messages.AuthResult.FAILURE);
stop();
};
new AlertDialog.Builder(context)
.setView(view)
.setPositiveButton(strings.getGoToSettingsButton(), goToSettingHandler)
.setNegativeButton(strings.getCancelButton(), cancelHandler)
.setCancelable(false)
.show();
}

// Unused methods for activity lifecycle.

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@

package io.flutter.plugins.localauth;

import static android.app.Activity.RESULT_OK;
import static android.content.Context.KEYGUARD_SERVICE;

import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
Expand All @@ -21,11 +19,11 @@
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter;
import io.flutter.plugin.common.PluginRegistry;
import io.flutter.plugins.localauth.AuthenticationHelper.AuthCompletionHandler;
import io.flutter.plugins.localauth.Messages.AuthClassification;
import io.flutter.plugins.localauth.Messages.AuthOptions;
import io.flutter.plugins.localauth.Messages.AuthResult;
import io.flutter.plugins.localauth.Messages.AuthResultCode;
import io.flutter.plugins.localauth.Messages.AuthStrings;
import io.flutter.plugins.localauth.Messages.LocalAuthApi;
import io.flutter.plugins.localauth.Messages.Result;
Expand All @@ -39,32 +37,14 @@
* <p>Instantiate this in an add to app scenario to gracefully handle activity and context changes.
*/
public class LocalAuthPlugin implements FlutterPlugin, ActivityAware, LocalAuthApi {
private static final int LOCK_REQUEST_CODE = 221;
private Activity activity;
private AuthenticationHelper authHelper;

@VisibleForTesting final AtomicBoolean authInProgress = new AtomicBoolean(false);

// These are null when not using v2 embedding.
private Lifecycle lifecycle;
private BiometricManager biometricManager;
private KeyguardManager keyguardManager;
Result<AuthResult> lockRequestResult;
private final PluginRegistry.ActivityResultListener resultListener =
new PluginRegistry.ActivityResultListener() {
@Override
public boolean onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == LOCK_REQUEST_CODE) {
if (resultCode == RESULT_OK && lockRequestResult != null) {
onAuthenticationCompleted(lockRequestResult, AuthResult.SUCCESS);
} else {
onAuthenticationCompleted(lockRequestResult, AuthResult.FAILURE);
}
lockRequestResult = null;
}
return false;
}
};

/**
* Default constructor for LocalAuthPlugin.
Expand All @@ -73,15 +53,21 @@ public boolean onActivityResult(int requestCode, int resultCode, Intent data) {
*/
public LocalAuthPlugin() {}

@Override
public @NonNull Boolean isDeviceSupported() {
return isDeviceSecure() || canAuthenticateWithBiometrics();
}

@Override
public @NonNull Boolean deviceCanSupportBiometrics() {
return hasBiometricHardware();
}

@Override
public @NonNull List<AuthClassification> getEnrolledBiometrics() {
if (biometricManager == null) {
return null;
}
ArrayList<AuthClassification> biometrics = new ArrayList<>();
if (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
== BiometricManager.BIOMETRIC_SUCCESS) {
Expand All @@ -94,6 +80,7 @@ public LocalAuthPlugin() {}
return biometrics;
}

@Override
public @NonNull Boolean stopAuthentication() {
try {
if (authHelper != null && authInProgress.get()) {
Expand All @@ -107,27 +94,29 @@ public LocalAuthPlugin() {}
}
}

@Override
public void authenticate(
@NonNull AuthOptions options,
@NonNull AuthStrings strings,
@NonNull Result<AuthResult> result) {
if (authInProgress.get()) {
result.success(AuthResult.ERROR_ALREADY_IN_PROGRESS);
result.success(new AuthResult.Builder().setCode(AuthResultCode.ALREADY_IN_PROGRESS).build());
return;
}

if (activity == null || activity.isFinishing()) {
result.success(AuthResult.ERROR_NO_ACTIVITY);
result.success(new AuthResult.Builder().setCode(AuthResultCode.NO_ACTIVITY).build());
return;
}

if (!(activity instanceof FragmentActivity)) {
result.success(AuthResult.ERROR_NOT_FRAGMENT_ACTIVITY);
result.success(
new AuthResult.Builder().setCode(AuthResultCode.NOT_FRAGMENT_ACTIVITY).build());
return;
}

if (!isDeviceSupported()) {
result.success(AuthResult.ERROR_NOT_AVAILABLE);
result.success(new AuthResult.Builder().setCode(AuthResultCode.NO_CREDENTIALS).build());
return;
}

Expand Down Expand Up @@ -221,7 +210,6 @@ private void setServicesFromActivity(Activity activity) {

@Override
public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {
binding.addActivityResultListener(resultListener);
setServicesFromActivity(binding.getActivity());
lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding);
}
Expand All @@ -234,7 +222,6 @@ public void onDetachedFromActivityForConfigChanges() {

@Override
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {
binding.addActivityResultListener(resultListener);
setServicesFromActivity(binding.getActivity());
lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding);
}
Expand Down
Loading