file_uploader_util.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import 'dart:async';
  2. import "dart:convert";
  3. import "dart:io";
  4. import 'dart:typed_data';
  5. import 'dart:ui' as ui;
  6. import "package:archive/archive_io.dart";
  7. import "package:computer/computer.dart";
  8. import 'package:logging/logging.dart';
  9. import "package:motion_photos/motion_photos.dart";
  10. import 'package:motionphoto/motionphoto.dart';
  11. import 'package:path/path.dart';
  12. import 'package:path_provider/path_provider.dart';
  13. import 'package:photo_manager/photo_manager.dart';
  14. import 'package:photos/core/configuration.dart';
  15. import 'package:photos/core/constants.dart';
  16. import 'package:photos/core/errors.dart';
  17. import 'package:photos/models/file/file.dart';
  18. import 'package:photos/models/file/file_type.dart';
  19. import "package:photos/models/location/location.dart";
  20. import "package:photos/models/metadata/file_magic.dart";
  21. import "package:photos/services/file_magic_service.dart";
  22. import 'package:photos/utils/crypto_util.dart';
  23. import 'package:photos/utils/file_util.dart';
  24. import "package:uuid/uuid.dart";
  25. import 'package:video_thumbnail/video_thumbnail.dart';
  26. final _logger = Logger("FileUtil");
  27. const kMaximumThumbnailCompressionAttempts = 2;
  28. const kLivePhotoHashSeparator = ':';
  29. class MediaUploadData {
  30. final File? sourceFile;
  31. final Uint8List? thumbnail;
  32. final bool isDeleted;
  33. final FileHashData? hashData;
  34. final int? height;
  35. final int? width;
  36. // For android motion photos, the startIndex is the index of the first frame
  37. // For iOS, this value will be always null.
  38. final int? motionPhotoStartIndex;
  39. MediaUploadData(
  40. this.sourceFile,
  41. this.thumbnail,
  42. this.isDeleted,
  43. this.hashData, {
  44. this.height,
  45. this.width,
  46. this.motionPhotoStartIndex,
  47. });
  48. }
  49. class FileHashData {
  50. // For livePhotos, the fileHash value will be imageHash:videoHash
  51. final String? fileHash;
  52. // zipHash is used to take care of existing live photo uploads from older
  53. // mobile clients
  54. String? zipHash;
  55. FileHashData(this.fileHash, {this.zipHash});
  56. }
  57. Future<MediaUploadData> getUploadDataFromEnteFile(EnteFile file) async {
  58. if (file.isSharedMediaToAppSandbox) {
  59. return await _getMediaUploadDataFromAppCache(file);
  60. } else {
  61. return await _getMediaUploadDataFromAssetFile(file);
  62. }
  63. }
  64. Future<MediaUploadData> _getMediaUploadDataFromAssetFile(EnteFile file) async {
  65. File? sourceFile;
  66. Uint8List? thumbnailData;
  67. bool isDeleted;
  68. String? zipHash;
  69. String fileHash;
  70. // The timeouts are to safeguard against https://github.com/CaiJingLong/flutter_photo_manager/issues/467
  71. final asset = await file.getAsset
  72. .timeout(const Duration(seconds: 3))
  73. .catchError((e) async {
  74. if (e is TimeoutException) {
  75. _logger.info("Asset fetch timed out for " + file.toString());
  76. return await file.getAsset;
  77. } else {
  78. throw e;
  79. }
  80. });
  81. if (asset == null) {
  82. throw InvalidFileError("", InvalidReason.assetDeleted);
  83. }
  84. _assertFileType(asset, file);
  85. sourceFile = await asset.originFile
  86. .timeout(const Duration(seconds: 3))
  87. .catchError((e) async {
  88. if (e is TimeoutException) {
  89. _logger.info("Origin file fetch timed out for " + file.toString());
  90. return await asset.originFile;
  91. } else {
  92. throw e;
  93. }
  94. });
  95. if (sourceFile == null || !sourceFile.existsSync()) {
  96. throw InvalidFileError(
  97. "id: ${file.localID}",
  98. InvalidReason.sourceFileMissing,
  99. );
  100. }
  101. // h4ck to fetch location data if missing (thank you Android Q+) lazily only during uploads
  102. await _decorateEnteFileData(file, asset);
  103. fileHash = CryptoUtil.bin2base64(await CryptoUtil.getHash(sourceFile));
  104. if (file.fileType == FileType.livePhoto && Platform.isIOS) {
  105. final File? videoUrl = await Motionphoto.getLivePhotoFile(file.localID!);
  106. if (videoUrl == null || !videoUrl.existsSync()) {
  107. final String errMsg =
  108. "missing livePhoto url for ${file.toString()} with subType ${file.fileSubType}";
  109. _logger.severe(errMsg);
  110. throw InvalidFileError(errMsg, InvalidReason.livePhotoVideoMissing);
  111. }
  112. final String livePhotoVideoHash =
  113. CryptoUtil.bin2base64(await CryptoUtil.getHash(videoUrl));
  114. // imgHash:vidHash
  115. fileHash = '$fileHash$kLivePhotoHashSeparator$livePhotoVideoHash';
  116. final tempPath = Configuration.instance.getTempDirectory();
  117. // .elp -> ente live photo
  118. final uniqueId = const Uuid().v4().toString();
  119. final livePhotoPath = tempPath + uniqueId + "_${file.generatedID}.elp";
  120. _logger.fine("Creating zip for live photo from " + livePhotoPath);
  121. await zip(
  122. zipPath: livePhotoPath,
  123. imagePath: sourceFile.path,
  124. videoPath: videoUrl.path,
  125. );
  126. // delete the temporary video and image copy (only in IOS)
  127. if (Platform.isIOS) {
  128. await sourceFile.delete();
  129. }
  130. // new sourceFile which needs to be uploaded
  131. sourceFile = File(livePhotoPath);
  132. zipHash = CryptoUtil.bin2base64(await CryptoUtil.getHash(sourceFile));
  133. }
  134. thumbnailData = await _getThumbnailForUpload(asset, file);
  135. isDeleted = !(await asset.exists);
  136. int? h, w;
  137. if (asset.width != 0 && asset.height != 0) {
  138. h = asset.height;
  139. w = asset.width;
  140. }
  141. int? motionPhotoStartingIndex;
  142. if (Platform.isAndroid && asset.type == AssetType.image) {
  143. try {
  144. motionPhotoStartingIndex =
  145. (await MotionPhotos(sourceFile.path).getMotionVideoIndex())?.start;
  146. } catch (e) {
  147. _logger.severe('error while detecthing motion photo start index', e);
  148. }
  149. }
  150. return MediaUploadData(
  151. sourceFile,
  152. thumbnailData,
  153. isDeleted,
  154. FileHashData(fileHash, zipHash: zipHash),
  155. height: h,
  156. width: w,
  157. motionPhotoStartIndex: motionPhotoStartingIndex,
  158. );
  159. }
  160. Future<void> _computeZip(Map<String, dynamic> args) async {
  161. final String zipPath = args['zipPath'];
  162. final String imagePath = args['imagePath'];
  163. final String videoPath = args['videoPath'];
  164. final encoder = ZipFileEncoder();
  165. encoder.create(zipPath);
  166. await encoder.addFile(File(imagePath), "image" + extension(imagePath));
  167. await encoder.addFile(File(videoPath), "video" + extension(videoPath));
  168. encoder.close();
  169. }
  170. Future<void> zip({
  171. required String zipPath,
  172. required String imagePath,
  173. required String videoPath,
  174. }) {
  175. return Computer.shared().compute(
  176. _computeZip,
  177. param: {
  178. 'zipPath': zipPath,
  179. 'imagePath': imagePath,
  180. 'videoPath': videoPath,
  181. },
  182. taskName: 'zip',
  183. );
  184. }
  185. Future<Uint8List?> _getThumbnailForUpload(
  186. AssetEntity asset,
  187. EnteFile file,
  188. ) async {
  189. try {
  190. Uint8List? thumbnailData = await asset.thumbnailDataWithSize(
  191. const ThumbnailSize(thumbnailLargeSize, thumbnailLargeSize),
  192. quality: thumbnailQuality,
  193. );
  194. if (thumbnailData == null) {
  195. // allow videos to be uploaded without thumbnails
  196. if (asset.type == AssetType.video) {
  197. return null;
  198. }
  199. throw InvalidFileError(
  200. "no thumbnail : ${file.fileType} ${file.tag}",
  201. InvalidReason.thumbnailMissing,
  202. );
  203. }
  204. int compressionAttempts = 0;
  205. while (thumbnailData!.length > thumbnailDataLimit &&
  206. compressionAttempts < kMaximumThumbnailCompressionAttempts) {
  207. _logger.info("Thumbnail size " + thumbnailData.length.toString());
  208. thumbnailData = await compressThumbnail(thumbnailData);
  209. _logger
  210. .info("Compressed thumbnail size " + thumbnailData.length.toString());
  211. compressionAttempts++;
  212. }
  213. return thumbnailData;
  214. } catch (e) {
  215. final String errMessage =
  216. "thumbErr for ${file.fileType}, ${extension(file.displayName)} ${file.tag}";
  217. _logger.warning(errMessage, e);
  218. // allow videos to be uploaded without thumbnails
  219. if (asset.type == AssetType.video) {
  220. return null;
  221. }
  222. throw InvalidFileError(errMessage, InvalidReason.thumbnailMissing);
  223. }
  224. }
  225. // check if the assetType is still the same. This can happen for livePhotos
  226. // if the user turns off the video using native photos app
  227. void _assertFileType(AssetEntity asset, EnteFile file) {
  228. final assetType = fileTypeFromAsset(asset);
  229. if (assetType == file.fileType) {
  230. return;
  231. }
  232. if (Platform.isIOS || Platform.isMacOS) {
  233. if (assetType == FileType.image && file.fileType == FileType.livePhoto) {
  234. throw InvalidFileError(
  235. 'id ${asset.id}',
  236. InvalidReason.livePhotoToImageTypeChanged,
  237. );
  238. } else if (assetType == FileType.livePhoto &&
  239. file.fileType == FileType.image) {
  240. throw InvalidFileError(
  241. 'id ${asset.id}',
  242. InvalidReason.imageToLivePhotoTypeChanged,
  243. );
  244. }
  245. }
  246. throw InvalidFileError(
  247. 'fileType mismatch for id ${asset.id} assetType $assetType fileType ${file.fileType}',
  248. InvalidReason.unknown,
  249. );
  250. }
  251. Future<void> _decorateEnteFileData(EnteFile file, AssetEntity asset) async {
  252. // h4ck to fetch location data if missing (thank you Android Q+) lazily only during uploads
  253. if (file.location == null ||
  254. (file.location!.latitude == 0 && file.location!.longitude == 0)) {
  255. final latLong = await asset.latlngAsync();
  256. file.location =
  257. Location(latitude: latLong.latitude, longitude: latLong.longitude);
  258. }
  259. if (file.title == null || file.title!.isEmpty) {
  260. _logger.warning("Title was missing ${file.tag}");
  261. file.title = await asset.titleAsync;
  262. }
  263. }
  264. Future<MetadataRequest> getPubMetadataRequest(
  265. EnteFile file,
  266. Map<String, dynamic> newData,
  267. Uint8List fileKey,
  268. ) async {
  269. final Map<String, dynamic> jsonToUpdate =
  270. jsonDecode(file.pubMmdEncodedJson ?? '{}');
  271. newData.forEach((key, value) {
  272. jsonToUpdate[key] = value;
  273. });
  274. // update the local information so that it's reflected on UI
  275. file.pubMmdEncodedJson = jsonEncode(jsonToUpdate);
  276. file.pubMagicMetadata = PubMagicMetadata.fromJson(jsonToUpdate);
  277. final encryptedMMd = await CryptoUtil.encryptChaCha(
  278. utf8.encode(jsonEncode(jsonToUpdate)) as Uint8List,
  279. fileKey,
  280. );
  281. return MetadataRequest(
  282. version: file.pubMmdVersion == 0 ? 1 : file.pubMmdVersion,
  283. count: jsonToUpdate.length,
  284. data: CryptoUtil.bin2base64(encryptedMMd.encryptedData!),
  285. header: CryptoUtil.bin2base64(encryptedMMd.header!),
  286. );
  287. }
  288. Future<MediaUploadData> _getMediaUploadDataFromAppCache(EnteFile file) async {
  289. File sourceFile;
  290. Uint8List? thumbnailData;
  291. const bool isDeleted = false;
  292. final localPath = getSharedMediaFilePath(file);
  293. sourceFile = File(localPath);
  294. if (!sourceFile.existsSync()) {
  295. _logger.warning("File doesn't exist in app sandbox");
  296. throw InvalidFileError(
  297. "source missing in sandbox",
  298. InvalidReason.sourceFileMissing,
  299. );
  300. }
  301. try {
  302. thumbnailData = await getThumbnailFromInAppCacheFile(file);
  303. final fileHash =
  304. CryptoUtil.bin2base64(await CryptoUtil.getHash(sourceFile));
  305. Map<String, int>? dimensions;
  306. if (file.fileType == FileType.image) {
  307. dimensions = await getImageHeightAndWith(imagePath: localPath);
  308. } else {
  309. // for video, we need to use the thumbnail data with any max width/height
  310. final thumbnailFilePath = await VideoThumbnail.thumbnailFile(
  311. video: localPath,
  312. imageFormat: ImageFormat.JPEG,
  313. thumbnailPath: (await getTemporaryDirectory()).path,
  314. quality: 10,
  315. );
  316. dimensions = await getImageHeightAndWith(imagePath: thumbnailFilePath);
  317. }
  318. return MediaUploadData(
  319. sourceFile,
  320. thumbnailData,
  321. isDeleted,
  322. FileHashData(fileHash),
  323. height: dimensions?['height'],
  324. width: dimensions?['width'],
  325. );
  326. } catch (e, s) {
  327. _logger.severe("failed to generate thumbnail", e, s);
  328. throw InvalidFileError(
  329. "thumbnail failed for appCache fileType: ${file.fileType.toString()}",
  330. InvalidReason.thumbnailMissing,
  331. );
  332. }
  333. }
  334. Future<Map<String, int>?> getImageHeightAndWith({
  335. String? imagePath,
  336. Uint8List? imageBytes,
  337. }) async {
  338. if (imagePath == null && imageBytes == null) {
  339. throw ArgumentError("imagePath and imageBytes cannot be null");
  340. }
  341. try {
  342. late Uint8List bytes;
  343. if (imagePath != null) {
  344. final File imageFile = File(imagePath);
  345. bytes = await imageFile.readAsBytes();
  346. } else {
  347. bytes = imageBytes!;
  348. }
  349. final ui.Codec codec = await ui.instantiateImageCodec(bytes);
  350. final ui.FrameInfo frameInfo = await codec.getNextFrame();
  351. if (frameInfo.image.width == 0 || frameInfo.image.height == 0) {
  352. return null;
  353. } else {
  354. return {
  355. "width": frameInfo.image.width,
  356. "height": frameInfo.image.height,
  357. };
  358. }
  359. } catch (e) {
  360. _logger.severe("Failed to get image size", e);
  361. return null;
  362. }
  363. }
  364. Future<Uint8List?> getThumbnailFromInAppCacheFile(EnteFile file) async {
  365. var localFile = File(getSharedMediaFilePath(file));
  366. if (!localFile.existsSync()) {
  367. return null;
  368. }
  369. if (file.fileType == FileType.video) {
  370. final thumbnailFilePath = await VideoThumbnail.thumbnailFile(
  371. video: localFile.path,
  372. imageFormat: ImageFormat.JPEG,
  373. thumbnailPath: (await getTemporaryDirectory()).path,
  374. maxWidth: thumbnailLargeSize,
  375. quality: 80,
  376. );
  377. localFile = File(thumbnailFilePath!);
  378. }
  379. var thumbnailData = await localFile.readAsBytes();
  380. int compressionAttempts = 0;
  381. while (thumbnailData.length > thumbnailDataLimit &&
  382. compressionAttempts < kMaximumThumbnailCompressionAttempts) {
  383. _logger.info("Thumbnail size " + thumbnailData.length.toString());
  384. thumbnailData = await compressThumbnail(thumbnailData);
  385. _logger
  386. .info("Compressed thumbnail size " + thumbnailData.length.toString());
  387. compressionAttempts++;
  388. }
  389. return thumbnailData;
  390. }