Skip to content
Merged
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
3 changes: 3 additions & 0 deletions client/lib/utils/pcsc_scanner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export 'pcsc_scanner_stub.dart'
if (dart.library.io) 'pcsc_scanner_native.dart'
if (dart.library.js_interop) 'pcsc_scanner_web.dart';
149 changes: 149 additions & 0 deletions client/lib/utils/pcsc_scanner_native.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:async/async.dart';
import 'package:dart_pcsc/dart_pcsc.dart';
import 'package:logger/logger.dart';

final _log = Logger();

String _hexColon(List<int> bytes) => bytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');

class PcscScanner {
final void Function(String scannedUid) onScan;
final void Function(String message)? onError;

bool _running = false;
Context? _context;
CancelableOperation<List<String>>? _pendingWait;

/// APDU command to read UID from most ISO 14443 cards.
static final _getUidCommand = Uint8List.fromList([
0xFF,
0xCA,
0x00,
0x00,
0x00,
]);

/// Debounce: ignore the same UID within this window.
static const _debounceDuration = Duration(seconds: 3);
String? _lastUid;
DateTime? _lastScanTime;

PcscScanner({required this.onScan, this.onError});

Future<void> start() async {
_running = true;
_log.i('[PCSC] Scanner starting');

while (_running) {
try {
await _scanLoop();
} catch (e) {
if (!_running) break;
_log.e('[PCSC] Scan loop error, retrying in 2s: $e');
await Future<void>.delayed(const Duration(seconds: 2));
}
}
}

Future<void> _scanLoop() async {
final context = Context(Scope.user);
_context = context;

try {
await context.establish();

final readers = await context.listReaders();
if (readers.isEmpty) {
_log.w('[PCSC] No readers found, retrying in 3s');
await context.release();
_context = null;
await Future<void>.delayed(const Duration(seconds: 3));
return;
}

_log.i('[PCSC] Using reader: "${readers.first}"');
final reader = readers.first;

while (_running) {
final waitOp = context.waitForCard([reader]);
_pendingWait = waitOp;
final readersWithCard = await waitOp.valueOrCancellation(null);
_pendingWait = null;

if (readersWithCard == null || !_running) break;

Card? card;
try {
card = await context.connect(
readersWithCard.first,
ShareMode.shared,
Protocol.any,
);

final response = await card.transmit(_getUidCommand);

if (response.length >= 2) {
final sw1 = response[response.length - 2];
final sw2 = response[response.length - 1];
final uid = response.sublist(0, response.length - 2);

if (sw1 == 0x90 && sw2 == 0x00 && uid.isNotEmpty) {
final hexUid = _hexColon(uid);

_log.i('[PCSC] Card UID: $hexUid (${uid.length} bytes)');

// Debounce: skip if same card scanned within window
final now = DateTime.now();
if (hexUid != _lastUid ||
_lastScanTime == null ||
now.difference(_lastScanTime!) > _debounceDuration) {
_lastUid = hexUid;
_lastScanTime = now;
onScan(hexUid);
}
} else {
_log.w(
'[PCSC] Card error status: '
'${sw1.toRadixString(16).padLeft(2, '0')} '
'${sw2.toRadixString(16).padLeft(2, '0')}',
);
}
}
} catch (e) {
_log.e('[PCSC] Error reading card: $e');
onError?.call('Could not read card. Please try again.');
} finally {
if (card != null) {
try {
await card.disconnect(Disposition.resetCard);
} catch (_) {}
}
}

if (_running) {
await Future<void>.delayed(const Duration(seconds: 2));
}
}
} finally {
try {
await context.release();
} catch (_) {}
_context = null;
}
}

void dispose() {
_running = false;
_pendingWait?.cancel();
_pendingWait = null;
final ctx = _context;
if (ctx != null) {
ctx.release().catchError((_) {});
_context = null;
}
}
}
16 changes: 16 additions & 0 deletions client/lib/utils/pcsc_scanner_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:logger/logger.dart';

final _log = Logger();

class PcscScanner {
final void Function(String scannedUid) onScan;
final void Function(String message)? onError;

PcscScanner({required this.onScan, this.onError});

Future<void> start() async {
_log.w('PCSC scanning is not supported on this platform');
}

void dispose() {}
}
1 change: 1 addition & 0 deletions client/lib/utils/pcsc_scanner_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'pcsc_scanner_stub.dart';
130 changes: 119 additions & 11 deletions client/lib/views/kiosk/kiosk_scan_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,29 @@ import 'package:time_keeper/widgets/dialogs/toast_overlay.dart';

final _log = Logger();

/// Handles an RFID scan input by matching it against team members
/// Handles a PCSC card scan by matching the UID against team members
/// and checking them in/out.
Future<void> handleKioskScan({
required String input,
required BuildContext context,
required WidgetRef ref,
}) async {
final trimmed = input.trim().toLowerCase();
final trimmed = input.trim();
if (trimmed.isEmpty) return;

final teamMembers = ref.read(teamMembersProvider);
final match = _findMember(trimmed, teamMembers);
final variants = _buildUidVariants(trimmed);
final match = _findMember(variants, teamMembers);

if (match == null) {
_log.w('No member matched scan: $input');
_log.w(
'No member matched scan: $input (tried ${variants.length} variants)',
);
if (context.mounted) {
ToastOverlay.error(
context,
title: 'Unrecognized',
message: 'Unrecognized value "$input", contact admin.',
message: 'Unrecognized card "$input", contact admin.',
);
}
return;
Expand Down Expand Up @@ -86,10 +89,114 @@ Future<void> handleKioskScan({
}
}

/// Tries to match scan input against team member fields.
/// Checks in order: first+last name, alias, secondary alias.
/// Builds all reasonable representations of a scanned UID so we can
/// match flexibly against however the user stored it.
///
/// From a colon-separated hex input like "89:02:9E:40" we produce:
/// - 89:02:9e:40 (colon-separated, lowercase)
/// - 89 02 9e 40 (space-separated)
/// - 89029e40 (no separator)
/// - decimal BE string (e.g. "2299477568")
/// - decimal LE string (e.g. "1075381897")
///
/// If a user stored a decimal value in their alias, we also parse it
/// to hex and generate the hex variants from that.
Set<String> _buildUidVariants(String input) {
final variants = <String>{};
final normalized = input.trim().toLowerCase();

// Try to extract hex bytes from the input regardless of separator
final hexBytes = _parseHexBytes(normalized);

if (hexBytes != null && hexBytes.isNotEmpty) {
final hexParts = hexBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.toList();

variants.add(hexParts.join(':')); // 89:02:9e:40
variants.add(hexParts.join(' ')); // 89 02 9e 40
variants.add(hexParts.join()); // 89029e40

// Decimal big-endian
var be = BigInt.zero;
for (final b in hexBytes) {
be = (be << 8) | BigInt.from(b);
}
variants.add(be.toString());

// Decimal little-endian
var le = BigInt.zero;
for (final b in hexBytes.reversed) {
le = (le << 8) | BigInt.from(b);
}
variants.add(le.toString());
}

// If input looks like a plain decimal number, parse it to hex bytes
// and add those variants too (in case user stored hex but card gave decimal)
final asInt = BigInt.tryParse(normalized);
if (asInt != null && asInt > BigInt.zero) {
final bytes = _bigIntToBytes(asInt);
if (bytes.isNotEmpty) {
final hexParts = bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.toList();
variants.add(hexParts.join(':'));
variants.add(hexParts.join(' '));
variants.add(hexParts.join());
}
}

// Always include the raw input as-is
variants.add(normalized);

return variants;
}

/// Try to parse hex bytes from a string with any common separator
/// (colon, space, dash, or no separator for even-length hex strings).
List<int>? _parseHexBytes(String input) {
// Try colon, space, or dash separated
for (final sep in [':', ' ', '-']) {
if (input.contains(sep)) {
final parts = input.split(sep);
if (parts.every((p) => RegExp(r'^[0-9a-f]{1,2}$').hasMatch(p))) {
return parts.map((p) => int.parse(p, radix: 16)).toList();
}
return null; // has separator but not valid hex
}
}

// No separator — try as continuous hex (must be even length)
if (input.length.isEven &&
input.length >= 4 &&
RegExp(r'^[0-9a-f]+$').hasMatch(input)) {
final bytes = <int>[];
for (var i = 0; i < input.length; i += 2) {
bytes.add(int.parse(input.substring(i, i + 2), radix: 16));
}
return bytes;
}

return null;
}

/// Convert a BigInt to a list of bytes (big-endian, minimal length).
List<int> _bigIntToBytes(BigInt value) {
if (value == BigInt.zero) return [0];
final bytes = <int>[];
var v = value;
while (v > BigInt.zero) {
bytes.add((v & BigInt.from(0xFF)).toInt());
v = v >> 8;
}
return bytes.reversed.toList();
}

/// Tries to match any of the UID [variants] against team member fields.
/// Checks: first+last name, alias, secondary alias.
MapEntry<String, TeamMember>? _findMember(
String input,
Set<String> variants,
Map<String, TeamMember> teamMembers,
) {
for (final entry in teamMembers.entries) {
Expand All @@ -98,18 +205,19 @@ MapEntry<String, TeamMember>? _findMember(
.trim()
.toLowerCase();

if (fullName == input) return entry;
if (variants.contains(fullName)) return entry;
}

for (final entry in teamMembers.entries) {
final member = entry.value;

if (member.alias.isNotEmpty && member.alias.trim().toLowerCase() == input) {
if (member.alias.isNotEmpty &&
variants.contains(member.alias.trim().toLowerCase())) {
return entry;
}

if (member.secondaryAlias.isNotEmpty &&
member.secondaryAlias.trim().toLowerCase() == input) {
variants.contains(member.secondaryAlias.trim().toLowerCase())) {
return entry;
}
}
Expand Down
Loading
Loading