file_util.dart 12 KB

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