Backup.

M committed Jan 13, 2021 at 18:43 UTC c09a41b090aa19202321f9d3f269e63301c404ec
18 files changed +827 -25
ios/Podfile.lock
+50
@@ -26,8 +26,42 @@ PODS:
26 - Flutter
27 - devicelocale (0.0.1):
28 - Flutter
29 + - DKImagePickerController/Core (4.3.2):
30 + - DKImagePickerController/ImageDataManager
31 + - DKImagePickerController/Resource
32 + - DKImagePickerController/ImageDataManager (4.3.2)
33 + - DKImagePickerController/PhotoGallery (4.3.2):
34 + - DKImagePickerController/Core
35 + - DKPhotoGallery
36 + - DKImagePickerController/Resource (4.3.2)
37 + - DKPhotoGallery (0.0.17):
38 + - DKPhotoGallery/Core (= 0.0.17)
39 + - DKPhotoGallery/Model (= 0.0.17)
40 + - DKPhotoGallery/Preview (= 0.0.17)
41 + - DKPhotoGallery/Resource (= 0.0.17)
42 + - SDWebImage
43 + - SwiftyGif
44 + - DKPhotoGallery/Core (0.0.17):
45 + - DKPhotoGallery/Model
46 + - DKPhotoGallery/Preview
47 + - SDWebImage
48 + - SwiftyGif
49 + - DKPhotoGallery/Model (0.0.17):
50 + - SDWebImage
51 + - SwiftyGif
52 + - DKPhotoGallery/Preview (0.0.17):
53 + - DKPhotoGallery/Model
54 + - DKPhotoGallery/Resource
55 + - SDWebImage
56 + - SwiftyGif
57 + - DKPhotoGallery/Resource (0.0.17):
58 + - SDWebImage
59 + - SwiftyGif
60 - esys_flutter_share (0.0.1):
61 - Flutter
62 + - file_picker (0.0.1):
63 + - DKImagePickerController/PhotoGallery
64 + - Flutter
65 - Flutter (1.0.0)
66 - flutter_secure_storage (3.3.1):
67 - Flutter
@@ -39,11 +73,15 @@ PODS:
73 - path_provider (0.0.1):
74 - Flutter
75 - Reachability (3.2)
76 + - SDWebImage (5.9.1):
77 + - SDWebImage/Core (= 5.9.1)
78 + - SDWebImage/Core (5.9.1)
79 - share (0.0.1):
80 - Flutter
81 - shared_preferences (0.0.1):
82 - Flutter
83 - SwiftProtobuf (1.12.0)
84 + - SwiftyGif (5.3.0)
85 - url_launcher (0.0.1):
86 - Flutter
87
@@ -54,6 +92,7 @@ DEPENDENCIES:
92 - cw_monero (from `.symlinks/plugins/cw_monero/ios`)
93 - devicelocale (from `.symlinks/plugins/devicelocale/ios`)
94 - esys_flutter_share (from `.symlinks/plugins/esys_flutter_share/ios`)
95 + - file_picker (from `.symlinks/plugins/file_picker/ios`)
96 - Flutter (from `Flutter`)
97 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
98 - local_auth (from `.symlinks/plugins/local_auth/ios`)
@@ -66,9 +105,13 @@ DEPENDENCIES:
105 SPEC REPOS:
106 https://github.com/CocoaPods/Specs.git:
107 - CryptoSwift
108 + - DKImagePickerController
109 + - DKPhotoGallery
110 - MTBBarcodeScanner
111 - Reachability
112 + - SDWebImage
113 - SwiftProtobuf
114 + - SwiftyGif
115
116 EXTERNAL SOURCES:
117 barcode_scan:
@@ -81,6 +124,8 @@ EXTERNAL SOURCES:
124 :path: ".symlinks/plugins/devicelocale/ios"
125 esys_flutter_share:
126 :path: ".symlinks/plugins/esys_flutter_share/ios"
127 + file_picker:
128 + :path: ".symlinks/plugins/file_picker/ios"
129 Flutter:
130 :path: Flutter
131 flutter_secure_storage:
@@ -104,7 +149,10 @@ SPEC CHECKSUMS:
149 CryptoSwift: 093499be1a94b0cae36e6c26b70870668cb56060
150 cw_monero: 2e1f79929880cc2293b5bc1b25e28152e4d84649
151 devicelocale: feebbe5e7a30adb8c4f83185de1b50ff19b44f00
152 + DKImagePickerController: b5eb7f7a388e4643264105d648d01f727110fc3d
153 + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
154 esys_flutter_share: 403498dab005b36ce1f8d7aff377e81f0621b0b4
155 + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
156 Flutter: 0e3d915762c693b495b44d77113d4970485de6ec
157 flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
158 local_auth: 25938960984c3a7f6e3253e3f8d962fdd16852bd
@@ -112,9 +160,11 @@ SPEC CHECKSUMS:
160 package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
161 path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
162 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
163 + SDWebImage: a990c053fff71e388a10f3357edb0be17929c9c5
164 share: 0b2c3e82132f5888bccca3351c504d0003b3b410
165 shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d
166 SwiftProtobuf: 4ef85479c18ca85b5482b343df9c319c62bda699
167 + SwiftyGif: e466e86c660d343357ab944a819a101c4127cb40
168 url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
169
170 PODFILE CHECKSUM: ba3d2157523e2f4dc333b987efdac6635da8125d
lib/core/backup.dart new
+244
@@ -0,0 +1,244 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +import 'dart:typed_data';
4 +import 'package:flutter/foundation.dart';
5 +import 'package:hive/hive.dart';
6 +import 'package:flutter_secure_storage/flutter_secure_storage.dart';
7 +import 'package:path_provider/path_provider.dart';
8 +import 'package:cryptography/cryptography.dart';
9 +import 'package:shared_preferences/shared_preferences.dart';
10 +import 'package:archive/archive_io.dart';
11 +import 'package:cake_wallet/core/auth_service.dart';
12 +import 'package:cake_wallet/core/key_service.dart';
13 +import 'package:cake_wallet/entities/encrypt.dart';
14 +import 'package:cake_wallet/entities/preferences_key.dart';
15 +import 'package:cake_wallet/entities/secret_store_key.dart';
16 +import 'package:cake_wallet/entities/wallet_info.dart';
17 +
18 +class BackupService {
19 + BackupService(this._flutterSecureStorage, this._authService,
20 + this._walletInfoSource, this._keyService, this._sharedPreferences)
21 + : _cipher = chacha20Poly1305Aead;
22 +
23 + final Cipher _cipher;
24 + final FlutterSecureStorage _flutterSecureStorage;
25 + final SharedPreferences _sharedPreferences;
26 + final AuthService _authService;
27 + final Box<WalletInfo> _walletInfoSource;
28 + final KeyService _keyService;
29 +
30 + Future<void> importBackup(Uint8List data, String password,
31 + {@required String nonce}) async {
32 + final appDir = await getApplicationDocumentsDirectory();
33 + final decryptedData = await _decrypt(data, password, nonce);
34 + final zip = ZipDecoder().decodeBytes(decryptedData);
35 +
36 + zip.files.forEach((file) {
37 + final filename = file.name;
38 +
39 + if (file.isFile) {
40 + final data = file.content as List<int>;
41 + File('${appDir.path}/' + filename)
42 + ..createSync(recursive: true)
43 + ..writeAsBytesSync(data);
44 + } else {
45 + Directory('${appDir.path}/' + filename)..create(recursive: true);
46 + }
47 +
48 + print(filename);
49 + });
50 +
51 + await importKeychainDump(password, nonce: nonce);
52 + await importPreferencesDump();
53 + }
54 +
55 + Future<void> importPreferencesDump() async {
56 + final appDir = await getApplicationDocumentsDirectory();
57 + final preferencesFile = File('${appDir.path}/~_preferences_dump');
58 +
59 + if (!preferencesFile.existsSync()) {
60 + return;
61 + }
62 +
63 + final data =
64 + json.decode(preferencesFile.readAsStringSync()) as Map<String, Object>;
65 + print('data $data');
66 +
67 + await _sharedPreferences.setString(PreferencesKey.currentWalletName,
68 + data[PreferencesKey.currentWalletName] as String);
69 + await _sharedPreferences.setInt(PreferencesKey.currentNodeIdKey,
70 + data[PreferencesKey.currentNodeIdKey] as int);
71 + await _sharedPreferences.setInt(PreferencesKey.currentBalanceDisplayModeKey,
72 + data[PreferencesKey.currentBalanceDisplayModeKey] as int);
73 + await _sharedPreferences.setInt(PreferencesKey.currentWalletType,
74 + data[PreferencesKey.currentWalletType] as int);
75 + await _sharedPreferences.setString(PreferencesKey.currentFiatCurrencyKey,
76 + data[PreferencesKey.currentFiatCurrencyKey] as String);
77 + await _sharedPreferences.setBool(
78 + PreferencesKey.shouldSaveRecipientAddressKey,
79 + data[PreferencesKey.shouldSaveRecipientAddressKey] as bool);
80 + await _sharedPreferences.setInt(
81 + PreferencesKey.currentTransactionPriorityKey,
82 + data[PreferencesKey.currentTransactionPriorityKey] as int);
83 + await _sharedPreferences.setBool(
84 + PreferencesKey.allowBiometricalAuthenticationKey,
85 + data[PreferencesKey.allowBiometricalAuthenticationKey] as bool);
86 + await _sharedPreferences.setInt(
87 + PreferencesKey.currentBitcoinElectrumSererIdKey,
88 + data[PreferencesKey.currentBitcoinElectrumSererIdKey] as int);
89 + await _sharedPreferences.setInt(PreferencesKey.currentLanguageCode,
90 + data[PreferencesKey.currentLanguageCode] as int);
91 + await _sharedPreferences.setInt(PreferencesKey.displayActionListModeKey,
92 + data[PreferencesKey.displayActionListModeKey] as int);
93 + await _sharedPreferences.setInt(
94 + 'current_theme', data['current_theme'] as int);
95 +
96 + await preferencesFile.delete();
97 + }
98 +
99 + Future<void> importKeychainDump(String password,
100 + {@required String nonce}) async {
101 + final appDir = await getApplicationDocumentsDirectory();
102 + final keychainDumpFile = File('${appDir.path}/~_keychain_dump');
103 + final decryptedKeychainDumpFileData =
104 + await _decrypt(keychainDumpFile.readAsBytesSync(), password, nonce);
105 + final keychainJSON = json.decode(utf8.decode(decryptedKeychainDumpFileData))
106 + as Map<String, dynamic>;
107 + final keychainWalletsInfo = keychainJSON['wallets'] as List;
108 + final decodedPin = keychainJSON['pin'] as String;
109 + final pinCodeKey = generateStoreKeyFor(key: SecretStoreKey.pinCodePassword);
110 +
111 + keychainWalletsInfo.forEach((dynamic rawInfo) async {
112 + final info = rawInfo as Map<String, dynamic>;
113 + await importWalletKeychainInfo(info);
114 + });
115 +
116 + await _flutterSecureStorage.write(
117 + key: pinCodeKey, value: encodedPinCode(pin: decodedPin));
118 +
119 + keychainDumpFile.deleteSync();
120 + }
121 +
122 + Future<void> importWalletKeychainInfo(Map<String, dynamic> info) async {
123 + final name = info['name'] as String;
124 + final password = info['password'] as String;
125 +
126 + await _keyService.saveWalletPassword(walletName: name, password: password);
127 + }
128 +
129 + Future<Uint8List> exportBackup(String password,
130 + {@required String nonce}) async {
131 + final zipEncoder = ZipFileEncoder();
132 + final appDir = await getApplicationDocumentsDirectory();
133 + final now = DateTime.now();
134 + final tmpDir = Directory('${appDir.path}/~_BACKUP_TMP');
135 + final archivePath = '${tmpDir.path}/backup_${now.toString()}.zip';
136 + final fileEntities = appDir.listSync(recursive: false);
137 + final keychainDump = await exportKeychainDump(password, nonce: nonce);
138 + final preferencesDump = await exportPreferencesJSON();
139 + final preferencesDumpFile = File('${tmpDir.path}/~_preferences_dump_TMP');
140 + final keychainDumpFile = File('${tmpDir.path}/~_keychain_dump_TMP');
141 +
142 + if (tmpDir.existsSync()) {
143 + tmpDir.deleteSync(recursive: true);
144 + }
145 +
146 + tmpDir.createSync();
147 + zipEncoder.create(archivePath);
148 +
149 + fileEntities.forEach((entity) {
150 + if (entity.path == archivePath || entity.path == tmpDir.path) {
151 + return;
152 + }
153 +
154 + if (entity.statSync().type == FileSystemEntityType.directory) {
155 + zipEncoder.addDirectory(Directory(entity.path));
156 + } else {
157 + zipEncoder.addFile(File(entity.path));
158 + }
159 + });
160 + await keychainDumpFile.writeAsBytes(keychainDump.toList());
161 + await preferencesDumpFile.writeAsString(preferencesDump);
162 + zipEncoder.addFile(preferencesDumpFile, '~_preferences_dump');
163 + zipEncoder.addFile(keychainDumpFile, '~_keychain_dump');
164 + zipEncoder.close();
165 +
166 + final content = File(archivePath).readAsBytesSync();
167 + tmpDir.deleteSync(recursive: true);
168 +
169 + return await _encrypt(content, password, nonce);
170 + }
171 +
172 + Future<Uint8List> exportKeychainDump(String password,
173 + {@required String nonce}) async {
174 + final key = generateStoreKeyFor(key: SecretStoreKey.pinCodePassword);
175 + final encodedPin = await _flutterSecureStorage.read(key: key);
176 + final decodedPin = decodedPinCode(pin: encodedPin);
177 + final wallets =
178 + await Future.wait(_walletInfoSource.values.map((walletInfo) async {
179 + return {
180 + 'name': walletInfo.name,
181 + 'type': walletInfo.type.toString(),
182 + 'password':
183 + await _keyService.getWalletPassword(walletName: walletInfo.name)
184 + };
185 + }));
186 +
187 + final data =
188 + utf8.encode(json.encode({'pin': decodedPin, 'wallets': wallets}));
189 + final encrypted = await _encrypt(Uint8List.fromList(data), password, nonce);
190 +
191 + return encrypted;
192 + }
193 +
194 + Future<String> exportPreferencesJSON() async {
195 + final preferences = <String, Object>{
196 + PreferencesKey.currentWalletName:
197 + _sharedPreferences.getString(PreferencesKey.currentWalletName),
198 + PreferencesKey.currentNodeIdKey:
199 + _sharedPreferences.getInt(PreferencesKey.currentNodeIdKey),
200 + PreferencesKey.currentBalanceDisplayModeKey: _sharedPreferences
201 + .getInt(PreferencesKey.currentBalanceDisplayModeKey),
202 + PreferencesKey.currentWalletType:
203 + _sharedPreferences.getInt(PreferencesKey.currentWalletType),
204 + PreferencesKey.currentFiatCurrencyKey:
205 + _sharedPreferences.getString(PreferencesKey.currentFiatCurrencyKey),
206 + PreferencesKey.shouldSaveRecipientAddressKey: _sharedPreferences
207 + .getBool(PreferencesKey.shouldSaveRecipientAddressKey),
208 + PreferencesKey.currentDarkTheme:
209 + _sharedPreferences.getBool(PreferencesKey.currentDarkTheme),
210 + PreferencesKey.currentPinLength:
211 + _sharedPreferences.getInt(PreferencesKey.currentPinLength),
212 + PreferencesKey.currentTransactionPriorityKey: _sharedPreferences
213 + .getInt(PreferencesKey.currentTransactionPriorityKey),
214 + PreferencesKey.allowBiometricalAuthenticationKey: _sharedPreferences
215 + .getBool(PreferencesKey.allowBiometricalAuthenticationKey),
216 + PreferencesKey.currentBitcoinElectrumSererIdKey: _sharedPreferences
217 + .getInt(PreferencesKey.currentBitcoinElectrumSererIdKey),
218 + PreferencesKey.currentLanguageCode:
219 + _sharedPreferences.getString(PreferencesKey.currentLanguageCode),
220 + PreferencesKey.displayActionListModeKey:
221 + _sharedPreferences.getInt(PreferencesKey.displayActionListModeKey),
222 + 'currentTheme': _sharedPreferences.getInt('current_theme')
223 + // FIX-ME: Unnamed constant.
224 + };
225 +
226 + return json.encode(preferences);
227 + }
228 +
229 + Future<Uint8List> _encrypt(
230 + Uint8List data, String secretKeySource, String nonceBase64) async {
231 + final secretKeyHash = await sha256.hash(utf8.encode(secretKeySource));
232 + final secretKey = SecretKey(secretKeyHash.bytes);
233 + final nonce = Nonce(base64.decode(nonceBase64));
234 + return await _cipher.encrypt(data, secretKey: secretKey, nonce: nonce);
235 + }
236 +
237 + Future<Uint8List> _decrypt(
238 + Uint8List data, String secretKeySource, String nonceBase64) async {
239 + final secretKeyHash = await sha256.hash(utf8.encode(secretKeySource));
240 + final secretKey = SecretKey(secretKeyHash.bytes);
241 + final nonce = Nonce(base64.decode(nonceBase64));
242 + return await _cipher.decrypt(data, secretKey: secretKey, nonce: nonce);
243 + }
244 +}
lib/di.dart
+51 -10
@@ -1,4 +1,5 @@
1 import 'package:cake_wallet/bitcoin/bitcoin_wallet_service.dart';
2 +import 'package:cake_wallet/core/backup.dart';
3 import 'package:cake_wallet/core/wallet_service.dart';
4 import 'package:cake_wallet/entities/biometric_auth.dart';
5 import 'package:cake_wallet/entities/contact_record.dart';
@@ -10,6 +11,8 @@ import 'package:cake_wallet/entities/contact.dart';
11 import 'package:cake_wallet/entities/node.dart';
12 import 'package:cake_wallet/exchange/trade.dart';
13 import 'package:cake_wallet/reactions/on_authentication_state_change.dart';
14 +import 'package:cake_wallet/src/screens/backup/backup_page.dart';
15 +import 'package:cake_wallet/src/screens/backup/edit_backup_password_page.dart';
16
17 import 'package:cake_wallet/src/screens/contact/contact_list_page.dart';
18 import 'package:cake_wallet/src/screens/contact/contact_page.dart';
@@ -20,6 +23,8 @@ import 'package:cake_wallet/src/screens/nodes/node_create_or_edit_page.dart';
23 import 'package:cake_wallet/src/screens/nodes/nodes_list_page.dart';
24 import 'package:cake_wallet/src/screens/pin_code/pin_code_widget.dart';
25 import 'package:cake_wallet/src/screens/rescan/rescan_page.dart';
26 +import 'package:cake_wallet/src/screens/restore/restore_from_backup_page.dart';
27 +import 'package:cake_wallet/src/screens/restore/restore_options_page.dart';
28 import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart';
29 import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart';
30 import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart';
@@ -32,6 +37,7 @@ import 'package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart';
37 import 'package:cake_wallet/src/screens/exchange/exchange_page.dart';
38 import 'package:cake_wallet/src/screens/exchange/exchange_template_page.dart';
39 import 'package:cake_wallet/store/node_list_store.dart';
40 +import 'package:cake_wallet/store/secret_store.dart';
41 import 'package:cake_wallet/store/settings_store.dart';
42 import 'package:cake_wallet/core/auth_service.dart';
43 import 'package:cake_wallet/core/key_service.dart';
@@ -46,13 +52,16 @@ import 'package:cake_wallet/src/screens/send/send_page.dart';
52 import 'package:cake_wallet/src/screens/subaddress/address_edit_or_create_page.dart';
53 import 'package:cake_wallet/src/screens/wallet_list/wallet_list_page.dart';
54 import 'package:cake_wallet/store/wallet_list_store.dart';
55 +import 'package:cake_wallet/view_model/backup_view_model.dart';
56 import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
57 import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart';
58 +import 'package:cake_wallet/view_model/edit_backup_password_view_model.dart';
59 import 'package:cake_wallet/view_model/exchange/exchange_trade_view_model.dart';
60 import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dart';
61 import 'package:cake_wallet/view_model/node_list/node_list_view_model.dart';
62 import 'package:cake_wallet/view_model/node_list/node_create_or_edit_view_model.dart';
63 import 'package:cake_wallet/view_model/rescan_view_model.dart';
64 +import 'package:cake_wallet/view_model/restore_from_backup_view_model.dart';
65 import 'package:cake_wallet/view_model/setup_pin_code_view_model.dart';
66 import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart';
67 import 'package:cake_wallet/view_model/auth_view_model.dart';
@@ -127,6 +136,11 @@ Future setup(
136 getIt.registerSingleton<ExchangeTemplateStore>(
137 ExchangeTemplateStore(templateSource: exchangeTemplates));
138
139 + final secretStore =
140 + await SecretStoreBase.load(getIt.get<FlutterSecureStorage>());
141 +
142 + getIt.registerSingleton<SecretStore>(secretStore);
143 +
144 getIt.registerFactory<KeyService>(
145 () => KeyService(getIt.get<FlutterSecureStorage>()));
146
@@ -291,17 +305,16 @@ Future setup(
305 getIt.get<MoneroAccountEditOrCreateViewModel>()));*/
306
307 getIt.registerFactoryParam<MoneroAccountEditOrCreateViewModel,
294 - AccountListItem, void>(
295 - (AccountListItem account, _) =>
296 - MoneroAccountEditOrCreateViewModel((
297 - getIt.get<AppStore>().wallet as MoneroWallet).accountList,
298 - accountListItem: account));
299 -
300 - getIt.registerFactoryParam<MoneroAccountEditOrCreatePage,
301 - AccountListItem, void>((AccountListItem account, _) =>
302 - MoneroAccountEditOrCreatePage(
308 + AccountListItem, void>(
309 + (AccountListItem account, _) => MoneroAccountEditOrCreateViewModel(
310 + (getIt.get<AppStore>().wallet as MoneroWallet).accountList,
311 + accountListItem: account));
312 +
313 + getIt.registerFactoryParam<MoneroAccountEditOrCreatePage, AccountListItem,
314 + void>(
315 + (AccountListItem account, _) => MoneroAccountEditOrCreatePage(
316 moneroAccountCreationViewModel:
304 - getIt.get<MoneroAccountEditOrCreateViewModel>(param1: account)));
317 + getIt.get<MoneroAccountEditOrCreateViewModel>(param1: account)));
318
319 getIt.registerFactory(() {
320 final appStore = getIt.get<AppStore>();
@@ -422,4 +435,32 @@ Future setup(
435 transactionDescriptionBox));
436
437 getIt.registerFactory(() => PreSeedPage());
438 +
439 + getIt.registerFactory(() => BackupService(
440 + getIt.get<FlutterSecureStorage>(),
441 + getIt.get<AuthService>(),
442 + walletInfoSource,
443 + getIt.get<KeyService>(),
444 + getIt.get<SharedPreferences>()));
445 +
446 + getIt.registerFactory(() => BackupViewModel(getIt.get<FlutterSecureStorage>(),
447 + getIt.get<SecretStore>(), getIt.get<BackupService>()));
448 +
449 + getIt.registerFactory(() => BackupPage(getIt.get<BackupViewModel>()));
450 +
451 + getIt.registerFactory(() => EditBackupPasswordViewModel(
452 + getIt.get<FlutterSecureStorage>(), getIt.get<SecretStore>())
453 + ..init());
454 +
455 + getIt.registerFactory(
456 + () => EditBackupPasswordPage(getIt.get<EditBackupPasswordViewModel>()));
457 +
458 + getIt.registerFactoryParam<RestoreOptionsPage, WalletType, void>(
459 + (WalletType type, _) => RestoreOptionsPage(type: type));
460 +
461 + getIt.registerFactory(
462 + () => RestoreFromBackupViewModel(getIt.get<BackupService>()));
463 +
464 + getIt.registerFactory(
465 + () => RestoreFromBackupPage(getIt.get<RestoreFromBackupViewModel>()));
466 }
lib/entities/secret_store_key.dart
+13 -3
@@ -1,9 +1,13 @@
1 -enum SecretStoreKey { moneroWalletPassword, pinCodePassword }
1 +enum SecretStoreKey { moneroWalletPassword, pinCodePassword, backupPassword }
2
3 const moneroWalletPassword = "MONERO_WALLET_PASSWORD";
4 const pinCodePassword = "PIN_CODE_PASSWORD";
5 +const backupPassword = "BACKUP_CODE_PASSWORD";
6
6 -String generateStoreKeyFor({SecretStoreKey key, String walletName = "",}) {
7 +String generateStoreKeyFor({
8 + SecretStoreKey key,
9 + String walletName = "",
10 +}) {
11 var _key = "";
12
13 switch (key) {
@@ -19,9 +23,15 @@ String generateStoreKeyFor({SecretStoreKey key, String walletName = "",}) {
23 }
24 break;
25
26 + case SecretStoreKey.backupPassword:
27 + {
28 + _key = backupPassword;
29 + }
30 + break;
31 +
32 default:
33 {}
34 }
35
36 return _key;
27 -}
\ No newline at end of file
37 +}
lib/main.dart
+2
@@ -1,3 +1,5 @@
1 +import 'package:cake_wallet/core/backup.dart';
2 +import 'package:cake_wallet/src/screens/backup/backup_page.dart';
3 import 'package:flutter/material.dart';
4 import 'package:flutter/services.dart';
5 import 'package:hive/hive.dart';
lib/router.dart
+20 -8
@@ -1,6 +1,9 @@
1 import 'package:cake_wallet/entities/contact_record.dart';
2 import 'package:cake_wallet/entities/transaction_description.dart';
3 +import 'package:cake_wallet/src/screens/backup/backup_page.dart';
4 +import 'package:cake_wallet/src/screens/backup/edit_backup_password_page.dart';
5 import 'package:cake_wallet/src/screens/pin_code/pin_code_widget.dart';
6 +import 'package:cake_wallet/src/screens/restore/restore_from_backup_page.dart';
7 import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart';
8 import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart';
9 import 'package:cake_wallet/store/settings_store.dart';
@@ -65,15 +68,13 @@ Route<dynamic> createRoute(RouteSettings settings) {
68 builder: (_) => getIt.get<SetupPinCodePage>(param1:
69 (PinCodeState<PinCodeWidget> context, dynamic _) async {
70 try {
68 - context.changeProcessText(
69 - S.current.creating_new_wallet);
71 + context.changeProcessText(S.current.creating_new_wallet);
72 final newWalletVM =
73 getIt.get<WalletNewVM>(param1: WalletType.monero);
74 await newWalletVM.create(
75 options: 'English'); // FIXME: Unnamed constant
76 context.hideProgressText();
75 - await Navigator.of(context.context)
76 - .pushNamed(Routes.preSeed);
77 + await Navigator.of(context.context).pushNamed(Routes.preSeed);
78 } catch (e) {
79 context.changeProcessText('Error: ${e.toString()}');
80 }
@@ -115,7 +116,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
116 case Routes.restoreOptions:
117 final type = settings.arguments as WalletType;
118 return CupertinoPageRoute<void>(
118 - builder: (_) => RestoreOptionsPage(type: type));
119 + builder: (_) => getIt.get<RestoreOptionsPage>(param1: type));
120
121 case Routes.restoreWalletOptions:
122 final type = WalletType.monero; //settings.arguments as WalletType;
@@ -264,7 +265,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
265 case Routes.accountCreation:
266 return CupertinoPageRoute<String>(
267 builder: (_) => getIt.get<MoneroAccountEditOrCreatePage>(
267 - param1: settings.arguments as AccountListItem));
268 + param1: settings.arguments as AccountListItem));
269
270 case Routes.addressBook:
271 return MaterialPageRoute<void>(
@@ -326,8 +327,19 @@ Route<dynamic> createRoute(RouteSettings settings) {
327 builder: (_) => getIt.get<LanguageListPage>());
328
329 case Routes.preSeed:
329 - return MaterialPageRoute<void>(
330 - builder: (_) => getIt.get<PreSeedPage>());
330 + return MaterialPageRoute<void>(builder: (_) => getIt.get<PreSeedPage>());
331 +
332 + case Routes.backup:
333 + return CupertinoPageRoute<void>(
334 + fullscreenDialog: true, builder: (_) => getIt.get<BackupPage>());
335 +
336 + case Routes.editBackupPassword:
337 + return CupertinoPageRoute<void>(
338 + builder: (_) => getIt.get<EditBackupPasswordPage>());
339 +
340 + case Routes.restoreFromBackup:
341 + return CupertinoPageRoute<void>(
342 + builder: (_) => getIt.get<RestoreFromBackupPage>());
343
344 default:
345 return MaterialPageRoute<void>(
lib/routes.dart
+3
@@ -48,4 +48,7 @@ class Routes {
48 static const restoreWalletType = '/restore_wallet_type';
49 static const restoreWallet = '/restore_wallet';
50 static const preSeed = '/pre_seed';
51 + static const backup = '/backup';
52 + static const editBackupPassword = '/edit_backup_passowrd';
53 + static const restoreFromBackup = '/restore_from_backup';
54 }
\ No newline at end of file
lib/src/screens/backup/backup_page.dart new
+93
@@ -0,0 +1,93 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter/cupertino.dart';
3 +import 'package:flutter_mobx/flutter_mobx.dart';
4 +import 'package:esys_flutter_share/esys_flutter_share.dart';
5 +import 'package:cake_wallet/routes.dart';
6 +import 'package:cake_wallet/generated/i18n.dart';
7 +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
8 +import 'package:cake_wallet/src/widgets/primary_button.dart';
9 +import 'package:cake_wallet/src/widgets/trail_button.dart';
10 +import 'package:cake_wallet/utils/show_pop_up.dart';
11 +import 'package:cake_wallet/view_model/backup_view_model.dart';
12 +import 'package:cake_wallet/core/execution_state.dart';
13 +import 'package:cake_wallet/src/screens/base_page.dart';
14 +
15 +class BackupPage extends BasePage {
16 + BackupPage(this.backupViewModelBase);
17 +
18 + final BackupViewModelBase backupViewModelBase;
19 +
20 + @override
21 + String get title => 'Backup';
22 +
23 + @override
24 + Widget trailing(BuildContext context) => TrailButton(
25 + caption: S.of(context).edit,
26 + onPressed: () =>
27 + Navigator.of(context).pushNamed(Routes.editBackupPassword));
28 +
29 + @override
30 + Widget body(BuildContext context) {
31 + return Stack(
32 + fit: StackFit.expand,
33 + children: [
34 + Center(
35 + child: Container(
36 + padding: EdgeInsets.only(left: 20, right: 20),
37 + height: 225,
38 + child: Column(children: [
39 + Text(
40 + 'Backup password:',
41 + style: TextStyle(fontSize: 30),
42 + ),
43 + Padding(
44 + padding: EdgeInsets.only(top: 20, bottom: 10),
45 + child: Observer(
46 + builder: (_) => Text(
47 + backupViewModelBase.backupPassword,
48 + style: TextStyle(fontSize: 26),
49 + ))),
50 + Padding(
51 + padding: EdgeInsets.all(20),
52 + child: Text(
53 + 'Please write down your Backup Password. Backup Password uses for import of backup files.',
54 + style: TextStyle(fontSize: 14, color: Colors.grey),
55 + textAlign: TextAlign.center,
56 + ))
57 + ]))),
58 + Positioned(
59 + child: Observer(
60 + builder: (_) => LoadingPrimaryButton(
61 + isLoading: backupViewModelBase.state is IsExecutingState,
62 + onPressed: () => onExportBackup(context),
63 + text: 'Export backup',
64 + color: Theme.of(context).accentTextTheme.body2.color,
65 + textColor: Colors.white)),
66 + bottom: 30,
67 + left: 20,
68 + right: 20,
69 + )
70 + ],
71 + );
72 + }
73 +
74 + void onExportBackup(BuildContext context) {
75 + showPopUp<void>(
76 + context: context,
77 + builder: (dialogContext) {
78 + return AlertWithTwoActions(
79 + alertTitle: 'Export backup',
80 + alertContent:
81 + 'Please be sure that you have saved your Backup Password.You will be no available to import backup files without Backup Password.\n\nHave you written it down?',
82 + rightButtonText: S.of(context).seed_alert_yes,
83 + leftButtonText: S.of(context).seed_alert_back,
84 + actionRightButton: () async {
85 + Navigator.of(dialogContext).pop();
86 + final backup = await backupViewModelBase.exportBackup();
87 + await Share.file(
88 + 'Backup file', backup.name, backup.content, 'text');
89 + },
90 + actionLeftButton: () => Navigator.of(dialogContext).pop());
91 + });
92 + }
93 +}
lib/src/screens/backup/edit_backup_password_page.dart new
+73
@@ -0,0 +1,73 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter/cupertino.dart';
3 +import 'package:flutter_mobx/flutter_mobx.dart';
4 +import 'package:cake_wallet/generated/i18n.dart';
5 +import 'package:cake_wallet/utils/show_pop_up.dart';
6 +import 'package:cake_wallet/src/screens/base_page.dart';
7 +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart';
8 +import 'package:cake_wallet/src/widgets/primary_button.dart';
9 +import 'package:cake_wallet/view_model/edit_backup_password_view_model.dart';
10 +
11 +class EditBackupPasswordPage extends BasePage {
12 + EditBackupPasswordPage(this.editBackupPasswordViewModel)
13 + : textEditingController = TextEditingController() {
14 + textEditingController.text = editBackupPasswordViewModel.backupPassword;
15 + textEditingController.addListener(() => editBackupPasswordViewModel
16 + .backupPassword = textEditingController.text);
17 + }
18 +
19 + final EditBackupPasswordViewModel editBackupPasswordViewModel;
20 + final TextEditingController textEditingController;
21 +
22 + @override
23 + String get title => 'Edit Backup Password';
24 +
25 + @override
26 + Widget body(BuildContext context) {
27 + return Padding(
28 + padding: EdgeInsets.only(left: 20, right: 20),
29 + child: Stack(
30 + fit: StackFit.expand,
31 + children: [
32 + Center(
33 + child: Observer(
34 + builder: (_) => TextFormField(
35 + autofocus: true,
36 + enableSuggestions: false,
37 + autocorrect: false,
38 + keyboardType: TextInputType.visiblePassword,
39 + controller: textEditingController,
40 + style: TextStyle(fontSize: 26, color: Colors.black)))),
41 + Positioned(
42 + child: Observer(
43 + builder: (_) => PrimaryButton(
44 + onPressed: () => onSave(context),
45 + text: 'Save',
46 + color: Theme.of(context).accentTextTheme.body2.color,
47 + textColor: Colors.white,
48 + isDisabled: !editBackupPasswordViewModel.canSave)),
49 + bottom: 30,
50 + left: 0,
51 + right: 0)
52 + ],
53 + ));
54 + }
55 +
56 + void onSave(BuildContext context) {
57 + showPopUp<void>(
58 + context: context,
59 + builder: (dialogContext) {
60 + return AlertWithTwoActions(
61 + alertTitle: 'Save backup password',
62 + alertContent:
63 + 'Your previous backup files will be not available to import with new backup password. New backup password will be used only for new backup files. Are you sure that you want to change backup password ?',
64 + rightButtonText: S.of(context).ok,
65 + leftButtonText: S.of(context).cancel,
66 + actionRightButton: () async {
67 + await editBackupPasswordViewModel.save();
68 + Navigator.of(dialogContext)..pop()..pop();
69 + },
70 + actionLeftButton: () => Navigator.of(dialogContext).pop());
71 + });
72 + }
73 +}
lib/src/screens/restore/restore_from_backup_page.dart new
+88
@@ -0,0 +1,88 @@
1 +import 'package:cake_wallet/generated/i18n.dart';
2 +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
3 +import 'package:cake_wallet/utils/show_pop_up.dart';
4 +import 'package:flutter/material.dart';
5 +import 'package:flutter/src/widgets/framework.dart';
6 +import 'package:cake_wallet/src/widgets/primary_button.dart';
7 +import 'package:cake_wallet/view_model/restore_from_backup_view_model.dart';
8 +import 'package:cake_wallet/src/screens/base_page.dart';
9 +import 'package:file_picker/file_picker.dart';
10 +
11 +class RestoreFromBackupPage extends BasePage {
12 + RestoreFromBackupPage(this.restoreFromBackupViewModel)
13 + : textEditingController = TextEditingController();
14 +
15 + final RestoreFromBackupViewModel restoreFromBackupViewModel;
16 + final TextEditingController textEditingController;
17 +
18 + @override
19 + String get title => 'Restore from backup';
20 +
21 + @override
22 + Widget body(BuildContext context) {
23 + return Container(
24 + padding: EdgeInsets.only(bottom: 30, left: 25, right: 25),
25 + child: Column(children: [
26 + Expanded(
27 + child: Container(
28 + child: Center(
29 + child: TextFormField(
30 + obscureText: true,
31 + enableSuggestions: false,
32 + autocorrect: false,
33 + decoration: InputDecoration(
34 + hintText: 'Enter backup password here'),
35 + keyboardType: TextInputType.visiblePassword,
36 + controller: textEditingController,
37 + style: TextStyle(fontSize: 26, color: Colors.black))),
38 + ),
39 + ),
40 + Container(
41 + child: Row(children: [
42 + Expanded(
43 + child: PrimaryButton(
44 + onPressed: () => presentFilePicker(),
45 + text: 'Select backup file',
46 + color: Colors.grey,
47 + textColor: Colors.white)),
48 + SizedBox(width: 20),
49 + Expanded(
50 + child: PrimaryButton(
51 + onPressed: () => onImportHandler(context),
52 + text: 'Import',
53 + color: Theme.of(context).accentTextTheme.body2.color,
54 + textColor: Colors.white))
55 + ])),
56 + ]));
57 + }
58 +
59 + Future<void> presentFilePicker() async {
60 + final result = await FilePicker.platform.pickFiles();
61 +
62 + if (result?.files?.isEmpty ?? true) {
63 + return;
64 + }
65 +
66 + restoreFromBackupViewModel.filePath = result.files.first.path;
67 + }
68 +
69 + Future<void> onImportHandler(BuildContext context) async {
70 + if (textEditingController.text.isEmpty ||
71 + (restoreFromBackupViewModel.filePath.isEmpty ?? true)) {
72 + await showPopUp<void>(
73 + context: context,
74 + builder: (_) {
75 + return AlertWithOneAction(
76 + alertTitle: S.current.error,
77 + alertContent:
78 + 'Please select backup file and enter backup password.',
79 + buttonText: S.of(context).ok,
80 + buttonAction: () => Navigator.of(context).pop());
81 + });
82 +
83 + return;
84 + }
85 +
86 + await restoreFromBackupViewModel.import(textEditingController.text);
87 + }
88 +}
lib/src/screens/restore/restore_options_page.dart
+2 -2
@@ -1,6 +1,5 @@
1 import 'package:cake_wallet/entities/wallet_type.dart';
2 import 'package:flutter/material.dart';
3 -import 'package:cake_wallet/palette.dart';
3 import 'package:cake_wallet/routes.dart';
4 import 'package:flutter/cupertino.dart';
5 import 'package:cake_wallet/src/screens/restore/widgets/restore_button.dart';
@@ -39,7 +38,8 @@ class RestoreOptionsPage extends BasePage {
38 Padding(
39 padding: EdgeInsets.only(top: 24),
40 child: RestoreButton(
42 - onPressed: () {},
41 + onPressed: () => Navigator.pushNamed(
42 + context, Routes.restoreFromBackup),
43 image: imageBackup,
44 title: S.of(context).restore_title_from_backup,
45 description: S.of(context).restore_description_from_backup
lib/src/widgets/trail_button.dart
+1 -1
@@ -21,7 +21,7 @@ class TrailButton extends StatelessWidget {
21 child: Text(
22 caption,
23 style: TextStyle(
24 - color: Theme.of(context).textTheme.subhead.decorationColor,
24 + color: Theme.of(context).textTheme.display1.color,
25 fontWeight: FontWeight.w500,
26 fontSize: 14),
27 ),
lib/store/secret_store.dart new
+28
@@ -0,0 +1,28 @@
1 +import 'package:cake_wallet/entities/secret_store_key.dart';
2 +import 'package:flutter/foundation.dart';
3 +import 'package:flutter_secure_storage/flutter_secure_storage.dart';
4 +import 'package:mobx/mobx.dart';
5 +
6 +part 'secret_store.g.dart';
7 +
8 +class SecretStore = SecretStoreBase with _$SecretStore;
9 +
10 +abstract class SecretStoreBase with Store {
11 + static Future<SecretStore> load(FlutterSecureStorage storage) async {
12 + final secretStore = SecretStore();
13 + final backupPasswordKey = generateStoreKeyFor(key: SecretStoreKey.backupPassword);
14 + final backupPassword = await storage.read(key: backupPasswordKey);
15 + secretStore.write(key: backupPasswordKey, value: backupPassword);
16 +
17 + return secretStore;
18 + }
19 +
20 + SecretStoreBase() : values = ObservableMap<String, String>();
21 +
22 + ObservableMap values;
23 +
24 + String read(String key) => values[key] as String;
25 +
26 + String write({@required String key, @required String value}) =>
27 + values[key] = value;
28 +}
lib/view_model/backup_view_model.dart new
+68
@@ -0,0 +1,68 @@
1 +import 'package:cake_wallet/core/backup.dart';
2 +import 'package:cake_wallet/core/execution_state.dart';
3 +import 'package:cake_wallet/entities/secret_store_key.dart';
4 +import 'package:cake_wallet/store/secret_store.dart';
5 +import 'package:flutter/foundation.dart';
6 +import 'package:flutter_secure_storage/flutter_secure_storage.dart';
7 +import 'package:mobx/mobx.dart';
8 +
9 +part 'backup_view_model.g.dart';
10 +
11 +class BackupExportFile {
12 + BackupExportFile(this.content, {@required this.name});
13 +
14 + final String name;
15 + final List<int> content;
16 +}
17 +
18 +class BackupViewModel = BackupViewModelBase with _$BackupViewModel;
19 +
20 +abstract class BackupViewModelBase with Store {
21 + BackupViewModelBase(this.secureStorage, this.secretStore, this.backupService)
22 + : isBackupPasswordVisible = false {
23 + state = InitialExecutionState();
24 + final key = generateStoreKeyFor(key: SecretStoreKey.backupPassword);
25 + secretStore.values.observe((change) {
26 + if (change.key == key) {
27 + backupPassword = secretStore.read(key);
28 + }
29 + }, fireImmediately: true);
30 + }
31 +
32 + final FlutterSecureStorage secureStorage;
33 + final SecretStore secretStore;
34 + final BackupService backupService;
35 +
36 + @observable
37 + ExecutionState state;
38 +
39 + @observable
40 + bool isBackupPasswordVisible;
41 +
42 + @observable
43 + String backupPassword;
44 +
45 + @action
46 + Future<void> init() async {
47 + final key = generateStoreKeyFor(key: SecretStoreKey.backupPassword);
48 + backupPassword = await secureStorage.read(key: key);
49 + }
50 +
51 + @action
52 + Future<BackupExportFile> exportBackup() async {
53 + try {
54 + state = IsExecutingState();
55 + final backupContent = await backupService.exportBackup('', nonce: '');
56 + state = ExecutedSuccessfullyState();
57 +
58 + return BackupExportFile(backupContent.toList(),
59 + name: 'backup_${DateTime.now().toString()}.zip');
60 + } catch (e) {
61 + print(e.toString());
62 + state = FailureState(e.toString());
63 + }
64 + }
65 +
66 + @action
67 + void showMasterPassword() => isBackupPasswordVisible = true;
68 +}
lib/view_model/edit_backup_password_view_model.dart new
+44
@@ -0,0 +1,44 @@
1 +import 'package:mobx/mobx.dart';
2 +import 'package:flutter_secure_storage/flutter_secure_storage.dart';
3 +import 'package:cake_wallet/entities/secret_store_key.dart';
4 +import 'package:cake_wallet/store/secret_store.dart';
5 +
6 +part 'edit_backup_password_view_model.g.dart';
7 +
8 +class EditBackupPasswordViewModel = EditBackupPasswordViewModelBase
9 + with _$EditBackupPasswordViewModel;
10 +
11 +abstract class EditBackupPasswordViewModelBase with Store {
12 + EditBackupPasswordViewModelBase(this.secureStorage, this.secretStore) {
13 + final key = generateStoreKeyFor(key: SecretStoreKey.backupPassword);
14 + backupPassword = secretStore.read(key);
15 + }
16 +
17 + final FlutterSecureStorage secureStorage;
18 + final SecretStore secretStore;
19 +
20 + @observable
21 + String backupPassword;
22 +
23 + @computed
24 + bool get canSave {
25 + return !(_originalPassword == backupPassword);
26 + }
27 +
28 + String _originalPassword;
29 +
30 + @action
31 + Future<void> init() async {
32 + final key = generateStoreKeyFor(key: SecretStoreKey.backupPassword);
33 + final password = await secureStorage.read(key: key);
34 + _originalPassword = password;
35 + backupPassword = password;
36 + }
37 +
38 + @action
39 + Future<void> save() async {
40 + final key = generateStoreKeyFor(key: SecretStoreKey.backupPassword);
41 + await secureStorage.write(key: key, value: backupPassword);
42 + secretStore.write(key: key, value: backupPassword);
43 + }
44 +}
lib/view_model/restore_from_backup_view_model.dart new
+29
@@ -0,0 +1,29 @@
1 +import 'dart:io';
2 +
3 +import 'package:cake_wallet/core/backup.dart';
4 +import 'package:mobx/mobx.dart';
5 +
6 +part 'restore_from_backup_view_model.g.dart';
7 +
8 +class RestoreFromBackupViewModel = RestoreFromBackupViewModelBase with _$RestoreFromBackupViewModel;
9 +
10 +abstract class RestoreFromBackupViewModelBase with Store {
11 + RestoreFromBackupViewModelBase(this.backupService);
12 +
13 + @observable
14 + String filePath;
15 +
16 + final BackupService backupService;
17 +
18 + Future<void> import(String password) async {
19 + if (filePath?.isEmpty ?? true) {
20 + // FIXME: throw exception;
21 + return;
22 + }
23 +
24 + final file = File(filePath);
25 + final data = await file.readAsBytes();
26 +
27 + await backupService.importBackup(data, password, nonce: null);
28 + }
29 +}
\ No newline at end of file
pubspec.lock
+15 -1
@@ -16,7 +16,7 @@ packages:
16 source: hosted
17 version: "0.40.6"
18 archive:
19 - dependency: transitive
19 + dependency: "direct main"
20 description:
21 name: archive
22 url: "https://pub.dartlang.org"
@@ -253,6 +253,13 @@ packages:
253 url: "https://pub.dartlang.org"
254 source: hosted
255 version: "2.1.5"
256 + cryptography:
257 + dependency: "direct main"
258 + description:
259 + name: cryptography
260 + url: "https://pub.dartlang.org"
261 + source: hosted
262 + version: "1.4.1"
263 cupertino_icons:
264 dependency: "direct main"
265 description:
@@ -344,6 +351,13 @@ packages:
351 url: "https://pub.dartlang.org"
352 source: hosted
353 version: "5.2.1"
354 + file_picker:
355 + dependency: "direct main"
356 + description:
357 + name: file_picker
358 + url: "https://pub.dartlang.org"
359 + source: hosted
360 + version: "2.1.4"
361 fixnum:
362 dependency: transitive
363 description:
pubspec.yaml
+3
@@ -67,6 +67,9 @@ dependencies:
67 connectivity: ^0.4.9+2
68 keyboard_actions: ^3.3.0
69 flushbar: ^1.10.4
70 + archive: ^2.0.13
71 + cryptography: ^1.4.0
72 + file_picker: ^2.1.4
73
74 dev_dependencies:
75 flutter_test: