backup.service.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'dart:io';
  4. import 'package:cancellation_token_http/http.dart';
  5. import 'package:collection/collection.dart';
  6. import 'package:flutter/material.dart';
  7. import 'package:hooks_riverpod/hooks_riverpod.dart';
  8. import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
  9. import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
  10. import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
  11. import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
  12. import 'package:immich_mobile/shared/models/store.dart';
  13. import 'package:immich_mobile/shared/providers/api.provider.dart';
  14. import 'package:immich_mobile/shared/providers/db.provider.dart';
  15. import 'package:immich_mobile/shared/services/api.service.dart';
  16. import 'package:isar/isar.dart';
  17. import 'package:logging/logging.dart';
  18. import 'package:openapi/api.dart';
  19. import 'package:permission_handler/permission_handler.dart';
  20. import 'package:photo_manager/photo_manager.dart';
  21. import 'package:cancellation_token_http/http.dart' as http;
  22. import 'package:path/path.dart' as p;
  23. final backupServiceProvider = Provider(
  24. (ref) => BackupService(
  25. ref.watch(apiServiceProvider),
  26. ref.watch(dbProvider),
  27. ),
  28. );
  29. class BackupService {
  30. final httpClient = http.Client();
  31. final ApiService _apiService;
  32. final Isar _db;
  33. final Logger _log = Logger("BackupService");
  34. BackupService(this._apiService, this._db);
  35. Future<List<String>?> getDeviceBackupAsset() async {
  36. final String deviceId = Store.get(StoreKey.deviceId);
  37. try {
  38. return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
  39. } catch (e) {
  40. debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
  41. return null;
  42. }
  43. }
  44. Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
  45. final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
  46. return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
  47. }
  48. /// Get duplicated asset id from database
  49. Future<Set<String>> getDuplicatedAssetIds() async {
  50. final duplicates = await _db.duplicatedAssets.where().findAll();
  51. return duplicates.map((e) => e.id).toSet();
  52. }
  53. QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
  54. selectedAlbumsQuery() =>
  55. _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
  56. QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
  57. excludedAlbumsQuery() =>
  58. _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
  59. /// Returns all assets newer than the last successful backup per album
  60. Future<List<AssetEntity>> buildUploadCandidates(
  61. List<BackupAlbum> selectedBackupAlbums,
  62. List<BackupAlbum> excludedBackupAlbums,
  63. ) async {
  64. final filter = FilterOptionGroup(
  65. containsPathModified: true,
  66. orders: [const OrderOption(type: OrderOptionType.updateDate)],
  67. // title is needed to create Assets
  68. imageOption: const FilterOption(needTitle: true),
  69. videoOption: const FilterOption(needTitle: true),
  70. );
  71. final now = DateTime.now();
  72. final List<AssetPathEntity?> selectedAlbums =
  73. await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
  74. if (selectedAlbums.every((e) => e == null)) {
  75. return [];
  76. }
  77. final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
  78. if (allIdx != -1) {
  79. final List<AssetPathEntity?> excludedAlbums =
  80. await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
  81. final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
  82. selectedAlbums.slice(allIdx, allIdx + 1),
  83. selectedBackupAlbums.slice(allIdx, allIdx + 1),
  84. now,
  85. );
  86. final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
  87. excludedAlbums,
  88. excludedBackupAlbums,
  89. now,
  90. );
  91. return toAdd.toSet().difference(toRemove.toSet()).toList();
  92. } else {
  93. return await _fetchAssetsAndUpdateLastBackup(
  94. selectedAlbums,
  95. selectedBackupAlbums,
  96. now,
  97. );
  98. }
  99. }
  100. Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
  101. List<BackupAlbum> albums,
  102. FilterOptionGroup filter,
  103. DateTime now,
  104. ) async {
  105. List<AssetPathEntity?> result = [];
  106. for (BackupAlbum a in albums) {
  107. try {
  108. final AssetPathEntity album =
  109. await AssetPathEntity.obtainPathFromProperties(
  110. id: a.id,
  111. optionGroup: filter.copyWith(
  112. updateTimeCond: DateTimeCond(
  113. // subtract 2 seconds to prevent missing assets due to rounding issues
  114. min: a.lastBackup.subtract(const Duration(seconds: 2)),
  115. max: now,
  116. ),
  117. ),
  118. maxDateTimeToNow: false,
  119. );
  120. result.add(album);
  121. } on StateError {
  122. // either there are no assets matching the filter criteria OR the album no longer exists
  123. }
  124. }
  125. return result;
  126. }
  127. Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
  128. List<AssetPathEntity?> albums,
  129. List<BackupAlbum> backupAlbums,
  130. DateTime now,
  131. ) async {
  132. List<AssetEntity> result = [];
  133. for (int i = 0; i < albums.length; i++) {
  134. final AssetPathEntity? a = albums[i];
  135. if (a != null &&
  136. a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
  137. result.addAll(
  138. await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
  139. );
  140. backupAlbums[i].lastBackup = now;
  141. }
  142. }
  143. return result;
  144. }
  145. /// Returns a new list of assets not yet uploaded
  146. Future<List<AssetEntity>> removeAlreadyUploadedAssets(
  147. List<AssetEntity> candidates,
  148. ) async {
  149. if (candidates.isEmpty) {
  150. return candidates;
  151. }
  152. final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
  153. candidates = duplicatedAssetIds.isEmpty
  154. ? candidates
  155. : candidates
  156. .whereNot((asset) => duplicatedAssetIds.contains(asset.id))
  157. .toList();
  158. if (candidates.isEmpty) {
  159. return candidates;
  160. }
  161. final Set<String> existing = {};
  162. try {
  163. final String deviceId = Store.get(StoreKey.deviceId);
  164. final CheckExistingAssetsResponseDto? duplicates =
  165. await _apiService.assetApi.checkExistingAssets(
  166. CheckExistingAssetsDto(
  167. deviceAssetIds: candidates.map((e) => e.id).toList(),
  168. deviceId: deviceId,
  169. ),
  170. );
  171. if (duplicates != null) {
  172. existing.addAll(duplicates.existingIds);
  173. }
  174. } on ApiException {
  175. // workaround for older server versions or when checking for too many assets at once
  176. final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
  177. if (allAssetsInDatabase != null) {
  178. existing.addAll(allAssetsInDatabase);
  179. }
  180. }
  181. return existing.isEmpty
  182. ? candidates
  183. : candidates.whereNot((e) => existing.contains(e.id)).toList();
  184. }
  185. Future<bool> backupAsset(
  186. Iterable<AssetEntity> assetList,
  187. http.CancellationToken cancelToken,
  188. Function(String, String, bool) uploadSuccessCb,
  189. Function(int, int) uploadProgressCb,
  190. Function(CurrentUploadAsset) setCurrentUploadAssetCb,
  191. Function(ErrorUploadAsset) errorCb,
  192. ) async {
  193. if (Platform.isAndroid &&
  194. !(await Permission.accessMediaLocation.status).isGranted) {
  195. // double check that permission is granted here, to guard against
  196. // uploading corrupt assets without EXIF information
  197. _log.warning("Media location permission is not granted. "
  198. "Cannot access original assets for backup.");
  199. return false;
  200. }
  201. final String deviceId = Store.get(StoreKey.deviceId);
  202. final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
  203. File? file;
  204. bool anyErrors = false;
  205. final List<String> duplicatedAssetIds = [];
  206. // Upload images before video assets
  207. // these are further sorted by using their creation date so the upload goes as follows
  208. // older images -> latest images -> older videos -> latest videos
  209. List<AssetEntity> sortedAssets = assetList.sorted(
  210. (a, b) {
  211. final cmp = a.typeInt - b.typeInt;
  212. if (cmp != 0) return cmp;
  213. return a.createDateTime.compareTo(b.createDateTime);
  214. },
  215. );
  216. for (var entity in sortedAssets) {
  217. try {
  218. if (entity.type == AssetType.video) {
  219. file = await entity.originFile;
  220. } else {
  221. file = await entity.originFile.timeout(const Duration(seconds: 5));
  222. }
  223. if (file != null) {
  224. String originalFileName = await entity.titleAsync;
  225. var fileStream = file.openRead();
  226. var assetRawUploadData = http.MultipartFile(
  227. "assetData",
  228. fileStream,
  229. file.lengthSync(),
  230. filename: originalFileName,
  231. );
  232. var req = MultipartRequest(
  233. 'POST',
  234. Uri.parse('$savedEndpoint/asset/upload'),
  235. onProgress: ((bytes, totalBytes) =>
  236. uploadProgressCb(bytes, totalBytes)),
  237. );
  238. req.headers["Authorization"] =
  239. "Bearer ${Store.get(StoreKey.accessToken)}";
  240. req.headers["Transfer-Encoding"] = "chunked";
  241. req.fields['deviceAssetId'] = entity.id;
  242. req.fields['deviceId'] = deviceId;
  243. req.fields['fileCreatedAt'] =
  244. entity.createDateTime.toUtc().toIso8601String();
  245. req.fields['fileModifiedAt'] =
  246. entity.modifiedDateTime.toUtc().toIso8601String();
  247. req.fields['isFavorite'] = entity.isFavorite.toString();
  248. req.fields['duration'] = entity.videoDuration.toString();
  249. req.files.add(assetRawUploadData);
  250. if (entity.isLivePhoto) {
  251. var livePhotoRawUploadData = await _getLivePhotoFile(entity);
  252. if (livePhotoRawUploadData != null) {
  253. req.files.add(livePhotoRawUploadData);
  254. }
  255. }
  256. setCurrentUploadAssetCb(
  257. CurrentUploadAsset(
  258. id: entity.id,
  259. fileCreatedAt: entity.createDateTime.year == 1970
  260. ? entity.modifiedDateTime
  261. : entity.createDateTime,
  262. fileName: originalFileName,
  263. fileType: _getAssetType(entity.type),
  264. ),
  265. );
  266. var response =
  267. await httpClient.send(req, cancellationToken: cancelToken);
  268. if (response.statusCode == 200) {
  269. // asset is a duplicate (already exists on the server)
  270. duplicatedAssetIds.add(entity.id);
  271. uploadSuccessCb(entity.id, deviceId, true);
  272. } else if (response.statusCode == 201) {
  273. // stored a new asset on the server
  274. uploadSuccessCb(entity.id, deviceId, false);
  275. } else {
  276. var data = await response.stream.bytesToString();
  277. var error = jsonDecode(data);
  278. debugPrint(
  279. "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
  280. );
  281. errorCb(
  282. ErrorUploadAsset(
  283. asset: entity,
  284. id: entity.id,
  285. fileCreatedAt: entity.createDateTime,
  286. fileName: originalFileName,
  287. fileType: _getAssetType(entity.type),
  288. errorMessage: error['error'],
  289. ),
  290. );
  291. continue;
  292. }
  293. }
  294. } on http.CancelledException {
  295. debugPrint("Backup was cancelled by the user");
  296. anyErrors = true;
  297. break;
  298. } catch (e) {
  299. debugPrint("ERROR backupAsset: ${e.toString()}");
  300. anyErrors = true;
  301. continue;
  302. } finally {
  303. if (Platform.isIOS) {
  304. file?.deleteSync();
  305. }
  306. }
  307. }
  308. if (duplicatedAssetIds.isNotEmpty) {
  309. await _saveDuplicatedAssetIds(duplicatedAssetIds);
  310. }
  311. return !anyErrors;
  312. }
  313. Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
  314. var motionFilePath = await entity.getMediaUrl();
  315. if (motionFilePath != null) {
  316. var validPath = motionFilePath.replaceAll('file://', '');
  317. var motionFile = File(validPath);
  318. var fileStream = motionFile.openRead();
  319. String fileName = p.basename(motionFile.path);
  320. return http.MultipartFile(
  321. "livePhotoData",
  322. fileStream,
  323. motionFile.lengthSync(),
  324. filename: fileName,
  325. );
  326. }
  327. return null;
  328. }
  329. String _getAssetType(AssetType assetType) {
  330. switch (assetType) {
  331. case AssetType.audio:
  332. return "AUDIO";
  333. case AssetType.image:
  334. return "IMAGE";
  335. case AssetType.video:
  336. return "VIDEO";
  337. case AssetType.other:
  338. return "OTHER";
  339. }
  340. }
  341. }
  342. class MultipartRequest extends http.MultipartRequest {
  343. /// Creates a new [MultipartRequest].
  344. MultipartRequest(
  345. String method,
  346. Uri url, {
  347. required this.onProgress,
  348. }) : super(method, url);
  349. final void Function(int bytes, int totalBytes) onProgress;
  350. /// Freezes all mutable fields and returns a
  351. /// single-subscription [http.ByteStream]
  352. /// that will emit the request body.
  353. @override
  354. http.ByteStream finalize() {
  355. final byteStream = super.finalize();
  356. final total = contentLength;
  357. var bytes = 0;
  358. final t = StreamTransformer.fromHandlers(
  359. handleData: (List<int> data, EventSink<List<int>> sink) {
  360. bytes += data.length;
  361. onProgress.call(bytes, total);
  362. sink.add(data);
  363. },
  364. );
  365. final stream = byteStream.transform(t);
  366. return http.ByteStream(stream);
  367. }
  368. }