| 1 | import 'dart:convert'; |
| 2 | import 'dart:io'; |
| 3 | import 'dart:typed_data'; |
| 4 | |
| 5 | import 'package:archive/archive_io.dart'; |
| 6 | import 'package:cake_wallet/core/backup_service.dart'; |
| 7 | import 'package:cake_wallet/.secrets.g.dart' as secrets; |
| 8 | import 'package:cake_backup/backup.dart' as cake_backup; |
| 9 | import 'package:cake_wallet/utils/package_info.dart'; |
| 10 | import 'package:crypto/crypto.dart'; |
| 11 | import 'package:cw_core/db/sqlite.dart'; |
| 12 | import 'package:cw_core/root_dir.dart'; |
| 13 | import 'package:cw_core/utils/print_verbose.dart'; |
| 14 | import 'package:cw_core/wallet_info.dart'; |
| 15 | import 'package:cw_core/wallet_type.dart'; |
| 16 | import 'package:flutter/foundation.dart'; |
| 17 | |
| 18 | enum BackupVersion { |
| 19 | unknown, // index 0 |
| 20 | v1, |
| 21 | v2, |
| 22 | v3, |
| 23 | } |
| 24 | |
| 25 | class ChunkChecksum { |
| 26 | ChunkChecksum({ |
| 27 | required this.encrypted, |
| 28 | required this.plain, |
| 29 | }); |
| 30 | |
| 31 | final String encrypted; |
| 32 | final String plain; |
| 33 | |
| 34 | factory ChunkChecksum.fromJson(Map<String, dynamic> json) { |
| 35 | return ChunkChecksum( |
| 36 | encrypted: json['encrypted'] as String, |
| 37 | plain: json['plain'] as String, |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | Map<String, dynamic> toJson() { |
| 42 | return { |
| 43 | 'encrypted': encrypted, |
| 44 | 'plain': plain, |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | @override |
| 49 | String toString() { |
| 50 | return 'ChunkChecksum(encrypted: $encrypted, plain: $plain)'; |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | class ChunkLength { |
| 55 | ChunkLength({ |
| 56 | required this.encrypted, |
| 57 | required this.plain, |
| 58 | }); |
| 59 | |
| 60 | final int encrypted; |
| 61 | final int plain; |
| 62 | |
| 63 | factory ChunkLength.fromJson(Map<String, dynamic> json) { |
| 64 | return ChunkLength( |
| 65 | encrypted: json['encrypted'] as int, |
| 66 | plain: json['plain'] as int, |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | Map<String, dynamic> toJson() { |
| 71 | return { |
| 72 | 'encrypted': encrypted, |
| 73 | 'plain': plain, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | @override |
| 78 | String toString() { |
| 79 | return 'ChunkLength(encrypted: $encrypted, plain: $plain)'; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | class ChunkDetails { |
| 84 | ChunkDetails({ |
| 85 | required this.sha512sum, |
| 86 | required this.length, |
| 87 | }); |
| 88 | |
| 89 | final ChunkChecksum sha512sum; |
| 90 | final ChunkLength length; |
| 91 | |
| 92 | factory ChunkDetails.fromJson(Map<String, dynamic> json) { |
| 93 | return ChunkDetails( |
| 94 | sha512sum: ChunkChecksum.fromJson(json['sha512sum'] as Map<String, dynamic>), |
| 95 | length: ChunkLength.fromJson(json['length'] as Map<String, dynamic>), |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | Map<String, dynamic> toJson() { |
| 100 | return { |
| 101 | 'sha512sum': sha512sum, |
| 102 | 'length': length, |
| 103 | }; |
| 104 | } |
| 105 | |
| 106 | @override |
| 107 | String toString() { |
| 108 | return 'ChunkDetails(sha512sum: $sha512sum, length: $length)'; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | class BackupMetadata { |
| 113 | BackupMetadata({ |
| 114 | required this.version, |
| 115 | required this.sha512sum, |
| 116 | required this.chunks, |
| 117 | required this.cakeVersion, |
| 118 | }); |
| 119 | |
| 120 | final BackupVersion version; |
| 121 | String sha512sum; |
| 122 | final List<ChunkDetails> chunks; |
| 123 | String cakeVersion; |
| 124 | factory BackupMetadata.fromJson(Map<String, dynamic> json) { |
| 125 | return BackupMetadata( |
| 126 | version: BackupVersion.values[json['version'] as int], |
| 127 | sha512sum: json['sha512sum'] as String, |
| 128 | chunks: (json['chunks'] as List<dynamic>) |
| 129 | .map((chunk) => ChunkDetails.fromJson(chunk as Map<String, dynamic>)) |
| 130 | .toList(), |
| 131 | cakeVersion: json['cakeVersion'] as String, |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | Map<String, dynamic> toJson() { |
| 136 | return { |
| 137 | 'version': version.index, |
| 138 | 'sha512sum': sha512sum, |
| 139 | 'chunks': chunks.map((chunk) => chunk.toJson()).toList(), |
| 140 | 'cakeVersion': cakeVersion, |
| 141 | }; |
| 142 | } |
| 143 | |
| 144 | @override |
| 145 | String toString() { |
| 146 | return 'BackupMetadata(version: $version, sha512sum: $sha512sum, chunks: $chunks)'; |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | class IncompatibleBackupAppException implements Exception { |
| 151 | IncompatibleBackupAppException({ |
| 152 | required this.sourceAppName, |
| 153 | required this.currentAppName, |
| 154 | }); |
| 155 | |
| 156 | final String sourceAppName; |
| 157 | final String currentAppName; |
| 158 | |
| 159 | @override |
| 160 | String toString() { |
| 161 | return 'This backup was created in $sourceAppName and cannot be restored in $currentAppName.'; |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | class BackupServiceV3 extends $BackupService { |
| 166 | BackupServiceV3(super.secureStorage, super.transactionDescriptionBox, super.keyService, |
| 167 | super.sharedPreferences); |
| 168 | |
| 169 | static BackupVersion get currentVersion => BackupVersion.v3; |
| 170 | |
| 171 | Future<File> exportBackupFile(String password, {String nonce = secrets.backupSalt}) { |
| 172 | return exportBackupFileV3(password, nonce: nonce); |
| 173 | } |
| 174 | |
| 175 | BackupVersion getVersionFile(File data) { |
| 176 | final raf = data.openSync(mode: FileMode.read); |
| 177 | |
| 178 | try { |
| 179 | // Read first 4 bytes to check both version and zip signature |
| 180 | final buffer = Uint8List(1); |
| 181 | final bytesRead = raf.readIntoSync(buffer); |
| 182 | |
| 183 | if (bytesRead == 0) { |
| 184 | throw Exception('Invalid backup file: empty file'); |
| 185 | } |
| 186 | |
| 187 | // Check if first byte is version 1 or 2 |
| 188 | if (buffer[0] == 1) { |
| 189 | return BackupVersion.v1; |
| 190 | } else if (buffer[0] == 2) { |
| 191 | return BackupVersion.v2; |
| 192 | } else if (buffer[0] == 0x50) { |
| 193 | // $ head -c 64 test-archive.zip | hexdump -C |
| 194 | // 00000000 50 4b 03 04 .... |
| 195 | // Here we just check if the first byte is the zip signature |
| 196 | // Inside of v3 backup we have multiple files. |
| 197 | // Check metadata.json for version in v3 backup |
| 198 | final inputStream = InputFileStream(data.path); |
| 199 | final archive = ZipDecoder().decodeStream(inputStream); |
| 200 | final metadataFile = archive.findFile('metadata.json'); |
| 201 | if (metadataFile == null) { |
| 202 | return BackupVersion.unknown; |
| 203 | } |
| 204 | final metadataBytes = metadataFile.rawContent!.readBytes(); |
| 205 | final metadataString = utf8.decode(metadataBytes); |
| 206 | final metadataJsonRaw = json.decode(metadataString) as Map<String, dynamic>; |
| 207 | final metadata = BackupMetadata.fromJson(metadataJsonRaw); |
| 208 | if (metadata.version == BackupVersion.v3) { |
| 209 | return BackupVersion.v3; |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | return BackupVersion.unknown; |
| 214 | } finally { |
| 215 | raf.closeSync(); |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | Future<void> importBackupFile(File file, String password, |
| 220 | {String nonce = secrets.backupSalt, bool checkBackupApp = true}) { |
| 221 | final version = getVersionFile(file); |
| 222 | switch (version) { |
| 223 | case BackupVersion.unknown: |
| 224 | throw Exception('unknown_backup_version'); |
| 225 | case BackupVersion.v1: |
| 226 | final data = file.readAsBytesSync(); |
| 227 | final backupBytes = data.toList()..removeAt(0); |
| 228 | final backupData = Uint8List.fromList(backupBytes); |
| 229 | return super.importBackupV1(backupData, password, nonce: nonce); |
| 230 | case BackupVersion.v2: |
| 231 | return super.importBackupV2(file.readAsBytesSync(), password); |
| 232 | case BackupVersion.v3: |
| 233 | return importBackupFileV3(file, password, nonce: nonce, checkBackupApp: checkBackupApp); |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | Future<void> importBackupFileV3(File file, String password, |
| 238 | {String nonce = secrets.backupSalt, bool checkBackupApp = true}) async { |
| 239 | // Overall design of v3 backup is the following: |
| 240 | // 1. backup.zip - plaintext zip file that user can open with any archive manager |
| 241 | // 2. backup.zip/README.txt - text file to let user know what is inside of this file |
| 242 | // 3. backup.zip/metadata.json - json file with metadata about backup. |
| 243 | // 4. backup.zip/data.bin - v2 backup file |
| 244 | |
| 245 | final inputStream = InputFileStream(file.path); |
| 246 | final archive = ZipDecoder().decodeStream(inputStream); |
| 247 | |
| 248 | if (checkBackupApp) { |
| 249 | await _throwIfBackupWasCreatedInAnotherApp(archive); |
| 250 | } |
| 251 | |
| 252 | final metadataFile = archive.findFile('metadata.json'); |
| 253 | if (metadataFile == null) { |
| 254 | throw Exception('Invalid v3 backup: missing metadata.json'); |
| 255 | } |
| 256 | final metadataBytes = metadataFile.rawContent!.readBytes(); |
| 257 | final metadataString = utf8.decode(metadataBytes); |
| 258 | final metadataJsonRaw = json.decode(metadataString) as Map<String, dynamic>; |
| 259 | final metadata = BackupMetadata.fromJson(metadataJsonRaw); |
| 260 | |
| 261 | final dataFile = archive.findFile('data.bin'); |
| 262 | if (dataFile == null) { |
| 263 | throw Exception('Invalid v3 backup: missing data.bin'); |
| 264 | } |
| 265 | final dataStream = dataFile.rawContent!.getStream(); |
| 266 | |
| 267 | final decryptedData = File('${file.path}_decrypted'); // decrypted zip file |
| 268 | if (decryptedData.existsSync()) { |
| 269 | decryptedData.deleteSync(); |
| 270 | } |
| 271 | decryptedData.createSync(recursive: true); |
| 272 | decryptedData.writeAsBytesSync(Uint8List(0), mode: FileMode.write, flush: true); |
| 273 | |
| 274 | int chunkIndex = 0; |
| 275 | for (var chunk in metadata.chunks) { |
| 276 | chunkIndex++; |
| 277 | final chunkBytes = dataStream.readBytes(chunk.length.encrypted).toUint8List(); |
| 278 | final chunkChecksum = (await sha512.bind(Stream.fromIterable([chunkBytes])).first).toString(); |
| 279 | |
| 280 | // readBytes stores position internally, so we don't need to think about it. |
| 281 | if (chunk.sha512sum.encrypted != chunkChecksum) { |
| 282 | throw Exception( |
| 283 | 'Invalid v3 backup: chunk (${chunk.length.encrypted} bytes) checksum mismatch at index $chunkIndex\n' |
| 284 | 'expected: ${chunk.sha512sum.encrypted}\n' |
| 285 | 'got: $chunkChecksum'); |
| 286 | } |
| 287 | final decryptedChunk = await cake_backup.decrypt(password, chunkBytes); |
| 288 | decryptedData.writeAsBytesSync(decryptedChunk, mode: FileMode.append, flush: true); |
| 289 | } |
| 290 | |
| 291 | final sha512sum = (await sha512.bind(decryptedData.openRead()).first).toString(); |
| 292 | if (sha512sum.toString() != metadata.sha512sum) { |
| 293 | throw Exception('Invalid v3 backup: SHA512 checksum mismatch\n' |
| 294 | 'expected: ${metadata.sha512sum}\n' |
| 295 | 'got: $sha512sum'); |
| 296 | } |
| 297 | |
| 298 | // Decryption done, now we can import the backup (that is, unzip app data) |
| 299 | |
| 300 | // archive is **NOT** backup, it is just a zip file that contains data.bin inside. |
| 301 | // We need to unzip it to get the backup. |
| 302 | // data.bin after decryption is available in decryptedData. |
| 303 | |
| 304 | final zip = ZipDecoder(); |
| 305 | final decryptedDataStream = InputFileStream(decryptedData.path); |
| 306 | final backupArchive = zip.decodeStream(decryptedDataStream); |
| 307 | |
| 308 | final appDir = await getAppDir(); |
| 309 | |
| 310 | outer: |
| 311 | for (var file in backupArchive.files) { |
| 312 | final filename = file.name; |
| 313 | for (var ignore in $BackupService.ignoreFiles) { |
| 314 | if (filename.endsWith(ignore) && !filename.contains("wallets/")) { |
| 315 | printV("ignoring backup file: $filename"); |
| 316 | continue outer; |
| 317 | } |
| 318 | } |
| 319 | printV("restoring: $filename"); |
| 320 | if (file.isFile) { |
| 321 | final output = File('${appDir.path}/' + filename)..createSync(recursive: true); |
| 322 | final outputStream = OutputFileStream(output.path); |
| 323 | file.writeContent(outputStream); |
| 324 | outputStream.flush(); |
| 325 | } else { |
| 326 | final dir = Directory('${appDir.path}/' + filename); |
| 327 | if (!dir.existsSync()) { |
| 328 | dir.createSync(recursive: true); |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | ; |
| 333 | |
| 334 | // Continue importing the backup the old way |
| 335 | await super.verifyWallets(); |
| 336 | await verifyHardwareWallets(password); |
| 337 | await super.importKeychainDumpV2(password); |
| 338 | await super.importPreferencesDump(); |
| 339 | await super.importTransactionDescriptionDump(); |
| 340 | |
| 341 | // Delete decrypted data file |
| 342 | decryptedData.deleteSync(); |
| 343 | await initDb(); |
| 344 | } |
| 345 | |
| 346 | Future<void> verifyHardwareWallets(String password, |
| 347 | {String keychainSalt = secrets.backupKeychainSalt}) async { |
| 348 | final appDir = await getAppDir(); |
| 349 | final keychainDumpFile = File('${appDir.path}/~_keychain_dump'); |
| 350 | final decryptedKeychainDumpFileData = |
| 351 | await decryptV2(keychainDumpFile.readAsBytesSync(), '$keychainSalt$password'); |
| 352 | final keychainJSON = |
| 353 | json.decode(utf8.decode(decryptedKeychainDumpFileData)) as Map<String, dynamic>; |
| 354 | final keychainWalletsInfo = keychainJSON['wallets'] as List; |
| 355 | |
| 356 | final expectedHardwareWallets = keychainWalletsInfo |
| 357 | .where((e) => |
| 358 | (e as Map<String, dynamic>).containsKey("hardwareWalletType") && |
| 359 | e["hardwareWalletType"] != null) |
| 360 | .toList(); |
| 361 | |
| 362 | for (final expectedHardwareWallet in expectedHardwareWallets) { |
| 363 | final info = expectedHardwareWallet as Map<String, dynamic>; |
| 364 | final actualWalletInfo = await WalletInfo.get(info['name'] as String, |
| 365 | WalletType.values.firstWhere((e) => e.toString() == info['type'] as String)); |
| 366 | if (actualWalletInfo != null && |
| 367 | info["hardwareWalletType"] != actualWalletInfo.hardwareWalletType?.index) { |
| 368 | actualWalletInfo.hardwareWalletType = |
| 369 | HardwareWalletType.values[info["hardwareWalletType"] as int]; |
| 370 | await actualWalletInfo.save(); |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | Future<File> exportBackupFileV3(String password, {String nonce = secrets.backupSalt}) async { |
| 376 | final metadata = BackupMetadata( |
| 377 | version: BackupVersion.v3, |
| 378 | sha512sum: 'tbd', |
| 379 | chunks: [], |
| 380 | cakeVersion: 'tbd', |
| 381 | ); |
| 382 | final zipEncoder = ZipFileEncoder(); |
| 383 | final appDir = await getAppDir(); |
| 384 | final now = DateTime.now().toIso8601String().replaceAll(':', '-'); |
| 385 | final tmpDir = Directory('${appDir.path}/~_BACKUP_TMP'); |
| 386 | final archivePath = '${tmpDir.path}/backup_${now}.tmp.zip'; |
| 387 | final archivePathExport = '${tmpDir.path}/backup_${now}.zip'; |
| 388 | final fileEntities = appDir.listSync(recursive: false); |
| 389 | final keychainDump = await super.exportKeychainDumpV2(password); |
| 390 | final preferencesDump = await super.exportPreferencesJSON(); |
| 391 | final preferencesDumpFile = File('${tmpDir.path}/~_preferences_dump_TMP'); |
| 392 | final keychainDumpFile = File('${tmpDir.path}/~_keychain_dump_TMP'); |
| 393 | final transactionDescriptionDumpFile = |
| 394 | File('${tmpDir.path}/~_transaction_descriptions_dump_TMP'); |
| 395 | |
| 396 | final transactionDescriptionData = super |
| 397 | .transactionDescriptionBox |
| 398 | .toMap() |
| 399 | .map((key, value) => MapEntry(key.toString(), value.toJson())); |
| 400 | final transactionDescriptionDump = jsonEncode(transactionDescriptionData); |
| 401 | |
| 402 | if (tmpDir.existsSync()) { |
| 403 | tmpDir.deleteSync(recursive: true); |
| 404 | } |
| 405 | |
| 406 | tmpDir.createSync(); |
| 407 | zipEncoder.create(archivePath); |
| 408 | outer: |
| 409 | for (var entity in fileEntities) { |
| 410 | if (entity.path == archivePath || entity.path == tmpDir.path) { |
| 411 | continue; |
| 412 | } |
| 413 | for (var ignore in $BackupService.ignoreFiles) { |
| 414 | final filename = entity.absolute.path; |
| 415 | if (filename.endsWith(ignore) && !filename.contains("wallets/")) { |
| 416 | printV("ignoring backup file: $filename"); |
| 417 | continue outer; |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | if (entity.statSync().type == FileSystemEntityType.directory) { |
| 422 | await zipEncoder.addDirectory(Directory(entity.path)); |
| 423 | } else { |
| 424 | await zipEncoder.addFile(File(entity.path)); |
| 425 | } |
| 426 | } |
| 427 | await keychainDumpFile.writeAsBytes(keychainDump.toList()); |
| 428 | await preferencesDumpFile.writeAsString(preferencesDump); |
| 429 | await transactionDescriptionDumpFile.writeAsString(transactionDescriptionDump); |
| 430 | await zipEncoder.addFile(preferencesDumpFile, '~_preferences_dump'); |
| 431 | await zipEncoder.addFile(keychainDumpFile, '~_keychain_dump'); |
| 432 | await zipEncoder.addFile(transactionDescriptionDumpFile, '~_transaction_descriptions_dump'); |
| 433 | await zipEncoder.close(); |
| 434 | |
| 435 | final dataBinUnencrypted = File(archivePath); |
| 436 | |
| 437 | final dataBin = File('${tmpDir.path}/data.bin'); |
| 438 | dataBin.writeAsBytesSync(Uint8List(0), mode: FileMode.write, flush: true); |
| 439 | final dataBinWriter = dataBin.openWrite(); |
| 440 | |
| 441 | printV("------ Backup stats ------"); |
| 442 | printV("Backup version: ${metadata.version}"); |
| 443 | printV("Backup size: ${await dataBinUnencrypted.length()}"); |
| 444 | printV("Backup chunks: ${(await dataBinUnencrypted.length()) / chunkSize}"); |
| 445 | printV("------ Backup stats ------"); |
| 446 | |
| 447 | int chunkIndex = 0; |
| 448 | final stopwatch = Stopwatch()..start(); |
| 449 | printV("Starting backup encryption..."); |
| 450 | |
| 451 | metadata.sha512sum = (await sha512.bind(dataBinUnencrypted.openRead()).first).toString(); |
| 452 | |
| 453 | final raf = await dataBinUnencrypted.open(); |
| 454 | |
| 455 | while (true) { |
| 456 | printV("Reading chunk ${chunkIndex++}"); |
| 457 | |
| 458 | stopwatch.reset(); |
| 459 | final chunk = await raf.read(chunkSize); |
| 460 | printV("Chunk read completed in ${stopwatch.elapsed}"); |
| 461 | printV("Chunk length: ${chunk.length} expected: $chunkSize"); |
| 462 | if (chunk.length == 0) { |
| 463 | break; |
| 464 | } |
| 465 | |
| 466 | stopwatch.reset(); |
| 467 | final encryptedChunk = await cake_backup.encrypt(password, chunk); |
| 468 | printV("Encryption completed in ${stopwatch.elapsed}"); |
| 469 | |
| 470 | stopwatch.reset(); |
| 471 | final sha512sumEncryptedChunk = |
| 472 | await sha512.bind(Stream.fromIterable([encryptedChunk])).first; |
| 473 | final sha512sumUnencryptedChunk = await sha512.bind(Stream.fromIterable([chunk])).first; |
| 474 | printV("Hashing completed in ${stopwatch.elapsed}"); |
| 475 | |
| 476 | stopwatch.reset(); |
| 477 | dataBinWriter.add(encryptedChunk); |
| 478 | metadata.chunks.add(ChunkDetails( |
| 479 | sha512sum: ChunkChecksum( |
| 480 | encrypted: sha512sumEncryptedChunk.toString(), |
| 481 | plain: sha512sumUnencryptedChunk.toString(), |
| 482 | ), |
| 483 | length: ChunkLength( |
| 484 | encrypted: encryptedChunk.length, |
| 485 | plain: chunk.length, |
| 486 | ), |
| 487 | )); |
| 488 | |
| 489 | await dataBinWriter.flush(); |
| 490 | printV("Writing completed in ${stopwatch.elapsed}"); |
| 491 | } |
| 492 | await raf.close(); |
| 493 | |
| 494 | // Give the file to the user |
| 495 | |
| 496 | final metadataFile = File('${tmpDir.path}/metadata.json'); |
| 497 | final packageInfo = await PackageInfo.fromPlatform(); |
| 498 | metadata.cakeVersion = packageInfo.version; |
| 499 | |
| 500 | metadataFile.writeAsStringSync(JsonEncoder.withIndent(' ').convert(metadata.toJson())); |
| 501 | final readmeFile = File('${tmpDir.path}/README.txt'); |
| 502 | readmeFile |
| 503 | .writeAsStringSync('''This is a ${packageInfo.appName} backup. Do not modify this archive. |
| 504 | |
| 505 | App version: ${packageInfo.version} |
| 506 | |
| 507 | If you have any issues with this backup, please contact our in-app support. |
| 508 | This backup was created on ${DateTime.now().toIso8601String()} |
| 509 | '''); |
| 510 | final zip = ZipFileEncoder(); |
| 511 | zip.create(archivePathExport, level: 9); |
| 512 | await zip.addFile(dataBin, 'data.bin'); |
| 513 | await zip.addFile(metadataFile, 'metadata.json'); |
| 514 | await zip.addFile(readmeFile, 'README.txt'); |
| 515 | await zip.close(); |
| 516 | // tmpDir.deleteSync(recursive: true); |
| 517 | final file = File(archivePathExport); |
| 518 | return file; |
| 519 | } |
| 520 | |
| 521 | Future<void> _throwIfBackupWasCreatedInAnotherApp(Archive archive) async { |
| 522 | final readmeFile = archive.findFile('README.txt'); |
| 523 | if (readmeFile == null) return; |
| 524 | |
| 525 | final readmeBytes = readmeFile.rawContent?.readBytes(); |
| 526 | if (readmeBytes == null) return; |
| 527 | |
| 528 | final readmeString = utf8.decode(readmeBytes, allowMalformed: true); |
| 529 | final sourceAppName = _extractBackupAppName(readmeString); |
| 530 | if (sourceAppName == null) return; |
| 531 | |
| 532 | final currentAppName = (await PackageInfo.fromPlatform()).appName; |
| 533 | if (sourceAppName == currentAppName) return; |
| 534 | |
| 535 | throw IncompatibleBackupAppException( |
| 536 | sourceAppName: sourceAppName, |
| 537 | currentAppName: currentAppName, |
| 538 | ); |
| 539 | } |
| 540 | |
| 541 | String? _extractBackupAppName(String readme) { |
| 542 | final match = RegExp(r'^This is a (.+) backup\.', multiLine: true).firstMatch(readme); |
| 543 | return match?.group(1)?.trim(); |
| 544 | } |
| 545 | |
| 546 | static const chunkSize = 24 * 1024 * 1024; // 24MiB |
| 547 | |
| 548 | File setVersionFile(File file, BackupVersion version) { |
| 549 | if (version == BackupVersion.v3) return file; // v3 uses |
| 550 | // helper function to call super.setVersion(); |
| 551 | final data = file.readAsBytesSync(); |
| 552 | super.setVersion(data, version.index); |
| 553 | file.writeAsBytesSync(data); |
| 554 | return file; |
| 555 | } |
| 556 | } |