From c7c6d61985b1ae919abf66a9a9857f81f0dd1d54 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 30 Dec 2024 17:54:58 -0600 Subject: [PATCH] feat: add NFC key exchange --- android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/xml/apduservice.xml | 13 + .../multisig_coordinator_view.dart | 299 +++++++++++++----- pubspec.lock | 12 +- .../android/app/src/main/AndroidManifest.xml | 15 + scripts/app_config/templates/pubspec.template | 3 +- 6 files changed, 270 insertions(+), 76 deletions(-) create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/xml/apduservice.xml diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..7f4ef48b8 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Flutter NFC HCE Service + NFC Host Card Emulation AID + diff --git a/android/app/src/main/res/xml/apduservice.xml b/android/app/src/main/res/xml/apduservice.xml new file mode 100644 index 000000000..71a775af4 --- /dev/null +++ b/android/app/src/main/res/xml/apduservice.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart index e10d2b40b..3e2b2b391 100644 --- a/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart +++ b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart @@ -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 { 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 { } }); - // _checkNfcAvailability(); + _checkNfcAvailability(); } @override @@ -157,73 +162,6 @@ class _MultisigSetupViewState extends ConsumerState { super.dispose(); } - // Future _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 _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 { ), ), ), + 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()! + .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 { }, ), ), + 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()! + .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 { 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 { }); 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 { } } + Future _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 _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 _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 _stopNfcSession() async { + await _flutterNfcHcePlugin.stopNfcHce(); + } + + Future _showNfcDialog({ + required String title, + required Future Function() onStartNfc, + bool isWriting = false, + }) async { + setState(() => _nfcStatus = 'Initializing NFC...'); + + return showDialog( + 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 scanQr(int cosignerIndex) async { try { if (Platform.isAndroid || Platform.isIOS) { @@ -655,7 +808,7 @@ class _MultisigSetupViewState extends ConsumerState { .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( diff --git a/pubspec.lock b/pubspec.lock index e12ac86a4..da4e083eb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml b/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml index 204663635..ef1257bf9 100644 --- a/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml +++ b/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,10 @@ android:name="android.permission.READ_INTERNAL_STORAGE"/> + + + + + + + + + + +