credit_card_nfc_reader 0.2.1
credit_card_nfc_reader: ^0.2.1 copied to clipboard
Android NFC EMV card reader wrapping devnied's EMV NFC Paycard Enrollment library.
example/lib/main.dart
import 'dart:convert';
import 'package:credit_card_nfc_reader/credit_card_nfc_reader.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'feature_catalog.dart';
void main() => runApp(
MaterialApp(
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const ReaderPage(),
),
);
class ReaderPage extends StatefulWidget {
const ReaderPage({super.key});
@override
State<ReaderPage> createState() => _ReaderPageState();
}
class _ReaderPageState extends State<ReaderPage> {
final _reader = EmvNfcReader();
final _country = TextEditingController(text: 'FR');
final _terminal = TextEditingController(text: '{}');
final _timeout = TextEditingController(text: '30');
final _exchangeTimeout = TextEditingController(text: '5000');
final _input = TextEditingController(text: decoderExamples['tlv']);
final _tag = TextEditingController(text: '82');
final _replayInput = TextEditingController();
String _preset = 'Quick read';
String _decoder = 'tlv';
String _group = 'All fields';
String _search = '';
String _message =
'Choose a preset, then read a card or run a synthetic replay.';
String _source = '';
String _status = 'Not checked';
int _page = 0;
bool _busy = false;
bool _reveal = false;
EmvParserMode _mode = EmvParserMode.all;
Map<String, bool> _flags = {};
Map<String, Object?>? _result;
Object? _decoded;
Map<String, dynamic>? _catalogs;
@override
void initState() {
super.initState();
_applyPreset(_preset);
}
@override
void dispose() {
for (final controller in [
_country,
_terminal,
_timeout,
_exchangeTimeout,
_input,
_tag,
_replayInput,
]) {
controller.dispose();
}
super.dispose();
}
void _applyPreset(String name) {
final options = featurePresets[name]!;
_preset = name;
_flags = {
'contactLess': options.contactLess,
'readAllAids': options.readAllAids,
'readTransactions': options.readTransactions,
'readAllRecords': options.readAllRecords,
'readAt': options.readAt,
'readCplc': options.readCplc,
'readExtendedData': options.readExtendedData,
'extendedSelectionSupported': options.extendedSelectionSupported,
'captureApduTrace': options.captureApduTrace,
};
_country.text = options.terminalCountryCode;
_terminal.text = jsonEncode(options.terminalValues);
_mode = options.parserMode;
_exchangeTimeout.text = '${options.transceiveTimeout.inMilliseconds}';
}
EmvReadOptions _options() => EmvReadOptions(
contactLess: _flags['contactLess']!,
readAllAids: _flags['readAllAids']!,
readTransactions: _flags['readTransactions']!,
readAllRecords: _flags['readAllRecords']!,
readAt: _flags['readAt']!,
readCplc: _flags['readCplc']!,
readExtendedData: _flags['readExtendedData']!,
extendedSelectionSupported: _flags['extendedSelectionSupported']!,
captureApduTrace: _flags['captureApduTrace']!,
terminalCountryCode: _country.text.trim(),
parserMode: _mode,
terminalValues: Map<String, String>.from(jsonDecode(_terminal.text) as Map),
transceiveTimeout: Duration(milliseconds: int.parse(_exchangeTimeout.text)),
);
Future<void> _run(Future<void> Function() action) async {
setState(() {
_busy = true;
_message = 'Running…';
});
try {
await action();
if (mounted) setState(() => _message = 'Completed');
} on PlatformException catch (e) {
if (mounted) setState(() => _message = '${e.code}: ${e.message}');
} catch (e) {
if (mounted) setState(() => _message = 'Unable to run: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _read() => _run(() async {
final options = _options();
setState(() {
_result = null;
_message = 'Hold a card against the NFC antenna…';
});
final card = await _reader.readCard(
timeout: Duration(seconds: int.parse(_timeout.text)),
options: options,
);
if (mounted) {
setState(() {
_result = card.toJson();
_source = 'Live NFC result';
_page = 1;
});
}
});
Future<void> _replay() => _run(() async {
final text = _replayInput.text.trim().isEmpty
? await rootBundle.loadString('assets/replay.json')
: _replayInput.text;
final fixture = jsonDecode(text) as Map;
final responses = (fixture['responses'] as List)
.map(
(row) => EmvReplayResponse(
command: row['command'] as String,
response: row['response'] as String,
prefix: row['prefix'] == true,
),
)
.toList();
final card = await _reader.replay(
responses: responses,
at: fixture['at'] as String? ?? '',
options: _options(),
);
if (mounted) {
setState(() {
_result = card.toJson();
_source = 'Synthetic APDU replay — parsed by Android library';
_page = 1;
});
}
});
Future<void> _loadDemo() => _run(() async {
final data =
jsonDecode(await rootBundle.loadString('assets/all_fields.json'))
as Map;
if (mounted) {
setState(() {
_result = Map<String, Object?>.from(data);
_source = 'Synthetic UI fixture — every field, not a parsed card';
_page = 1;
});
}
});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('EMV Developer Console')),
bottomNavigationBar: NavigationBar(
selectedIndex: _page,
onDestinationSelected: (page) => setState(() => _page = page),
destinations: const [
NavigationDestination(icon: Icon(Icons.nfc), label: 'Reader'),
NavigationDestination(icon: Icon(Icons.account_tree), label: 'Results'),
NavigationDestination(icon: Icon(Icons.build), label: 'Tools'),
NavigationDestination(icon: Icon(Icons.menu_book), label: 'Catalogs'),
],
),
body: Column(
children: [
if (_busy) const LinearProgressIndicator(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(child: Text(_message)),
if (_busy)
TextButton(
onPressed: () async {
await _reader.cancel();
},
child: const Text('Cancel'),
),
],
),
),
Expanded(
child: switch (_page) {
0 => _readerPage(),
1 => _resultsPage(),
2 => _toolsPage(),
_ => _catalogPage(),
},
),
],
),
);
Widget _readerPage() => ListView(
padding: const EdgeInsets.all(16),
children: [
Text('NFC: $_status'),
Wrap(
spacing: 8,
children: [
OutlinedButton(
onPressed: _busy
? null
: () => _run(() async {
final status = await _reader.getStatus();
if (mounted) setState(() => _status = status.name);
}),
child: const Text('Check NFC status'),
),
FilledButton(
onPressed: _busy ? null : _read,
child: const Text('Read card'),
),
OutlinedButton(
onPressed: _busy ? null : _replay,
child: const Text('Run synthetic replay'),
),
TextButton(
onPressed: _busy ? null : _loadDemo,
child: const Text('Load all-fields UI fixture'),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _preset,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Feature example'),
items: featurePresets.keys
.map(
(key) => DropdownMenuItem(
value: key,
child: Text(key, overflow: TextOverflow.ellipsis),
),
)
.toList(),
onChanged: _busy
? null
: (value) => setState(() => _applyPreset(value!)),
),
const SizedBox(height: 8),
const Text(
'Presets fill the controls below. Edit any option before running. Each card exposes a different subset of data.',
),
for (final key in _flags.keys)
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(key),
value: _flags[key]!,
onChanged: _busy
? null
: (value) => setState(() => _flags[key] = value),
),
DropdownButtonFormField<EmvParserMode>(
key: ValueKey(_mode),
initialValue: _mode,
decoration: const InputDecoration(labelText: 'Parser registration'),
items: EmvParserMode.values
.map(
(mode) => DropdownMenuItem(value: mode, child: Text(mode.name)),
)
.toList(),
onChanged: _busy ? null : (value) => setState(() => _mode = value!),
),
ExpansionTile(
title: const Text('Native extension examples'),
children: [
const Text(
'These example-only hooks replace the builder terminal/provider or add a Java parser. For custom parser select mode none. For provider use replay. Reset restores the normal pipeline.',
),
Wrap(
spacing: 8,
children: [
for (final mode in ['terminal', 'parser', 'provider', 'reset'])
OutlinedButton(
onPressed: _busy
? null
: () => _run(() async {
await const MethodChannel(
'credit_card_nfc_reader_example/extensions',
).invokeMethod<void>(mode);
if (mounted) {
setState(() => _source = 'Native extension: $mode');
}
}),
child: Text('Native $mode'),
),
],
),
],
),
_field(_country, 'Terminal country enum (FR, TR, CA, DE…)'),
_field(_timeout, 'Session timeout in seconds (1–300)'),
_field(_exchangeTimeout, 'APDU timeout in milliseconds (1–60000)'),
_field(
_terminal,
'Custom terminal values JSON: tag → exact-length hex',
lines: 3,
),
ExpansionTile(
title: const Text('Custom replay provider input'),
children: [
const Text(
'Leave empty for the bundled synthetic Visa script. Exact commands take priority over prefix rules. Unmatched commands return file/record not found.',
),
_field(
_replayInput,
'{"at":"", "responses":[{"command":"…","response":"…","prefix":false}]}',
lines: 8,
),
],
),
],
);
Widget _resultsPage() {
final result = _result;
if (result == null) {
return const Center(
child: Text('Read a card, replay APDUs, or load the UI fixture.'),
);
}
final terms = resultGroups[_group]!;
final entries = flatten(result).entries.where((e) {
final path = e.key.toLowerCase();
return (terms.isEmpty ||
terms.any((term) => path.contains(term.toLowerCase()))) &&
path.contains(_search.toLowerCase());
}).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_source, style: Theme.of(context).textTheme.titleSmall),
DropdownButton<String>(
value: _group,
isExpanded: true,
items: resultGroups.keys
.map((g) => DropdownMenuItem(value: g, child: Text(g)))
.toList(),
onChanged: (g) => setState(() => _group = g!),
),
TextField(
decoration: const InputDecoration(
labelText: 'Filter field path',
),
onChanged: (text) => setState(() => _search = text),
),
Row(
children: [
Checkbox(
value: _reveal,
onChanged: (value) => setState(() => _reveal = value!),
),
const Expanded(child: Text('Reveal card data and raw bytes')),
TextButton(
onPressed: _reveal
? () async {
await Clipboard.setData(
ClipboardData(
text: const JsonEncoder.withIndent(
' ',
).convert(result),
),
);
if (mounted) {
setState(
() =>
_message = 'Full JSON copied to clipboard',
);
}
}
: null,
child: const Text('Copy JSON'),
),
],
),
Text(
'${entries.length} fields • null = unavailable • -1 = upstream UNKNOWN',
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: entries.length,
itemBuilder: (_, index) {
final entry = entries[index];
final sensitive = RegExp(
r'pan|cardnumber|holder|track|raw|record|apdu|discretionary|iban|paymentaccount',
caseSensitive: false,
).hasMatch(entry.key);
return ListTile(
dense: true,
title: Text(entry.key),
subtitle: SelectableText(
sensitive && !_reveal && entry.value != null
? 'Hidden — enable Reveal'
: '${entry.value}',
),
);
},
),
),
],
);
}
Widget _toolsPage() => ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Offline upstream decoders. Each tool has an editable synthetic example; no NFC hardware required. Android runtime is required.',
),
DropdownButton<String>(
value: _decoder,
isExpanded: true,
items: decoderExamples.keys
.map((tool) => DropdownMenuItem(value: tool, child: Text(tool)))
.toList(),
onChanged: _busy
? null
: (tool) => setState(() {
_decoder = tool!;
_input.text = decoderExamples[tool]!;
_decoded = null;
}),
),
_field(
_input,
_decoder == 'schemeByPan' ? 'Synthetic PAN digits' : 'Input hex',
lines: 4,
),
if (_decoder == 'tag') _field(_tag, 'Tag hex'),
FilledButton(
onPressed: _busy
? null
: () => _run(() async {
final data = await _reader.decode(
_decoder,
_input.text,
tag: _decoder == 'tag' ? _tag.text : null,
);
if (mounted) {
setState(() => _decoded = data ?? 'No matching data');
}
}),
child: const Text('Run decoder'),
),
const SizedBox(height: 16),
SelectableText(const JsonEncoder.withIndent(' ').convert(_decoded)),
],
);
Widget _catalogPage() => ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Countries, currencies and supported AID schemes from the installed upstream library.',
),
FilledButton(
onPressed: _busy
? null
: () => _run(() async {
final catalogs = await _reader.getCatalogs();
if (mounted) setState(() => _catalogs = catalogs);
}),
child: const Text('Load catalogs'),
),
if (_catalogs case final catalogs?) ...[
Text('Library ${catalogs['libraryVersion']}'),
for (final key in ['countries', 'currencies', 'schemes'])
ExpansionTile(
title: Text('$key (${(catalogs[key] as List).length})'),
children: [
for (final item in catalogs[key] as List)
ListTile(
title: Text('${item['code']} — ${item['label']}'),
subtitle: Text(jsonEncode(item)),
),
],
),
],
],
);
Widget _field(
TextEditingController controller,
String label, {
int lines = 1,
}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextField(
controller: controller,
enabled: !_busy,
minLines: lines,
maxLines: lines + 2,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
/// Paths retain list indices so every application and nested model is testable.
Map<String, Object?> flatten(Object? value, [String prefix = '']) {
final result = <String, Object?>{};
if (value is Map && value.isNotEmpty) {
for (final entry in value.entries) {
result.addAll(
flatten(
entry.value,
prefix.isEmpty ? '${entry.key}' : '$prefix.${entry.key}',
),
);
}
} else if (value is List && value.isNotEmpty) {
for (var i = 0; i < value.length; i++) {
result.addAll(flatten(value[i], '$prefix[$i]'));
}
} else {
result[prefix] = value;
}
return result;
}