[local_auth] Fix cancel handling on Android (#4120)
Fixes a regression introduced during the Pigeon conversion where a canceled auth returned success instead of failure.
Adds initial unit test coverage of `AuthenticationHelper`, which was previously untested, including coverage of the incorrect code path.
Fixes https://github.com/flutter/flutter/issues/127732
diff --git a/packages/local_auth/local_auth_android/CHANGELOG.md b/packages/local_auth/local_auth_android/CHANGELOG.md
index b574726..5ee097c 100644
--- a/packages/local_auth/local_auth_android/CHANGELOG.md
+++ b/packages/local_auth/local_auth_android/CHANGELOG.md
@@ -1,5 +1,6 @@
-## NEXT
+## 1.0.29
+* Fixes a regression in 1.0.23 that caused canceled auths to return success.
* Updates minimum supported SDK version to Flutter 3.3/Dart 2.18.
## 1.0.28
diff --git a/packages/local_auth/local_auth_android/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java b/packages/local_auth/local_auth_android/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java
index b5b8245..fcb32c5 100644
--- a/packages/local_auth/local_auth_android/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java
+++ b/packages/local_auth/local_auth_android/android/src/main/java/io/flutter/plugins/localauth/AuthenticationHelper.java
@@ -155,7 +155,7 @@
if (activityPaused && isAuthSticky) {
return;
} else {
- completionHandler.complete(Messages.AuthResult.SUCCESS);
+ completionHandler.complete(Messages.AuthResult.FAILURE);
}
break;
default:
diff --git a/packages/local_auth/local_auth_android/android/src/test/java/io/flutter/plugins/localauth/AuthenticationHelperTest.java b/packages/local_auth/local_auth_android/android/src/test/java/io/flutter/plugins/localauth/AuthenticationHelperTest.java
new file mode 100644
index 0000000..d23532e
--- /dev/null
+++ b/packages/local_auth/local_auth_android/android/src/test/java/io/flutter/plugins/localauth/AuthenticationHelperTest.java
@@ -0,0 +1,194 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugins.localauth;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Application;
+import android.content.Context;
+import androidx.biometric.BiometricPrompt;
+import androidx.fragment.app.FragmentActivity;
+import io.flutter.plugins.localauth.AuthenticationHelper.AuthCompletionHandler;
+import io.flutter.plugins.localauth.Messages.AuthOptions;
+import io.flutter.plugins.localauth.Messages.AuthResult;
+import io.flutter.plugins.localauth.Messages.AuthStrings;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+// TODO(stuartmorgan): Add injectable BiometricPrompt factory, and AlertDialog factor, and add
+// testing of the rest of the flows.
+
+@RunWith(RobolectricTestRunner.class)
+public class AuthenticationHelperTest {
+ static final AuthStrings dummyStrings =
+ new AuthStrings.Builder()
+ .setReason("a reason")
+ .setBiometricHint("a hint")
+ .setBiometricNotRecognized("biometric not recognized")
+ .setBiometricRequiredTitle("biometric required")
+ .setCancelButton("cancel")
+ .setDeviceCredentialsRequiredTitle("credentials required")
+ .setDeviceCredentialsSetupDescription("credentials setup description")
+ .setGoToSettingsButton("go")
+ .setGoToSettingsDescription("go to settings description")
+ .setSignInTitle("sign in")
+ .build();
+
+ static final AuthOptions defaultOptions =
+ new AuthOptions.Builder()
+ .setBiometricOnly(false)
+ .setSensitiveTransaction(false)
+ .setSticky(false)
+ .setUseErrorDialgs(false)
+ .build();
+
+ @Test
+ public void onAuthenticationError_withoutDialogs_returnsNotAvailableForNoCredential() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, "");
+
+ verify(handler).complete(AuthResult.ERROR_NOT_AVAILABLE);
+ }
+
+ @Test
+ public void onAuthenticationError_withoutDialogs_returnsNotEnrolledForNoBiometrics() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_NO_BIOMETRICS, "");
+
+ verify(handler).complete(AuthResult.ERROR_NOT_ENROLLED);
+ }
+
+ @Test
+ public void onAuthenticationError_returnsNotAvailableForHardwareUnavailable() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_HW_UNAVAILABLE, "");
+
+ verify(handler).complete(AuthResult.ERROR_NOT_AVAILABLE);
+ }
+
+ @Test
+ public void onAuthenticationError_returnsNotAvailableForHardwareNotPresent() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_HW_NOT_PRESENT, "");
+
+ verify(handler).complete(AuthResult.ERROR_NOT_AVAILABLE);
+ }
+
+ @Test
+ public void onAuthenticationError_returnsTemporaryLockoutForLockout() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_LOCKOUT, "");
+
+ verify(handler).complete(AuthResult.ERROR_LOCKED_OUT_TEMPORARILY);
+ }
+
+ @Test
+ public void onAuthenticationError_returnsPermanentLockoutForLockoutPermanent() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_LOCKOUT_PERMANENT, "");
+
+ verify(handler).complete(AuthResult.ERROR_LOCKED_OUT_PERMANENTLY);
+ }
+
+ @Test
+ public void onAuthenticationError_withoutSticky_returnsFailureForCanceled() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_CANCELED, "");
+
+ verify(handler).complete(AuthResult.FAILURE);
+ }
+
+ @Test
+ public void onAuthenticationError_withoutSticky_returnsFailureForOtherCases() {
+ final AuthCompletionHandler handler = mock(AuthCompletionHandler.class);
+ final AuthenticationHelper helper =
+ new AuthenticationHelper(
+ null,
+ buildMockActivityWithContext(mock(FragmentActivity.class)),
+ defaultOptions,
+ dummyStrings,
+ handler,
+ true);
+
+ helper.onAuthenticationError(BiometricPrompt.ERROR_VENDOR, "");
+
+ verify(handler).complete(AuthResult.FAILURE);
+ }
+
+ private FragmentActivity buildMockActivityWithContext(FragmentActivity mockActivity) {
+ final Application mockApplication = mock(Application.class);
+ final Context mockContext = mock(Context.class);
+ when(mockActivity.getBaseContext()).thenReturn(mockContext);
+ when(mockActivity.getApplicationContext()).thenReturn(mockContext);
+ when(mockActivity.getApplication()).thenReturn(mockApplication);
+ return mockActivity;
+ }
+}
diff --git a/packages/local_auth/local_auth_android/pubspec.yaml b/packages/local_auth/local_auth_android/pubspec.yaml
index d22f8c9..98cb76c 100644
--- a/packages/local_auth/local_auth_android/pubspec.yaml
+++ b/packages/local_auth/local_auth_android/pubspec.yaml
@@ -2,7 +2,7 @@
description: Android implementation of the local_auth plugin.
repository: https://github.com/flutter/packages/tree/main/packages/local_auth/local_auth_android
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+local_auth%22
-version: 1.0.28
+version: 1.0.29
environment:
sdk: ">=2.18.0 <4.0.0"