file_uploader.dart 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  1. // @dart=2.9
  2. import 'dart:async';
  3. import 'dart:collection';
  4. import 'dart:convert';
  5. import 'dart:io' as io;
  6. import 'dart:math';
  7. import 'dart:typed_data';
  8. import 'package:connectivity/connectivity.dart';
  9. import 'package:dio/dio.dart';
  10. import 'package:flutter/foundation.dart';
  11. import 'package:flutter_sodium/flutter_sodium.dart';
  12. import 'package:logging/logging.dart';
  13. import 'package:path/path.dart';
  14. import 'package:photos/core/configuration.dart';
  15. import 'package:photos/core/errors.dart';
  16. import 'package:photos/core/event_bus.dart';
  17. import 'package:photos/core/network.dart';
  18. import 'package:photos/db/files_db.dart';
  19. import 'package:photos/db/upload_locks_db.dart';
  20. import 'package:photos/events/files_updated_event.dart';
  21. import 'package:photos/events/local_photos_updated_event.dart';
  22. import 'package:photos/events/subscription_purchased_event.dart';
  23. import 'package:photos/main.dart';
  24. import 'package:photos/models/encryption_result.dart';
  25. import 'package:photos/models/file.dart';
  26. import 'package:photos/models/file_type.dart';
  27. import 'package:photos/models/upload_url.dart';
  28. import 'package:photos/services/collections_service.dart';
  29. import 'package:photos/services/local_sync_service.dart';
  30. import 'package:photos/services/sync_service.dart';
  31. import 'package:photos/utils/crypto_util.dart';
  32. import 'package:photos/utils/file_download_util.dart';
  33. import 'package:photos/utils/file_uploader_util.dart';
  34. import 'package:shared_preferences/shared_preferences.dart';
  35. import 'package:tuple/tuple.dart';
  36. class FileUploader {
  37. static const kMaximumConcurrentUploads = 4;
  38. static const kMaximumConcurrentVideoUploads = 2;
  39. static const kMaximumThumbnailCompressionAttempts = 2;
  40. static const kMaximumUploadAttempts = 4;
  41. static const kBlockedUploadsPollFrequency = Duration(seconds: 2);
  42. static const kFileUploadTimeout = Duration(minutes: 50);
  43. final _logger = Logger("FileUploader");
  44. final _dio = Network.instance.getDio();
  45. final _enteDio = Network.instance.enteDio;
  46. final LinkedHashMap _queue = LinkedHashMap<String, FileUploadItem>();
  47. final _uploadLocks = UploadLocksDB.instance;
  48. final kSafeBufferForLockExpiry = const Duration(days: 1).inMicroseconds;
  49. final kBGTaskDeathTimeout = const Duration(seconds: 5).inMicroseconds;
  50. final _uploadURLs = Queue<UploadURL>();
  51. // Maintains the count of files in the current upload session.
  52. // Upload session is the period between the first entry into the _queue and last entry out of the _queue
  53. int _totalCountInUploadSession = 0;
  54. // _uploadCounter indicates number of uploads which are currently in progress
  55. int _uploadCounter = 0;
  56. int _videoUploadCounter = 0;
  57. ProcessType _processType;
  58. bool _isBackground;
  59. SharedPreferences _prefs;
  60. FileUploader._privateConstructor() {
  61. Bus.instance.on<SubscriptionPurchasedEvent>().listen((event) {
  62. _uploadURLFetchInProgress = null;
  63. });
  64. }
  65. static FileUploader instance = FileUploader._privateConstructor();
  66. Future<void> init(SharedPreferences preferences, bool isBackground) async {
  67. _prefs = preferences;
  68. _isBackground = isBackground;
  69. _processType =
  70. isBackground ? ProcessType.background : ProcessType.foreground;
  71. final currentTime = DateTime.now().microsecondsSinceEpoch;
  72. await _uploadLocks.releaseLocksAcquiredByOwnerBefore(
  73. _processType.toString(),
  74. currentTime,
  75. );
  76. await _uploadLocks
  77. .releaseAllLocksAcquiredBefore(currentTime - kSafeBufferForLockExpiry);
  78. if (!isBackground) {
  79. await _prefs.reload();
  80. final isBGTaskDead = (_prefs.getInt(kLastBGTaskHeartBeatTime) ?? 0) <
  81. (currentTime - kBGTaskDeathTimeout);
  82. if (isBGTaskDead) {
  83. await _uploadLocks.releaseLocksAcquiredByOwnerBefore(
  84. ProcessType.background.toString(),
  85. currentTime,
  86. );
  87. _logger.info("BG task was found dead, cleared all locks");
  88. }
  89. _pollBackgroundUploadStatus();
  90. }
  91. Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) {
  92. if (event.type == EventType.deletedFromDevice ||
  93. event.type == EventType.deletedFromEverywhere) {
  94. removeFromQueueWhere(
  95. (file) {
  96. for (final updatedFile in event.updatedFiles) {
  97. if (file.generatedID == updatedFile.generatedID) {
  98. return true;
  99. }
  100. }
  101. return false;
  102. },
  103. InvalidFileError("File already deleted"),
  104. );
  105. }
  106. });
  107. }
  108. // upload future will return null as File when the file entry is deleted
  109. // locally because it's already present in the destination collection.
  110. Future<File> upload(File file, int collectionID) {
  111. // If the file hasn't been queued yet, queue it
  112. _totalCountInUploadSession++;
  113. if (!_queue.containsKey(file.localID)) {
  114. final completer = Completer<File>();
  115. _queue[file.localID] = FileUploadItem(file, collectionID, completer);
  116. _pollQueue();
  117. return completer.future;
  118. }
  119. // If the file exists in the queue for a matching collectionID,
  120. // return the existing future
  121. final item = _queue[file.localID];
  122. if (item.collectionID == collectionID) {
  123. _totalCountInUploadSession--;
  124. return item.completer.future;
  125. }
  126. debugPrint(
  127. "Wait on another upload on same local ID to finish before "
  128. "adding it to new collection",
  129. );
  130. // Else wait for the existing upload to complete,
  131. // and add it to the relevant collection
  132. return item.completer.future.then((uploadedFile) {
  133. // If the fileUploader completer returned null,
  134. _logger.info(
  135. "original upload completer resolved, try adding the file to another "
  136. "collection",
  137. );
  138. if (uploadedFile == null) {
  139. /* todo: handle this case, ideally during next sync the localId
  140. should be uploaded to this collection ID
  141. */
  142. _logger.severe('unexpected upload state');
  143. return null;
  144. }
  145. return CollectionsService.instance
  146. .addToCollection(collectionID, [uploadedFile]).then((aVoid) {
  147. return uploadedFile as File;
  148. });
  149. });
  150. }
  151. int getCurrentSessionUploadCount() {
  152. return _totalCountInUploadSession;
  153. }
  154. void clearQueue(final Error reason) {
  155. final List<String> uploadsToBeRemoved = [];
  156. _queue.entries
  157. .where((entry) => entry.value.status == UploadStatus.notStarted)
  158. .forEach((pendingUpload) {
  159. uploadsToBeRemoved.add(pendingUpload.key);
  160. });
  161. for (final id in uploadsToBeRemoved) {
  162. _queue.remove(id).completer.completeError(reason);
  163. }
  164. _totalCountInUploadSession = 0;
  165. }
  166. void removeFromQueueWhere(final bool Function(File) fn, final Error reason) {
  167. final List<String> uploadsToBeRemoved = [];
  168. _queue.entries
  169. .where((entry) => entry.value.status == UploadStatus.notStarted)
  170. .forEach((pendingUpload) {
  171. if (fn(pendingUpload.value.file)) {
  172. uploadsToBeRemoved.add(pendingUpload.key);
  173. }
  174. });
  175. for (final id in uploadsToBeRemoved) {
  176. _queue.remove(id).completer.completeError(reason);
  177. }
  178. _logger.info(
  179. 'number of enteries removed from queue ${uploadsToBeRemoved.length}',
  180. );
  181. _totalCountInUploadSession -= uploadsToBeRemoved.length;
  182. }
  183. void _pollQueue() {
  184. if (SyncService.instance.shouldStopSync()) {
  185. clearQueue(SyncStopRequestedError());
  186. }
  187. if (_queue.isEmpty) {
  188. // Upload session completed
  189. _totalCountInUploadSession = 0;
  190. return;
  191. }
  192. if (_uploadCounter < kMaximumConcurrentUploads) {
  193. var pendingEntry = _queue.entries
  194. .firstWhere(
  195. (entry) => entry.value.status == UploadStatus.notStarted,
  196. orElse: () => null,
  197. )
  198. ?.value;
  199. if (pendingEntry != null &&
  200. pendingEntry.file.fileType == FileType.video &&
  201. _videoUploadCounter >= kMaximumConcurrentVideoUploads) {
  202. // check if there's any non-video entry which can be queued for upload
  203. pendingEntry = _queue.entries
  204. .firstWhere(
  205. (entry) =>
  206. entry.value.status == UploadStatus.notStarted &&
  207. entry.value.file.fileType != FileType.video,
  208. orElse: () => null,
  209. )
  210. ?.value;
  211. }
  212. if (pendingEntry != null) {
  213. pendingEntry.status = UploadStatus.inProgress;
  214. _encryptAndUploadFileToCollection(
  215. pendingEntry.file,
  216. pendingEntry.collectionID,
  217. );
  218. }
  219. }
  220. }
  221. Future<File> _encryptAndUploadFileToCollection(
  222. File file,
  223. int collectionID, {
  224. bool forcedUpload = false,
  225. }) async {
  226. _uploadCounter++;
  227. if (file.fileType == FileType.video) {
  228. _videoUploadCounter++;
  229. }
  230. final localID = file.localID;
  231. try {
  232. final uploadedFile =
  233. await _tryToUpload(file, collectionID, forcedUpload).timeout(
  234. kFileUploadTimeout,
  235. onTimeout: () {
  236. final message = "Upload timed out for file " + file.toString();
  237. _logger.severe(message);
  238. throw TimeoutException(message);
  239. },
  240. );
  241. _queue.remove(localID).completer.complete(uploadedFile);
  242. return uploadedFile;
  243. } catch (e) {
  244. if (e is LockAlreadyAcquiredError) {
  245. _queue[localID].status = UploadStatus.inBackground;
  246. return _queue[localID].completer.future;
  247. } else {
  248. _queue.remove(localID).completer.completeError(e);
  249. return null;
  250. }
  251. } finally {
  252. _uploadCounter--;
  253. if (file.fileType == FileType.video) {
  254. _videoUploadCounter--;
  255. }
  256. _pollQueue();
  257. }
  258. }
  259. Future<File> _tryToUpload(
  260. File file,
  261. int collectionID,
  262. bool forcedUpload,
  263. ) async {
  264. final connectivityResult = await (Connectivity().checkConnectivity());
  265. final canUploadUnderCurrentNetworkConditions =
  266. (connectivityResult == ConnectivityResult.wifi ||
  267. Configuration.instance.shouldBackupOverMobileData());
  268. if (!canUploadUnderCurrentNetworkConditions && !forcedUpload) {
  269. throw WiFiUnavailableError();
  270. }
  271. final fileOnDisk = await FilesDB.instance.getFile(file.generatedID);
  272. final wasAlreadyUploaded = fileOnDisk.uploadedFileID != null &&
  273. fileOnDisk.updationTime != -1 &&
  274. fileOnDisk.collectionID == collectionID;
  275. if (wasAlreadyUploaded) {
  276. debugPrint("File is already uploaded ${fileOnDisk.tag}");
  277. return fileOnDisk;
  278. }
  279. try {
  280. await _uploadLocks.acquireLock(
  281. file.localID,
  282. _processType.toString(),
  283. DateTime.now().microsecondsSinceEpoch,
  284. );
  285. } catch (e) {
  286. _logger.warning("Lock was already taken for " + file.toString());
  287. throw LockAlreadyAcquiredError();
  288. }
  289. final tempDirectory = Configuration.instance.getTempDirectory();
  290. final encryptedFilePath = tempDirectory +
  291. file.generatedID.toString() +
  292. (_isBackground ? "_bg" : "") +
  293. ".encrypted";
  294. final encryptedThumbnailPath = tempDirectory +
  295. file.generatedID.toString() +
  296. "_thumbnail" +
  297. (_isBackground ? "_bg" : "") +
  298. ".encrypted";
  299. MediaUploadData mediaUploadData;
  300. var uploadCompleted = false;
  301. // This flag is used to decide whether to clear the iOS origin file cache
  302. // or not.
  303. var uploadHardFailure = false;
  304. try {
  305. _logger.info(
  306. "Trying to upload " +
  307. file.toString() +
  308. ", isForced: " +
  309. forcedUpload.toString(),
  310. );
  311. try {
  312. mediaUploadData = await getUploadDataFromEnteFile(file);
  313. } catch (e) {
  314. if (e is InvalidFileError) {
  315. await _onInvalidFileError(file, e);
  316. } else {
  317. rethrow;
  318. }
  319. }
  320. Uint8List key;
  321. final bool isUpdatedFile =
  322. file.uploadedFileID != null && file.updationTime == -1;
  323. if (isUpdatedFile) {
  324. _logger.info("File was updated " + file.toString());
  325. key = decryptFileKey(file);
  326. } else {
  327. key = null;
  328. // check if the file is already uploaded and can be mapped to existing
  329. // uploaded file. If map is found, it also returns the corresponding
  330. // mapped or update file entry.
  331. final result = await _mapToExistingUploadWithSameHash(
  332. mediaUploadData,
  333. file,
  334. collectionID,
  335. );
  336. final isMappedToExistingUpload = result.item1;
  337. if (isMappedToExistingUpload) {
  338. debugPrint(
  339. "File success mapped to existing uploaded ${file.toString()}",
  340. );
  341. // return the mapped file
  342. return result.item2;
  343. }
  344. }
  345. if (io.File(encryptedFilePath).existsSync()) {
  346. await io.File(encryptedFilePath).delete();
  347. }
  348. final encryptedFile = io.File(encryptedFilePath);
  349. final fileAttributes = await CryptoUtil.encryptFile(
  350. mediaUploadData.sourceFile.path,
  351. encryptedFilePath,
  352. key: key,
  353. );
  354. final thumbnailData = mediaUploadData.thumbnail;
  355. final encryptedThumbnailData =
  356. await CryptoUtil.encryptChaCha(thumbnailData, fileAttributes.key);
  357. if (io.File(encryptedThumbnailPath).existsSync()) {
  358. await io.File(encryptedThumbnailPath).delete();
  359. }
  360. final encryptedThumbnailFile = io.File(encryptedThumbnailPath);
  361. await encryptedThumbnailFile
  362. .writeAsBytes(encryptedThumbnailData.encryptedData);
  363. final thumbnailUploadURL = await _getUploadURL();
  364. final String thumbnailObjectKey =
  365. await _putFile(thumbnailUploadURL, encryptedThumbnailFile);
  366. final fileUploadURL = await _getUploadURL();
  367. final String fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
  368. final metadata = await file.getMetadataForUpload(mediaUploadData);
  369. final encryptedMetadataData = await CryptoUtil.encryptChaCha(
  370. utf8.encode(jsonEncode(metadata)),
  371. fileAttributes.key,
  372. );
  373. final fileDecryptionHeader = Sodium.bin2base64(fileAttributes.header);
  374. final thumbnailDecryptionHeader =
  375. Sodium.bin2base64(encryptedThumbnailData.header);
  376. final encryptedMetadata =
  377. Sodium.bin2base64(encryptedMetadataData.encryptedData);
  378. final metadataDecryptionHeader =
  379. Sodium.bin2base64(encryptedMetadataData.header);
  380. if (SyncService.instance.shouldStopSync()) {
  381. throw SyncStopRequestedError();
  382. }
  383. File remoteFile;
  384. if (isUpdatedFile) {
  385. remoteFile = await _updateFile(
  386. file,
  387. fileObjectKey,
  388. fileDecryptionHeader,
  389. await encryptedFile.length(),
  390. thumbnailObjectKey,
  391. thumbnailDecryptionHeader,
  392. await encryptedThumbnailFile.length(),
  393. encryptedMetadata,
  394. metadataDecryptionHeader,
  395. );
  396. // Update across all collections
  397. await FilesDB.instance.updateUploadedFileAcrossCollections(remoteFile);
  398. } else {
  399. final encryptedFileKeyData = CryptoUtil.encryptSync(
  400. fileAttributes.key,
  401. CollectionsService.instance.getCollectionKey(collectionID),
  402. );
  403. final encryptedKey =
  404. Sodium.bin2base64(encryptedFileKeyData.encryptedData);
  405. final keyDecryptionNonce =
  406. Sodium.bin2base64(encryptedFileKeyData.nonce);
  407. remoteFile = await _uploadFile(
  408. file,
  409. collectionID,
  410. encryptedKey,
  411. keyDecryptionNonce,
  412. fileAttributes,
  413. fileObjectKey,
  414. fileDecryptionHeader,
  415. await encryptedFile.length(),
  416. thumbnailObjectKey,
  417. thumbnailDecryptionHeader,
  418. await encryptedThumbnailFile.length(),
  419. encryptedMetadata,
  420. metadataDecryptionHeader,
  421. );
  422. if (mediaUploadData.isDeleted) {
  423. _logger.info("File found to be deleted");
  424. remoteFile.localID = null;
  425. }
  426. await FilesDB.instance.update(remoteFile);
  427. }
  428. if (!_isBackground) {
  429. Bus.instance.fire(
  430. LocalPhotosUpdatedEvent(
  431. [remoteFile],
  432. source: "downloadComplete",
  433. ),
  434. );
  435. }
  436. _logger.info("File upload complete for " + remoteFile.toString());
  437. uploadCompleted = true;
  438. return remoteFile;
  439. } catch (e, s) {
  440. if (!(e is NoActiveSubscriptionError ||
  441. e is StorageLimitExceededError ||
  442. e is WiFiUnavailableError ||
  443. e is SilentlyCancelUploadsError ||
  444. e is FileTooLargeForPlanError)) {
  445. _logger.severe("File upload failed for " + file.toString(), e, s);
  446. }
  447. if ((e is StorageLimitExceededError ||
  448. e is FileTooLargeForPlanError ||
  449. e is NoActiveSubscriptionError)) {
  450. // file upload can be be retried in such cases without user intervention
  451. uploadHardFailure = false;
  452. }
  453. rethrow;
  454. } finally {
  455. await _onUploadDone(
  456. mediaUploadData,
  457. uploadCompleted,
  458. uploadHardFailure,
  459. file,
  460. encryptedFilePath,
  461. encryptedThumbnailPath,
  462. );
  463. }
  464. }
  465. /*
  466. _mapToExistingUpload links the fileToUpload with the existing uploaded
  467. files. if the link is successful, it returns true otherwise false.
  468. When false, we should go ahead and re-upload or update the file.
  469. It performs following checks:
  470. a) Uploaded file with same localID and destination collection. Delete the
  471. fileToUpload entry
  472. b) Uploaded file in destination collection but with missing localID.
  473. Update the localID for uploadedFile and delete the fileToUpload entry
  474. c) A uploaded file exist with same localID but in a different collection.
  475. or
  476. d) Uploaded file in different collection but missing localID.
  477. For both c and d, perform add to collection operation.
  478. e) File already exists but different localID. Re-upload
  479. In case the existing files already have local identifier, which is
  480. different from the {fileToUpload}, then most probably device has
  481. duplicate files.
  482. */
  483. Future<Tuple2<bool, File>> _mapToExistingUploadWithSameHash(
  484. MediaUploadData mediaUploadData,
  485. File fileToUpload,
  486. int toCollectionID,
  487. ) async {
  488. if (fileToUpload.uploadedFileID != null) {
  489. // ideally this should never happen, but because the code below this case
  490. // can do unexpected mapping, we are adding this additional check
  491. _logger.severe(
  492. 'Critical: file is already uploaded, skipped mapping',
  493. );
  494. return Tuple2(false, fileToUpload);
  495. }
  496. final List<File> existingUploadedFiles =
  497. await FilesDB.instance.getUploadedFilesWithHashes(
  498. mediaUploadData.hashData,
  499. fileToUpload.fileType,
  500. Configuration.instance.getUserID(),
  501. );
  502. if (existingUploadedFiles?.isEmpty ?? true) {
  503. // continueUploading this file
  504. return Tuple2(false, fileToUpload);
  505. }
  506. // case a
  507. final File sameLocalSameCollection = existingUploadedFiles.firstWhere(
  508. (e) =>
  509. e.collectionID == toCollectionID && e.localID == fileToUpload.localID,
  510. orElse: () => null,
  511. );
  512. if (sameLocalSameCollection != null) {
  513. _logger.fine(
  514. "sameLocalSameCollection: \n toUpload ${fileToUpload.tag} "
  515. "\n existing: ${sameLocalSameCollection.tag}",
  516. );
  517. // should delete the fileToUploadEntry
  518. await FilesDB.instance.deleteByGeneratedID(fileToUpload.generatedID);
  519. Bus.instance.fire(
  520. LocalPhotosUpdatedEvent(
  521. [fileToUpload],
  522. type: EventType.deletedFromEverywhere,
  523. source: "sameLocalSameCollection", //
  524. ),
  525. );
  526. return Tuple2(true, sameLocalSameCollection);
  527. }
  528. // case b
  529. final File fileMissingLocalButSameCollection =
  530. existingUploadedFiles.firstWhere(
  531. (e) => e.collectionID == toCollectionID && e.localID == null,
  532. orElse: () => null,
  533. );
  534. if (fileMissingLocalButSameCollection != null) {
  535. // update the local id of the existing file and delete the fileToUpload
  536. // entry
  537. _logger.fine(
  538. "fileMissingLocalButSameCollection: \n toUpload ${fileToUpload.tag} "
  539. "\n existing: ${fileMissingLocalButSameCollection.tag}",
  540. );
  541. fileMissingLocalButSameCollection.localID = fileToUpload.localID;
  542. // set localID for the given uploadedID across collections
  543. await FilesDB.instance.updateLocalIDForUploaded(
  544. fileMissingLocalButSameCollection.uploadedFileID,
  545. fileToUpload.localID,
  546. );
  547. await FilesDB.instance.deleteByGeneratedID(fileToUpload.generatedID);
  548. Bus.instance.fire(
  549. LocalPhotosUpdatedEvent(
  550. [fileToUpload],
  551. source: "alreadyUploadedInSameCollection",
  552. type: EventType.deletedFromEverywhere, //
  553. ),
  554. );
  555. return Tuple2(true, fileMissingLocalButSameCollection);
  556. }
  557. // case c and d
  558. final File fileExistsButDifferentCollection =
  559. existingUploadedFiles.firstWhere(
  560. (e) => e.collectionID != toCollectionID,
  561. orElse: () => null,
  562. );
  563. if (fileExistsButDifferentCollection != null) {
  564. _logger.fine(
  565. "fileExistsButDifferentCollection: \n toUpload ${fileToUpload.tag} "
  566. "\n existing: ${fileExistsButDifferentCollection.tag}",
  567. );
  568. final linkedFile = await CollectionsService.instance
  569. .linkLocalFileToExistingUploadedFileInAnotherCollection(
  570. toCollectionID,
  571. localFileToUpload: fileToUpload,
  572. existingUploadedFile: fileExistsButDifferentCollection,
  573. );
  574. return Tuple2(true, linkedFile);
  575. }
  576. final Set<String> matchLocalIDs = existingUploadedFiles
  577. .where(
  578. (e) => e.localID != null,
  579. )
  580. .map((e) => e.localID)
  581. .toSet();
  582. _logger.fine(
  583. "Found hashMatch but probably with diff localIDs "
  584. "$matchLocalIDs",
  585. );
  586. // case e
  587. return Tuple2(false, fileToUpload);
  588. }
  589. Future<void> _onUploadDone(
  590. MediaUploadData mediaUploadData,
  591. bool uploadCompleted,
  592. bool uploadHardFailure,
  593. File file,
  594. String encryptedFilePath,
  595. String encryptedThumbnailPath,
  596. ) async {
  597. if (mediaUploadData != null && mediaUploadData.sourceFile != null) {
  598. // delete the file from app's internal cache if it was copied to app
  599. // for upload. On iOS, only remove the file from photo_manager/app cache
  600. // when upload is either completed or there's a tempFailure
  601. // Shared Media should only be cleared when the upload
  602. // succeeds.
  603. if ((io.Platform.isIOS && (uploadCompleted || uploadHardFailure)) ||
  604. (uploadCompleted && file.isSharedMediaToAppSandbox)) {
  605. await mediaUploadData.sourceFile.delete();
  606. }
  607. }
  608. if (io.File(encryptedFilePath).existsSync()) {
  609. await io.File(encryptedFilePath).delete();
  610. }
  611. if (io.File(encryptedThumbnailPath).existsSync()) {
  612. await io.File(encryptedThumbnailPath).delete();
  613. }
  614. await _uploadLocks.releaseLock(file.localID, _processType.toString());
  615. }
  616. Future _onInvalidFileError(File file, InvalidFileError e) async {
  617. final String ext = file.title == null ? "no title" : extension(file.title);
  618. _logger.severe(
  619. "Invalid file: (ext: $ext) encountered: " + file.toString(),
  620. e,
  621. );
  622. await FilesDB.instance.deleteLocalFile(file);
  623. await LocalSyncService.instance.trackInvalidFile(file);
  624. throw e;
  625. }
  626. Future<File> _uploadFile(
  627. File file,
  628. int collectionID,
  629. String encryptedKey,
  630. String keyDecryptionNonce,
  631. EncryptionResult fileAttributes,
  632. String fileObjectKey,
  633. String fileDecryptionHeader,
  634. int fileSize,
  635. String thumbnailObjectKey,
  636. String thumbnailDecryptionHeader,
  637. int thumbnailSize,
  638. String encryptedMetadata,
  639. String metadataDecryptionHeader, {
  640. int attempt = 1,
  641. }) async {
  642. final request = {
  643. "collectionID": collectionID,
  644. "encryptedKey": encryptedKey,
  645. "keyDecryptionNonce": keyDecryptionNonce,
  646. "file": {
  647. "objectKey": fileObjectKey,
  648. "decryptionHeader": fileDecryptionHeader,
  649. "size": fileSize,
  650. },
  651. "thumbnail": {
  652. "objectKey": thumbnailObjectKey,
  653. "decryptionHeader": thumbnailDecryptionHeader,
  654. "size": thumbnailSize,
  655. },
  656. "metadata": {
  657. "encryptedData": encryptedMetadata,
  658. "decryptionHeader": metadataDecryptionHeader,
  659. }
  660. };
  661. try {
  662. final response = await _enteDio.post("/files", data: request);
  663. final data = response.data;
  664. file.uploadedFileID = data["id"];
  665. file.collectionID = collectionID;
  666. file.updationTime = data["updationTime"];
  667. file.ownerID = data["ownerID"];
  668. file.encryptedKey = encryptedKey;
  669. file.keyDecryptionNonce = keyDecryptionNonce;
  670. file.fileDecryptionHeader = fileDecryptionHeader;
  671. file.thumbnailDecryptionHeader = thumbnailDecryptionHeader;
  672. file.metadataDecryptionHeader = metadataDecryptionHeader;
  673. return file;
  674. } on DioError catch (e) {
  675. if (e.response?.statusCode == 413) {
  676. throw FileTooLargeForPlanError();
  677. } else if (e.response?.statusCode == 426) {
  678. _onStorageLimitExceeded();
  679. } else if (attempt < kMaximumUploadAttempts) {
  680. _logger.info("Upload file failed, will retry in 3 seconds");
  681. await Future.delayed(const Duration(seconds: 3));
  682. return _uploadFile(
  683. file,
  684. collectionID,
  685. encryptedKey,
  686. keyDecryptionNonce,
  687. fileAttributes,
  688. fileObjectKey,
  689. fileDecryptionHeader,
  690. fileSize,
  691. thumbnailObjectKey,
  692. thumbnailDecryptionHeader,
  693. thumbnailSize,
  694. encryptedMetadata,
  695. metadataDecryptionHeader,
  696. attempt: attempt + 1,
  697. );
  698. }
  699. rethrow;
  700. }
  701. }
  702. Future<File> _updateFile(
  703. File file,
  704. String fileObjectKey,
  705. String fileDecryptionHeader,
  706. int fileSize,
  707. String thumbnailObjectKey,
  708. String thumbnailDecryptionHeader,
  709. int thumbnailSize,
  710. String encryptedMetadata,
  711. String metadataDecryptionHeader, {
  712. int attempt = 1,
  713. }) async {
  714. final request = {
  715. "id": file.uploadedFileID,
  716. "file": {
  717. "objectKey": fileObjectKey,
  718. "decryptionHeader": fileDecryptionHeader,
  719. "size": fileSize,
  720. },
  721. "thumbnail": {
  722. "objectKey": thumbnailObjectKey,
  723. "decryptionHeader": thumbnailDecryptionHeader,
  724. "size": thumbnailSize,
  725. },
  726. "metadata": {
  727. "encryptedData": encryptedMetadata,
  728. "decryptionHeader": metadataDecryptionHeader,
  729. }
  730. };
  731. try {
  732. final response = await _enteDio.put("/files/update", data: request);
  733. final data = response.data;
  734. file.uploadedFileID = data["id"];
  735. file.updationTime = data["updationTime"];
  736. file.fileDecryptionHeader = fileDecryptionHeader;
  737. file.thumbnailDecryptionHeader = thumbnailDecryptionHeader;
  738. file.metadataDecryptionHeader = metadataDecryptionHeader;
  739. return file;
  740. } on DioError catch (e) {
  741. if (e.response?.statusCode == 426) {
  742. _onStorageLimitExceeded();
  743. } else if (attempt < kMaximumUploadAttempts) {
  744. _logger.info("Update file failed, will retry in 3 seconds");
  745. await Future.delayed(const Duration(seconds: 3));
  746. return _updateFile(
  747. file,
  748. fileObjectKey,
  749. fileDecryptionHeader,
  750. fileSize,
  751. thumbnailObjectKey,
  752. thumbnailDecryptionHeader,
  753. thumbnailSize,
  754. encryptedMetadata,
  755. metadataDecryptionHeader,
  756. attempt: attempt + 1,
  757. );
  758. }
  759. rethrow;
  760. }
  761. }
  762. Future<UploadURL> _getUploadURL() async {
  763. if (_uploadURLs.isEmpty) {
  764. await fetchUploadURLs(_queue.length);
  765. }
  766. try {
  767. return _uploadURLs.removeFirst();
  768. } catch (e) {
  769. if (e is StateError && e.message == 'No element' && _queue.isNotEmpty) {
  770. _logger.warning("Oops, uploadUrls has no element now, fetching again");
  771. return _getUploadURL();
  772. } else {
  773. rethrow;
  774. }
  775. }
  776. }
  777. Future<void> _uploadURLFetchInProgress;
  778. Future<void> fetchUploadURLs(int fileCount) async {
  779. _uploadURLFetchInProgress ??= Future<void>(() async {
  780. try {
  781. final response = await _enteDio.get(
  782. "/files/upload-urls",
  783. queryParameters: {
  784. "count": min(42, fileCount * 2), // m4gic number
  785. },
  786. );
  787. final urls = (response.data["urls"] as List)
  788. .map((e) => UploadURL.fromMap(e))
  789. .toList();
  790. _uploadURLs.addAll(urls);
  791. } on DioError catch (e, s) {
  792. if (e.response != null) {
  793. if (e.response.statusCode == 402) {
  794. final error = NoActiveSubscriptionError();
  795. clearQueue(error);
  796. throw error;
  797. } else if (e.response.statusCode == 426) {
  798. final error = StorageLimitExceededError();
  799. clearQueue(error);
  800. throw error;
  801. } else {
  802. _logger.severe("Could not fetch upload URLs", e, s);
  803. }
  804. }
  805. rethrow;
  806. } finally {
  807. _uploadURLFetchInProgress = null;
  808. }
  809. });
  810. return _uploadURLFetchInProgress;
  811. }
  812. void _onStorageLimitExceeded() {
  813. clearQueue(StorageLimitExceededError());
  814. throw StorageLimitExceededError();
  815. }
  816. Future<String> _putFile(
  817. UploadURL uploadURL,
  818. io.File file, {
  819. int contentLength,
  820. int attempt = 1,
  821. }) async {
  822. final fileSize = contentLength ?? await file.length();
  823. _logger.info(
  824. "Putting object for " +
  825. file.toString() +
  826. " of size: " +
  827. fileSize.toString(),
  828. );
  829. final startTime = DateTime.now().millisecondsSinceEpoch;
  830. try {
  831. await _dio.put(
  832. uploadURL.url,
  833. data: file.openRead(),
  834. options: Options(
  835. headers: {
  836. Headers.contentLengthHeader: fileSize,
  837. },
  838. ),
  839. );
  840. _logger.info(
  841. "Upload speed : " +
  842. (fileSize / (DateTime.now().millisecondsSinceEpoch - startTime))
  843. .toString() +
  844. " kilo bytes per second",
  845. );
  846. return uploadURL.objectKey;
  847. } on DioError catch (e) {
  848. if (e.message.startsWith(
  849. "HttpException: Content size exceeds specified contentLength.",
  850. ) &&
  851. attempt == 1) {
  852. return _putFile(
  853. uploadURL,
  854. file,
  855. contentLength: (await file.readAsBytes()).length,
  856. attempt: 2,
  857. );
  858. } else if (attempt < kMaximumUploadAttempts) {
  859. final newUploadURL = await _getUploadURL();
  860. return _putFile(
  861. newUploadURL,
  862. file,
  863. contentLength: (await file.readAsBytes()).length,
  864. attempt: attempt + 1,
  865. );
  866. } else {
  867. _logger.info(
  868. "Upload failed for file with size " + fileSize.toString(),
  869. e,
  870. );
  871. rethrow;
  872. }
  873. }
  874. }
  875. Future<void> _pollBackgroundUploadStatus() async {
  876. final blockedUploads = _queue.entries
  877. .where((e) => e.value.status == UploadStatus.inBackground)
  878. .toList();
  879. for (final upload in blockedUploads) {
  880. final file = upload.value.file;
  881. final isStillLocked = await _uploadLocks.isLocked(
  882. file.localID,
  883. ProcessType.background.toString(),
  884. );
  885. if (!isStillLocked) {
  886. final completer = _queue.remove(upload.key).completer;
  887. final dbFile =
  888. await FilesDB.instance.getFile(upload.value.file.generatedID);
  889. if (dbFile.uploadedFileID != null) {
  890. _logger.info("Background upload success detected");
  891. completer.complete(dbFile);
  892. } else {
  893. _logger.info("Background upload failure detected");
  894. completer.completeError(SilentlyCancelUploadsError());
  895. }
  896. }
  897. }
  898. Future.delayed(kBlockedUploadsPollFrequency, () async {
  899. await _pollBackgroundUploadStatus();
  900. });
  901. }
  902. }
  903. class FileUploadItem {
  904. final File file;
  905. final int collectionID;
  906. final Completer<File> completer;
  907. UploadStatus status;
  908. FileUploadItem(
  909. this.file,
  910. this.collectionID,
  911. this.completer, {
  912. this.status = UploadStatus.notStarted,
  913. });
  914. }
  915. enum UploadStatus {
  916. notStarted,
  917. inProgress,
  918. inBackground,
  919. completed,
  920. }
  921. enum ProcessType {
  922. background,
  923. foreground,
  924. }