diff --git a/assets/translations/en.json b/assets/translations/en.json index a9230c2216141e8624419c696eb6e0e665c1eb4a..9117028c48318bcf0b26cd761edaaa325d4239ba 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -285,5 +285,31 @@ "contact_about_me": "Profile", "contact_address": "Physical address", "contact_socials": "Social Media and Websites", - "info": "Info" + "info": "Info", + "other_auth_methods": "Other authentication methods", + "wallet_key_prefix": "keychain", + "keyfile_auth": "Authentication using keyfile", + "select_auth_file": "Select the authentication file", + "cesium_compatible_exports": "Keyfile Exports (compatible with Cesium)", + "export_pubsec_format": "Export as PubSec File", + "export_pubsec_format_description": "Generates a file that allows you to authenticate without entering your credentials. Keep it secure, as it holds sensitive data!", + "export_wif_format": "Export as WIF File", + "export_wif_format_description": "Creates a WIF file with your wallet for easy access. Keep it also secure, as it holds sensitive data!", + "export_ewif_format": "Export as Encrypted WIF (EWIF) File", + "export_ewif_format_description": "Generates an encrypted WIF file, requiring a password to access your wallet. This is more secure but still handle with care!", + "continue": "CONTINUE", + "ewif_intro": "Introduce a password to encrypt your WIF file", + "enter_a_password": "Enter a password", + "enter_password": "Enter Password", + "confirm_password": "Confirm password", + "password": "Password", + "password_hint": "Enter the password for this file", + "password_empty_error": "Password cannot be empty", + "ewif_parse_error": "Failed to parse EWIF file.", + "qr_scan_error_empty": "No valid QR data found.", + "auth_file_error": "Failed to parse the authentication file.", + "auth_file_pubkey_mismatch": "The public key from the file does not match.", + "scan_qr_auth": "Scan a QR code", + "qr_scanner_no_qr_detected": "No QR code detected. Please try again.", + "qr_scanner_error": "An error occurred while scanning the QR code." } diff --git a/assets/translations/es.json b/assets/translations/es.json index e93954c18333e567d8f1f50b7a66f55b2ebf4e6b..c0d32911aedef9d629650df8f123139e7ffea7a1 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -291,5 +291,31 @@ "contact_address": "Dirección", "contact_socials": "Redes sociales y web", "no_transactions_simple": "Este monedero no tiene saldo", - "info": "Info" + "info": "Info", + "other_auth_methods": "Otras formas de autenticación", + "wallet_key_prefix": "llavero", + "keyfile_auth": "Autenticación con archivo de llave", + "select_auth_file": "Selecciona un archivo de autenticación", + "cesium_compatible_exports": "Exportaciones de Llaves (compatible con Cesium)", + "export_pubsec_format": "Exportar como Archivo PubSec", + "export_pubsec_format_description": "Genera un archivo que permite autenticarte sin ingresar tus credenciales. Mantenlo seguro, ya que contiene datos sensibles.", + "export_wif_format": "Exportar como Archivo WIF", + "export_wif_format_description": "Crea un archivo WIF con tu monedero para un acceso fácil. Mantenlo también seguro, ya que contiene datos sensibles.", + "export_ewif_format": "Exportar como Archivo WIF Encriptado (EWIF)", + "export_ewif_format_description": "Genera un archivo WIF encriptado que requiere una contraseña para acceder a tu monedero. Es más seguro, pero igual manéjalo con cuidado.", + "continue": "CONTINUA", + "ewif_intro": "Introduce una contraseña para encriptar tu archivo WIF", + "enter_a_password": "Introduce una contraseña", + "enter_password": "Introduce contraseña", + "confirm_password": "Confirmar contraseña", + "password": "Contraseña", + "password_hint": "Introduce la contraseña de este fichero", + "password_empty_error": "La contraseña no puede estar vacÃa", + "ewif_parse_error": "Error al desencriptar el archivo EWIF", + "qr_scan_error_empty": "No se encontraron datos válidos en el QR.", + "auth_file_error": "Error al procesar el archivo de autenticación.", + "auth_file_pubkey_mismatch": "La clave pública del archivo no coincide.", + "scan_qr_auth": "Escanear QR", + "qr_scanner_no_qr_detected": "No se detectó ningún código QR. Inténtalo de nuevo.", + "qr_scanner_error": "Ocurrió un error al escanear el código QR." } diff --git a/lib/g1/g1_export_utils.dart b/lib/g1/g1_export_utils.dart new file mode 100644 index 0000000000000000000000000000000000000000..21045e21ff0c542e634f91746afb7f0403176b35 --- /dev/null +++ b/lib/g1/g1_export_utils.dart @@ -0,0 +1,260 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:durt/durt.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fast_base58/fast_base58.dart'; +import 'package:flutter/material.dart'; +import 'package:pointycastle/pointycastle.dart'; + +import 'g1_helper.dart'; + +String generateWif(CesiumWallet wallet) { + final Uint8List seed = wallet.seed; + if (seed.length != 32) { + throw ArgumentError('Private key must be 32 bytes long'); + } + final List<int> wifData = <int>[0x01]; + wifData.addAll(seed); + final List<int> doubleHash = + sha256.convert(sha256.convert(Uint8List.fromList(wifData)).bytes).bytes; + final List<int> checksum = doubleHash.sublist(0, 2); + wifData.addAll(checksum); + return Base58Encode(Uint8List.fromList(wifData)); +} + +String generateEwif(CesiumWallet wallet, String password) { + final Uint8List seed = wallet.seed; + final Uint8List signPk = Uint8List.fromList(Base58Decode(wallet.pubkey)); + final List<int> ewifData = <int>[0x02]; + final List<int> salt = + sha256.convert(sha256.convert(signPk).bytes).bytes.sublist(0, 4); + ewifData.addAll(salt); + final ScryptParameters scryptParams = + ScryptParameters(16384, 8, 8, 64, Uint8List.fromList(salt)); + final KeyDerivator scrypt = KeyDerivator('scrypt')..init(scryptParams); + final Uint8List scryptSeed = + scrypt.process(Uint8List.fromList(password.codeUnits)); + final Uint8List derivedhalf1 = scryptSeed.sublist(0, 32); + final Uint8List derivedhalf2 = scryptSeed.sublist(32, 64); + final Uint8List xorHalf1 = Uint8List(16); + final Uint8List xorHalf2 = Uint8List(16); + for (int i = 0; i < 16; i++) { + xorHalf1[i] = seed[i] ^ derivedhalf1[i]; + xorHalf2[i] = seed[i + 16] ^ derivedhalf1[i + 16]; + } + + final Uint8List encryptedHalf1 = encryptAes(xorHalf1, derivedhalf2); + final Uint8List encryptedHalf2 = encryptAes(xorHalf2, derivedhalf2); + + ewifData.addAll(encryptedHalf1); + ewifData.addAll(encryptedHalf2); + + final List<int> checksum = sha256 + .convert(sha256.convert(Uint8List.fromList(ewifData)).bytes) + .bytes + .sublist(0, 2); + ewifData.addAll(checksum); + + return Base58Encode(Uint8List.fromList(ewifData)); +} + +final String keyFileNamePrefix = tr('wallet_key_prefix'); + +Map<String, String> generatePubSecFile(String pubKey, String secKey) { + final String fileName = 'g1-$keyFileNamePrefix-$pubKey-PubSec.dunikey'; + final String content = ''' +Type: PubSec +Version: 1 +pub: $pubKey +sec: $secKey +'''; + return <String, String>{fileName: content}; +} + +Map<String, String> generateWifFile(String pubKey, String wifData) { + final String fileName = 'g1-$keyFileNamePrefix-$pubKey-WIF.dunikey'; + final String content = ''' +Type: WIF +Version: 1 +Data: $wifData +'''; + return <String, String>{fileName: content}; +} + +Map<String, String> generateEwifFile(String pubKey, String ewifData) { + final String fileName = 'g1-$keyFileNamePrefix-$pubKey-EWIF.dunikey'; + final String content = ''' +Type: EWIF +Version: 1 +Data: $ewifData +'''; + return <String, String>{fileName: content}; +} + +String getPrivKey(CesiumWallet wallet) { + final Uint8List privKeyComplete = + Uint8List.fromList(wallet.seed + wallet.rootKey.publicKey); + return Base58Encode(privKeyComplete); +} + +Future<CesiumWallet> parseKeyFile(String fileContent, + [BuildContext? context, String? password]) async { + final RegExp typeRegExp = RegExp(r'^Type: (\w+)', multiLine: true); + final RegExp pubRegExp = RegExp(r'pub: ([a-zA-Z0-9]+)', multiLine: true); + final RegExp secRegExp = RegExp(r'sec: ([a-zA-Z0-9]+)', multiLine: true); + final RegExp dataRegExp = RegExp(r'Data: ([a-zA-Z0-9]+)', multiLine: true); + + final Match? typeMatch = typeRegExp.firstMatch(fileContent); + if (typeMatch == null) { + throw const FormatException('We cannot detect the type of the file.'); + } + + final String fileType = typeMatch.group(1)!; + switch (fileType) { + case 'PubSec': + return _parsePubSec(fileContent, pubRegExp, secRegExp); + case 'WIF': + return _parseWif(fileContent, dataRegExp); + case 'EWIF': + return password != null && password.isNotEmpty + ? _parseEwif(fileContent, password, dataRegExp) + : _promptPasswordForEwif(context!, fileContent, dataRegExp); + default: + throw FormatException('Type $fileType not supported.'); + } +} + +Future<CesiumWallet> _promptPasswordForEwif( + BuildContext context, String fileContent, RegExp dataRegExp) async { + final TextEditingController passwordController = TextEditingController(); + final CesiumWallet? wallet = await showDialog<CesiumWallet>( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(tr('enter_password')), + content: TextField( + controller: passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr('password'), + hintText: tr('password_hint'), + ), + ), + actions: <Widget>[ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(tr('cancel')), + ), + TextButton( + onPressed: () { + final String password = passwordController.text.trim(); + if (password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr('password_empty_error')), + backgroundColor: Colors.red, + ), + ); + return; + } + try { + final CesiumWallet wallet = + _parseEwif(fileContent, password, dataRegExp); + Navigator.of(context).pop(wallet); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr('ewif_parse_error')), + backgroundColor: Colors.red, + ), + ); + } + }, + child: Text(tr('ok')), + ), + ], + ); + }, + ); + + if (wallet == null) { + throw const FormatException('EWIF file parsing was cancelled.'); + } + + return wallet; +} + +CesiumWallet _parsePubSec( + String fileContent, RegExp pubRegExp, RegExp secRegExp) { + final Match? pubMatch = pubRegExp.firstMatch(fileContent); + final Match? secMatch = secRegExp.firstMatch(fileContent); + + if (pubMatch == null || secMatch == null) { + throw const FormatException('Missing data in PubSec file.'); + } + + final Uint8List privKeyComplete = + Uint8List.fromList(Base58Decode(secMatch.group(1)!)); + + final Uint8List privKey = privKeyComplete.sublist(0, 32); + + return CesiumWallet.fromSeed(privKey); +} + +CesiumWallet _parseWif(String fileContent, RegExp dataRegExp) { + final Match? dataMatch = dataRegExp.firstMatch(fileContent); + if (dataMatch == null) { + throw const FormatException('Missing data in WIF file.'); + } + + final Uint8List wifBytes = + Uint8List.fromList(Base58Decode(dataMatch.group(1)!)); + final Uint8List privKey = + wifBytes.sublist(1, 33); // Exclude prefix and checksum + + if (privKey.length != 32) { + throw FormatException( + 'Private key length is not 32 bytes, found: ${privKey.length}'); + } + + return CesiumWallet.fromSeed(privKey); +} + +CesiumWallet _parseEwif( + String fileContent, String password, RegExp dataRegExp) { + final Match? dataMatch = dataRegExp.firstMatch(fileContent); + if (dataMatch == null) { + throw const FormatException('Wrong data in EWIF file.'); + } + + final Uint8List ewifBytes = + Uint8List.fromList(Base58Decode(dataMatch.group(1)!)); + final Uint8List salt = ewifBytes.sublist(1, 5); + final Uint8List encryptedHalf1 = ewifBytes.sublist(5, 21); + final Uint8List encryptedHalf2 = ewifBytes.sublist(21, 37); + + final ScryptParameters scryptParams = ScryptParameters(16384, 8, 8, 64, salt); + final KeyDerivator scrypt = KeyDerivator('scrypt')..init(scryptParams); + final Uint8List scryptSeed = + scrypt.process(Uint8List.fromList(password.codeUnits)); + + final Uint8List derivedhalf1 = scryptSeed.sublist(0, 32); + final Uint8List derivedhalf2 = scryptSeed.sublist(32, 64); + + final Uint8List decryptedHalf1 = decryptAes(encryptedHalf1, derivedhalf2); + final Uint8List decryptedHalf2 = decryptAes(encryptedHalf2, derivedhalf2); + + final Uint8List privKey = Uint8List(32); + for (int i = 0; i < 16; i++) { + privKey[i] = decryptedHalf1[i] ^ derivedhalf1[i]; + privKey[i + 16] = decryptedHalf2[i] ^ derivedhalf1[i + 16]; + } + + if (privKey.length != 32) { + throw FormatException( + 'Private key length is not 32 bytes, found: ${privKey.length}'); + } + + return CesiumWallet.fromSeed(privKey); +} diff --git a/lib/g1/g1_helper.dart b/lib/g1/g1_helper.dart index 815695b551d85878b573dca1b7bd9c27242299de..44029e437bc669a0e1bafeda175a99554ea319ca 100644 --- a/lib/g1/g1_helper.dart +++ b/lib/g1/g1_helper.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; +import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart'; import 'package:durt/durt.dart'; import 'package:encrypt/encrypt.dart' as encrypt; @@ -158,8 +159,8 @@ String pkChecksum(String pubkey) { } // Double SHA256 hash - final Digest firstHash = sha256.convert(signpkInt8); - final Digest secondHash = sha256.convert(firstHash.bytes); + final crypto.Digest firstHash = sha256.convert(signpkInt8); + final crypto.Digest secondHash = sha256.convert(firstHash.bytes); // Base58 encode and take the first 3 characters final String checksum = Base58Encode(secondHash.bytes).substring(0, 3); @@ -395,3 +396,26 @@ List<Transaction> lastTx(List<Transaction> origTxs) { areDatesClose(DateTime.now(), tx.time, paymentTimeRange)) .toList(); } + +Uint8List encryptAes(Uint8List data, Uint8List key) { + final encrypt.Encrypter encrypter = encrypt.Encrypter(encrypt.AES( + encrypt.Key(key), + mode: encrypt.AESMode.ecb, + padding: null, + )); + final encrypt.Encrypted encrypted = + encrypter.encryptBytes(data, iv: encrypt.IV(Uint8List(16))); + return Uint8List.fromList(encrypted.bytes); +} + +Uint8List decryptAes(Uint8List encryptedData, Uint8List key) { + final encrypt.Encrypter encrypter = encrypt.Encrypter(encrypt.AES( + encrypt.Key(key), + mode: encrypt.AESMode.ecb, + padding: null, + )); + final List<int> decrypted = encrypter.decryptBytes( + encrypt.Encrypted(encryptedData), + iv: encrypt.IV(Uint8List(16))); + return Uint8List.fromList(decrypted); +} diff --git a/lib/ui/screens/fifth_screen.dart b/lib/ui/screens/fifth_screen.dart index cc76043b743dece9dd15f1ab76bba3e56b5dbf18..d8283ca25bbd0d916fed3abae764a31c86608a43 100644 --- a/lib/ui/screens/fifth_screen.dart +++ b/lib/ui/screens/fifth_screen.dart @@ -56,7 +56,8 @@ class _FifthScreenState extends State<FifthScreen> { ); } - Future<void> _openWalletSelector(BuildContext context) async { + Future<void> _openWalletSelector( + BuildContext context, bool expertMode) async { _showMultiWalletSelectorDialog(context, (List<CesiumCard> selectedCards, bool exportContacts) { setState(() { @@ -64,7 +65,8 @@ class _FifthScreenState extends State<FifthScreen> { _selectedWallets = selectedCards; _exportContacts = exportContacts; }); - _showSelectExportMethodDialog(); + _showSelectExportMethodDialog( + onlyOneWalletSelected: selectedCards.length == 1 && expertMode); }); } @@ -229,7 +231,7 @@ class _FifthScreenState extends State<FifthScreen> { title: 'export_key$pluralSuffix', icon: Icons.download, onTap: () async { - _openWalletSelector(context); + _openWalletSelector(context, state.expertMode); }), GridItem( title: 'import_key$pluralSuffix', @@ -308,10 +310,12 @@ class _FifthScreenState extends State<FifthScreen> { }); } - Future<void> _showSelectExportMethodDialog() async { + Future<void> _showSelectExportMethodDialog( + {required bool onlyOneWalletSelected}) async { final ExportType? method = await showDialog<ExportType>( context: context, - builder: (BuildContext context) => const SelectExportMethodDialog(), + builder: (BuildContext context) => SelectExportMethodDialog( + onlyOneWalletSelected: onlyOneWalletSelected), ); if (method != null) { if (!mounted) { diff --git a/lib/ui/screens/select_export_method_dialog.dart b/lib/ui/screens/select_export_method_dialog.dart index 2af5e50f407299b36d34711272f3abd04e81af0e..f410ef48eb66a6d9e095cdad63c4b2ce2f8ae456 100644 --- a/lib/ui/screens/select_export_method_dialog.dart +++ b/lib/ui/screens/select_export_method_dialog.dart @@ -4,26 +4,123 @@ import 'package:flutter/material.dart'; import '../widgets/fifth_screen/export_dialog.dart'; class SelectExportMethodDialog extends StatelessWidget { - const SelectExportMethodDialog({super.key}); + const SelectExportMethodDialog( + {super.key, this.onlyOneWalletSelected = false}); + + final bool onlyOneWalletSelected; @override Widget build(BuildContext context) { - return AlertDialog( - title: Text(tr('select_export_method')), - // content: Text(tr('select_export_method_desc')), - actions: <Widget>[ - TextButton.icon( - icon: const Icon(Icons.file_present), - label: Text(tr('file_export')), - onPressed: () => Navigator.of(context).pop(ExportType.file)), - TextButton.icon( - icon: const Icon(Icons.content_paste), - label: Text(tr('clipboard_export')), - onPressed: () => Navigator.of(context).pop(ExportType.clipboard)), + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + tr('select_export_method'), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: <Widget>[ + TextButton.icon( + icon: const Icon(Icons.file_present), + label: Text(tr('file_export')), + onPressed: () => Navigator.of(context).pop(ExportType.file), + ), + TextButton.icon( + icon: const Icon(Icons.content_paste), + label: Text(tr('clipboard_export')), + onPressed: () => + Navigator.of(context).pop(ExportType.clipboard), + ), + TextButton.icon( + icon: const Icon(Icons.share), + label: Text(tr('share_export')), + onPressed: () => + Navigator.of(context).pop(ExportType.share), + ), + ], + ), + if (onlyOneWalletSelected) _buildKeyExportContent(context), + ], + ), + ), + ), + ); + } + + Widget _buildKeyExportContent(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + const Divider(), + const SizedBox(height: 8), + Text( + tr('cesium_compatible_exports'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + _buildExportOption( + context, + ExportType.pubsec, + tr('export_pubsec_format'), + tr('export_pubsec_format_description'), + ), + _buildExportOption( + context, + ExportType.wif, + tr('export_wif_format'), + tr('export_wif_format_description'), + ), + _buildExportOption( + context, + ExportType.ewif, + tr('export_ewif_format'), + tr('export_ewif_format_description'), + ), + ], + ), + ); + } + + Widget _buildExportOption( + BuildContext context, + ExportType type, + String buttonText, + String descriptionText, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ TextButton.icon( - icon: const Icon(Icons.share), - label: Text(tr('share_export')), - onPressed: () => Navigator.of(context).pop(ExportType.share)), + icon: const Icon(Icons.article_outlined), + label: Text(buttonText), + onPressed: () { + Navigator.of(context).pop(type); + }, + ), + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + descriptionText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[700], + ), + textAlign: TextAlign.left, + ), + ), ], ); } diff --git a/lib/ui/widgets/cesium_auth_dialog.dart b/lib/ui/widgets/cesium_auth_dialog.dart index 91ddab641afcbc74916d13f5613edc5d124b6682..1b0aadd1b736f223a7bfc50b0d70c98e2c5b9c19 100644 --- a/lib/ui/widgets/cesium_auth_dialog.dart +++ b/lib/ui/widgets/cesium_auth_dialog.dart @@ -2,17 +2,23 @@ import 'dart:math'; import 'package:durt/durt.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/models/app_cubit.dart'; import '../../data/models/bottom_nav_cubit.dart'; import '../../data/models/cesium_card.dart'; import '../../data/models/contact.dart'; import '../../data/models/credit_card_themes.dart'; import '../../g1/api.dart'; +import '../../g1/g1_export_utils.dart'; import '../../g1/g1_helper.dart'; import '../../shared_prefs_helper.dart'; +import '../logger.dart'; +import '../qr_manager.dart'; import '../ui_helpers.dart'; +import 'fifth_screen/import_dialog.dart'; import 'form_error_widget.dart'; class CesiumAddDialog extends StatefulWidget { @@ -40,120 +46,226 @@ class _CesiumAddDialogState extends State<CesiumAddDialog> { future: getProfile(widget.publicKey, onlyCPlusProfile: true), builder: (BuildContext context, AsyncSnapshot<Contact> snapshot) { if (snapshot.hasData) { - return showDialog( - context, - snapshot.data!, - ); + return _buildCustomAlertDialog(context, snapshot.data!); } - return showDialog(context, Contact(pubKey: widget.publicKey)); + return _buildCustomAlertDialog( + context, Contact(pubKey: widget.publicKey)); }, ); } - AlertDialog showDialog(BuildContext context, Contact contact) { + AlertDialog _buildCustomAlertDialog(BuildContext context, Contact contact) { return AlertDialog( - title: Text(tr('cesium_auth_dialog_title', namedArgs: <String, String>{ - 'key': humanizeContact(widget.publicKey, contact) - })), - content: SingleChildScrollView( - child: ListBody( - children: <Widget>[ - TextField( - controller: secretPhraseController, - obscureText: _obscureText1, - onChanged: (String? value) { - _feedbackNotifier.value = ''; + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + Text(tr('cesium_auth_dialog_title', namedArgs: <String, String>{ + 'key': humanizeContact(widget.publicKey, contact), + })), + if (context.read<AppCubit>().isExpertMode) + PopupMenuButton<String>( + tooltip: tr('other_auth_methods'), + onSelected: (String result) async { + if (result == 'import') { + await _showFileImportDialog(context, contact); + } else if (result == 'scan') { + await _showScanQrDialog(context, contact); + } }, - decoration: InputDecoration( - labelText: tr('cesium_secret_phrase'), - suffixIcon: IconButton( - icon: Icon( - _obscureText1 ? Icons.visibility : Icons.visibility_off, + itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[ + PopupMenuItem<String>( + value: 'import', + child: Row( + children: <Widget>[ + const Icon(Icons.upload_file), + const SizedBox(width: 8), + Text(tr('keyfile_auth')), + ], ), - onPressed: () { - setState(() { - _obscureText1 = !_obscureText1; - }); - }, ), - ), - ), - TextField( - controller: passwordController, - obscureText: _obscureText2, - onChanged: (String? value) { - _feedbackNotifier.value = ''; - }, - decoration: InputDecoration( - labelText: tr('cesium_password'), - suffixIcon: IconButton( - icon: Icon( - _obscureText2 ? Icons.visibility : Icons.visibility_off, + PopupMenuItem<String>( + value: 'scan', + child: Row( + children: <Widget>[ + const Icon(Icons.qr_code_scanner), + const SizedBox(width: 8), + Text(tr('scan_qr_auth')), + ], ), - onPressed: () { - setState(() { - _obscureText2 = !_obscureText2; - }); - }, ), - ), + ], ), - FormErrorWidget(feedbackNotifier: _feedbackNotifier), - ], - ), + ], ), - actions: <Widget>[ - TextButton( - child: Text(tr('cancel')), - onPressed: () { - Navigator.of(context).pop(false); - }, - ), - TextButton( - onPressed: _isProcessing - ? null - : () { - _feedbackNotifier.value = ''; + content: _buildDialogContent(context), + actions: _buildDialogActions(context, contact), + ); + } + + Widget _buildDialogContent(BuildContext context) { + return SingleChildScrollView( + child: ListBody( + children: <Widget>[ + TextField( + controller: secretPhraseController, + obscureText: _obscureText1, + onChanged: (String? value) { + _feedbackNotifier.value = ''; + }, + decoration: InputDecoration( + labelText: tr('cesium_secret_phrase'), + suffixIcon: IconButton( + icon: Icon( + _obscureText1 ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { setState(() { - _isProcessing = true; + _obscureText1 = !_obscureText1; }); - final String secret = secretPhraseController.text; - final String password = passwordController.text; - final CesiumWallet wallet = CesiumWallet(secret, password); - - // logger('wallet.pubkey: ${wallet.pubkey} vs ${widget.publicKey}'); + }, + ), + ), + ), + TextField( + controller: passwordController, + obscureText: _obscureText2, + onChanged: (String? value) { + _feedbackNotifier.value = ''; + }, + decoration: InputDecoration( + labelText: tr('cesium_password'), + suffixIcon: IconButton( + icon: Icon( + _obscureText2 ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { setState(() { - _isProcessing = false; + _obscureText2 = !_obscureText2; }); - if (wallet.pubkey != extractPublicKey(widget.publicKey)) { - _feedbackNotifier.value = tr('incorrect_passwords'); - } else { - final CesiumCard card = CesiumCard( - name: contact.name ?? '', - pubKey: extractPublicKey(widget.publicKey), - seed: '', - theme: CreditCardThemes.themes[Random().nextInt(10)]); - if (!SharedPreferencesHelper() - .has(extractPublicKey(widget.publicKey))) { - SharedPreferencesHelper().addCesiumCard(card); - SharedPreferencesHelper().selectCurrentWallet(card); - } - SharedPreferencesHelper().addCesiumVolatileCard(wallet); - if (context.read<BottomNavCubit>().currentIndex != - widget.returnTo) { - context - .read<BottomNavCubit>() - .updateIndex(widget.returnTo); - } - _feedbackNotifier.value = ''; - Navigator.of(context).pop(true); - } }, - child: _isProcessing - ? const CircularProgressIndicator() - : Text(tr('accept')), - ), - ], + ), + ), + ), + FormErrorWidget(feedbackNotifier: _feedbackNotifier), + ], + ), ); } + + List<Widget> _buildDialogActions(BuildContext context, Contact contact) { + return <Widget>[ + TextButton( + child: Text(tr('cancel')), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + onPressed: _isProcessing + ? null + : () async { + _feedbackNotifier.value = ''; + setState(() { + _isProcessing = true; + }); + final String secret = secretPhraseController.text; + final String password = passwordController.text; + final CesiumWallet wallet = CesiumWallet(secret, password); + + setState(() { + _isProcessing = false; + }); + if (wallet.pubkey != extractPublicKey(widget.publicKey)) { + _feedbackNotifier.value = tr('incorrect_passwords'); + } else { + _onCorrectAuth(contact, wallet, context); + } + }, + child: _isProcessing + ? const CircularProgressIndicator() + : Text(tr('accept')), + ), + ]; + } + + void _onCorrectAuth( + Contact contact, CesiumWallet wallet, BuildContext context) { + final CesiumCard card = CesiumCard( + name: contact.name ?? '', + pubKey: extractPublicKey(widget.publicKey), + seed: '', + theme: CreditCardThemes.themes[Random().nextInt(10)], + ); + if (!SharedPreferencesHelper().has(extractPublicKey(widget.publicKey))) { + SharedPreferencesHelper().addCesiumCard(card); + SharedPreferencesHelper().selectCurrentWallet(card); + } + SharedPreferencesHelper().addCesiumVolatileCard(wallet); + if (context.read<BottomNavCubit>().currentIndex != widget.returnTo) { + context.read<BottomNavCubit>().updateIndex(widget.returnTo); + } + _feedbackNotifier.value = ''; + Navigator.of(context).pop(true); + } + + Future<void> _showFileImportDialog(BuildContext c, Contact contact) async { + if (!c.mounted) { + return; + } + String? fileContent; + if (kIsWeb) { + fileContent = await importWalletWeb(c, '.dunikey'); + } else { + fileContent = + await importWallet(c, <String>['.dunikey'], 'select_auth_file'); + } + + if (fileContent != null && fileContent.isNotEmpty && mounted) { + try { + final CesiumWallet importedWallet = + await parseKeyFile(fileContent, context); + + // loggerDev('Imported wallet: ${importedWallet.pubkey}'); + // loggerDev('Wallet to auth: ${extractPublicKey(widget.publicKey)}'); + if (importedWallet.pubkey == extractPublicKey(widget.publicKey)) { + if (!mounted) { + return; + } + _onCorrectAuth(contact, importedWallet, context); + } else { + _feedbackNotifier.value = tr('auth_file_pubkey_mismatch'); + } + } catch (e) { + _feedbackNotifier.value = tr('auth_file_error'); + } + } + } + + Future<void> _showScanQrDialog(BuildContext context, Contact contact) async { + try { + final String? scannedKey = await QrManager.qrScan(context); + + if (scannedKey != null && scannedKey.isNotEmpty) { + if (!context.mounted) { + return; + } + final CesiumWallet importedWallet = + await parseKeyFile(scannedKey, context); + + if (importedWallet.pubkey == extractPublicKey(widget.publicKey)) { + if (!context.mounted) { + return; + } + _onCorrectAuth(contact, importedWallet, context); + } else { + _feedbackNotifier.value = tr('auth_file_pubkey_mismatch'); + } + } else { + _feedbackNotifier.value = tr('qr_scan_error_empty'); + } + } catch (e) { + logger('Error scanning QR: $e'); + _feedbackNotifier.value = tr('auth_file_error'); + } + } } diff --git a/lib/ui/widgets/fifth_screen/export_dialog.dart b/lib/ui/widgets/fifth_screen/export_dialog.dart index ac46e897b628fa74d708a2acc0e60ec58543489a..dddcc5b4599f41c89e8fc59ee09eeb3975d43586 100644 --- a/lib/ui/widgets/fifth_screen/export_dialog.dart +++ b/lib/ui/widgets/fifth_screen/export_dialog.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'package:clipboard/clipboard.dart'; +import 'package:durt/durt.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:file_saver/file_saver.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -17,19 +17,21 @@ import 'package:universal_html/html.dart' as html; import '../../../data/models/cesium_card.dart'; import '../../../data/models/contact.dart'; import '../../../data/models/contact_cubit.dart'; +import '../../../g1/g1_export_utils.dart'; import '../../../g1/g1_helper.dart'; import '../../logger.dart'; import '../../ui_helpers.dart'; import 'pattern_util.dart'; -enum ExportType { clipboard, file, share } +enum ExportType { clipboard, file, share, pubsec, wif, ewif } class ExportDialog extends StatefulWidget { - const ExportDialog( - {super.key, - required this.type, - required this.wallets, - required this.exportContacts}); + const ExportDialog({ + super.key, + required this.type, + required this.wallets, + required this.exportContacts, + }); final ExportType type; final List<CesiumCard> wallets; @@ -48,65 +50,133 @@ class _ExportDialogState extends State<ExportDialog> { @override Widget build(BuildContext context) { + final bool requiresPattern = _requiresPattern(widget.type); + return Scaffold( key: _exportKey, appBar: AppBar( - title: Text(tr('intro_some_pattern_to_export')), + title: + requiresPattern ? Text(tr('intro_some_pattern_to_export')) : null, ), - body: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: <Widget>[ - Flexible( - child: Text( - isConfirm ? tr('confirm_pattern') : tr('draw_pattern'), - style: const TextStyle(fontSize: 26), - ), + body: requiresPattern + ? _buildPatternLockScreen(context) + : widget.type == ExportType.ewif + ? _buildPasswordScreen(context) + : _executeExportDirectly(context), + ); + } + + Widget _buildPatternLockScreen(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: <Widget>[ + Flexible( + child: Text( + isConfirm ? tr('confirm_pattern') : tr('draw_pattern'), + style: const TextStyle(fontSize: 26), ), - Flexible( - child: PatternLock( - selectedColor: selectedPatternLock(), - notSelectedColor: notSelectedPatternLock(), - pointRadius: 12, - onInputComplete: (List<int> input) { - if (input.length < 3) { + ), + Flexible( + child: PatternLock( + selectedColor: selectedPatternLock(), + notSelectedColor: notSelectedPatternLock(), + pointRadius: 12, + onInputComplete: (List<int> input) { + if (input.length < 3) { + context.replaceSnackbar( + content: Text( + tr('at_least_3'), + style: const TextStyle(color: Colors.red), + ), + ); + return; + } + if (isConfirm) { + if (listEquals<int>(input, pattern)) { + Navigator.of(context).pop(); + _export(pattern!.join(), context, widget.type); + } else { context.replaceSnackbar( content: Text( - tr('at_least_3'), + tr('pattern_do_not_match'), style: const TextStyle(color: Colors.red), ), ); - return; - } - if (isConfirm) { - if (listEquals<int>(input, pattern)) { - Navigator.of(context).pop(); - _export(pattern!.join(), context, widget.type); - } else { - context.replaceSnackbar( - content: Text( - tr('pattern_do_not_match'), - style: const TextStyle(color: Colors.red), - ), - ); - setState(() { - pattern = null; - isConfirm = false; - }); - } - } else { setState(() { - pattern = input; - isConfirm = true; + pattern = null; + isConfirm = false; }); } - }, + } else { + setState(() { + pattern = input; + isConfirm = true; + }); + } + }, + ), + ), + ], + ); + } + + Widget _buildPasswordScreen(BuildContext context) { + final TextEditingController passwordController = TextEditingController(); + final TextEditingController confirmPasswordController = + TextEditingController(); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: <Widget>[ + Text(tr('ewif_intro')), + TextField( + controller: passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr('enter_a_password'), ), ), + TextField( + controller: confirmPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr('confirm_password'), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + final String password = passwordController.text; + final String confirmPassword = confirmPasswordController.text; + + if (password != confirmPassword) { + context.replaceSnackbar( + content: Text( + tr('passwords_do_not_match'), + style: const TextStyle(color: Colors.red), + ), + ); + } else { + Navigator.of(context).pop(); + _export(password, context, widget.type); + } + }, + child: Text(tr('export')), + ), ], ), ); } + Widget _executeExportDirectly(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _export('', context, widget.type); + }); + return const Center(child: CircularProgressIndicator()); + } + Future<void> _export( String password, BuildContext context, ExportType type) async { final ContactsCubit cubit = context.read<ContactsCubit>(); @@ -131,7 +201,6 @@ class _ExportDialogState extends State<ExportDialog> { loggerDev('Exporting: $jsonData and contacts: ${cubit.contacts.length}'); final String fileJson = jsonEncode(jsonData); final List<int> bytes = utf8.encode(fileJson); - switch (type) { case ExportType.clipboard: FlutterClipboard.copy(fileJson).then((dynamic value) { @@ -148,13 +217,13 @@ class _ExportDialogState extends State<ExportDialog> { case ExportType.file: bool result = false; if (kIsWeb) { - webDownload(bytes); + _webFileDownload(bytes); result = true; } else { if (!context.mounted) { return; } - result = await saveFile(context, bytes); + result = await _saveFileNonWeb(context, bytes); } if (!context.mounted) { return; @@ -174,9 +243,71 @@ class _ExportDialogState extends State<ExportDialog> { } shareExport(context, fileJson); break; + case ExportType.pubsec: + await _saveSpecialFile(context, generatePubSecFile, type); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + break; + case ExportType.wif: + await _saveSpecialFile(context, generateWifFile, type); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + break; + case ExportType.ewif: + await _saveSpecialFile(context, generateEwifFile, type, + password: password); + break; } } + bool _requiresPattern(ExportType type) { + return type == ExportType.clipboard || + type == ExportType.file || + type == ExportType.share; + } + + Future<void> _saveSpecialFile( + BuildContext context, + Map<String, String> Function(String, String) generateFile, + ExportType type, { + String? password, + }) async { + final CesiumWallet wallet = _getFirstWallet(); + final String pubKey = wallet.pubkey; + final String privKey = + password != null ? generateEwif(wallet, password) : getPrivKey(wallet); + + final Map<String, String> fileResult = generateFile(pubKey, privKey); + final String fileName = fileResult.keys.first; + final String content = fileResult.values.first; + bool result = false; + if (kIsWeb) { + _webFileDownload(utf8.encode(content), fileName); + result = true; + } else { + result = await _saveFileNonWeb(context, utf8.encode(content), fileName); + } + if (context.mounted && result) { + context.replaceSnackbar( + content: Text( + tr('wallet_exported'), + style: const TextStyle(color: Colors.green), + ), + ); + } + } + + CesiumWallet _getFirstWallet() { + final CesiumCard card = widget.wallets.first; + final CesiumWallet wallet = + CesiumWallet.fromSeed(seedFromString(card.seed)); + return wallet; + } + Future<void> shareExport(BuildContext context, String fileJson) { if (kIsWeb) { final Uri uri = Uri.parse(html.window.location.href); @@ -190,16 +321,17 @@ class _ExportDialogState extends State<ExportDialog> { } } - void webDownload(List<int> bytes) { + void _webFileDownload(List<int> bytes, [String? fileNameArg]) { final html.Blob blob = html.Blob(<dynamic>[bytes]); final String url = html.Url.createObjectUrlFromBlob(blob); final html.AnchorElement anchor = html.AnchorElement(href: url); - anchor.download = getWalletFileName(); + anchor.download = fileNameArg ?? getWalletFileName(); anchor.click(); } - Future<bool> saveFile(BuildContext context, List<int> bytes) async { + Future<bool> _saveFileNonWeb(BuildContext context, List<int> bytes, + [String? fileNameArg]) async { try { final bool hasPermission = await requestStoragePermission(context); if (!hasPermission) { @@ -208,34 +340,21 @@ class _ExportDialogState extends State<ExportDialog> { final Directory? directory = await getGinkgoDownloadDirectory(); if (directory == null) { - logger('App files directory not found'); + loggerDev('App files directory not found'); return false; } - final String fileName = getWalletFileName(); + final String fileName = fileNameArg ?? getWalletFileName(); final File file = File(join(directory.path, fileName)); await file.writeAsBytes(bytes); - logger('File saved at: ${file.path}'); + loggerDev('File saved at: ${file.path}'); return true; } catch (e, stacktrace) { - logger('Error saving wallet file $e'); + loggerDev('Error saving wallet file $e'); await Sentry.captureException(e, stackTrace: stacktrace); return false; } } - Future<void> saveFileApp(List<int> bytesList) async { - final Uint8List bytes = Uint8List.fromList(bytesList); - - final String fileName = getWalletFileName(); - - await FileSaver.instance.saveFile( - name: fileName, - bytes: bytes, - // 'application/json', - mimeType: MimeType.json, - ); - } - String getWalletFileName() { final DateTime now = DateTime.now(); final String formattedDate = todayS(now); diff --git a/lib/ui/widgets/fifth_screen/import_dialog.dart b/lib/ui/widgets/fifth_screen/import_dialog.dart index 0a201a7066cf6592ca1e392b35bea96b3ff936c0..2d3a7c67f76e8f03a4b1aee5c292411b60f7152e 100644 --- a/lib/ui/widgets/fifth_screen/import_dialog.dart +++ b/lib/ui/widgets/fifth_screen/import_dialog.dart @@ -33,14 +33,14 @@ class ImportDialog extends StatefulWidget { class _ImportDialogState extends State<ImportDialog> { final GlobalKey<ScaffoldState> _importKey = - GlobalKey<ScaffoldState>(debugLabel: 'importKey'); + GlobalKey<ScaffoldState>(debugLabel: 'importKey'); int _attempts = 0; @override Widget build(BuildContext c) { return FutureBuilder<String>( future: widget.wallet == null - ? (kIsWeb ? _importWalletWeb(c) : _importWallet(c)) + ? (kIsWeb ? importWalletWeb(c) : importWallet(c)) : Future<String>.value(widget.wallet), builder: (BuildContext context, AsyncSnapshot<String> snapshot) { if (snapshot.hasData && @@ -48,7 +48,7 @@ class _ImportDialogState extends State<ImportDialog> { snapshot.data!.isNotEmpty) { final String keyEncString = snapshot.data!; final Map<String, dynamic> keyJson = - jsonDecode(keyEncString) as Map<String, dynamic>; + jsonDecode(keyEncString) as Map<String, dynamic>; final String keyEncrypted = keyJson['key'] as String; return Scaffold( key: _importKey, @@ -86,17 +86,17 @@ class _ImportDialogState extends State<ImportDialog> { try { // try to decrypt final Map<String, dynamic> keys = - decryptJsonForImport( - keyEncrypted, pattern.join()); + decryptJsonForImport( + keyEncrypted, pattern.join()); try { final dynamic cesiumCards = keys['cesiumCards']; final List<dynamic>? contacts = - keys['contacts'] as List<dynamic>?; + keys['contacts'] as List<dynamic>?; importContacts(contacts, context); if (cesiumCards != null) { final List<dynamic> cesiumCardList = - jsonDecode(cesiumCards as String) - as List<dynamic>; + jsonDecode(cesiumCards as String) + as List<dynamic>; // ignore: avoid_function_literals_in_foreach_calls int imported = 0; for (final dynamic cesiumCard in cesiumCardList) { @@ -111,9 +111,9 @@ class _ImportDialogState extends State<ImportDialog> { imported == 0 ? tr('no_wallets_imported') : tr('wallets_imported', - namedArgs: <String, String>{ - 'number': imported.toString() - }), + namedArgs: <String, String>{ + 'number': imported.toString() + }), style: TextStyle( color: imported == 0 ? Colors.red @@ -194,19 +194,19 @@ class _ImportDialogState extends State<ImportDialog> { if (existingContacts.isNotEmpty) { for (final dynamic contactJson in contacts) { final Contact contact = - Contact.fromJson(contactJson as Map<String, dynamic>); + Contact.fromJson(contactJson as Map<String, dynamic>); if (!contactsCubit.isContact(contact.pubKey)) { contactsCubit.addContact(contact); } else { final Contact storedContact = - contactsCubit.getContact(contact.pubKey)!; + contactsCubit.getContact(contact.pubKey)!; contactsCubit.updateContact(storedContact.merge(contact)); } } } else { for (final dynamic contactJson in contacts) { final Contact contact = - Contact.fromJson(contactJson as Map<String, dynamic>); + Contact.fromJson(contactJson as Map<String, dynamic>); contactsCubit.addContact(contact); } } @@ -217,7 +217,7 @@ class _ImportDialogState extends State<ImportDialog> { bool importWalletToSharedPrefs(Map<String, dynamic> cesiumCard) { final dynamic pub = cesiumCard['pub']; final String pubKey = - pub != null ? pub as String : cesiumCard['pubKey'] as String; + pub != null ? pub as String : cesiumCard['pubKey'] as String; if (!SharedPreferencesHelper().has(pubKey)) { SharedPreferencesHelper().addCesiumCard(SharedPreferencesHelper() .buildCesiumCard(pubKey: pubKey, seed: cesiumCard['seed'] as String)); @@ -226,100 +226,10 @@ class _ImportDialogState extends State<ImportDialog> { return false; } } - - Future<String> _importWallet(BuildContext context) async { - try { - final bool hasPermission = await requestStoragePermission(context); - if (!hasPermission) { - logger('No permission to access storage'); - return ''; - } - - final Directory? directory = await getGinkgoDownloadDirectory(); - if (directory == null) { - logger('App files directory not found'); - return ''; - } - - logger('appDocDir: ${directory.path}'); - - if (!context.mounted) { - return ''; - } - - final String? filePath = await FilesystemPicker.openDialog( - title: tr('select_file_to_import'), - context: context, - rootDirectory: directory, - showGoUp: true, - fsType: FilesystemType.all, - allowedExtensions: <String>['.json'], - requestPermission: () async => requestStoragePermission(context), - fileTileSelectMode: FileTileSelectMode.wholeTile, - ); - - if (filePath == null || filePath.isEmpty) { - return ''; - } - - final File file = File(filePath); - final String jsonString = await file.readAsString(); - - // Log the content if not in release mode - if (!kReleaseMode) { - logger(jsonString); - } - - return jsonString; - } catch (e, stacktrace) { - logger('Error importing wallet $e'); - await Sentry.captureException(e, stackTrace: stacktrace); - // Handle the exception using Sentry or any other error reporting tool - // await Sentry.captureException(e, stackTrace: stacktrace); - return ''; - } - } - - Future<String> _importWalletWeb(BuildContext context) async { - final Completer<String> completer = Completer<String>(); - final html.InputElement input = html.InputElement() - ..type = 'file'; - - input.multiple = false; - input.accept = '.json'; // limit file types - input.click(); - - input.onChange.listen((html.Event event) async { - if (input.files != null && input.files!.isEmpty) { - completer.complete(''); - return; - } - - final html.File file = input.files!.first; - final html.FileReader reader = html.FileReader(); - - // Read as text - reader.readAsText(file); - await reader.onLoadEnd.first; - - try { - final String? jsonString = reader.result as String?; - if (jsonString != null && !kReleaseMode) { - logger(jsonString); - } - completer.complete(jsonString); - } catch (e, stacktrace) { - logger('Error importing wallet $e'); - await Sentry.captureException(e, stackTrace: stacktrace); - completer.complete(''); - } - }); - return completer.future; - } } -Future<void> showSelectImportMethodDialog(BuildContext context, - int returnTo) async { +Future<void> showSelectImportMethodDialog( + BuildContext context, int returnTo) async { final String? method = await showDialog<String>( context: context, builder: (BuildContext context) => const SelectImportMethodDialog(), @@ -358,8 +268,8 @@ Future<void> showSelectImportMethodDialog(BuildContext context, } } -Future<bool?> showImportCesiumWalletDialog(BuildContext context, String wallet, - int returnTo) { +Future<bool?> showImportCesiumWalletDialog( + BuildContext context, String wallet, int returnTo) { return showDialog<bool>( context: context, barrierDismissible: false, @@ -406,3 +316,95 @@ class SelectImportMethodDialog extends StatelessWidget { ); } } + +Future<String> importWalletWeb(BuildContext context, + [String allowedExtension = '.json']) async { + final Completer<String> completer = Completer<String>(); + final html.InputElement input = html.InputElement()..type = 'file'; + + input.multiple = false; + input.accept = allowedExtension; // limit file types + input.click(); + + input.onChange.listen((html.Event event) async { + if (input.files != null && input.files!.isEmpty) { + completer.complete(''); + return; + } + + final html.File file = input.files!.first; + final html.FileReader reader = html.FileReader(); + + // Read as text + reader.readAsText(file); + await reader.onLoadEnd.first; + + try { + final String? jsonString = reader.result as String?; + if (jsonString != null && !kReleaseMode) { + // logger(jsonString); + } + completer.complete(jsonString); + } catch (e, stacktrace) { + logger('Error importing wallet $e'); + await Sentry.captureException(e, stackTrace: stacktrace); + completer.complete(''); + } + }); + return completer.future; +} + +Future<String> importWallet(BuildContext context, + [List<String> allowedExtensions = const <String>['.json'], + String messageKey = 'select_file_to_import']) async { + try { + final bool hasPermission = await requestStoragePermission(context); + if (!hasPermission) { + logger('No permission to access storage'); + return ''; + } + + final Directory? directory = await getGinkgoDownloadDirectory(); + if (directory == null) { + logger('App files directory not found'); + return ''; + } + + logger('appDocDir: ${directory.path}'); + + if (!context.mounted) { + return ''; + } + + final String? filePath = await FilesystemPicker.openDialog( + title: tr(messageKey), + context: context, + rootDirectory: directory, + showGoUp: true, + fsType: FilesystemType.all, + allowedExtensions: allowedExtensions, + requestPermission: () async => requestStoragePermission(context), + fileTileSelectMode: FileTileSelectMode.wholeTile, + ); + + if (filePath == null || filePath.isEmpty) { + return ''; + } + + final File file = File(filePath); + final String jsonString = await file.readAsString(); + + // Log the content if not in release mode + if (!kReleaseMode) { + // logger(jsonString); + } + + return jsonString; + } catch (e, stacktrace) { + logger('Error importing wallet $e'); + await Sentry.captureException(e, stackTrace: stacktrace); + // Handle the exception using Sentry or any other error reporting tool + // await Sentry.captureException(e, stackTrace: stacktrace); + return ''; + } +} diff --git a/test/g1_test.dart b/test/g1_test.dart index de9bc3407208026aeeaef8783d94c35d1733628e..50afa499cda875357021db99b66e8353e3c4ebeb 100644 --- a/test/g1_test.dart +++ b/test/g1_test.dart @@ -8,9 +8,22 @@ import 'package:ginkgo/data/models/contact.dart'; import 'package:ginkgo/data/models/payment_state.dart'; import 'package:ginkgo/data/models/transaction.dart'; import 'package:ginkgo/data/models/transaction_type.dart'; +import 'package:ginkgo/g1/g1_export_utils.dart'; import 'package:ginkgo/g1/g1_helper.dart'; import 'package:ginkgo/ui/logger.dart'; +String _generateRandomPatternPassword(Random random) { + final int length = random.nextInt(8) + 2; // Password length between 2 and 9. + final Set<int> digits = <int>{1, 2, 3, 4, 5, 6, 7, 8, 9}; + final List<int> passwordDigits = <int>[]; + for (int i = 0; i < length; i++) { + final int selectedDigit = digits.elementAt(random.nextInt(digits.length)); + passwordDigits.add(selectedDigit); + digits.remove(selectedDigit); + } + return passwordDigits.join(); +} + void main() { const String testPubKey = '7wnDh2FPdwNW8Dd5JyoJTbspuu8b9QJKps2xAYenefsu'; const String testPubKey1 = '7XtCpQSj8HRQxAD7rjZrMJ1knxBm6yx317R7sYzu3Hy6'; @@ -651,16 +664,96 @@ void main() { final String primaryKey = firstCard['pubKey'] as String; expect(primaryKey, equals(expectedPrimaryKey)); }); -} -String _generateRandomPatternPassword(Random random) { - final int length = random.nextInt(8) + 2; // Password length between 2 and 9. - final Set<int> digits = <int>{1, 2, 3, 4, 5, 6, 7, 8, 9}; - final List<int> passwordDigits = <int>[]; - for (int i = 0; i < length; i++) { - final int selectedDigit = digits.elementAt(random.nextInt(digits.length)); - passwordDigits.add(selectedDigit); - digits.remove(selectedDigit); - } - return passwordDigits.join(); + test('Create Cesium account with devtest/devtest and validate pubkey', () { + // Given + const String expectedPubKey = + '6SvSMyZSTUFtKo8BJEN959xRX4ze9K3WT7SBK9tqR5vh'; + const String expectedSecKey = + '3WUvt7z9M2tNNfmQkYakJ12VGGGaLdVVjQu4wMYdo92CMuL3cnkf4Zr29dWyrGM2JKKYo6D1BkQsRVouV33s1zim'; + const String expectedWifData = + '9dA4Ciza7hLv1ShRgb1XSqd95BDQtYEi31ZwH6gzCb1gJBE'; + const String expectedEwifData = + '2RTjpjZMnFnKHhgUadgT7JUvGeQem5sC6DQQpeuo5dCL6V1fgqsg8'; + + const String password = 'devtest'; + + // When + final CesiumWallet wallet = CesiumWallet(password, password); + + // Generate + final String secKey = getPrivKey(wallet); + final String wifData = generateWif(wallet); + final String ewifData = generateEwif(wallet, 'devtest'); + + // Generate files + final Map<String, String> pubSecFile = + generatePubSecFile(wallet.pubkey, secKey); + final Map<String, String> wifFile = generateWifFile(wallet.pubkey, wifData); + final Map<String, String> ewifFile = + generateEwifFile(wallet.pubkey, ewifData); + + // Validate + expect(wallet.pubkey, equals(expectedPubKey)); + expect(secKey, equals(expectedSecKey)); + expect(wifData, equals(expectedWifData)); + expect(ewifData, equals(expectedEwifData)); + expect(pubSecFile.values.first, equals(''' +Type: PubSec +Version: 1 +pub: $expectedPubKey +sec: $expectedSecKey +''')); + + expect(wifFile.values.first, equals(''' +Type: WIF +Version: 1 +Data: $expectedWifData +''')); + + expect(ewifFile.values.first, equals(''' +Type: EWIF +Version: 1 +Data: $expectedEwifData +''')); + }); + + test('Parse PubSec file and validate wallet', () async { + const String pubSecContent = ''' +Type: PubSec +Version: 1 +pub: 6SvSMyZSTUFtKo8BJEN959xRX4ze9K3WT7SBK9tqR5vh +sec: 3WUvt7z9M2tNNfmQkYakJ12VGGGaLdVVjQu4wMYdo92CMuL3cnkf4Zr29dWyrGM2JKKYo6D1BkQsRVouV33s1zim +'''; + + final CesiumWallet wallet = + await parseKeyFile(pubSecContent, null, 'devtest'); + expect( + wallet.pubkey, equals('6SvSMyZSTUFtKo8BJEN959xRX4ze9K3WT7SBK9tqR5vh')); + }); + + test('Parse WIF file and validate wallet', () async { + const String wifContent = ''' +Type: WIF +Version: 1 +Data: 9dA4Ciza7hLv1ShRgb1XSqd95BDQtYEi31ZwH6gzCb1gJBE +'''; + + final CesiumWallet wallet = await parseKeyFile(wifContent, null, 'devtest'); + expect( + wallet.pubkey, equals('6SvSMyZSTUFtKo8BJEN959xRX4ze9K3WT7SBK9tqR5vh')); + }); + + test('Parse EWIF file and validate wallet', () async { + const String ewifContent = ''' +Type: EWIF +Version: 1 +Data: 2RTjpjZMnFnKHhgUadgT7JUvGeQem5sC6DQQpeuo5dCL6V1fgqsg8 +'''; + + final CesiumWallet wallet = + await parseKeyFile(ewifContent, null, 'devtest'); + expect( + wallet.pubkey, equals('6SvSMyZSTUFtKo8BJEN959xRX4ze9K3WT7SBK9tqR5vh')); + }); }