You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
284 lines
9.6 KiB
Dart
284 lines
9.6 KiB
Dart
import 'dart:convert';
|
|
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:zkfinger10/finger_status.dart';
|
|
import 'package:zkfinger10/finger_status_type.dart';
|
|
import 'package:zkfinger10/zk_finger.dart';
|
|
|
|
import 'finger_print_manager.dart';
|
|
|
|
class ZktFingerPrintScanner implements FingerprintManager {
|
|
bool _isConnected = false;
|
|
|
|
// ── Scan state (fresh per scan call) ────────────────────────────────────
|
|
Completer<String>? _scanCompleter;
|
|
StreamController<String>? _base64ImageController;
|
|
StreamSubscription<dynamic>? _imageSubscription;
|
|
|
|
// ── Status stream state ──────────────────────────────────────────────────
|
|
StreamSubscription<dynamic>? _statusSubscription;
|
|
FingerStatus? lastStatus;
|
|
FingerStatusType? _tempStatusType;
|
|
|
|
/// Set during connect(); completed by _updateStatus when a success status
|
|
/// arrives. Reset at the start of every retry attempt.
|
|
Completer<void>? _deviceReadyCompleter;
|
|
|
|
static const int _maxConnectAttempts = 5;
|
|
static const Duration _attemptTimeout = Duration(seconds: 4);
|
|
static const Duration _retryDelay = Duration(seconds: 4);
|
|
|
|
static const List<FingerStatusType> _successStatuses = [
|
|
FingerStatusType.STARTED_ALREADY,
|
|
FingerStatusType.STARTED_SUCCESS,
|
|
];
|
|
|
|
static const List<FingerStatusType> _usbConnectionFailureStatuses = [
|
|
FingerStatusType.FINGER_USB_PERMISSION_DENIED,
|
|
FingerStatusType.FINGER_USB_PERMISSION_ERROR,
|
|
];
|
|
|
|
// ── Stream accessor ──────────────────────────────────────────────────────
|
|
Stream<String> get base64ImageStream =>
|
|
_base64ImageController?.stream ?? const Stream.empty();
|
|
|
|
ZktFingerPrintScanner();
|
|
|
|
// ── Status callback ──────────────────────────────────────────────────────
|
|
|
|
void _updateStatus(dynamic value) {
|
|
try {
|
|
final statusMap = value as Map<dynamic, dynamic>;
|
|
final statusType =
|
|
FingerStatusType.values[statusMap['fingerStatus'] as int];
|
|
lastStatus = FingerStatus(
|
|
statusMap['message'],
|
|
statusType,
|
|
statusMap['id'],
|
|
statusMap['data'],
|
|
);
|
|
|
|
|
|
if (statusType == _tempStatusType &&
|
|
_tempStatusType == FingerStatusType.CAPTURE_ERROR) {
|
|
// Ignore repeated CAPTURE_ERROR — device is stuck, do nothing
|
|
} else {
|
|
_tempStatusType = statusType;
|
|
if (_deviceReadyCompleter != null &&
|
|
!_deviceReadyCompleter!.isCompleted &&
|
|
_usbConnectionFailureStatuses.contains(statusType) ){
|
|
_deviceReadyCompleter!.completeError(Exception(
|
|
'Device not connected'
|
|
));
|
|
}
|
|
|
|
// If we are in the connect-retry loop and got a success status, resolve
|
|
if (_deviceReadyCompleter != null &&
|
|
!_deviceReadyCompleter!.isCompleted &&
|
|
lastStatus != null &&
|
|
_successStatuses.contains(statusType) && _successStatuses.contains(lastStatus!.statusType)) {
|
|
_deviceReadyCompleter!.complete();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (_deviceReadyCompleter != null &&
|
|
!_deviceReadyCompleter!.isCompleted) {
|
|
_deviceReadyCompleter!.completeError(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Image callback ───────────────────────────────────────────────────────
|
|
|
|
void _onNativeImage(dynamic imageBytes) {
|
|
try {
|
|
final base64Image = uint8ListTob64(imageBytes as Uint8List);
|
|
|
|
if (_base64ImageController != null &&
|
|
!_base64ImageController!.isClosed) {
|
|
_base64ImageController!.add(base64Image);
|
|
}
|
|
|
|
if (_scanCompleter != null && !_scanCompleter!.isCompleted) {
|
|
_scanCompleter!.complete(base64Image);
|
|
}
|
|
} catch (e) {
|
|
if (_base64ImageController != null &&
|
|
!_base64ImageController!.isClosed) {
|
|
_base64ImageController!.addError(e);
|
|
}
|
|
if (_scanCompleter != null && !_scanCompleter!.isCompleted) {
|
|
_scanCompleter!.completeError(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────
|
|
|
|
@override
|
|
Future<void> connect() async {
|
|
try {
|
|
if (_isConnected) await disconnect();
|
|
|
|
final result = await ZkFinger.openConnection();
|
|
if (result != true) {
|
|
throw Exception('Failed to initialize fingerprint device');
|
|
}
|
|
|
|
// ── Start status stream ──────────────────────────────────────────────
|
|
await _statusSubscription?.cancel();
|
|
_statusSubscription =
|
|
ZkFinger.statusChangeStream.receiveBroadcastStream().listen(
|
|
_updateStatus,
|
|
onError: (e) {
|
|
if (_deviceReadyCompleter != null &&
|
|
!_deviceReadyCompleter!.isCompleted) {
|
|
_deviceReadyCompleter!.completeError(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Retry loop: up to 5 attempts, 5 s window each ───────────────────
|
|
bool deviceReady = false;
|
|
|
|
for (int attempt = 1; attempt <= _maxConnectAttempts; attempt++) {
|
|
|
|
_deviceReadyCompleter = Completer<void>();
|
|
final result = await ZkFinger.openConnection();
|
|
if (result != true) {
|
|
throw Exception('Failed to initialize fingerprint device');
|
|
}
|
|
|
|
try {
|
|
await _deviceReadyCompleter!.future.timeout(_attemptTimeout);
|
|
// Success status received within the window
|
|
deviceReady = true;
|
|
break;
|
|
} on TimeoutException {
|
|
if (attempt < _maxConnectAttempts) {
|
|
await Future.delayed(_retryDelay);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!deviceReady) {
|
|
// Clean up status subscription and close hardware
|
|
await _statusSubscription?.cancel();
|
|
_statusSubscription = null;
|
|
try { await ZkFinger.closeConnection(); } catch (_) {}
|
|
throw Exception(
|
|
'Device not ready after $_maxConnectAttempts attempts. '
|
|
'Last status: ${lastStatus?.message ?? "no status received"}',
|
|
);
|
|
}
|
|
|
|
_isConnected = true;
|
|
|
|
// ── Subscribe to image stream now that device is confirmed ready ─────
|
|
_imageSubscription =
|
|
ZkFinger.imageStream.receiveBroadcastStream().listen(
|
|
_onNativeImage,
|
|
onError: (e) {
|
|
if (_scanCompleter != null && !_scanCompleter!.isCompleted) {
|
|
_scanCompleter!.completeError(e);
|
|
}
|
|
if (_base64ImageController != null &&
|
|
!_base64ImageController!.isClosed) {
|
|
_base64ImageController!.addError(e);
|
|
}
|
|
},
|
|
);
|
|
} catch (e) {
|
|
_isConnected = false;
|
|
rethrow; // preserve original message
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> disconnect() async {
|
|
try { await ZkFinger.closeConnection(); } catch (_) {}
|
|
|
|
try { await _imageSubscription?.cancel(); } catch (_) {}
|
|
_imageSubscription = null;
|
|
|
|
try { await _statusSubscription?.cancel(); } catch (_) {}
|
|
_statusSubscription = null;
|
|
|
|
if (_deviceReadyCompleter != null &&
|
|
!_deviceReadyCompleter!.isCompleted) {
|
|
_deviceReadyCompleter!.completeError(StateError('Disconnected'));
|
|
}
|
|
_deviceReadyCompleter = null;
|
|
|
|
if (_scanCompleter != null && !_scanCompleter!.isCompleted) {
|
|
_scanCompleter!.completeError(StateError('Disconnected'));
|
|
}
|
|
_scanCompleter = null;
|
|
|
|
try {
|
|
if (_base64ImageController != null &&
|
|
!_base64ImageController!.isClosed) {
|
|
await _base64ImageController!.close();
|
|
}
|
|
} catch (_) {}
|
|
_base64ImageController = null;
|
|
|
|
_isConnected = false;
|
|
_tempStatusType = null;
|
|
}
|
|
|
|
/// Returns a base64-encoded fingerprint for a single scan.
|
|
/// Creates a fresh [Completer] + [StreamController] each call so
|
|
/// consecutive scans always work.
|
|
@override
|
|
Future<String?> scan(
|
|
{Duration timeout = const Duration(seconds: 20)}) async {
|
|
try {
|
|
if (!_isConnected) await connect();
|
|
|
|
try {
|
|
if (_base64ImageController != null &&
|
|
!_base64ImageController!.isClosed) {
|
|
await _base64ImageController!.close();
|
|
}
|
|
} catch (_) {}
|
|
|
|
_base64ImageController = StreamController<String>.broadcast();
|
|
_scanCompleter = Completer<String>();
|
|
|
|
final timer = Timer(timeout, () {
|
|
if (_scanCompleter != null && !_scanCompleter!.isCompleted) {
|
|
_scanCompleter!
|
|
.completeError(TimeoutException('Scan timed out', timeout));
|
|
}
|
|
});
|
|
|
|
try {
|
|
return await _scanCompleter!.future;
|
|
} finally {
|
|
timer.cancel();
|
|
}
|
|
} catch (e) {
|
|
throw Exception('Fingerprint scan failed: ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
String uint8ListTob64(Uint8List uint8list) {
|
|
return 'data:image/png;base64,${base64Encode(uint8list)}';
|
|
}
|
|
|
|
bool get isConnected => _isConnected;
|
|
|
|
void dispose() {
|
|
_imageSubscription?.cancel();
|
|
_statusSubscription?.cancel();
|
|
_scanCompleter = null;
|
|
_deviceReadyCompleter = null;
|
|
_base64ImageController?.close();
|
|
_base64ImageController = null;
|
|
}
|
|
}
|