feat: add NFC key exchange

This commit is contained in:
sneurlax 2024-12-30 17:54:58 -06:00
parent 81008841cc
commit c7c6d61985
6 changed files with 270 additions and 76 deletions

View file

@ -0,0 +1,4 @@
<resources>
<string name="servicedesc">Flutter NFC HCE Service</string>
<string name="aiddescription">NFC Host Card Emulation AID</string>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<host-apdu-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/servicedesc"
android:requireDeviceScreenOn="false"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/aiddescription"
android:category="other" >
<aid-filter android:name="D2760000850101"/>
</aid-group>
</host-apdu-service>

View file

@ -6,7 +6,9 @@ import 'package:barcode_scan2/platform_wrapper.dart';
import 'package:bip48/bip48.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_nfc_hce/flutter_nfc_hce.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nfc_manager/nfc_manager.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../../../themes/stack_colors.dart';
@ -31,8 +33,10 @@ import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../widgets/desktop/primary_button.dart';
import '../../../widgets/desktop/qr_code_scanner_dialog.dart';
import '../../../widgets/desktop/secondary_button.dart';
import '../../../widgets/icon_widgets/clipboard_icon.dart';
import '../../../widgets/icon_widgets/copy_icon.dart';
import '../../../widgets/icon_widgets/qrcode_icon.dart';
import '../../../widgets/icon_widgets/share_icon.dart';
import '../../add_wallet_views/restore_wallet_view/sub_widgets/restore_failed_dialog.dart';
import '../../add_wallet_views/restore_wallet_view/sub_widgets/restore_succeeded_dialog.dart';
import '../../add_wallet_views/restore_wallet_view/sub_widgets/restoring_dialog.dart';
@ -98,8 +102,9 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
String _myXpub = "";
late final bool isDesktop;
// bool _isNfcAvailable = false;
// String _nfcStatus = 'Checking NFC availability...';
final _flutterNfcHcePlugin = FlutterNfcHce();
bool _isNfcAvailable = false;
String _nfcStatus = 'Checking NFC availability...';
@override
void initState() {
@ -146,7 +151,7 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
}
});
// _checkNfcAvailability();
_checkNfcAvailability();
}
@override
@ -157,73 +162,6 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
super.dispose();
}
// Future<void> _checkNfcAvailability() async {
// try {
// final availability = await NfcManager.instance.isAvailable();
// setState(() {
// _isNfcAvailable = availability;
// _nfcStatus = _isNfcAvailable
// ? 'NFC is available'
// : 'NFC is not available on this device';
// });
// } catch (e) {
// setState(() {
// _nfcStatus = 'Error checking NFC: $e';
// _isNfcAvailable = false;
// });
// }
// }
//
// Future<void> _startNfcSession() async {
// if (!_isNfcAvailable) return;
//
// setState(() => _nfcStatus = 'Ready to exchange information...');
//
// try {
// await NfcManager.instance.startSession(
// onDiscovered: (tag) async {
// try {
// final ndef = Ndef.from(tag);
//
// if (ndef == null) {
// setState(() => _nfcStatus = 'Tag is not NDEF compatible');
// return;
// }
//
// final setupData = ref.watch(multisigSetupStateProvider);
//
// if (ndef.isWritable) {
// final message = NdefMessage([
// NdefRecord.createMime(
// 'application/x-multisig-setup',
// Uint8List.fromList(
// utf8.encode(jsonEncode(setupData.toJson()))),
// ),
// ]);
//
// try {
// await ndef.write(message);
// setState(
// () => _nfcStatus = 'Configuration shared successfully');
// } catch (e) {
// setState(
// () => _nfcStatus = 'Failed to share configuration: $e');
// }
// }
//
// await NfcManager.instance.stopSession();
// } catch (e) {
// setState(() => _nfcStatus = 'Error during NFC exchange: $e');
// await NfcManager.instance.stopSession();
// }
// },
// );
// } catch (e) {
// setState(() => _nfcStatus = 'Error: $e');
// await NfcManager.instance.stopSession();
// }
// }
@override
Widget build(BuildContext context) {
return Background(
@ -302,6 +240,30 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
),
),
),
if (Platform.isAndroid)
const SizedBox(width: 8),
if (Platform.isAndroid)
SecondaryButton(
width: 44,
buttonHeight: ButtonHeight.xl,
icon: ShareIcon(
// TODO: Replace with NFC icon.
width: 20,
height: 20,
color: Theme.of(context)
.extension<StackColors>()!
.buttonTextSecondary,
),
onPressed: () async {
await _showNfcDialog(
title: 'Tap to share xPub',
onStartNfc: () =>
_startNfcSessionShareXpub(
_myXpub),
isWriting: true,
);
},
),
const SizedBox(width: 8),
SecondaryButton(
width: 44,
@ -391,6 +353,29 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
},
),
),
if (Platform.isAndroid)
const SizedBox(width: 8),
if (Platform.isAndroid)
SecondaryButton(
width: 44,
buttonHeight: ButtonHeight.xl,
icon: ShareIcon(
// TODO: Replace with NFC icon.
width: 20,
height: 20,
color: Theme.of(context)
.extension<StackColors>()!
.buttonTextSecondary,
),
onPressed: () async {
await _showNfcDialog(
title: 'Tap to scan xPub',
onStartNfc: () =>
_startNfcSessionScanXpub(
i - 1),
);
},
),
const SizedBox(width: 8),
SecondaryButton(
width: 44,
@ -408,7 +393,7 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
SecondaryButton(
width: 44,
buttonHeight: ButtonHeight.xl,
icon: CopyIcon(
icon: ClipboardIcon(
width: 20,
height: 20,
color: Theme.of(context)
@ -505,7 +490,7 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
});
final info = WalletInfo.createNew(
coin: parentWallet.cryptoCurrency,
coin: BIP48Bitcoin(parentWallet.cryptoCurrency.network),
name:
'widget.walletName', // TODO [prio=high]: Add wallet name input field to multisig setup view and pass it to the coordinator view here.
restoreHeight: await parentWallet.chainHeight,
@ -637,6 +622,174 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
}
}
Future<void> _checkNfcAvailability() async {
try {
final isSupported = await _flutterNfcHcePlugin.isNfcHceSupported();
setState(() {
_isNfcAvailable = isSupported;
_nfcStatus = _isNfcAvailable
? 'NFC HCE is available'
: 'NFC HCE is not available on this device';
});
} catch (e) {
setState(() {
_nfcStatus = 'Error checking NFC HCE: $e';
_isNfcAvailable = false;
});
}
}
Future<void> _startNfcSessionShareXpub(String xpub) async {
if (!_isNfcAvailable) {
setState(() => _nfcStatus = 'NFC is not available on this device');
return;
}
try {
final isEnabled = await _flutterNfcHcePlugin.isNfcEnabled();
if (!isEnabled) {
setState(() => _nfcStatus = 'Please enable NFC on your device');
return;
}
setState(() => _nfcStatus = 'Starting NFC sharing...');
final result = await _flutterNfcHcePlugin.startNfcHce(
xpub,
mimeType: 'text/plain',
persistMessage: false,
);
if (result == 'success') {
setState(() => _nfcStatus = 'Sharing xPub: hold devices together');
} else {
setState(() => _nfcStatus = 'Failed to start NFC sharing');
}
} catch (e) {
setState(() => _nfcStatus = 'Error: ${e.toString()}');
await _stopNfcSession();
}
}
Future<void> _startNfcSessionScanXpub(int cosignerIndex) async {
if (!_isNfcAvailable) {
setState(() => _nfcStatus = 'NFC is not available');
return;
}
try {
setState(() => _nfcStatus = 'Ready to scan xPub...');
await NfcManager.instance.startSession(
onDiscovered: (NfcTag tag) async {
try {
final ndef = Ndef.from(tag);
if (ndef == null) {
setState(() => _nfcStatus = 'Invalid NFC tag format');
return;
}
final message = await ndef.read();
if (message.records.isEmpty) {
setState(() => _nfcStatus = 'No data found on tag');
return;
}
final record = message.records.first;
String xpub;
if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown &&
record.type.length == 1 &&
record.type[0] == 'T'.codeUnitAt(0)) {
final payload = record.payload;
final languageCodeLength = payload[0] & 0x3F;
xpub = utf8.decode(payload.sublist(1 + languageCodeLength));
} else {
xpub = utf8.decode(record.payload);
}
if (!xpub.startsWith('xpub') && !xpub.startsWith('tpub')) {
setState(() => _nfcStatus = 'Invalid xPub format: $xpub');
return;
}
setState(() {
xpubControllers[cosignerIndex].text = xpub;
_nfcStatus = 'Successfully read xPub';
});
ref
.read(multisigCoordinatorStateProvider.notifier)
.addCosignerXpub(xpub);
await NfcManager.instance.stopSession();
// Delay to show success message before dismissing.
await Future.delayed(const Duration(milliseconds: 500));
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
setState(() => _nfcStatus = 'Error reading tag: ${e.toString()}');
await NfcManager.instance.stopSession();
}
},
);
} catch (e) {
setState(() => _nfcStatus = 'Error: ${e.toString()}');
}
}
Future<void> _stopNfcSession() async {
await _flutterNfcHcePlugin.stopNfcHce();
}
Future<void> _showNfcDialog({
required String title,
required Future<void> Function() onStartNfc,
bool isWriting = false,
}) async {
setState(() => _nfcStatus = 'Initializing NFC...');
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) {
WidgetsBinding.instance.addPostFrameCallback((_) => onStartNfc());
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_nfcStatus),
const SizedBox(height: 16),
if (!_nfcStatus.contains('Error') &&
!_nfcStatus.contains('Success'))
const CircularProgressIndicator(),
],
),
actions: [
TextButton(
onPressed: () async {
if (_isNfcAvailable) {
if (isWriting) {
await _flutterNfcHcePlugin.stopNfcHce();
} else {
await NfcManager.instance.stopSession();
}
}
if (ctx.mounted) Navigator.of(ctx).pop();
},
child: const Text('Cancel'),
),
],
);
},
);
}
Future<void> scanQr(int cosignerIndex) async {
try {
if (Platform.isAndroid || Platform.isIOS) {
@ -655,7 +808,7 @@ class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
.read(multisigCoordinatorStateProvider.notifier)
.addCosignerXpub(qrResult.rawContent);
setState(() {}); // Trigger rebuild to update button state.
setState(() {});
} else {
// Platform.isLinux, Platform.isWindows, or Platform.isMacOS.
final qrResult = await showDialog<String>(

View file

@ -125,10 +125,10 @@ packages:
dependency: "direct main"
description:
name: bip48
sha256: c31fa9a3fc1d755048c49317aa33b4cc8a396af387ffa1561010a981e4c9e8ca
sha256: "0ab248477a7a290a938d9fc6aa118742d2a7ba87eb5a8046348047ed1d55d84d"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
version: "0.0.4"
bitbox:
dependency: "direct main"
description:
@ -869,6 +869,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.7"
flutter_nfc_hce:
dependency: "direct main"
description:
name: flutter_nfc_hce
sha256: "4a8b12f87db6581e81653d5b6bbb8afa980c6171ab378e315a3f39fcde0d696c"
url: "https://pub.dev"
source: hosted
version: "0.1.8"
flutter_plugin_android_lifecycle:
dependency: transitive
description:

View file

@ -17,6 +17,10 @@
android:name="android.permission.READ_INTERNAL_STORAGE"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="true" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name="${applicationName}"
android:label="PlaceHolderName"
@ -72,6 +76,17 @@
<!-- android:name="android.support.FILE_PROVIDER_PATHS"-->
<!-- android:resource="@xml/provider_paths" />-->
<!-- </provider>-->
<service android:name="com.novice.flutter_nfc_hce.KHostApduService"
android:exported="true"
android:enabled="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/apduservice"/>
</service>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View file

@ -204,8 +204,9 @@ dependencies:
cbor: ^6.3.3
cs_monero: 1.0.0-pre.1
cs_monero_flutter_libs: 1.0.0-pre.0
nfc_manager: ^3.5.0
bip48: ^0.0.4
flutter_nfc_hce: ^0.1.8 # For writing.
nfc_manager: ^3.5.0 # For reading.
dev_dependencies:
flutter_test: