file_uploader.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import 'dart:async';
  2. import 'dart:collection';
  3. import 'dart:convert';
  4. import 'dart:io' as io;
  5. import 'package:connectivity/connectivity.dart';
  6. import 'package:dio/dio.dart';
  7. import 'package:flutter_sodium/flutter_sodium.dart';
  8. import 'package:logging/logging.dart';
  9. import 'package:photos/core/configuration.dart';
  10. import 'package:photos/core/constants.dart';
  11. import 'package:photos/core/network.dart';
  12. import 'package:photos/db/files_db.dart';
  13. import 'package:photos/models/encryption_result.dart';
  14. import 'package:photos/models/file.dart';
  15. import 'package:photos/models/location.dart';
  16. import 'package:photos/models/upload_url.dart';
  17. import 'package:photos/services/collections_service.dart';
  18. import 'package:photos/services/sync_service.dart';
  19. import 'package:photos/utils/crypto_util.dart';
  20. import 'package:photos/utils/file_util.dart';
  21. class FileUploader {
  22. final _logger = Logger("FileUploader");
  23. final _dio = Network.instance.getDio();
  24. final _queue = LinkedHashMap<int, FileUploadItem>();
  25. final _maximumConcurrentUploads = 4;
  26. int _currentlyUploading = 0;
  27. final _uploadURLs = Queue<UploadURL>();
  28. FileUploader._privateConstructor();
  29. static FileUploader instance = FileUploader._privateConstructor();
  30. Future<File> upload(File file, int collectionID) {
  31. // If the file hasn't been queued yet, queue it
  32. if (!_queue.containsKey(file.generatedID)) {
  33. final completer = Completer<File>();
  34. _queue[file.generatedID] = FileUploadItem(file, collectionID, completer);
  35. _pollQueue();
  36. return completer.future;
  37. }
  38. // If the file exists in the queue for a matching collectionID,
  39. // return the existing future
  40. final item = _queue[file.generatedID];
  41. if (item.collectionID == collectionID) {
  42. return item.completer.future;
  43. }
  44. // Else wait for the existing upload to complete,
  45. // and add it to the relevant collection
  46. return item.completer.future.then((uploadedFile) {
  47. return CollectionsService.instance
  48. .addToCollection(collectionID, [uploadedFile]).then((aVoid) {
  49. return uploadedFile;
  50. });
  51. });
  52. }
  53. Future<File> forceUpload(File file, int collectionID) async {
  54. // If the file hasn't been queued yet, ez.
  55. if (!_queue.containsKey(file.generatedID)) {
  56. final completer = Completer<File>();
  57. _queue[file.generatedID] = FileUploadItem(
  58. file,
  59. collectionID,
  60. completer,
  61. status: UploadStatus.in_progress,
  62. );
  63. _encryptAndUploadFileToCollection(file, collectionID, forcedUpload: true);
  64. return completer.future;
  65. }
  66. var item = _queue[file.generatedID];
  67. // If the file is being uploaded right now, wait and proceed
  68. if (item.status == UploadStatus.in_progress) {
  69. return item.completer.future.then((uploadedFile) async {
  70. if (uploadedFile.collectionID == collectionID) {
  71. // Do nothing
  72. return uploadedFile;
  73. } else {
  74. return CollectionsService.instance
  75. .addToCollection(collectionID, [uploadedFile]).then((aVoid) {
  76. return uploadedFile;
  77. });
  78. }
  79. });
  80. } else {
  81. // If the file is yet to be processed,
  82. // 1. Remove it from the queue,
  83. // 2. Force upload the current file
  84. // 3. Trigger the callback for the original request
  85. item = _queue.remove(file.generatedID);
  86. return _encryptAndUploadFileToCollection(file, collectionID,
  87. forcedUpload: true)
  88. .then((uploadedFile) {
  89. if (item.collectionID == collectionID) {
  90. item.completer.complete(uploadedFile);
  91. return uploadedFile;
  92. } else {
  93. CollectionsService.instance
  94. .addToCollection(item.collectionID, [uploadedFile]).then((aVoid) {
  95. item.completer.complete(uploadedFile);
  96. });
  97. return uploadedFile;
  98. }
  99. });
  100. }
  101. }
  102. void _pollQueue() {
  103. if (SyncService.instance.shouldStopSync()) {
  104. final uploadsToBeRemoved = List<int>();
  105. _queue.entries
  106. .where((entry) => entry.value.status == UploadStatus.not_started)
  107. .forEach((pendingUpload) {
  108. uploadsToBeRemoved.add(pendingUpload.key);
  109. });
  110. for (final id in uploadsToBeRemoved) {
  111. _queue.remove(id).completer.completeError(SyncStopRequestedError());
  112. }
  113. }
  114. if (_queue.length > 0 && _currentlyUploading < _maximumConcurrentUploads) {
  115. final firstPendingEntry = _queue.entries
  116. .firstWhere((entry) => entry.value.status == UploadStatus.not_started,
  117. orElse: () => null)
  118. ?.value;
  119. if (firstPendingEntry != null) {
  120. firstPendingEntry.status = UploadStatus.in_progress;
  121. _encryptAndUploadFileToCollection(
  122. firstPendingEntry.file, firstPendingEntry.collectionID);
  123. }
  124. }
  125. }
  126. Future<File> _encryptAndUploadFileToCollection(File file, int collectionID,
  127. {bool forcedUpload = false}) async {
  128. _currentlyUploading++;
  129. try {
  130. final uploadedFile = await _tryToUpload(file, collectionID, forcedUpload);
  131. _queue.remove(file.generatedID).completer.complete(uploadedFile);
  132. } catch (e) {
  133. _queue.remove(file.generatedID).completer.completeError(e);
  134. } finally {
  135. _currentlyUploading--;
  136. _pollQueue();
  137. }
  138. return null;
  139. }
  140. Future<File> _tryToUpload(
  141. File file, int collectionID, bool forcedUpload) async {
  142. final connectivityResult = await (Connectivity().checkConnectivity());
  143. var canUploadUnderCurrentNetworkConditions =
  144. (connectivityResult == ConnectivityResult.wifi ||
  145. Configuration.instance.shouldBackupOverMobileData());
  146. if (!canUploadUnderCurrentNetworkConditions && !forcedUpload) {
  147. throw WiFiUnavailableError();
  148. }
  149. final tempDirectory = Configuration.instance.getTempDirectory();
  150. final encryptedFilePath =
  151. tempDirectory + file.generatedID.toString() + ".encrypted";
  152. final encryptedThumbnailPath =
  153. tempDirectory + file.generatedID.toString() + "_thumbnail.encrypted";
  154. var sourceFile;
  155. try {
  156. // Placing this in the try-catch block to safe guard against: https://github.com/CaiJingLong/flutter_photo_manager/issues/405
  157. sourceFile = (await (await file.getAsset()).originFile);
  158. var key;
  159. var isAlreadyUploadedFile = file.uploadedFileID != null;
  160. if (isAlreadyUploadedFile) {
  161. key = decryptFileKey(file);
  162. } else {
  163. key = null;
  164. }
  165. if (io.File(encryptedFilePath).existsSync()) {
  166. io.File(encryptedFilePath).deleteSync();
  167. }
  168. final encryptedFile = io.File(encryptedFilePath);
  169. final fileAttributes = await CryptoUtil.encryptFile(
  170. sourceFile.path,
  171. encryptedFilePath,
  172. key: key,
  173. );
  174. var thumbnailData = (await (await file.getAsset()).thumbDataWithSize(
  175. THUMBNAIL_LARGE_SIZE,
  176. THUMBNAIL_LARGE_SIZE,
  177. quality: 50,
  178. ));
  179. if (thumbnailData == null) {
  180. _logger.severe("Could not generate thumbnail for " + file.toString());
  181. throw InvalidFileError();
  182. }
  183. final thumbnailSize = thumbnailData.length;
  184. if (thumbnailSize > THUMBNAIL_DATA_LIMIT) {
  185. thumbnailData = await compressThumbnail(thumbnailData);
  186. _logger.info("Thumbnail size " + thumbnailSize.toString());
  187. _logger.info(
  188. "Compressed thumbnail size " + thumbnailData.length.toString());
  189. }
  190. final encryptedThumbnailData =
  191. CryptoUtil.encryptChaCha(thumbnailData, fileAttributes.key);
  192. if (io.File(encryptedThumbnailPath).existsSync()) {
  193. io.File(encryptedThumbnailPath).deleteSync();
  194. }
  195. final encryptedThumbnailFile = io.File(encryptedThumbnailPath);
  196. encryptedThumbnailFile
  197. .writeAsBytesSync(encryptedThumbnailData.encryptedData);
  198. final fileUploadURL = await _getUploadURL();
  199. String fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
  200. final thumbnailUploadURL = await _getUploadURL();
  201. String thumbnailObjectKey =
  202. await _putFile(thumbnailUploadURL, encryptedThumbnailFile);
  203. // h4ck to fetch location data if missing (thank you Android Q+) lazily only during uploads
  204. if (file.location == null ||
  205. (file.location.latitude == 0 && file.location.longitude == 0)) {
  206. final latLong = await (await file.getAsset()).latlngAsync();
  207. file.location = Location(latLong.latitude, latLong.longitude);
  208. }
  209. final encryptedMetadataData = CryptoUtil.encryptChaCha(
  210. utf8.encode(jsonEncode(file.getMetadata())), fileAttributes.key);
  211. final fileDecryptionHeader = Sodium.bin2base64(fileAttributes.header);
  212. final thumbnailDecryptionHeader =
  213. Sodium.bin2base64(encryptedThumbnailData.header);
  214. final encryptedMetadata =
  215. Sodium.bin2base64(encryptedMetadataData.encryptedData);
  216. final metadataDecryptionHeader =
  217. Sodium.bin2base64(encryptedMetadataData.header);
  218. if (isAlreadyUploadedFile) {
  219. final updatedFile = await _updateFile(
  220. file,
  221. fileObjectKey,
  222. fileDecryptionHeader,
  223. thumbnailObjectKey,
  224. thumbnailDecryptionHeader,
  225. encryptedMetadata,
  226. metadataDecryptionHeader,
  227. );
  228. // Update across all collections
  229. await FilesDB.instance.updateUploadedFileAcrossCollections(updatedFile);
  230. return updatedFile;
  231. } else {
  232. final uploadedFile = await _uploadFile(
  233. file,
  234. collectionID,
  235. fileAttributes,
  236. fileObjectKey,
  237. fileDecryptionHeader,
  238. thumbnailObjectKey,
  239. thumbnailDecryptionHeader,
  240. encryptedMetadata,
  241. metadataDecryptionHeader,
  242. );
  243. await FilesDB.instance.update(uploadedFile);
  244. return uploadedFile;
  245. }
  246. } catch (e, s) {
  247. if (!(e is NoActiveSubscriptionError)) {
  248. _logger.severe(
  249. "File upload failed for " + file.generatedID.toString(), e, s);
  250. }
  251. throw e;
  252. } finally {
  253. if (io.Platform.isIOS && sourceFile != null) {
  254. sourceFile.deleteSync();
  255. }
  256. if (io.File(encryptedFilePath).existsSync()) {
  257. io.File(encryptedFilePath).deleteSync();
  258. }
  259. if (io.File(encryptedThumbnailPath).existsSync()) {
  260. io.File(encryptedThumbnailPath).deleteSync();
  261. }
  262. }
  263. }
  264. Future<File> _uploadFile(
  265. File file,
  266. int collectionID,
  267. EncryptionResult fileAttributes,
  268. String fileObjectKey,
  269. String fileDecryptionHeader,
  270. String thumbnailObjectKey,
  271. String thumbnailDecryptionHeader,
  272. String encryptedMetadata,
  273. String metadataDecryptionHeader,
  274. ) async {
  275. final encryptedFileKeyData = CryptoUtil.encryptSync(
  276. fileAttributes.key,
  277. CollectionsService.instance.getCollectionKey(collectionID),
  278. );
  279. final encryptedKey = Sodium.bin2base64(encryptedFileKeyData.encryptedData);
  280. final keyDecryptionNonce = Sodium.bin2base64(encryptedFileKeyData.nonce);
  281. final request = {
  282. "collectionID": collectionID,
  283. "encryptedKey": encryptedKey,
  284. "keyDecryptionNonce": keyDecryptionNonce,
  285. "file": {
  286. "objectKey": fileObjectKey,
  287. "decryptionHeader": fileDecryptionHeader,
  288. },
  289. "thumbnail": {
  290. "objectKey": thumbnailObjectKey,
  291. "decryptionHeader": thumbnailDecryptionHeader,
  292. },
  293. "metadata": {
  294. "encryptedData": encryptedMetadata,
  295. "decryptionHeader": metadataDecryptionHeader,
  296. }
  297. };
  298. final response = await _dio.post(
  299. Configuration.instance.getHttpEndpoint() + "/files",
  300. options:
  301. Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}),
  302. data: request,
  303. );
  304. final data = response.data;
  305. file.uploadedFileID = data["id"];
  306. file.collectionID = collectionID;
  307. file.updationTime = data["updationTime"];
  308. file.ownerID = data["ownerID"];
  309. file.encryptedKey = encryptedKey;
  310. file.keyDecryptionNonce = keyDecryptionNonce;
  311. file.fileDecryptionHeader = fileDecryptionHeader;
  312. file.thumbnailDecryptionHeader = thumbnailDecryptionHeader;
  313. file.metadataDecryptionHeader = metadataDecryptionHeader;
  314. return file;
  315. }
  316. Future<File> _updateFile(
  317. File file,
  318. String fileObjectKey,
  319. String fileDecryptionHeader,
  320. String thumbnailObjectKey,
  321. String thumbnailDecryptionHeader,
  322. String encryptedMetadata,
  323. String metadataDecryptionHeader,
  324. ) async {
  325. final request = {
  326. "id": file.uploadedFileID,
  327. "file": {
  328. "objectKey": fileObjectKey,
  329. "decryptionHeader": fileDecryptionHeader,
  330. },
  331. "thumbnail": {
  332. "objectKey": thumbnailObjectKey,
  333. "decryptionHeader": thumbnailDecryptionHeader,
  334. },
  335. "metadata": {
  336. "encryptedData": encryptedMetadata,
  337. "decryptionHeader": metadataDecryptionHeader,
  338. }
  339. };
  340. final response = await _dio.post(
  341. Configuration.instance.getHttpEndpoint() + "/files",
  342. options:
  343. Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}),
  344. data: request,
  345. );
  346. final data = response.data;
  347. file.uploadedFileID = data["id"];
  348. file.updationTime = data["updationTime"];
  349. file.fileDecryptionHeader = fileDecryptionHeader;
  350. file.thumbnailDecryptionHeader = thumbnailDecryptionHeader;
  351. file.metadataDecryptionHeader = metadataDecryptionHeader;
  352. return file;
  353. }
  354. Future<UploadURL> _getUploadURL() async {
  355. if (_uploadURLs.isEmpty) {
  356. await _fetchUploadURLs();
  357. }
  358. return _uploadURLs.removeFirst();
  359. }
  360. Future<void> _uploadURLFetchInProgress;
  361. Future<void> _fetchUploadURLs() async {
  362. if (_uploadURLFetchInProgress == null) {
  363. final completer = Completer<void>();
  364. _uploadURLFetchInProgress = completer.future;
  365. try {
  366. final response = await _dio.get(
  367. Configuration.instance.getHttpEndpoint() + "/files/upload-urls",
  368. queryParameters: {
  369. "count": 42, // m4gic number
  370. },
  371. options: Options(
  372. headers: {"X-Auth-Token": Configuration.instance.getToken()}),
  373. );
  374. final urls = (response.data["urls"] as List)
  375. .map((e) => UploadURL.fromMap(e))
  376. .toList();
  377. _uploadURLs.addAll(urls);
  378. } on DioError catch (e) {
  379. if (e.response.statusCode == 402) {
  380. throw NoActiveSubscriptionError();
  381. }
  382. throw e;
  383. }
  384. _uploadURLFetchInProgress = null;
  385. completer.complete();
  386. }
  387. return _uploadURLFetchInProgress;
  388. }
  389. Future<String> _putFile(UploadURL uploadURL, io.File file) async {
  390. final fileSize = file.lengthSync().toString();
  391. final startTime = DateTime.now().millisecondsSinceEpoch;
  392. _logger.info("Putting file of size " + fileSize + " to " + uploadURL.url);
  393. await _dio.put(uploadURL.url,
  394. data: file.openRead(),
  395. options: Options(headers: {
  396. Headers.contentLengthHeader: await file.length(),
  397. }));
  398. _logger.info("Upload speed : " +
  399. (file.lengthSync() /
  400. (DateTime.now().millisecondsSinceEpoch - startTime))
  401. .toString() +
  402. " kilo bytes per second");
  403. return uploadURL.objectKey;
  404. }
  405. }
  406. class FileUploadItem {
  407. final File file;
  408. final int collectionID;
  409. final Completer<File> completer;
  410. UploadStatus status;
  411. FileUploadItem(
  412. this.file,
  413. this.collectionID,
  414. this.completer, {
  415. this.status = UploadStatus.not_started,
  416. });
  417. }
  418. enum UploadStatus {
  419. not_started,
  420. in_progress,
  421. completed,
  422. }
  423. class InvalidFileError extends Error {}
  424. class WiFiUnavailableError extends Error {}
  425. class SyncStopRequestedError extends Error {}
  426. class NoActiveSubscriptionError extends Error {}