前言
生物识别技术(如指纹识别和面部识别)在移动应用中的使用越来越广泛,为用户提供了便捷且安全的身份验证方式。本文将介绍如何开发一个Flutter生物识别插件,使您能够在应用中轻松集成生物识别功能。
创建插件
可以使用终端命令或者Android Studio来创建插件项目:
终端命令创建
flutter create --template=plugin --platforms=android,ios biometric_authorization
参数说明:
- --template=plugin:指定项目模板为插件类型
- --platforms=android,ios:指定支持的平台
- biometric_authorization:项目名称
执行该命令会生成一个支持 Android 和 iOS 的 Flutter 插件结构。
Android Studio创建
在新建Flutter项目时,填写好项目名称,然后要选择Project Type为Plugin,包名可以根据自己的来进行修改,Platforms选择要指定的平台,这里我们要指定的是Android和iOS,选择好后点击创建即可。
目录结构
创建好插件项目后,默认的项目代码目录结构如下:
biometric_authorization/
├── android/ // Android平台特定实现
├── ios/ // iOS平台特定实现
├── lib/ // Dart接口和实现
│ ├── biometric_authorization.dart // 主API类
│ ├── biometric_authorization_method_channel.dart // 方法通道实现
│ └──biometric_authorization_platform_interface.dart // 平台接口定义
└── example/ // 示例应用
我们在lib文件夹里面新建一个biometric_type
的dart文件,用来定义生物识别的枚举类型,得到新的目录结构:
biometric_authorization/
├── android/ // Android平台特定实现
├── ios/ // iOS平台特定实现
├── lib/ // Dart接口和实现
│ ├── biometric_authorization.dart // 主API类
│ ├── biometric_authorization_method_channel.dart // 方法通道实现
│ ├── biometric_authorization_platform_interface.dart // 平台接口定义
│ └── biometric_type.dart // 生物识别类型枚举
└── example/ // 示例应用
Dart接口实现
定义生物识别类型
在biometric_type.dart
文件中,定义了一个枚举表示支持的生物识别方法:面部识别(face)和指纹识别(finngerprint),如果不支持则使用none类型。
/// Biometric Type
///
/// - face: Face ID
/// - fingerprint: Touch ID
/// - none: None
enum BiometricType { face, fingerprint, none }
定义平台接口
平台接口定义了插件的核心功能,所有平台特定实现必须实现这些方法,在biometric_authorization_platform_interface.dart
文件中定义。
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'biometric_authorization_method_channel.dart';
import 'biometric_type.dart';
abstract class BiometricAuthorizationPlatform extends PlatformInterface {
/// Constructs a BiometricAuthorizationPlatform.
BiometricAuthorizationPlatform() : super(token: _token);
static final Object _token = Object();
static BiometricAuthorizationPlatform _instance =
MethodChannelBiometricAuthorization();
/// The default instance of [BiometricAuthorizationPlatform] to use.
///
/// Defaults to [MethodChannelBiometricAuthorization].
static BiometricAuthorizationPlatform get instance => _instance;
/// Platform-specific implementations should set this with their own
/// platform-specific class that extends [BiometricAuthorizationPlatform] when
/// they register themselves.
static set instance(BiometricAuthorizationPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
Future<String?> getPlatformVersion() {
throw UnimplementedError('platformVersion() has not been implemented.');
}
/// Check if biometric is available on the device.
Future<bool> isBiometricAvailable() async {
throw UnimplementedError(
'isBiometricAvailable() has not been implemented.',
);
}
/// Check if biometric is enrolled on the device.
Future<bool> isBiometricEnrolled() async {
throw UnimplementedError('isBiometricEnrolled() has not been implemented.');
}
/// Get available biometric types on the device.
Future<List<BiometricType>> getAvailableBiometricTypes() async {
throw UnimplementedError(
'getAvailableBiometricTypes() has not been implemented.',
);
}
/// Authenticate with biometric.
Future<bool> authenticate({
BiometricType biometricType = BiometricType.none,
String reason = "Authenticate",
String? title,
String? confirmText,
bool useCustomUI = false,
bool useDialogUI = false,
String? cancelText,
}) async {
throw UnimplementedError('authenticate() has not been implemented.');
}
}
这里我们定义了四个生物识别的平台接口方法:
- isBiometricAvailable:
这个方法用来检系统是否支持生物识别的功能,放回一个bool类型的值。
- isBiometricEnrolled:
这个方法用来检测系统是否录入了生物识别的信息(录入指纹、面部信息)。
- getAvailableBiometricTypes:
这个方法用来放回系统支持的所有的生物识别的类型(面部识别、指纹识别)。
- authenticate:
这个方法用来进行生物识别的认证,接收了一些参数,参数具体的说明在后面接口实现中进行说明。
定义平台接口实现
平台接口实现定义了具体的平台方法逻辑,使用 MethodChannel 与原生系统进行通信。在biometric_authorization_method_channel.dart
文件中定义MethodChannelBiometricAuthorization
类并继承自 BiometricAuthorizationPlatform
,并通过MethodChannel实现与原生平台的通信。
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'biometric_authorization_platform_interface.dart';
import 'biometric_type.dart';
/// An implementation of [BiometricAuthorizationPlatform] that uses method channels.
class MethodChannelBiometricAuthorization
extends BiometricAuthorizationPlatform {
/// The method channel used to interact with the native platform.
@visibleForTesting
final methodChannel = const MethodChannel('biometric_authorization');
@override
Future<String?> getPlatformVersion() async {
final version = await methodChannel.invokeMethod<String>(
'getPlatformVersion',
);
return version;
}
/// Check if biometric is available on the device.
@override
Future<bool> isBiometricAvailable() async {
final result = await methodChannel.invokeMethod<bool>(
'isBiometricAvailable',
);
return result ?? false;
}
/// Check if biometric is enrolled on the device.
@override
Future<bool> isBiometricEnrolled() async {
final result = await methodChannel.invokeMethod<bool>(
'isBiometricEnrolled',
);
return result ?? false;
}
/// Get available biometric types on the device.
///
/// Returns a list of biometric types that are available on the device.
@override
Future<List<BiometricType>> getAvailableBiometricTypes() async {
final result = await methodChannel.invokeMethod<List<dynamic>>(
'getAvailableBiometricTypes',
);
if (result == null) {
return [];
}
// Convert the string list to a BiometricType enum list
return result.map<BiometricType>((item) {
final String type = item.toString();
switch (type) {
case 'face':
return BiometricType.face;
case 'fingerprint':
return BiometricType.fingerprint;
case 'none':
default:
return BiometricType.none;
}
}).toList();
}
/// Initiates biometric authentication using the device's biometric sensors.
///
/// This method triggers the biometric authentication flow, which can use fingerprint,
/// face recognition, or other biometric methods available on the device.
///
/// Parameters:
/// - [biometricType]: Specifies the type of biometric authentication to use.
/// Required on iOS, optional on Android (Android will automatically use available methods).
/// Defaults to [BiometricType.none].
/// - [reason]: The reason for requesting authentication, displayed to the user.
/// Defaults to "Authenticate".
/// - [title]: The title of the authentication dialog. If null, a default title will be used.
/// - [confirmText]: The text for the confirmation button in the authentication dialog.
/// If null, a default text will be used.
/// - [useCustomUI]: Whether to use a custom UI for authentication (true) or the system default UI (false).
/// Defaults to false.
/// - [useDialogUI]: Whether to use the Dialog UI for authentication (true) or the new UI (false) in Android.
/// Defaults to false.
/// - [cancelText]: The text for the cancel button in the authentication dialog.
/// Only used on Android. If null, a default text ("Cancel") will be used.
///
/// Returns a [Future<bool>] that completes with:
/// - true: If authentication was successful
/// - false: If authentication failed or was canceled by the user
@override
Future<bool> authenticate({
BiometricType biometricType = BiometricType.none,
String reason = "Authenticate",
String? title,
String? confirmText,
bool useCustomUI = false,
bool useDialogUI = false,
String? cancelText,
}) async {
final Map<String, dynamic> arguments = {
'biometricType': biometricType.name,
'reason': reason,
'title': title,
'confirmText': confirmText,
'useCustomUI': useCustomUI,
'useDialogUI': useDialogUI,
'cancelText': cancelText,
};
final result = await methodChannel.invokeMethod<bool>(
'authenticate',
arguments,
);
return result ?? false;
}
}
在使用getAvailableBiometricTypes
方法时,对返回的类型字符列表进行解析映射成BiometricType
列表。
对于authenticate
方法中的各参数说明如下:
-
biometricType:指定要使用的生物识别类型,在iOS上必须传递
-
reason:认证原因说明,用于显示在系统弹窗上
-
title:认证对话框的标题
-
confirmText:确认按钮文字
-
cancelText:取消按钮文字(Android专用)
-
useCustomUI:是否使用自定义 UI(默认 false)
-
useDialogUI:Android 中是否使用旧的对话框 UI(默认 false)
这些参数会被打包成 Map,传递给原生方法:
定义API给Flutter使用
在biometric_authorization.dart
文件中定义插件API,供Flutter进行调用使用。
import 'biometric_authorization_platform_interface.dart';
import 'biometric_type.dart';
class BiometricAuthorization {
Future<String?> getPlatformVersion() {
return BiometricAuthorizationPlatform.instance.getPlatformVersion();
}
/// Check if biometric is available on the device.
Future<bool> isBiometricAvailable() {
return BiometricAuthorizationPlatform.instance.isBiometricAvailable();
}
/// Check if biometric is enrolled on the device.
Future<bool> isBiometricEnrolled() {
return BiometricAuthorizationPlatform.instance.isBiometricEnrolled();
}
/// Get available biometric types on the device.
Future<List<BiometricType>> getAvailableBiometricTypes() {
return BiometricAuthorizationPlatform.instance.getAvailableBiometricTypes();
}
/// Initiates biometric authentication using the device's biometric sensors.
///
/// This method triggers the biometric authentication flow, which can use fingerprint,
/// face recognition, or other biometric methods available on the device.
///
/// Parameters:
/// - [biometricType]: Specifies the type of biometric authentication to use.
/// Required on iOS, optional on Android (Android will automatically use available methods).
/// Defaults to [BiometricType.none].
/// - [reason]: The reason for requesting authentication, displayed to the user.
/// Defaults to "Authenticate".
/// - [title]: The title of the authentication dialog. If null, a default title will be used.
/// - [confirmText]: The text for the confirmation button in the authentication dialog.
/// If null, a default text will be used.
/// - [useCustomUI]: Whether to use a custom UI for authentication (true) or the system default UI (false).
/// Defaults to false.
/// - [cancelText]: The text for the cancel button in the authentication dialog.
/// Only used on Android. If null, a default text ("Cancel") will be used.
///
/// Returns a [Future<bool>] that completes with:
/// - true: If authentication was successful
/// - false: If authentication failed or was canceled by the user
Future<bool> authenticate({
BiometricType biometricType = BiometricType.none,
String reason = "Authenticate",
String? title,
String? confirmText,
bool useCustomUI = false,
bool useDialogUI = false,
String? cancelText,
}) {
return BiometricAuthorizationPlatform.instance.authenticate(
biometricType: biometricType,
reason: reason,
title: title,
confirmText: confirmText,
useCustomUI: useCustomUI,
useDialogUI: useDialogUI,
cancelText: cancelText,
);
}
}
平台特定实现
平台特定实现是指在各特定平台上使用原生代码和方法实现插件中的方法,在iOS上一般使用Object-C/Swift,在Android上一般使用 Java/Kotlin。这里我们使用Swift和Kotlin来实现。
iOS
在ios文件夹中的Classes里面定义了三个文件,ios文件夹的主要目录结构如下:
ios/
├── Assets
├── Classes/
│ ├── BiometricAuthorizationPlugin.swift // 插件入口文件
│ ├── BiometricAuthorization.swift // 生物识别功能实现文件
│ └── BiometricAuthView.swift // 自定义生物识别的UI
└── Resources
BiometricAuthorizationPlugin
这个文件中定义了BiometricAuthorizationPlugin类,它实现了FlutterPlugin 协议,并负责处理从 Dart 端通过 MethodChannel 发起的调用请求。在这里的handle
方法中,call
参数包括了Flutter侧调用的方法和参数,result
参数则是返回参数。在这里的call.method
的名字必须与Dart平台接口实现中的名字一致,否则会进入默认处理逻辑(即 FlutterMethodNotImplemented)。
import Flutter
import UIKit
/**
* Flutter plugin that provides biometric authentication functionality for iOS.
* This plugin serves as the bridge between Flutter and native iOS authentication APIs.
*
* It implements the FlutterPlugin protocol to handle method calls from Flutter
* and delegates the actual biometric operations to the BiometricAuthorization class.
*/
public class BiometricAuthorizationPlugin: NSObject, FlutterPlugin {
/**
* Registers this plugin with the Flutter engine.
* Sets up the method channel and plugin instance.
*
* @param registrar The Flutter plugin registrar to register with.
*/
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "biometric_authorization", binaryMessenger: registrar.messenger())
let instance = BiometricAuthorizationPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
/**
* Handles method calls from Flutter.
* Routes each method to the appropriate BiometricAuthorization function.
*
* @param call The method call from Flutter with method name and arguments.
* @param result The result callback to send the response back to Flutter.
*/
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
// Returns the iOS version for plugin verification
result("iOS " + UIDevice.current.systemVersion)
case "isBiometricAvailable":
// Checks if the device has biometric hardware
result(BiometricAuthorization.isBiometricAvailable())
case "isBiometricEnrolled":
// Checks if biometrics are enrolled on the device
result(BiometricAuthorization.isBiometricEnrolled())
case "getAvailableBiometricTypes":
// Gets a list of available biometric types on the device
result(BiometricAuthorization.getAvailableBiometricTypes())
case "authenticate":
// Performs biometric authentication with parameters from Flutter
BiometricAuthorization.authenticate(call: call, result: result)
default:
// Returns not implemented for unknown methods
result(FlutterMethodNotImplemented)
}
}
}
BiometricAuthorization
这个文件定义了iOS侧生物识别的具体实现,基于LocalAuthentication
实现,通过LAContext
创建上下文对象,使用canEvaluatePolicy
来检查是否支持生物识别。
import Foundation
import LocalAuthentication
import SwiftUI
import Flutter
/**
* Represents the supported biometric authentication types.
* Maps to the corresponding types defined in the Dart code.
*/
enum BiometricType: String {
case face = "face" // Face ID on supported devices
case fingerprint = "fingerprint" // Touch ID on supported devices
case none = "none" // Fallback when no biometric is available
}
/**
* Main class that handles biometric authentication functionality.
* Provides methods to check availability, enrollment, and perform authentication.
*/
class BiometricAuthorization {
/**
* Creates and configures a new LAContext instance.
*
* @return A configured LAContext instance ready for biometric operations.
*/
private static func createContext() -> LAContext {
let context = LAContext()
return context
}
/**
* Checks if biometric authentication is available on the device.
* This checks if the hardware supports biometrics (Face ID or Touch ID).
*
* @return Boolean indicating if biometric authentication is available.
*/
static func isBiometricAvailable() -> Bool {
let context = createContext()
var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return available
}
/**
* Checks if biometric authentication is enrolled on the device.
* This verifies if the user has registered their biometrics (face or fingerprint).
*
* @return Boolean indicating if biometrics are enrolled.
*/
static func isBiometricEnrolled() -> Bool {
let context = createContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
if let err = error as? LAError {
return err.code != .biometryNotEnrolled
}
return true
}
/**
* Determines which biometric types are available on the device.
*
* @return Array of strings representing available biometric types.
* Returns ["none"] if no biometrics are available.
*/
static func getAvailableBiometricTypes() -> [String] {
let context = createContext()
var error: NSError?
var biometricTypes: [String] = []
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
switch context.biometryType {
case .faceID:
biometricTypes.append(BiometricType.face.rawValue)
case .touchID:
biometricTypes.append(BiometricType.fingerprint.rawValue)
default:
break
}
}
return biometricTypes.isEmpty ? [BiometricType.none.rawValue] : biometricTypes
}
/**
* Main authentication method called from Flutter through the method channel.
* Determines whether to use standard system authentication or custom UI.
*
* @param call The Flutter method call containing authentication parameters.
* @param result The Flutter result callback to return the authentication outcome.
*/
static func authenticate(call: FlutterMethodCall, result: @escaping FlutterResult) {
let context = createContext()
// Extract parameters from the method call
guard let args = call.arguments as? [String: Any],
let biometricType = args["biometricType"] as? String,
let reason = args["reason"] as? String
else {
result(false)
return
}
let title = args["title"] as? String
let confirmText = args["confirmText"] as? String
let useCustomUI = args["useCustomUI"] as? Bool ?? false
let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
if #available(iOS 13.0, *) {
if useCustomUI &&
(biometricType == BiometricType.face.rawValue ||
biometricType == BiometricType.fingerprint.rawValue) {
// Use custom SwiftUI-based authentication UI
showCustomUI(
biometricType: biometricType,
title: title,
confirmText: confirmText,
context: context,
policy: policy,
reason: reason,
result: result
)
} else {
// Use standard system authentication dialog
authenticateStandard(
context: context,
policy: policy,
reason: reason,
result: result
)
}
}
}
/**
* Initiates the custom UI authentication flow.
* This is a wrapper method that calls the async presentation method.
*
* @param biometricType The type of biometric to authenticate with.
* @param title Optional title for the authentication dialog.
* @param confirmText Optional text for the confirm button.
* @param context The LAContext instance for biometric operations.
* @param policy The authentication policy to use.
* @param reason The reason for authentication to display to user.
* @param result The Flutter result callback.
*/
@available(iOS 13.0, *)
private static func showCustomUI(
biometricType: String,
title: String?,
confirmText: String?,
context: LAContext,
policy: LAPolicy,
reason: String,
result: @escaping FlutterResult
) {
Task {
await presentBiometricSheet(
context: context,
policy: policy,
reason: reason,
title: title,
confirmText: confirmText,
biometricType: biometricType,
result: result
)
}
}
/**
* Presents the custom biometric authentication sheet using SwiftUI.
* Handles different iOS versions with appropriate UI adaptations.
*
* @param context The LAContext instance for biometric operations.
* @param policy The authentication policy to use.
* @param reason The reason for authentication to display to user.
* @param title Optional title for the authentication dialog.
* @param confirmText Optional text for the confirm button.
* @param biometricType The type of biometric to authenticate with.
* @param result The Flutter result callback.
*/
@available(iOS 13.0, *)
@MainActor
private static func presentBiometricSheet(
context: LAContext,
policy: LAPolicy,
reason: String,
title: String?,
confirmText: String?,
biometricType: String,
result: @escaping FlutterResult
) {
// Get the key window and root view controller
guard let keyWindow = getKeyWindow(),
let rootViewController = keyWindow.rootViewController else {
authenticateStandard(
context: context,
policy: policy,
reason: reason,
result: result
)
return
}
// Create the SwiftUI view for biometric authentication
let contentView = BiometricAuthView(
title: title ?? getBiometricTitle(type: biometricType),
reason: reason,
buttonText: confirmText ?? "Authenticate",
biometricType: biometricType,
onAuthenticate: result
)
let hostingController = UIHostingController(rootView: contentView)
if #available(iOS 15.0, *) {
// iOS 15+ uses the new sheet presentation API
hostingController.modalPresentationStyle = .pageSheet
if let sheet = hostingController.sheetPresentationController {
if #available(iOS 16.0, *) {
// iOS 16+ supports custom height using fraction
sheet.detents = [
.custom { context in
context.maximumDetentValue * 0.35
}
]
} else {
// iOS 15 only supports predefined detents
sheet.detents = [.medium()]
hostingController.view.heightAnchor.constraint(
equalToConstant: UIScreen.main.bounds.height * 0.35
).isActive = true
}
sheet.preferredCornerRadius = 25
sheet.prefersGrabberVisible = true
}
} else {
// iOS 13-14 uses the older form sheet presentation
hostingController.modalPresentationStyle = .formSheet
hostingController.preferredContentSize = CGSize(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height * 0.35
)
hostingController.view.backgroundColor = .systemBackground
hostingController.view.layer.cornerRadius = 20
hostingController.view.clipsToBounds = true
}
// Present the authentication sheet
rootViewController.present(hostingController, animated: true)
}
/**
* Helper method to get the key window in iOS 13+.
*
* @return The key UIWindow instance or nil if not found.
*/
@available(iOS 13.0, *)
@MainActor
private static func getKeyWindow() -> UIWindow? {
return UIApplication
.shared
.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}
/**
* Returns the appropriate title for the authentication dialog based on biometric type.
*
* @param type The biometric type string.
* @return A user-friendly title for the authentication dialog.
*/
private static func getBiometricTitle(type: String) -> String {
switch type {
case BiometricType.face.rawValue:
return "Face ID Authentication"
case BiometricType.fingerprint.rawValue:
return "Touch ID Authentication"
default:
return "Biometric Authentication"
}
}
/**
* Performs standard system biometric authentication without custom UI.
*
* @param context The LAContext instance for biometric operations.
* @param policy The authentication policy to use.
* @param reason The reason for authentication to display to user.
* @param result The Flutter result callback.
*/
@available(iOS 13.0, *)
private static func authenticateStandard(
context: LAContext,
policy: LAPolicy,
reason: String,
result: @escaping FlutterResult
) {
context.evaluatePolicy(policy, localizedReason: reason) { success, error in
Task {
if success {
result(true)
} else {
result(false)
}
}
}
}
}
BiometricAuthView
这个文件定义了自定义生物识别的UI样式,就是自定义了一个底部的弹出,使用了SwiftUI。
import SwiftUI
import LocalAuthentication
/**
* A SwiftUI view that provides a custom UI for biometric authentication.
* This view displays a stylish interface with an animated biometric icon,
* title, and authentication button.
*
* The view adapts to different screen sizes through responsive design.
* Compatible with iOS 13.0 and later.
*/
@available(iOS 13.0, *)
struct BiometricAuthView: View {
// Animation state variables
@State private var isAnimating = false
@State private var isAuthenticating = false
// Environment access to dismiss the view
@Environment(\.presentationMode) var presentationMode
// View configuration properties
var title: String
var reason: String
var buttonText: String
var biometricType: String
var onAuthenticate: (Bool) -> Void
var body: some View {
// GeometryReader allows the view to adapt to different screen sizes
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
let iconSize = min(width, height) * 0.35
let fontSize = min(width, height) * 0.15
VStack(spacing: height * 0.03) {
// Title text
Text(title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.center)
Spacer()
.frame(height: height * 0.02)
// Animated biometric icon
ZStack {
// Background circle
Circle()
.fill(Color.blue.opacity(0.1))
.frame(width: iconSize, height: iconSize)
// Biometric icon (Face ID or Touch ID)
Image(systemName: getSystemImageName())
.font(.system(size: fontSize))
.foregroundColor(.blue)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.onAppear {
// Create a continuous pulse animation
withAnimation(
Animation.easeInOut(duration: 1.0)
.repeatForever(autoreverses: true)
) {
isAnimating = true
}
}
}
Spacer()
.frame(height: height * 0.03)
// Authentication button
Button {
authorizationWithBiometric()
} label: {
HStack {
Text(buttonText)
.font(.system(size: fontSize * 0.5))
.fontWeight(.semibold)
Image(systemName: "arrow.right")
.font(.system(size: fontSize * 0.5))
}
.frame(maxWidth: .infinity)
.padding(EdgeInsets(
top: height * 0.05,
leading: width * 0.05,
bottom: height * 0.05,
trailing: width * 0.05
))
.background(
ZStack {
LinearGradient(
gradient: Gradient(colors: [
Color.blue,
Color.blue.opacity(0.8)
]),
startPoint: .leading,
endPoint: .trailing
)
}
)
.foregroundColor(.white)
.cornerRadius(15)
.shadow(color: Color.blue.opacity(0.3), radius: 5, x: 0, y: 3)
}
.padding(.horizontal, width * 0.05)
.disabled(isAuthenticating)
}
.padding(width * 0.05)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, height * 0.03)
}
.onDisappear {
isAnimating = false
}
}
/**
* Determines the system icon name based on the biometric type.
*
* @return The SF Symbol name for the appropriate biometric type.
*/
func getSystemImageName() -> String {
switch biometricType {
case "face":
return "faceid"
case "fingerprint":
return "touchid"
default:
return "person.circle"
}
}
/**
* Initiates the biometric authentication process when the button is tapped.
* Uses LocalAuthentication framework to authenticate with the device biometrics.
* The result is communicated back via the onAuthenticate callback.
*/
func authorizationWithBiometric() {
isAuthenticating = true
let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
) { success, error in
Task {
isAuthenticating = false
if success {
// Authentication successful
onAuthenticate(true)
presentationMode.wrappedValue.dismiss()
} else {
// Authentication failed
onAuthenticate(false)
}
}
}
} else {
// Device cannot use biometric authentication
isAuthenticating = false
onAuthenticate(false)
}
}
}
Android
在android文件夹中的mian里面定义了5个文件,android文件夹的主要目录结构如下:
android/src/main/kotlin/com/maojiu/biometric_authorization/
│ ├── BiometricAuthBottomSheet.kt
│ ├── BiometricAuthorizationManager.kt
│ ├── BiometricAuthorizationPlugin
│ ├── FingerprintAuthDialog.kt
│ ├── FingerprintDialogFragment.kt
└── AndroidMandiest.xml
BiometricAuthorizationPlugin
这个是Android侧的插件入口文件。
/**
* BiometricAuthorizationPlugin.kt
*
* Main entry point for the Flutter plugin that provides biometric authentication capabilities.
* This plugin serves as a bridge between Flutter code and native Android biometric APIs.
* It implements necessary Flutter plugin interfaces and handles method calls from the Flutter side.
*/
package com.maojiu.biometric_authorization
import android.app.Activity
import android.content.Context
import androidx.fragment.app.FragmentActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
/**
* Main plugin class that implements Flutter plugin interfaces.
*
* This class handles the plugin lifecycle and method calls from Flutter,
* and delegates the actual biometric operations to the BiometricAuthorizationManager.
* It implements:
* - FlutterPlugin: For plugin registration and lifecycle management
* - MethodCallHandler: For handling method calls from Flutter
* - ActivityAware: For accessing the current activity which is required for UI operations
*/
class BiometricAuthorizationPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
/**
* The MethodChannel that will be used to communicate with the Flutter side
*/
private lateinit var channel: MethodChannel
/**
* Application context provided by the Flutter engine
*/
private lateinit var context: Context
/**
* Current activity, needed for UI operations like showing biometric prompts
*/
private var activity: FragmentActivity? = null
/**
* Called when the plugin is attached to the Flutter engine.
*
* Sets up the MethodChannel and initializes the context.
*
* @param flutterPluginBinding Provides access to the Flutter engine's resources
*/
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "biometric_authorization")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.applicationContext
}
/**
* Called when the plugin is attached to an activity.
*
* Stores the current activity for later use.
*
* @param binding Provides access to the current activity
*/
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity as? FragmentActivity
}
/**
* Called when the plugin is detached from the activity for configuration changes.
*
* Clears the stored activity reference.
*/
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
/**
* Called when the plugin is reattached to the activity after configuration changes.
*
* Updates the stored activity reference.
*
* @param binding Provides access to the current activity
*/
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity as? FragmentActivity
}
/**
* Called when the plugin is detached from the activity.
*
* Clears the stored activity reference.
*/
override fun onDetachedFromActivity() {
activity = null
}
/**
* Handles method calls from the Flutter side.
*
* This method routes incoming calls to appropriate methods in the BiometricAuthorizationManager.
* It first checks if the activity is available before proceeding with the method call.
*
* @param call The method call from Flutter containing the method name and arguments
* @param result The result callback to send the result back to Flutter
*/
override fun onMethodCall(call: MethodCall, result: Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("ACTIVITY_NOT_AVAILABLE", "Activity is not available", null)
return
}
// Create a manager instance to handle the biometric operations
val biometricAuthorizationManager = BiometricAuthorizationManager(context, currentActivity)
// Route the method call to the appropriate handler
when (call.method) {
"getPlatformVersion" -> {
// Return the Android version
result.success("Android ${android.os.Build.VERSION.RELEASE}")
}
"isBiometricAvailable" -> {
// Check if biometric authentication is available on the device
result.success(biometricAuthorizationManager.isBiometricAvailable())
}
"isBiometricEnrolled" -> {
// Check if biometric credentials are enrolled on the device
result.success(biometricAuthorizationManager.isBiometricEnrolled())
}
"getAvailableBiometricTypes" -> {
// Get the list of available biometric types on the device
result.success(biometricAuthorizationManager.getAvailableBiometricTypes())
}
"authenticate" -> {
// Initiate the biometric authentication process
biometricAuthorizationManager.authenticate(call, result)
}
else -> {
// Method not implemented
result.notImplemented()
}
}
}
/**
* Called when the plugin is detached from the Flutter engine.
*
* Cleans up resources by removing the method call handler.
*
* @param binding The binding that was providing access to the Flutter engine's resources
*/
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
BiometricAuthorizationManager
这个文件是生物识别的具体实现。
/**
* BiometricAuthorizationManager.kt
*
* Core manager for biometric authentication operations in the Flutter plugin.
* This class handles biometric availability checks, enrollment status, and authentication processes.
* It supports both standard system UI and custom bottom sheet UI for biometric authentication.
*/
@file:Suppress("DEPRECATION")
package com.maojiu.biometric_authorization
import android.annotation.SuppressLint
import android.content.Context
import androidx.biometric.BiometricManager
import android.content.pm.PackageManager
import android.os.Build
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel.Result
import androidx.core.content.ContextCompat
import androidx.biometric.BiometricPrompt
import androidx.core.hardware.fingerprint.FingerprintManagerCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import android.util.Log
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.fragment.app.DialogFragment
import java.util.concurrent.atomic.AtomicBoolean
/**
* Enum class representing the supported biometric authentication types.
*
* @property rawValue String value representation of the biometric type
*/
enum class BiometricType(val rawValue: String) {
face("face"),
fingerprint("fingerprint"),
none("none")
}
/**
* Constants for fingerprint error codes from FingerprintManager
* These are not available in FingerprintManagerCompat but are needed for error handling
*/
object FingerprintConstants {
const val FINGERPRINT_ERROR_CANCELED = 5
const val FINGERPRINT_ERROR_USER_CANCELED = 10
const val FINGERPRINT_ERROR_LOCKOUT = 7
const val FINGERPRINT_ERROR_LOCKOUT_PERMANENT = 9
}
/**
* Manager class that handles all biometric authentication operations.
*
* This class provides methods to check biometric availability, enrollment status,
* and handles the authentication flow using either the system UI or a custom UI.
*
* @param context The application context
* @param activity The current activity, needed for UI operations
*/
@Suppress("DEPRECATION")
class BiometricAuthorizationManager(
private val context: Context,
private val activity: FragmentActivity
) {
/**
* use biometricManager to used new UI for biometric authentication
* use fingerprintManager to used deprecated UI for fingerprint authentication
*/
private val biometricManager = BiometricManager.from(context)
@SuppressLint("RestrictedApi")
private val fingerprintManager = FingerprintManagerCompat.from(context)
private val packageManager: PackageManager = context.packageManager
private lateinit var biometricPrompt: BiometricPrompt
private lateinit var promptInfo: BiometricPrompt.PromptInfo
/**
* Checks if biometric authentication is available on the device.
*
* This method verifies that the device has the necessary hardware and
* system support for strong biometric authentication.
*
* @return true if biometric authentication is available, false otherwise
*/
fun isBiometricAvailable(): Boolean {
val canAuthenticateResult = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG
)
return canAuthenticateResult == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Checks if biometric credentials are enrolled on the device.
*
* This verifies that the user has set up at least one biometric credential
* (fingerprint, face, etc.) that can be used for authentication.
*
* @return true if biometric credentials are enrolled, false otherwise
*/
fun isBiometricEnrolled(): Boolean {
val canAuthenticateResult = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG
)
return canAuthenticateResult == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Gets a list of available biometric authentication types on the device.
*
* This method checks which biometric features are supported by the device hardware.
* If no biometric features are available, it returns a list containing only "none".
*
* @return List of string values representing available biometric types
*/
fun getAvailableBiometricTypes(): List<String> {
val availableTypes = mutableListOf<String>()
// Check for fingerprint hardware support
if (packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) {
availableTypes.add(BiometricType.fingerprint.rawValue)
}
// Check for face authentication hardware support (Android 10+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (packageManager.hasSystemFeature(PackageManager.FEATURE_FACE)) {
availableTypes.add(BiometricType.face.rawValue)
}
}
// If no biometric types are available, add "none"
if (availableTypes.isEmpty()) {
availableTypes.add(BiometricType.none.rawValue)
}
return availableTypes
}
/**
* Initiates the biometric authentication process based on Flutter method call parameters.
*
* This method handles the authentication flow using either the standard system UI
* or a custom bottom sheet UI based on the useCustomUI parameter.
*
* @param call The method call from Flutter containing authentication parameters
* @param result The result callback to send the authentication result back to Flutter
*/
fun authenticate(call: MethodCall, result: Result) {
val args = call.arguments as? Map<*, *>
if (args == null) {
result.error("INVALID_ARGS", "Arguments cannot be null", null)
return
}
val reason = args["reason"] as? String ?: "Authenticate required"
val title = args["title"] as? String ?: "Biometric Authentication"
val confirmText = args["confirmText"] as? String ?: "Authenticate"
val useCustomUI = args["useCustomUI"] as? Boolean ?: false
val useDialogUI = args["useDialogUI"] as? Boolean ?: false
val cancelText = args["cancelText"] as? String ?: "Cancel"
try {
// Check if biometric authentication is available
if (!isBiometricAvailable()) {
result.error(
"BIOMETRIC_UNAVAILABLE",
"Biometric authentication is not available on this device.",
null
)
return
}
// Check if biometric credentials are enrolled
if (!isBiometricEnrolled()) {
result.error(
"BIOMETRIC_NOT_ENROLLED",
"No biometric features are enrolled on this device.",
null
)
return
}
if (useCustomUI) {
try {
// Set up biometric authentication with custom UI
setupBiometricAuth(title, reason, cancelText, activity) { success ->
try {
result.success(success)
} catch (e: Exception) {
// Ignore exceptions during result callback
}
}
// Show custom bottom sheet UI
BiometricAuthBottomSheet(title, confirmText) {
try {
// Set up biometric authentication with dialog UI
if (useDialogUI) {
startFingerprintAuth(result, title, cancelText)
} else {
startBiometricAuth()
}
} catch (e: Exception) {
result.error("BIOMETRIC_ERROR", e.message, null)
}
}.show(activity.supportFragmentManager, "biometric_auth_bottom_sheet")
} catch (e: Exception) {
result.error("BIOMETRIC_ERROR", "Failed to start biometric authentication: ${e.message}", null)
}
} else {
try {
// Set up biometric authentication with dialog UI
if (useDialogUI) {
startFingerprintAuth(result, title, cancelText)
return
}
// Set up biometric authentication with standard system UI
setupBiometricAuth(title, reason, cancelText, activity) { success ->
try {
result.success(success)
} catch (e: Exception) {
// Ignore exceptions during result callback
}
}
// Start authentication with standard UI
startBiometricAuth()
} catch (e: Exception) {
result.error("BIOMETRIC_ERROR", "Failed to start biometric authentication: ${e.message}", null)
}
}
} catch (e: Exception) {
result.error("UNEXPECTED_ERROR", "Error during biometric authentication: ${e.message}", null)
}
}
/**
* Starts the fingerprint authentication process used with the deprecated UI.
*
* This method is used when the useDeprecatedUI parameter is set to true.
*
* Android 10 and above only support fingerprint authentication.
*
* @param result The result callback to send the authentication result back to Flutter
*/
@SuppressLint("RestrictedApi", "MissingPermission")
private fun startFingerprintAuth(result: Result, title: String, cancelText: String) {
// Flag to ensure result is called only once
val resultSent = AtomicBoolean(false)
// Wrapper for result callback to prevent multiple calls
val safeResult = object {
fun success(value: Any?) {
if (resultSent.compareAndSet(false, true)) {
activity.runOnUiThread {
try { result.success(value) } catch (e: Exception) { Log.w("BiometricAuth", "Result success error: ${e.message}") }
}
}
}
fun error(code: String, message: String?, details: Any?) {
if (resultSent.compareAndSet(false, true)) {
activity.runOnUiThread {
try { result.error(code, message, details) } catch (e: Exception) { Log.w("BiometricAuth", "Result error error: ${e.message}") }
}
}
}
}
// Check if the device supports fingerprint authentication
if (!fingerprintManager.isHardwareDetected) {
Log.d("BiometricAuth", "Device does not support fingerprint authentication")
safeResult.error(
"FINGERPRINT_UNAVAILABLE",
"Device does not support fingerprint authentication",
null
)
return
}
// Check if there are any enrolled fingerprints
if (!fingerprintManager.hasEnrolledFingerprints()) {
Log.d("BiometricAuth", "No fingerprints are enrolled on this device")
safeResult.error(
"FINGERPRINT_NOT_ENROLLED",
"No fingerprints are enrolled on this device",
null
)
return
}
// Create a crypto object as an authentication token
val cryptoObject = createCryptoObject()
if (cryptoObject == null) {
Log.d("BiometricAuth", "Failed to create CryptoObject")
safeResult.error(
"FINGERPRINT_CRYPTO_ERROR",
"Failed to create cryptographic object for fingerprint authentication",
null
)
return
}
// Create a cancellation signal for the authentication
val cancellationSignal = androidx.core.os.CancellationSignal()
// Create authentication callback
val callback = object : FingerprintManagerCompat.AuthenticationCallback() {
override fun onAuthenticationSucceeded(authResult: FingerprintManagerCompat.AuthenticationResult) {
// Authentication succeeded
Log.d("BiometricAuth", "Fingerprint authentication succeeded")
// Dismiss the dialog if it's still showing
activity.supportFragmentManager.findFragmentByTag("FingerprintDialogFragment")?.let {
(it as? DialogFragment)?.dismissAllowingStateLoss()
}
safeResult.success(true)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Authentication error
Log.d("BiometricAuth", "Fingerprint authentication error: $errString ($errorCode)")
// Dismiss the dialog if it's still showing
activity.supportFragmentManager.findFragmentByTag("FingerprintDialogFragment")?.let {
(it as? DialogFragment)?.dismissAllowingStateLoss()
}
when (errorCode) {
FingerprintConstants.FINGERPRINT_ERROR_CANCELED,
FingerprintConstants.FINGERPRINT_ERROR_USER_CANCELED -> {
// User canceled authentication via system prompt or custom dialog cancel
safeResult.success(false)
}
FingerprintConstants.FINGERPRINT_ERROR_LOCKOUT,
FingerprintConstants.FINGERPRINT_ERROR_LOCKOUT_PERMANENT -> {
// Device is locked out
safeResult.success(false) // Reporting false, could be a specific error too
}
else -> {
// Other errors
safeResult.error(
"FINGERPRINT_ERROR",
errString.toString(),
errorCode // Include error code in details
)
}
}
}
override fun onAuthenticationFailed() {
// Authentication failed but can be retried
Log.d("BiometricAuth", "Fingerprint authentication failed but can be retried")
}
}
/**
* Start fingerprint authentication
*
* Parameters:
* @param crypto: the crypto object
* @param flags: optional flags, usually 0
* @param cancel: a cancellation signal object to cancel the authentication
* @param callback: the callback that receives authentication results
* @param handler: handler for delivering messages, or null for default handler
*/
fingerprintManager.authenticate(
cryptoObject,
0,
cancellationSignal,
callback,
null
)
// --- Display the Custom Dialog ---
val fragmentManager = activity.supportFragmentManager
// Ensure previous dialog is dismissed if any (e.g., rapid calls)
fragmentManager.findFragmentByTag("FingerprintDialogFragment")?.let {
(it as? DialogFragment)?.dismissAllowingStateLoss()
}
val dialogFragment = FingerprintDialogFragment.newInstance(title, cancelText) {
// onCancel lambda from custom dialog
Log.d("BiometricAuth", "Custom dialog cancelled by user.")
if (!cancellationSignal.isCanceled) {
// IMPORTANT: Calling cancel here will trigger the onAuthenticationError callback
// with FINGERPRINT_ERROR_CANCELED. The callback will handle sending the result.
cancellationSignal.cancel()
}
}
// Show the dialog. It will appear over the activity while the fingerprint manager attempts auth.
dialogFragment.show(fragmentManager, "FingerprintDialogFragment")
}
/**
* Creates a cryptographic object for fingerprint authentication
*
* @return Crypto object, or null if creation fails
*/
@SuppressLint("RestrictedApi")
private fun createCryptoObject(): FingerprintManagerCompat.CryptoObject? {
// Early return for devices below Marshmallow
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
Log.e("BiometricAuth", "Fingerprint authentication requires Android 6.0 or above")
return null
}
try {
// Create and get Android KeyStore instance
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// Key alias
val keyName = "com.maojiu.biometric_authorization.key"
// Check if the key already exists, create it if not
if (!keyStore.containsAlias(keyName)) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val builder = KeyGenParameterSpec.Builder(
keyName,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setUserAuthenticationRequired(true)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
// Set authentication validity period if API level supports it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setInvalidatedByBiometricEnrollment(true)
}
keyGenerator.init(builder.build())
keyGenerator.generateKey()
}
// Get the key and initialize Cipher
val key = keyStore.getKey(keyName, null) as SecretKey
val cipher = Cipher.getInstance(
KeyProperties.KEY_ALGORITHM_AES + "/" +
KeyProperties.BLOCK_MODE_CBC + "/" +
KeyProperties.ENCRYPTION_PADDING_PKCS7
)
// Initialize the Cipher for encryption mode
cipher.init(Cipher.ENCRYPT_MODE, key)
// Create and return CryptoObject
return FingerprintManagerCompat.CryptoObject(cipher)
} catch (e: Exception) {
// Log the error but don't throw an exception, return null to indicate crypto object creation failed
Log.e("BiometricAuth", "Failed to create CryptoObject: ${e.message}", e)
return null
}
}
/**
* Starts the biometric authentication process using the configured prompt.
*
* This method triggers the system biometric authentication dialog.
*/
private fun startBiometricAuth() {
biometricPrompt.authenticate(promptInfo)
}
/**
* Sets up the biometric authentication components.
*
* This method configures the BiometricPrompt with appropriate callbacks and
* builds the prompt information with the specified parameters.
*
* @param title The title to display in the authentication dialog
* @param reason The description/reason for requesting authentication
* @param cancelText The text for the cancel button
* @param activity The activity context for the authentication UI
* @param onResult Callback function that receives the authentication result (true for success, false for failure)
*/
private fun setupBiometricAuth(
title: String,
reason: String,
cancelText: String,
activity: FragmentActivity,
onResult: (Boolean) -> Unit
) {
val executor = ContextCompat.getMainExecutor(activity)
biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
/**
* Called when authentication is successful.
*/
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onResult.invoke(true)
}
/**
* Called when authentication fails but can be retried.
* This doesn't count as a final failure, so we don't invoke the result callback
* to allow the user to retry.
*/
override fun onAuthenticationFailed() {
// Authentication failed but can be retried
// Don't call onResult here to allow the user to continue trying
}
/**
* Called when an authentication error occurs.
*
* Handles different error codes and determines appropriate responses:
* - User cancellation: Returns false without an exception
* - Device lockout: Returns false without an exception
* - Other errors: Returns false without an exception
*
* @param errorCode The error code from BiometricPrompt
* @param errString The error message
*/
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Handle different types of errors
when (errorCode) {
BiometricPrompt.ERROR_CANCELED,
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
// User canceled authentication, return false without raising an exception
onResult.invoke(false)
}
BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> {
// Device is locked out, return false without raising an exception
onResult.invoke(false)
}
else -> {
// Other errors, return false without raising an exception
onResult.invoke(false)
}
}
}
})
// Build the prompt information with the specified parameters
promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(reason)
.setNegativeButtonText(cancelText)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG
)
.build()
}
}
BiometricAuthBottomSheet
这个文件自定义了底部的弹出UI。
/**
* BiometricAuthBottomSheet.kt
* This file implements a custom bottom sheet dialog for biometric authentication using Jetpack Compose.
* It provides a user-friendly UI for biometric authentication with support for both light and dark themes.
*/
package com.maojiu.biometric_authorization
import android.app.Dialog
import android.os.Bundle
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Face
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.setViewTreeLifecycleOwner
import com.google.android.material.bottomsheet.BottomSheetDialog
/**
* A bottom sheet dialog fragment that displays a biometric authentication UI.
*
* This class creates a custom bottom sheet dialog with Jetpack Compose UI that
* shows a face icon animation and a confirmation button for biometric authentication.
*
* @param title The title text to display in the bottom sheet
* @param confirmText The text for the confirmation button
* @param onConfirmClick Callback function that is triggered when the user clicks the confirm button
*/
class BiometricAuthBottomSheet(
private val title: String,
private val confirmText: String,
private val onConfirmClick: () -> Unit
) : DialogFragment() {
/**
* Creates and configures the bottom sheet dialog.
*
* This method initializes the dialog with a Compose UI that automatically adapts
* to the system's theme (light or dark mode).
*
* @param savedInstanceState The saved instance state bundle
* @return A configured BottomSheetDialog instance
*/
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return BottomSheetDialog(requireContext()).apply {
setContentView(ComposeView(requireContext()).apply {
setViewTreeLifecycleOwner(this@BiometricAuthBottomSheet)
// Set up the Compose content with theme support
setContent {
// Detect if system is in dark mode
val isDarkTheme = isSystemInDarkTheme()
// Apply the appropriate Material theme based on system settings
MaterialTheme(
colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme()
) {
BiometricAuthContent(
title = title,
confirmText = confirmText,
onConfirmClick = {
onConfirmClick()
dismiss()
}
)
}
}
})
}
}
}
/**
* Composable function that defines the content of the biometric authentication bottom sheet.
*
* This composable creates a UI with a title, an animated face icon, and a confirmation button.
* The face icon pulses with a scale animation to draw user attention.
*
* @param title The title text to display
* @param confirmText The text for the confirmation button
* @param onConfirmClick Callback function that is triggered when the confirm button is clicked
*/
@Composable
fun BiometricAuthContent(
title: String,
confirmText: String,
onConfirmClick: () -> Unit
) {
// Create an infinite transition for the pulsing animation of the face icon
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")
val scale by infiniteTransition.animateFloat(
initialValue = 1.0f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "iconScale"
)
// Main container box with background color that adapts to the theme
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
) {
// Content column with centered alignment
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Drag handle at the top of the bottom sheet
Box(
modifier = Modifier
.width(50.dp)
.height(5.dp)
.clip(
RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
)
.background(
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f)
)
)
Spacer(modifier = Modifier.height(10.dp))
// Title text with theme-appropriate color
Text(
text = title,
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// Face icon with pulsing animation
Box(
contentAlignment = Alignment.Center
) {
// Face icon that scales up and down
Icon(
imageVector = Icons.Default.Face,
contentDescription = "Biometric Icon",
modifier = Modifier
.size(62.dp)
.graphicsLayer(
scaleX = scale,
scaleY = scale
),
tint = MaterialTheme.colorScheme.primary
)
// Circular background for the face icon
Box(
modifier = Modifier
.size(100.dp)
.background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
shape = CircleShape
)
)
}
Spacer(modifier = Modifier.height(24.dp))
// Confirm button that spans the full width
Button(
onClick = onConfirmClick,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
// Button content with text and arrow icon
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// Button text with appropriate contrast color
Text(confirmText, style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onPrimary
))
// Arrow icon that indicates action
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Confirm Icon",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.padding(start = 8.dp)
.size(22.dp)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
}
}
}
FingerprintAuthDialog
这个文件自定义了Dialog对话框认证样式。
package com.maojiu.biometric_authorization
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
/**
* A Composable function that displays a custom dialog for fingerprint authentication.
* This dialog shows a fingerprint icon, a title, and a cancel button.
* It adapts its color scheme based on the system's dark theme setting.
*
* @param title The main text displayed in the dialog, usually indicating the purpose (e.g., "Fingerprint Authentication").
* @param cancelText The text displayed on the cancel button.
* @param onDismissRequest A lambda function invoked when the user attempts to dismiss the dialog
* by interacting outside the dialog bounds or pressing the back button.
* This is mandatory for the [Dialog] composable.
* @param onCancel A lambda function invoked when the user explicitly clicks the cancel button within the dialog.
*/
@Composable
fun FingerprintDialog(
title: String,
cancelText: String,
onDismissRequest: () -> Unit = {},
onCancel: () -> Unit = {}
) {
// Apply Material 3 theme, automatically selecting light or dark color scheme
// based on the system settings.
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
) {
// The Dialog composable provides the basic dialog window structure.
Dialog(onDismissRequest = onDismissRequest) {
// Surface provides a background, shape, and elevation for the dialog content.
Surface(
shape = MaterialTheme.shapes.medium, // Use medium rounded corners defined in the theme.
color = MaterialTheme.colorScheme.surface, // Use the theme's surface color for the background.
modifier = Modifier.padding(16.dp) // Apply padding around the Surface within the Dialog window.
) {
// Column arranges its children vertically.
Column(
modifier = Modifier
.padding(horizontal = 16.dp) // Inner padding for the content inside the Surface.
.fillMaxWidth(), // Make the column take the full width available within the padding.
horizontalAlignment = Alignment.CenterHorizontally, // Center children horizontally.
verticalArrangement = Arrangement.Center // Center children vertically within the column (less relevant here due to specific Spacers).
) {
// Vertical space before the icon.
Spacer(modifier = Modifier.height(24.dp))
// Display the fingerprint icon.
Icon(
imageVector = Icons.Filled.Fingerprint, // Use the standard fingerprint icon.
contentDescription = "Fingerprint Icon", // Accessibility description.
tint = MaterialTheme.colorScheme.primary, // Tint the icon with the theme's primary color.
modifier = Modifier.size(64.dp) // Set the size of the icon.
)
// Vertical space between the icon and the title.
Spacer(modifier = Modifier.height(24.dp))
// Display the dialog title.
Text(
text = title,
style = MaterialTheme.typography.titleMedium // Use the medium title text style from the theme.
)
// Vertical space between the title and the divider.
Spacer(modifier = Modifier.height(10.dp))
// A thin horizontal line separator.
HorizontalDivider()
// The cancel button.
TextButton(
onClick = onCancel, // Invoke the onCancel lambda when clicked.
modifier = Modifier
.fillMaxWidth() // Make the button span the full width.
.padding(top = 8.dp) // Add padding above the button.
) {
// The text displayed within the cancel button.
Text(
text = cancelText,
color = MaterialTheme.colorScheme.primary // Use the primary color for the button text for emphasis.
)
}
// Vertical space after the cancel button.
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
FingerprintDialogFragment
在这里使用了自定义对话框进行认证。
package com.maojiu.biometric_authorization
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme // Using Material 3 Theme directly
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.DialogFragment
/**
* A DialogFragment that hosts the [FingerprintDialog] Composable.
* This fragment is responsible for displaying the fingerprint authentication dialog
* and handling user interactions like cancellation or dismissal.
*/
class FingerprintDialogFragment : DialogFragment() {
/**
* A lambda function to be executed when the dialog is cancelled or dismissed.
* This is typically used to trigger the cancellation of the underlying fingerprint authentication process.
*/
var onCancelAction: (() -> Unit)? = null
companion object {
private const val ARG_TITLE = "title"
private const val ARG_CANCEL_TEXT = "cancel_text"
/**
* Factory method to create a new instance of [FingerprintDialogFragment].
*
* @param title The title string to be displayed in the dialog.
* @param cancelText The text string for the cancel button.
* @param onCancel A lambda function that will be invoked when the dialog is cancelled or dismissed.
* @return A new instance of [FingerprintDialogFragment] with the provided arguments.
*/
fun newInstance(title: String, cancelText: String, onCancel: () -> Unit): FingerprintDialogFragment {
val fragment = FingerprintDialogFragment()
fragment.arguments = Bundle().apply {
putString(ARG_TITLE, title)
putString(ARG_CANCEL_TEXT, cancelText)
}
// Store the lambda directly. While DialogFragments can be recreated by the system
// (making direct lambda storage potentially fragile if the state needs to survive recreation),
// for this specific use case where the dialog is shown and interacts immediately
// with an ongoing process, this approach is often sufficient.
// More robust alternatives for complex state include using the Fragment Result API or a shared ViewModel.
fragment.onCancelAction = onCancel
return fragment
}
}
/**
* Creates and returns the view hierarchy associated with the fragment.
* Inflates the layout using [ComposeView] to host the Jetpack Compose UI.
*
* @param inflater The LayoutInflater object that can be used to inflate any views in the fragment.
* @param container If non-null, this is the parent view that the fragment's UI should be attached to.
* @param savedInstanceState If non-null, this fragment is being re-constructed from a previous saved state as given here.
* @return Returns the View for the fragment's UI, or null.
*/
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
// Retrieve arguments passed via newInstance
val title = arguments?.getString(ARG_TITLE) ?: "Fingerprint Authentication"
val cancelText = arguments?.getString(ARG_CANCEL_TEXT) ?: "Cancel"
return ComposeView(requireContext()).apply {
// Set the strategy for managing the Compose Composition lifecycle.
// DisposeOnViewTreeLifecycleDestroyed ensures the Composition is disposed when the Fragment's view lifecycle is destroyed,
// preventing potential memory leaks.
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
// Apply Material 3 Theme. Ensure the application's theme is correctly set up for Material 3.
MaterialTheme {
// Embed the FingerprintDialog Composable within the ComposeView
FingerprintDialog(
title = title,
cancelText = cancelText,
onDismissRequest = {
// This lambda is invoked when the dialog is dismissed by interactions outside its bounds
// (e.g., tapping the scrim or pressing the back button).
onCancelAction?.invoke() // Trigger cancel action on dismiss
dismiss() // Dismiss the DialogFragment itself
},
onCancel = {
// This lambda is invoked when the user clicks the explicit 'Cancel' button within the dialog.
onCancelAction?.invoke() // Trigger cancel action on cancel click
dismiss() // Dismiss the DialogFragment itself
}
)
}
}
}
}
/**
* Called when the fragment's activity has been created and this fragment's view hierarchy instantiated.
* Can be used to do final initialization once these pieces are in place.
*/
override fun onStart() {
super.onStart()
// Optional: Set the dialog window background to transparent.
// This is useful if the FingerprintDialog Composable defines its own background/shape (e.g., rounded corners within a Surface).
// If not set, the DialogFragment might have its own default background that could interfere with the Composable's visuals.
dialog?.window?.setBackgroundDrawableResource(android.R.color.transparent)
}
/**
* This method will be called when the dialog is cancelled, either by pressing the back button,
* tapping outside the dialog (if cancellable is true), or explicitly calling cancel().
*
* @param dialog The dialog that was canceled will be passed into the method.
*/
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
// Ensure the cancel action is invoked when the dialog is cancelled through standard mechanisms.
// This acts as a fallback for the onDismissRequest lambda.
onCancelAction?.invoke()
}
}
build.gradle
在build.gradle中进行相应的配置。
/**
* Gradle build configuration for the Biometric Authorization plugin.
* This file defines all the necessary configurations for building the Android component
* of the Flutter plugin, including dependencies, SDK versions, and build options.
*/
// Define the group ID and version for the plugin
group = "com.maojiu.biometric_authorization"
version = "1.0-SNAPSHOT"
// Load local properties file that contains environment-specific configurations
// such as the path to the Flutter SDK
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader("UTF-8") { reader ->
localProperties.load(reader)
}
}
// Verify that the Flutter SDK path is defined in local.properties
def flutterRoot = localProperties.getProperty("flutter.sdk")
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
/**
* Buildscript configuration section.
* This is where we define the Gradle plugins and repositories needed to build the project.
*/
buildscript {
// Define Kotlin version used across the project
ext.kotlin_version = "1.9.22"
ext.kotlin_ext_version = "1.5.10"
// Define repositories to download build dependencies
repositories {
google() // Google's Maven repository for Android specific dependencies
mavenCentral() // Central repository for general Java/Kotlin libraries
}
// Define build dependencies needed by Gradle to compile the project
dependencies {
classpath("com.android.tools.build:gradle:8.2.0") // Android Gradle Plugin
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") // Kotlin Gradle Plugin
}
}
/**
* Define repositories that will be used to resolve dependencies for all projects,
* including the app and the libraries it depends on.
*/
allprojects {
repositories {
google()
mavenCentral()
}
}
// Apply the Android library plugin, which configures Gradle to build an Android library (.aar)
apply plugin: "com.android.library"
// Apply the Kotlin Android plugin for Kotlin language support
apply plugin: "kotlin-android"
/**
* Android specific configuration.
* This block defines all Android-specific build settings.
*/
android {
// Package namespace for the library
namespace = "com.maojiu.biometric_authorization"
// Android SDK version to compile against
compileSdk = 35
// NDK version to use
ndkVersion = "26.3.11579264"
// Enable Jetpack Compose support
buildFeatures {
compose true
}
// Configure Jetpack Compose compiler options
composeOptions {
kotlinCompilerVersion("$kotlin_version")
kotlinCompilerExtensionVersion("$kotlin_ext_version")
}
// Configure Java compatibility options
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
// Configure Kotlin compiler options
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
// Define source code directories
sourceSets {
main.java.srcDirs += "src/main/kotlin" // Main source code directory
test.java.srcDirs += "src/test/kotlin" // Test source code directory
}
// Define the minimum SDK version required to run the library
defaultConfig {
minSdk = 21 // Android 5.0 (Lollipop) and above
}
/**
* Dependencies section.
* This is where we define external libraries that our plugin depends on.
*/
dependencies {
// Jetpack Compose dependencies
implementation(platform("androidx.compose:compose-bom:2024.06.00")) // Compose Bill of Materials
implementation("androidx.compose.ui:ui") // Compose UI core
implementation("androidx.compose.ui:ui-tooling-preview") // Compose UI tooling preview
implementation("androidx.compose.material3:material3") // Material 3 design system
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.10.1") // Activity with Compose support
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") // ViewModel for Compose
// Biometric authentication dependency
implementation("androidx.biometric:biometric:1.1.0") // Android Biometric API
// Material design components
implementation("com.google.android.material:material:1.12.0")
// Testing dependencies
testImplementation("org.jetbrains.kotlin:kotlin-test") // Kotlin test utilities
testImplementation("org.mockito:mockito-core:5.0.0") // Mockito for mocking in tests
// Flutter dependencies (currently commented out)
// compileOnly files("$flutterRoot/bin/cache/artifacts/engine/android-arm-release/flutter.jar")
// compileOnly("androidx.annotation:annotation:1.9.1")
}
/**
* Test configuration options.
* This block configures how tests are executed and how results are reported.
*/
testOptions {
unitTests.all {
useJUnitPlatform() // Use JUnit 5 platform for running tests
// Configure test logging
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError" // Log test events
outputs.upToDateWhen {false} // Always run tests
showStandardStreams = true // Show standard output and error streams
}
}
}
}
基本使用
Android 设置
- 在 AndroidManifest.xml 文件中添加以下权限:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
- 重要提示:你必须将 FlutterActivity 替换为 FlutterFragmentActivity。在你的 MainActivity.kt 文件中:
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity: FlutterFragmentActivity() {
// ...
}
iOS 设置
在你的 Info.plist 文件中添加以下内容:
<key>NSFaceIDUsageDescription</key>
<string>您的应用需要使用生物识别数据进行安全访问验证</string>
使用方法
检查生物识别可用性
final biometricAuth = BiometricAuthorization();
// 检查设备是否支持生物识别
bool isAvailable = await biometricAuth.isBiometricAvailable();
// 检查是否已经录入生物识别信息
bool isEnrolled = await biometricAuth.isBiometricEnrolled();
// 获取可用的生物识别类型
List<BiometricType> types = await biometricAuth.getAvailableBiometricTypes();
使用身份验证
使用默认系统界面
try {
bool authenticated = await biometricAuth.authenticate(
reason: '请验证身份以访问您的账户',
title: '生物识别验证',
confirmText: '验证',
);
if (authenticated) {
// 用户验证成功
print('验证成功');
} else {
// 验证失败或用户取消
print('验证失败');
}
} catch (e) {
print('验证过程中出现错误: $e');
}
使用自定义界面
bool authenticated = await biometricAuth.authenticate(
reason: '请验证身份以访问您的账户',
title: '生物识别验证',
confirmText: '验证',
useCustomUI: true,
);
使用对话框界面(仅支持 Android)
bool authenticated = await biometricAuth.authenticate(
reason: '请验证身份以访问您的账户',
title: '生物识别验证',
confirmText: '验证',
useDialog: true
);
平台特有参数说明
authenticate 方法包含一些在不同平台行为不同的参数:
- biometricType:该参数在 iOS 上是必需的,需要指定使用哪种生物识别方式(Face ID 或指纹)。而在 Android 上是可选的,系统会自动选择可用的方式。
// iOS 示例 - 必须指定 biometricType
await biometricAuth.authenticate(
biometricType: BiometricType.face, // 使用 Face ID
reason: '请验证身份以继续',
);
- cancelText:该参数仅在 Android 上生效,用于设置认证对话框中的取消按钮文本。在 iOS 上无效。
// Android 示例 - 设置取消按钮文本
await biometricAuth.authenticate(
reason: '请验证身份以继续',
cancelText: '稍后再说',
);
效果
iOS Authentication
Touch ID - System UI | Touch ID - Custom UI | Face ID - Custom UI |
---|---|---|
![]() | ![]() | ![]() |
Android Authentication
Default UI | Custom UI (Sheet) | Custom UI (Dialog) |
---|---|---|
![]() | ![]() | ![]() |
插件地址:pub.dev/packages/bi…