backup_verification.service.dart 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import 'dart:async';
  2. import 'dart:typed_data';
  3. import 'package:collection/collection.dart';
  4. import 'package:flutter/foundation.dart';
  5. import 'package:flutter/services.dart';
  6. import 'package:hooks_riverpod/hooks_riverpod.dart';
  7. import 'package:immich_mobile/shared/models/asset.dart';
  8. import 'package:immich_mobile/shared/models/exif_info.dart';
  9. import 'package:immich_mobile/shared/models/store.dart';
  10. import 'package:immich_mobile/shared/providers/db.provider.dart';
  11. import 'package:immich_mobile/shared/services/api.service.dart';
  12. import 'package:immich_mobile/utils/diff.dart';
  13. import 'package:isar/isar.dart';
  14. import 'package:photo_manager/photo_manager.dart' show PhotoManager;
  15. /// Finds duplicates originating from missing EXIF information
  16. class BackupVerificationService {
  17. final Isar _db;
  18. BackupVerificationService(this._db);
  19. /// Returns at most [limit] assets that were backed up without exif
  20. Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
  21. final owner = Store.get(StoreKey.currentUser).isarId;
  22. final List<Asset> onlyLocal = await _db.assets
  23. .where()
  24. .remoteIdIsNull()
  25. .filter()
  26. .ownerIdEqualTo(owner)
  27. .localIdIsNotNull()
  28. .findAll();
  29. final List<Asset> remoteMatches = await _getMatches(
  30. _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
  31. owner,
  32. onlyLocal,
  33. limit,
  34. );
  35. final List<Asset> localMatches = await _getMatches(
  36. _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
  37. owner,
  38. remoteMatches,
  39. limit,
  40. );
  41. final List<Asset> deleteCandidates = [], originals = [];
  42. await diffSortedLists(
  43. remoteMatches,
  44. localMatches,
  45. compare: (a, b) => a.fileName.compareTo(b.fileName),
  46. both: (a, b) async {
  47. a.exifInfo = await _db.exifInfos.get(a.id);
  48. deleteCandidates.add(a);
  49. originals.add(b);
  50. return false;
  51. },
  52. onlyFirst: (a) {},
  53. onlySecond: (b) {},
  54. );
  55. final isolateToken = ServicesBinding.rootIsolateToken!;
  56. final List<Asset> toDelete;
  57. if (deleteCandidates.length > 10) {
  58. // performs 2 checks in parallel for a nice speedup
  59. final half = deleteCandidates.length ~/ 2;
  60. final lower = compute(
  61. _computeSaveToDelete,
  62. (
  63. deleteCandidates: deleteCandidates.slice(0, half),
  64. originals: originals.slice(0, half),
  65. auth: Store.get(StoreKey.accessToken),
  66. endpoint: Store.get(StoreKey.serverEndpoint),
  67. rootIsolateToken: isolateToken,
  68. ),
  69. );
  70. final upper = compute(
  71. _computeSaveToDelete,
  72. (
  73. deleteCandidates: deleteCandidates.slice(half),
  74. originals: originals.slice(half),
  75. auth: Store.get(StoreKey.accessToken),
  76. endpoint: Store.get(StoreKey.serverEndpoint),
  77. rootIsolateToken: isolateToken,
  78. ),
  79. );
  80. toDelete = await lower + await upper;
  81. } else {
  82. toDelete = await compute(
  83. _computeSaveToDelete,
  84. (
  85. deleteCandidates: deleteCandidates,
  86. originals: originals,
  87. auth: Store.get(StoreKey.accessToken),
  88. endpoint: Store.get(StoreKey.serverEndpoint),
  89. rootIsolateToken: isolateToken,
  90. ),
  91. );
  92. }
  93. return toDelete;
  94. }
  95. static Future<List<Asset>> _computeSaveToDelete(
  96. ({
  97. List<Asset> deleteCandidates,
  98. List<Asset> originals,
  99. String auth,
  100. String endpoint,
  101. RootIsolateToken rootIsolateToken,
  102. }) tuple,
  103. ) async {
  104. assert(tuple.deleteCandidates.length == tuple.originals.length);
  105. final List<Asset> result = [];
  106. BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
  107. await PhotoManager.setIgnorePermissionCheck(true);
  108. final ApiService apiService = ApiService();
  109. apiService.setEndpoint(tuple.endpoint);
  110. apiService.setAccessToken(tuple.auth);
  111. for (int i = 0; i < tuple.deleteCandidates.length; i++) {
  112. if (await _compareAssets(
  113. tuple.deleteCandidates[i],
  114. tuple.originals[i],
  115. apiService,
  116. )) {
  117. result.add(tuple.deleteCandidates[i]);
  118. }
  119. }
  120. return result;
  121. }
  122. static Future<bool> _compareAssets(
  123. Asset remote,
  124. Asset local,
  125. ApiService apiService,
  126. ) async {
  127. if (remote.checksum == local.checksum) return false;
  128. ExifInfo? exif = remote.exifInfo;
  129. if (exif != null && exif.lat != null) return false;
  130. if (exif == null || exif.fileSize == null) {
  131. final dto = await apiService.assetApi.getAssetById(remote.remoteId!);
  132. if (dto != null && dto.exifInfo != null) {
  133. exif = ExifInfo.fromDto(dto.exifInfo!);
  134. }
  135. }
  136. final file = await local.local!.originFile;
  137. if (exif != null && file != null && exif.fileSize != null) {
  138. final origSize = await file.length();
  139. if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
  140. final latLng = await local.local!.latlngAsync();
  141. if (exif.lat == null &&
  142. latLng.latitude != null &&
  143. (remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
  144. remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
  145. _sameExceptTimeZone(
  146. remote.fileCreatedAt,
  147. local.fileCreatedAt,
  148. ))) {
  149. if (remote.type == AssetType.video) {
  150. // it's very unlikely that a video of same length, filesize, name
  151. // and date is wrong match. Cannot easily compare videos anyway
  152. return true;
  153. }
  154. // for images: make sure they are pixel-wise identical
  155. // (skip first few KBs containing metadata)
  156. final Uint64List localImage =
  157. _fakeDecodeImg(local, await file.readAsBytes());
  158. final res = await apiService.assetApi
  159. .downloadFileWithHttpInfo(remote.remoteId!);
  160. final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
  161. final eq = const ListEquality().equals(remoteImage, localImage);
  162. return eq;
  163. }
  164. }
  165. }
  166. return false;
  167. }
  168. static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
  169. const headerLength = 131072; // assume header is at most 128 KB
  170. final start = bytes.length < headerLength * 2
  171. ? (bytes.length ~/ (4 * 8)) * 8
  172. : headerLength;
  173. return bytes.buffer.asUint64List(start);
  174. }
  175. static Future<List<Asset>> _getMatches(
  176. QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
  177. int ownerId,
  178. List<Asset> assets,
  179. int limit,
  180. ) =>
  181. query
  182. .ownerIdEqualTo(ownerId)
  183. .anyOf(
  184. assets,
  185. (q, Asset a) => q
  186. .fileNameEqualTo(a.fileName)
  187. .and()
  188. .durationInSecondsEqualTo(a.durationInSeconds)
  189. .and()
  190. .fileCreatedAtBetween(
  191. a.fileCreatedAt.subtract(const Duration(hours: 12)),
  192. a.fileCreatedAt.add(const Duration(hours: 12)),
  193. )
  194. .and()
  195. .not()
  196. .checksumEqualTo(a.checksum),
  197. )
  198. .sortByFileName()
  199. .thenByFileCreatedAt()
  200. .thenByFileModifiedAt()
  201. .limit(limit)
  202. .findAll();
  203. static bool _sameExceptTimeZone(DateTime a, DateTime b) {
  204. final ms = a.isAfter(b)
  205. ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
  206. : b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
  207. final x = ms / (1000 * 60 * 30);
  208. final y = ms ~/ (1000 * 60 * 30);
  209. return y.toDouble() == x && y < 24;
  210. }
  211. }
  212. final backupVerificationServiceProvider = Provider(
  213. (ref) => BackupVerificationService(
  214. ref.watch(dbProvider),
  215. ),
  216. );