file_util.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import 'dart:async';
  2. import 'dart:collection';
  3. import 'dart:io' as io;
  4. import 'dart:io';
  5. import 'dart:typed_data';
  6. import 'package:flutter/widgets.dart';
  7. import 'package:flutter_sodium/flutter_sodium.dart';
  8. import 'package:logging/logging.dart';
  9. import 'package:path/path.dart';
  10. import 'package:dio/dio.dart';
  11. import 'package:flutter_cache_manager/flutter_cache_manager.dart';
  12. import 'package:flutter_image_compress/flutter_image_compress.dart';
  13. import 'package:photo_manager/photo_manager.dart';
  14. import 'package:photos/core/cache/image_cache.dart';
  15. import 'package:photos/core/cache/thumbnail_cache.dart';
  16. import 'package:photos/core/cache/thumbnail_cache_manager.dart';
  17. import 'package:photos/core/cache/video_cache_manager.dart';
  18. import 'package:photos/core/configuration.dart';
  19. import 'package:photos/core/constants.dart';
  20. import 'package:photos/core/event_bus.dart';
  21. import 'package:photos/core/network.dart';
  22. import 'package:photos/db/files_db.dart';
  23. import 'package:photos/events/collection_updated_event.dart';
  24. import 'package:photos/models/file.dart';
  25. import 'package:photos/models/file_type.dart';
  26. import 'package:photos/repositories/file_repository.dart';
  27. import 'package:photos/services/collections_service.dart';
  28. import 'package:photos/services/sync_service.dart';
  29. import 'package:photos/utils/dialog_util.dart';
  30. import 'crypto_util.dart';
  31. final _logger = Logger("FileUtil");
  32. Future<void> deleteFilesFromEverywhere(
  33. BuildContext context, List<File> files) async {
  34. final dialog = createProgressDialog(context, "deleting...");
  35. await dialog.show();
  36. final localIDs = List<String>();
  37. for (final file in files) {
  38. if (file.localID != null) {
  39. localIDs.add(file.localID);
  40. }
  41. }
  42. var deletedIDs;
  43. try {
  44. deletedIDs = (await PhotoManager.editor.deleteWithIds(localIDs)).toSet();
  45. } catch (e, s) {
  46. _logger.severe("Could not delete file", e, s);
  47. }
  48. bool hasUploadedFiles = false;
  49. final updatedCollectionIDs = Set<int>();
  50. for (final file in files) {
  51. if (file.localID != null) {
  52. // Remove only those files that have been removed from disk
  53. if (deletedIDs.contains(file.localID)) {
  54. if (file.uploadedFileID != null) {
  55. hasUploadedFiles = true;
  56. await FilesDB.instance.markForDeletion(file.uploadedFileID);
  57. updatedCollectionIDs.add(file.collectionID);
  58. } else {
  59. await FilesDB.instance.deleteLocalFile(file.localID);
  60. }
  61. }
  62. } else {
  63. hasUploadedFiles = true;
  64. await FilesDB.instance.markForDeletion(file.uploadedFileID);
  65. }
  66. await dialog.hide();
  67. }
  68. await FileRepository.instance.reloadFiles();
  69. if (hasUploadedFiles) {
  70. for (final collectionID in updatedCollectionIDs) {
  71. Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID));
  72. }
  73. // TODO: Blocking call?
  74. SyncService.instance.deleteFilesOnServer();
  75. }
  76. }
  77. Future<void> deleteFilesOnDeviceOnly(
  78. BuildContext context, List<File> files) async {
  79. final dialog = createProgressDialog(context, "deleting...");
  80. await dialog.show();
  81. final localIDs = List<String>();
  82. for (final file in files) {
  83. if (file.localID != null) {
  84. localIDs.add(file.localID);
  85. }
  86. }
  87. final deletedIDs =
  88. (await PhotoManager.editor.deleteWithIds(localIDs)).toSet();
  89. for (final file in files) {
  90. // Remove only those files that have been removed from disk
  91. if (deletedIDs.contains(file.localID)) {
  92. file.localID = null;
  93. FilesDB.instance.update(file);
  94. }
  95. }
  96. await FileRepository.instance.reloadFiles();
  97. await dialog.hide();
  98. }
  99. void preloadFile(File file) {
  100. if (file.fileType == FileType.video) {
  101. return;
  102. }
  103. if (file.localID == null) {
  104. // getFileFromServer(file);
  105. } else {
  106. if (FileLruCache.get(file) == null) {
  107. file.getAsset().then((asset) {
  108. asset.file.then((assetFile) {
  109. FileLruCache.put(file, assetFile);
  110. });
  111. });
  112. }
  113. }
  114. }
  115. void preloadLocalFileThumbnail(File file) {
  116. if (file.localID == null ||
  117. ThumbnailLruCache.get(file, THUMBNAIL_SMALL_SIZE) != null) {
  118. return;
  119. }
  120. file.getAsset().then((asset) {
  121. asset
  122. .thumbDataWithSize(
  123. THUMBNAIL_SMALL_SIZE,
  124. THUMBNAIL_SMALL_SIZE,
  125. quality: THUMBNAIL_QUALITY,
  126. )
  127. .then((data) {
  128. ThumbnailLruCache.put(file, THUMBNAIL_SMALL_SIZE, data);
  129. });
  130. });
  131. }
  132. Future<io.File> getNativeFile(File file) async {
  133. if (file.localID == null) {
  134. return getFileFromServer(file);
  135. } else {
  136. return file.getAsset().then((asset) => asset.originFile);
  137. }
  138. }
  139. Future<Uint8List> getBytes(File file, {int quality = 100}) async {
  140. if (file.localID == null) {
  141. return getFileFromServer(file).then((file) => file.readAsBytesSync());
  142. } else {
  143. return await getBytesFromDisk(file, quality: quality);
  144. }
  145. }
  146. Future<Uint8List> getBytesFromDisk(File file, {int quality = 100}) async {
  147. final originalBytes = (await file.getAsset()).originBytes;
  148. if (extension(file.title) == ".HEIC" || quality != 100) {
  149. return originalBytes.then((bytes) {
  150. return FlutterImageCompress.compressWithList(bytes, quality: quality)
  151. .then((converted) {
  152. return Uint8List.fromList(converted);
  153. });
  154. });
  155. } else {
  156. return originalBytes;
  157. }
  158. }
  159. final Map<int, Future<io.File>> fileDownloadsInProgress =
  160. Map<int, Future<io.File>>();
  161. final _thumbnailQueue = LinkedHashMap<int, FileDownloadItem>();
  162. int _currentlyDownloading = 0;
  163. int kMaximumConcurrentDownloads = 100;
  164. class FileDownloadItem {
  165. final File file;
  166. final Completer<io.File> completer;
  167. DownloadStatus status;
  168. FileDownloadItem(
  169. this.file,
  170. this.completer, {
  171. this.status = DownloadStatus.not_started,
  172. });
  173. }
  174. enum DownloadStatus {
  175. not_started,
  176. in_progress,
  177. }
  178. Future<io.File> getFileFromServer(File file,
  179. {ProgressCallback progressCallback}) async {
  180. final cacheManager = file.fileType == FileType.video
  181. ? VideoCacheManager()
  182. : DefaultCacheManager();
  183. return cacheManager.getFileFromCache(file.getDownloadUrl()).then((info) {
  184. if (info == null) {
  185. if (!fileDownloadsInProgress.containsKey(file.uploadedFileID)) {
  186. fileDownloadsInProgress[file.uploadedFileID] = _downloadAndDecrypt(
  187. file,
  188. cacheManager,
  189. progressCallback: progressCallback,
  190. );
  191. }
  192. return fileDownloadsInProgress[file.uploadedFileID];
  193. } else {
  194. return info.file;
  195. }
  196. });
  197. }
  198. Future<io.File> getThumbnailFromServer(File file) async {
  199. return ThumbnailCacheManager()
  200. .getFileFromCache(file.getThumbnailUrl())
  201. .then((info) {
  202. if (info == null) {
  203. if (!_thumbnailQueue.containsKey(file.uploadedFileID)) {
  204. final completer = Completer<io.File>();
  205. _thumbnailQueue[file.uploadedFileID] =
  206. FileDownloadItem(file, completer);
  207. _pollQueue();
  208. return completer.future;
  209. } else {
  210. return _thumbnailQueue[file.uploadedFileID].completer.future;
  211. }
  212. } else {
  213. ThumbnailFileLruCache.put(file, info.file);
  214. return info.file;
  215. }
  216. });
  217. }
  218. void removePendingGetThumbnailRequestIfAny(File file) {
  219. if (_thumbnailQueue[file.uploadedFileID] != null &&
  220. _thumbnailQueue[file.uploadedFileID].status ==
  221. DownloadStatus.not_started) {
  222. _thumbnailQueue.remove(file.uploadedFileID);
  223. }
  224. }
  225. void _pollQueue() async {
  226. if (_thumbnailQueue.length > 0 &&
  227. _currentlyDownloading < kMaximumConcurrentDownloads) {
  228. final firstPendingEntry = _thumbnailQueue.entries.firstWhere(
  229. (entry) => entry.value.status == DownloadStatus.not_started,
  230. orElse: () => null);
  231. if (firstPendingEntry != null) {
  232. final item = firstPendingEntry.value;
  233. _currentlyDownloading++;
  234. item.status = DownloadStatus.in_progress;
  235. try {
  236. final data = await _downloadAndDecryptThumbnail(item.file);
  237. ThumbnailFileLruCache.put(item.file, data);
  238. item.completer.complete(data);
  239. } catch (e, s) {
  240. _logger.severe(
  241. "Failed to download thumbnail " + item.file.toString(), e, s);
  242. item.completer.completeError(e);
  243. }
  244. _currentlyDownloading--;
  245. _thumbnailQueue.remove(firstPendingEntry.key);
  246. _pollQueue();
  247. }
  248. }
  249. }
  250. Future<io.File> _downloadAndDecrypt(File file, BaseCacheManager cacheManager,
  251. {ProgressCallback progressCallback}) async {
  252. _logger.info("Downloading file " + file.uploadedFileID.toString());
  253. final encryptedFilePath = Configuration.instance.getTempDirectory() +
  254. file.generatedID.toString() +
  255. ".encrypted";
  256. final decryptedFilePath = Configuration.instance.getTempDirectory() +
  257. file.generatedID.toString() +
  258. ".decrypted";
  259. final encryptedFile = io.File(encryptedFilePath);
  260. final decryptedFile = io.File(decryptedFilePath);
  261. final startTime = DateTime.now().millisecondsSinceEpoch;
  262. return Network.instance
  263. .getDio()
  264. .download(
  265. file.getDownloadUrl(),
  266. encryptedFilePath,
  267. options: Options(
  268. headers: {"X-Auth-Token": Configuration.instance.getToken()},
  269. ),
  270. onReceiveProgress: progressCallback,
  271. )
  272. .then((response) async {
  273. if (response.statusCode != 200) {
  274. _logger.warning("Could not download file: ", response.toString());
  275. return null;
  276. } else if (!encryptedFile.existsSync()) {
  277. _logger.warning("File was not downloaded correctly.");
  278. return null;
  279. }
  280. _logger.info("File downloaded: " + file.uploadedFileID.toString());
  281. _logger.info("Download speed: " +
  282. (io.File(encryptedFilePath).lengthSync() /
  283. (DateTime.now().millisecondsSinceEpoch - startTime))
  284. .toString() +
  285. "kBps");
  286. await CryptoUtil.decryptFile(encryptedFilePath, decryptedFilePath,
  287. Sodium.base642bin(file.fileDecryptionHeader), decryptFileKey(file));
  288. _logger.info("File decrypted: " + file.uploadedFileID.toString());
  289. encryptedFile.deleteSync();
  290. var fileExtension = extension(file.title).substring(1).toLowerCase();
  291. var outputFile = decryptedFile;
  292. if (Platform.isAndroid && fileExtension == "heic") {
  293. outputFile = await FlutterImageCompress.compressAndGetFile(
  294. decryptedFilePath,
  295. decryptedFilePath + ".jpg",
  296. keepExif: true,
  297. );
  298. decryptedFile.deleteSync();
  299. }
  300. final cachedFile = await cacheManager.putFile(
  301. file.getDownloadUrl(),
  302. outputFile.readAsBytesSync(),
  303. eTag: file.getDownloadUrl(),
  304. maxAge: Duration(days: 365),
  305. fileExtension: fileExtension,
  306. );
  307. outputFile.deleteSync();
  308. fileDownloadsInProgress.remove(file.uploadedFileID);
  309. return cachedFile;
  310. }).catchError((e) {
  311. fileDownloadsInProgress.remove(file.uploadedFileID);
  312. });
  313. }
  314. Future<io.File> _downloadAndDecryptThumbnail(File file) async {
  315. final temporaryPath = Configuration.instance.getTempDirectory() +
  316. file.generatedID.toString() +
  317. "_thumbnail.decrypted";
  318. await Network.instance.getDio().download(
  319. file.getThumbnailUrl(),
  320. temporaryPath,
  321. options: Options(
  322. headers: {"X-Auth-Token": Configuration.instance.getToken()},
  323. ),
  324. );
  325. final encryptedFile = io.File(temporaryPath);
  326. final thumbnailDecryptionKey = decryptFileKey(file);
  327. var data = CryptoUtil.decryptChaCha(
  328. encryptedFile.readAsBytesSync(),
  329. thumbnailDecryptionKey,
  330. Sodium.base642bin(file.thumbnailDecryptionHeader),
  331. );
  332. final thumbnailSize = data.length;
  333. if (thumbnailSize > THUMBNAIL_DATA_LIMIT) {
  334. data = await compressThumbnail(data);
  335. _logger.info("Compressed thumbnail from " +
  336. thumbnailSize.toString() +
  337. " to " +
  338. data.length.toString());
  339. }
  340. encryptedFile.deleteSync();
  341. final cachedThumbnail = ThumbnailCacheManager().putFile(
  342. file.getThumbnailUrl(),
  343. data,
  344. eTag: file.getThumbnailUrl(),
  345. maxAge: Duration(days: 365),
  346. );
  347. return cachedThumbnail;
  348. }
  349. Uint8List decryptFileKey(File file) {
  350. final encryptedKey = Sodium.base642bin(file.encryptedKey);
  351. final nonce = Sodium.base642bin(file.keyDecryptionNonce);
  352. final collectionKey =
  353. CollectionsService.instance.getCollectionKey(file.collectionID);
  354. return CryptoUtil.decryptSync(encryptedKey, collectionKey, nonce);
  355. }
  356. Future<Uint8List> compressThumbnail(Uint8List thumbnail) {
  357. return FlutterImageCompress.compressWithList(
  358. thumbnail,
  359. minHeight: COMPRESSED_THUMBNAIL_RESOLUTION,
  360. minWidth: COMPRESSED_THUMBNAIL_RESOLUTION,
  361. quality: 25,
  362. );
  363. }
  364. void clearCache(File file) {
  365. if (file.fileType == FileType.video) {
  366. VideoCacheManager().removeFile(file.getDownloadUrl());
  367. } else {
  368. DefaultCacheManager().removeFile(file.getDownloadUrl());
  369. }
  370. ThumbnailCacheManager().removeFile(file.getThumbnailUrl());
  371. }