backup.service.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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. for (var entity in assetList) {
  207. try {
  208. if (entity.type == AssetType.video) {
  209. file = await entity.originFile;
  210. } else {
  211. file = await entity.originFile.timeout(const Duration(seconds: 5));
  212. }
  213. if (file != null) {
  214. String originalFileName = await entity.titleAsync;
  215. var fileStream = file.openRead();
  216. var assetRawUploadData = http.MultipartFile(
  217. "assetData",
  218. fileStream,
  219. file.lengthSync(),
  220. filename: originalFileName,
  221. );
  222. var req = MultipartRequest(
  223. 'POST',
  224. Uri.parse('$savedEndpoint/asset/upload'),
  225. onProgress: ((bytes, totalBytes) =>
  226. uploadProgressCb(bytes, totalBytes)),
  227. );
  228. req.headers["Authorization"] =
  229. "Bearer ${Store.get(StoreKey.accessToken)}";
  230. req.headers["Transfer-Encoding"] = "chunked";
  231. req.fields['deviceAssetId'] = entity.id;
  232. req.fields['deviceId'] = deviceId;
  233. req.fields['fileCreatedAt'] = entity.createDateTime.toUtc().toIso8601String();
  234. req.fields['fileModifiedAt'] =
  235. entity.modifiedDateTime.toUtc().toIso8601String();
  236. req.fields['isFavorite'] = entity.isFavorite.toString();
  237. req.fields['duration'] = entity.videoDuration.toString();
  238. req.files.add(assetRawUploadData);
  239. if (entity.isLivePhoto) {
  240. var livePhotoRawUploadData = await _getLivePhotoFile(entity);
  241. if (livePhotoRawUploadData != null) {
  242. req.files.add(livePhotoRawUploadData);
  243. }
  244. }
  245. setCurrentUploadAssetCb(
  246. CurrentUploadAsset(
  247. id: entity.id,
  248. fileCreatedAt: entity.createDateTime.year == 1970
  249. ? entity.modifiedDateTime
  250. : entity.createDateTime,
  251. fileName: originalFileName,
  252. fileType: _getAssetType(entity.type),
  253. ),
  254. );
  255. var response =
  256. await httpClient.send(req, cancellationToken: cancelToken);
  257. if (response.statusCode == 200) {
  258. // asset is a duplicate (already exists on the server)
  259. duplicatedAssetIds.add(entity.id);
  260. uploadSuccessCb(entity.id, deviceId, true);
  261. } else if (response.statusCode == 201) {
  262. // stored a new asset on the server
  263. uploadSuccessCb(entity.id, deviceId, false);
  264. } else {
  265. var data = await response.stream.bytesToString();
  266. var error = jsonDecode(data);
  267. debugPrint(
  268. "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
  269. );
  270. errorCb(
  271. ErrorUploadAsset(
  272. asset: entity,
  273. id: entity.id,
  274. fileCreatedAt: entity.createDateTime,
  275. fileName: originalFileName,
  276. fileType: _getAssetType(entity.type),
  277. errorMessage: error['error'],
  278. ),
  279. );
  280. continue;
  281. }
  282. }
  283. } on http.CancelledException {
  284. debugPrint("Backup was cancelled by the user");
  285. anyErrors = true;
  286. break;
  287. } catch (e) {
  288. debugPrint("ERROR backupAsset: ${e.toString()}");
  289. anyErrors = true;
  290. continue;
  291. } finally {
  292. if (Platform.isIOS) {
  293. file?.deleteSync();
  294. }
  295. }
  296. }
  297. if (duplicatedAssetIds.isNotEmpty) {
  298. await _saveDuplicatedAssetIds(duplicatedAssetIds);
  299. }
  300. return !anyErrors;
  301. }
  302. Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
  303. var motionFilePath = await entity.getMediaUrl();
  304. if (motionFilePath != null) {
  305. var validPath = motionFilePath.replaceAll('file://', '');
  306. var motionFile = File(validPath);
  307. var fileStream = motionFile.openRead();
  308. String fileName = p.basename(motionFile.path);
  309. return http.MultipartFile(
  310. "livePhotoData",
  311. fileStream,
  312. motionFile.lengthSync(),
  313. filename: fileName,
  314. );
  315. }
  316. return null;
  317. }
  318. String _getAssetType(AssetType assetType) {
  319. switch (assetType) {
  320. case AssetType.audio:
  321. return "AUDIO";
  322. case AssetType.image:
  323. return "IMAGE";
  324. case AssetType.video:
  325. return "VIDEO";
  326. case AssetType.other:
  327. return "OTHER";
  328. }
  329. }
  330. }
  331. class MultipartRequest extends http.MultipartRequest {
  332. /// Creates a new [MultipartRequest].
  333. MultipartRequest(
  334. String method,
  335. Uri url, {
  336. required this.onProgress,
  337. }) : super(method, url);
  338. final void Function(int bytes, int totalBytes) onProgress;
  339. /// Freezes all mutable fields and returns a
  340. /// single-subscription [http.ByteStream]
  341. /// that will emit the request body.
  342. @override
  343. http.ByteStream finalize() {
  344. final byteStream = super.finalize();
  345. final total = contentLength;
  346. var bytes = 0;
  347. final t = StreamTransformer.fromHandlers(
  348. handleData: (List<int> data, EventSink<List<int>> sink) {
  349. bytes += data.length;
  350. onProgress.call(bytes, total);
  351. sink.add(data);
  352. },
  353. );
  354. final stream = byteStream.transform(t);
  355. return http.ByteStream(stream);
  356. }
  357. }