file_uploader.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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_image_compress/flutter_image_compress.dart';
  8. import 'package:flutter_sodium/flutter_sodium.dart';
  9. import 'package:logging/logging.dart';
  10. import 'package:photos/core/configuration.dart';
  11. import 'package:photos/core/constants.dart';
  12. import 'package:photos/core/network.dart';
  13. import 'package:photos/db/files_db.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. await FilesDB.instance.update(uploadedFile);
  121. _queue.remove(file.generatedID).completer.complete(uploadedFile);
  122. } catch (e) {
  123. _queue.remove(file.generatedID).completer.completeError(e);
  124. } finally {
  125. _currentlyUploading--;
  126. _pollQueue();
  127. }
  128. return null;
  129. }
  130. Future<File> _tryToUpload(
  131. File file, int collectionID, bool forcedUpload) async {
  132. final connectivityResult = await (Connectivity().checkConnectivity());
  133. var canUploadUnderCurrentNetworkConditions =
  134. (connectivityResult == ConnectivityResult.wifi ||
  135. Configuration.instance.shouldBackupOverMobileData());
  136. if (!canUploadUnderCurrentNetworkConditions && !forcedUpload) {
  137. throw WiFiUnavailableError();
  138. }
  139. final tempDirectory = Configuration.instance.getTempDirectory();
  140. final encryptedFilePath =
  141. tempDirectory + file.generatedID.toString() + ".encrypted";
  142. final encryptedThumbnailPath =
  143. tempDirectory + file.generatedID.toString() + "_thumbnail.encrypted";
  144. try {
  145. final sourceFile = (await (await file.getAsset()).originFile);
  146. if (io.File(encryptedFilePath).existsSync()) {
  147. io.File(encryptedFilePath).deleteSync();
  148. }
  149. final encryptedFile = io.File(encryptedFilePath);
  150. final fileAttributes =
  151. await CryptoUtil.encryptFile(sourceFile.path, encryptedFilePath);
  152. var thumbnailData = (await (await file.getAsset()).thumbDataWithSize(
  153. THUMBNAIL_LARGE_SIZE,
  154. THUMBNAIL_LARGE_SIZE,
  155. quality: 50,
  156. ));
  157. if (thumbnailData == null) {
  158. _logger.severe("Could not generate thumbnail for " + file.toString());
  159. throw InvalidFileError();
  160. }
  161. final thumbnailSize = thumbnailData.length;
  162. if (thumbnailSize > THUMBNAIL_DATA_LIMIT) {
  163. thumbnailData = await compressThumbnail(thumbnailData);
  164. _logger.info("Thumbnail size " + thumbnailSize.toString());
  165. _logger.info(
  166. "Compressed thumbnail size " + thumbnailData.length.toString());
  167. }
  168. final encryptedThumbnailData =
  169. CryptoUtil.encryptChaCha(thumbnailData, fileAttributes.key);
  170. if (io.File(encryptedThumbnailPath).existsSync()) {
  171. io.File(encryptedThumbnailPath).deleteSync();
  172. }
  173. final encryptedThumbnailFile = io.File(encryptedThumbnailPath);
  174. encryptedThumbnailFile
  175. .writeAsBytesSync(encryptedThumbnailData.encryptedData);
  176. final fileUploadURL = await _getUploadURL();
  177. String fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
  178. final thumbnailUploadURL = await _getUploadURL();
  179. String thumbnailObjectKey =
  180. await _putFile(thumbnailUploadURL, encryptedThumbnailFile);
  181. // h4ck to fetch location data if missing (thank you Android Q+) lazily only during uploads
  182. if (file.location.latitude == 0 && file.location.longitude == 0) {
  183. final latLong = await (await file.getAsset()).latlngAsync();
  184. file.location = Location(latLong.latitude, latLong.longitude);
  185. }
  186. final encryptedMetadataData = CryptoUtil.encryptChaCha(
  187. utf8.encode(jsonEncode(file.getMetadata())), fileAttributes.key);
  188. final encryptedFileKeyData = CryptoUtil.encryptSync(
  189. fileAttributes.key,
  190. CollectionsService.instance.getCollectionKey(collectionID),
  191. );
  192. final encryptedKey =
  193. Sodium.bin2base64(encryptedFileKeyData.encryptedData);
  194. final keyDecryptionNonce = Sodium.bin2base64(encryptedFileKeyData.nonce);
  195. final fileDecryptionHeader = Sodium.bin2base64(fileAttributes.header);
  196. final thumbnailDecryptionHeader =
  197. Sodium.bin2base64(encryptedThumbnailData.header);
  198. final encryptedMetadata =
  199. Sodium.bin2base64(encryptedMetadataData.encryptedData);
  200. final metadataDecryptionHeader =
  201. Sodium.bin2base64(encryptedMetadataData.header);
  202. final request = {
  203. "collectionID": collectionID,
  204. "encryptedKey": encryptedKey,
  205. "keyDecryptionNonce": keyDecryptionNonce,
  206. "file": {
  207. "objectKey": fileObjectKey,
  208. "decryptionHeader": fileDecryptionHeader,
  209. },
  210. "thumbnail": {
  211. "objectKey": thumbnailObjectKey,
  212. "decryptionHeader": thumbnailDecryptionHeader,
  213. },
  214. "metadata": {
  215. "encryptedData": encryptedMetadata,
  216. "decryptionHeader": metadataDecryptionHeader,
  217. }
  218. };
  219. final response = await _dio.post(
  220. Configuration.instance.getHttpEndpoint() + "/files",
  221. options: Options(
  222. headers: {"X-Auth-Token": Configuration.instance.getToken()}),
  223. data: request,
  224. );
  225. encryptedFile.deleteSync();
  226. encryptedThumbnailFile.deleteSync();
  227. final data = response.data;
  228. file.uploadedFileID = data["id"];
  229. file.collectionID = collectionID;
  230. file.updationTime = data["updationTime"];
  231. file.ownerID = data["ownerID"];
  232. file.encryptedKey = encryptedKey;
  233. file.keyDecryptionNonce = keyDecryptionNonce;
  234. file.fileDecryptionHeader = fileDecryptionHeader;
  235. file.thumbnailDecryptionHeader = thumbnailDecryptionHeader;
  236. file.metadataDecryptionHeader = metadataDecryptionHeader;
  237. return file;
  238. } catch (e, s) {
  239. _logger.severe(
  240. "File upload failed for " + file.generatedID.toString(), e, s);
  241. if (io.File(encryptedFilePath).existsSync()) {
  242. io.File(encryptedFilePath).deleteSync();
  243. }
  244. if (io.File(encryptedThumbnailPath).existsSync()) {
  245. io.File(encryptedThumbnailPath).deleteSync();
  246. }
  247. throw e;
  248. }
  249. }
  250. Future<UploadURL> _getUploadURL() async {
  251. if (_uploadURLs.isEmpty) {
  252. await _fetchUploadURLs();
  253. }
  254. return _uploadURLs.removeFirst();
  255. }
  256. Future<void> _uploadURLFetchInProgress;
  257. Future<void> _fetchUploadURLs() {
  258. if (_uploadURLFetchInProgress == null) {
  259. _uploadURLFetchInProgress = _dio
  260. .get(
  261. Configuration.instance.getHttpEndpoint() + "/files/upload-urls",
  262. queryParameters: {
  263. "count": 42, // m4gic number
  264. },
  265. options: Options(
  266. headers: {"X-Auth-Token": Configuration.instance.getToken()}),
  267. )
  268. .then((response) {
  269. _uploadURLFetchInProgress = null;
  270. final urls = (response.data["urls"] as List)
  271. .map((e) => UploadURL.fromMap(e))
  272. .toList();
  273. _uploadURLs.addAll(urls);
  274. });
  275. }
  276. return _uploadURLFetchInProgress;
  277. }
  278. Future<String> _putFile(UploadURL uploadURL, io.File file) async {
  279. final fileSize = file.lengthSync().toString();
  280. final startTime = DateTime.now().millisecondsSinceEpoch;
  281. _logger.info("Putting file of size " + fileSize + " to " + uploadURL.url);
  282. await _dio.put(uploadURL.url,
  283. data: file.openRead(),
  284. options: Options(headers: {
  285. Headers.contentLengthHeader: await file.length(),
  286. }));
  287. _logger.info("Upload speed : " +
  288. (file.lengthSync() /
  289. (DateTime.now().millisecondsSinceEpoch - startTime))
  290. .toString() +
  291. " kilo bytes per second");
  292. return uploadURL.objectKey;
  293. }
  294. }
  295. class FileUploadItem {
  296. final File file;
  297. final int collectionID;
  298. final Completer<File> completer;
  299. UploadStatus status;
  300. FileUploadItem(
  301. this.file,
  302. this.collectionID,
  303. this.completer, {
  304. this.status = UploadStatus.not_started,
  305. });
  306. }
  307. enum UploadStatus {
  308. not_started,
  309. in_progress,
  310. completed,
  311. }
  312. class InvalidFileError extends Error {}
  313. class WiFiUnavailableError extends Error {}