[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"