Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
```

## 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.
Expand Down
210 changes: 210 additions & 0 deletions Sources/SkipKit/BiometricAuthentication.swift
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions Sources/SkipKit/Skip/skip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ build:
- block: 'dependencies'
export: false
contents:
- 'implementation("androidx.biometric:biometric:1.1.0")'
- 'implementation("androidx.browser:browser:1.10.0")'
Loading