file_uploader.dart 14 KB

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