From cccfd26f87e58ca70ef49c52a5adce88e3d6070c Mon Sep 17 00:00:00 2001 From: fhasse95 <49185957+fhasse95@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:02:00 +0200 Subject: [PATCH] Added Biometric Authentication Support --- README.md | 77 +++++++ Sources/SkipKit/BiometricAuthentication.swift | 210 ++++++++++++++++++ Sources/SkipKit/Skip/skip.yml | 1 + 3 files changed, 288 insertions(+) create mode 100644 Sources/SkipKit/BiometricAuthentication.swift diff --git a/README.md b/README.md index 460c2f9..616fe11 100644 --- a/README.md +++ b/README.md @@ -827,6 +827,83 @@ extension View { } ``` +## BiometricAuthentication + +SkipKit provides a cross-platform biometric authentication API for checking the available biometric capability and presenting the platform authentication prompt. + +### Checking Availability + +Use `BiometricAuthentication.authenticationType` to check whether biometric authentication is available. On iOS, SkipKit reports the concrete biometric type as `.fingerprint` for Touch ID or `.facialRecognition` for Face ID. On Android, SkipKit reports `.unspecified` because Android exposes biometric availability without reliably identifying the active biometric modality. + +```swift +import SkipKit + +switch BiometricAuthentication.authenticationType { +case .fingerprint: + print("Fingerprint authentication is available") +case .facialRecognition: + print("Facial recognition is available") +case .unspecified: + print("Biometric authentication is available") +case .none: + print("No biometric authentication is available") +} +``` + +For simple availability checks, use `canAuthenticate`: + +```swift +if BiometricAuthentication.canAuthenticate { + print("Biometric authentication can be used") +} +``` + +### Authenticating + +Call `authenticate(localizedReason:allowsDeviceCredentialFallback:completion:)` to show the system biometric authentication prompt: + +```swift +BiometricAuthentication.authenticate( + localizedReason: "Unlock the app" +) { result in + switch result { + case .success: + print("Authenticated") + case .cancelled: + print("Cancelled") + case .failed: + print("Failed") + case .unavailable: + print("Unavailable") + } +} +``` + +By default, `allowsDeviceCredentialFallback` is `false`. On Android, this means the biometric prompt shows a cancel button. If you set `allowsDeviceCredentialFallback` to `true`, Android allows the device PIN, pattern, or password as a system fallback instead: + +```swift +BiometricAuthentication.authenticate( + localizedReason: "Unlock the app", + allowsDeviceCredentialFallback: true +) { result in + // Handle the result. +} +``` + +Use `allowsDeviceCredentialFallback: false` when your app presents its own passcode screen after cancellation. Use `true` when the platform device credential should complete authentication directly. + +### Platform Notes + +On iOS, `BiometricAuthentication` uses `LocalAuthentication.LAContext`. When `allowsDeviceCredentialFallback` is `true`, iOS uses `.deviceOwnerAuthentication`; otherwise it uses `.deviceOwnerAuthenticationWithBiometrics`. On Android, SkipKit uses AndroidX `BiometricPrompt` with strong biometric authenticators. When `allowsDeviceCredentialFallback` is `true`, Android uses `BIOMETRIC_STRONG | DEVICE_CREDENTIAL` and does not show a negative button, because AndroidX does not allow a negative button together with device credentials. + +### Android Permissions + +Android apps using biometric authentication must include the biometric permission in their manifest: + +```xml + +``` + ## HapticFeedback SkipKit provides a cross-platform haptic feedback API that works identically on iOS and Android. You define patterns using simple, platform-independent types, and SkipKit handles playback using [CoreHaptics](https://developer.apple.com/documentation/corehaptics) on iOS and [VibrationEffect.Composition](https://developer.android.com/reference/android/os/VibrationEffect.Composition) on Android. diff --git a/Sources/SkipKit/BiometricAuthentication.swift b/Sources/SkipKit/BiometricAuthentication.swift new file mode 100644 index 0000000..7636cc6 --- /dev/null +++ b/Sources/SkipKit/BiometricAuthentication.swift @@ -0,0 +1,210 @@ +// Copyright 2025-2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation +import SwiftUI + +#if !SKIP +import LocalAuthentication +#else +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +#endif + +public enum BiometricAuthenticationType: String, Sendable { + case none + case fingerprint + case facialRecognition + case unspecified +} + +public enum BiometricAuthenticationResult: Sendable { + case success + case cancelled + case failed + case unavailable +} + +public enum BiometricAuthentication { + + // MARK: - Properties + public static var authenticationType: BiometricAuthenticationType { + #if !SKIP + let context = LAContext() + var error: NSError? + + guard context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) else { + return .none + } + + switch context.biometryType { + case .touchID: + return .fingerprint + case .faceID: + return .facialRecognition + default: + return .none + } + #else + guard let activity = UIApplication.shared.androidActivity else { + return .none + } + + let manager = BiometricManager.from(activity) + let authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG + + if manager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS { + return .unspecified + } else { + return .none + } + #endif + } + + public static var canAuthenticate: Bool { + self.authenticationType != .none + } + + // MARK: - Functions + + /// Authenticates the user with the device's biometric authentication method. + /// + /// - Parameters: + /// - localizedReason: The authentication reason. + /// - allowsDeviceCredentialFallback: A Boolean value indicating whether the system device credential can be used as a fallback. + /// - completion: The authentication completion handler. + public static func authenticate( + localizedReason: String, + allowsDeviceCredentialFallback: Bool = false, + completion: @escaping @MainActor (BiometricAuthenticationResult) -> Void + ) { + #if !SKIP + let context = LAContext() + let policy: LAPolicy = allowsDeviceCredentialFallback + ? .deviceOwnerAuthentication + : .deviceOwnerAuthenticationWithBiometrics + var error: NSError? + + guard context.canEvaluatePolicy( + policy, + error: &error + ) else { + Task { @MainActor in + completion(.unavailable) + } + return + } + + context.evaluatePolicy( + policy, + localizedReason: localizedReason + ) { success, error in + Task { @MainActor in + if success { + completion(.success) + } else if let error = error as? LAError, error.isCancellation { + completion(.cancelled) + } else { + completion(.failed) + } + } + } + #else + guard let activity = UIApplication.shared.androidActivity as? FragmentActivity else { + completion(.unavailable) + return + } + + let manager = BiometricManager.from(activity) + let authenticators = allowsDeviceCredentialFallback + ? BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL + : BiometricManager.Authenticators.BIOMETRIC_STRONG + + guard manager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS else { + completion(.unavailable) + return + } + + let executor = ContextCompat.getMainExecutor(activity) + let callback = AndroidBiometricAuthenticationCallback(completion: completion) + let prompt = BiometricPrompt(activity, executor, callback) + let builder = BiometricPrompt.PromptInfo.Builder() + .setTitle(localizedReason) + .setAllowedAuthenticators(authenticators) + + if !allowsDeviceCredentialFallback { + builder.setNegativeButtonText(activity.getString(android.R.string.cancel)) + } + + prompt.authenticate(builder.build()) + #endif + } +} + +#if SKIP +private final class AndroidBiometricAuthenticationCallback: BiometricPrompt.AuthenticationCallback { + + // MARK: - Properties + let completion: @MainActor (BiometricAuthenticationResult) -> Void + + // MARK: - Initialization + + /// Initializes a new instance of the `AndroidBiometricAuthenticationCallback` class. + /// + /// - Parameter completion: The authentication completion handler. + init(completion: @escaping @MainActor (BiometricAuthenticationResult) -> Void) { + self.completion = completion + } + + // MARK: - Functions + + /// Handles successful biometric authentication. + /// + /// - Parameter result: The biometric prompt authentication result. + override func onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + self.completion(.success) + } + + /// Handles biometric authentication errors. + /// + /// - Parameters: + /// - errorCode: The Android biometric prompt error code. + /// - errString: The Android biometric prompt error message. + override func onAuthenticationError(errorCode: Int, errString: CharSequence) { + switch errorCode { + case BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED: + self.completion(.cancelled) + default: + self.completion(.failed) + } + } + + /// Handles a failed biometric match while the prompt remains active. + override func onAuthenticationFailed() { + // Nothing to do here. Android keeps the biometric prompt active. + } +} +#endif + +#if !SKIP +private extension LAError { + + // MARK: - Properties + var isCancellation: Bool { + switch self.code { + case .appCancel, .systemCancel, .userCancel, .userFallback: + return true + default: + return false + } + } +} +#endif +#endif diff --git a/Sources/SkipKit/Skip/skip.yml b/Sources/SkipKit/Skip/skip.yml index 7f11a1a..8edf9db 100644 --- a/Sources/SkipKit/Skip/skip.yml +++ b/Sources/SkipKit/Skip/skip.yml @@ -9,4 +9,5 @@ build: - block: 'dependencies' export: false contents: + - 'implementation("androidx.biometric:biometric:1.1.0")' - 'implementation("androidx.browser:browser:1.10.0")'