file_uploader.dart 37 KB

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