1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 11:24:37 +02:00
immich/mobile/lib/modules/backup/services/backup_verification.service.dart
Fynn Petersen-Frey de42ebf3d8
feat(Android): find & delete corrupt asset backups (#2963)
* feat(mobile): find & delete corrupt asset backups

* show backup fix only for advanced troubleshooting
2023-06-27 12:25:00 -05:00

233 lines
7.6 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
/// Finds duplicates originating from missing EXIF information
class BackupVerificationService {
final Isar _db;
BackupVerificationService(this._db);
/// Returns at most [limit] assets that were backed up without exif
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
final owner = Store.get(StoreKey.currentUser).isarId;
final List<Asset> onlyLocal = await _db.assets
.where()
.remoteIdIsNull()
.filter()
.ownerIdEqualTo(owner)
.localIdIsNotNull()
.findAll();
final List<Asset> remoteMatches = await _getMatches(
_db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
owner,
onlyLocal,
limit,
);
final List<Asset> localMatches = await _getMatches(
_db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
owner,
remoteMatches,
limit,
);
final List<Asset> deleteCandidates = [], originals = [];
await diffSortedLists(
remoteMatches,
localMatches,
compare: (a, b) => a.fileName.compareTo(b.fileName),
both: (a, b) async {
a.exifInfo = await _db.exifInfos.get(a.id);
deleteCandidates.add(a);
originals.add(b);
return false;
},
onlyFirst: (a) {},
onlySecond: (b) {},
);
final isolateToken = ServicesBinding.rootIsolateToken!;
final List<Asset> toDelete;
if (deleteCandidates.length > 10) {
// performs 2 checks in parallel for a nice speedup
final half = deleteCandidates.length ~/ 2;
final lower = compute(
_computeSaveToDelete,
(
deleteCandidates: deleteCandidates.slice(0, half),
originals: originals.slice(0, half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
),
);
final upper = compute(
_computeSaveToDelete,
(
deleteCandidates: deleteCandidates.slice(half),
originals: originals.slice(half),
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
),
);
toDelete = await lower + await upper;
} else {
toDelete = await compute(
_computeSaveToDelete,
(
deleteCandidates: deleteCandidates,
originals: originals,
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
),
);
}
return toDelete;
}
static Future<List<Asset>> _computeSaveToDelete(
({
List<Asset> deleteCandidates,
List<Asset> originals,
String auth,
String endpoint,
RootIsolateToken rootIsolateToken,
}) tuple,
) async {
assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
await PhotoManager.setIgnorePermissionCheck(true);
final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint);
apiService.setAccessToken(tuple.auth);
for (int i = 0; i < tuple.deleteCandidates.length; i++) {
if (await _compareAssets(
tuple.deleteCandidates[i],
tuple.originals[i],
apiService,
)) {
result.add(tuple.deleteCandidates[i]);
}
}
return result;
}
static Future<bool> _compareAssets(
Asset remote,
Asset local,
ApiService apiService,
) async {
if (remote.checksum == local.checksum) return false;
ExifInfo? exif = remote.exifInfo;
if (exif != null && exif.lat != null) return false;
if (exif == null || exif.fileSize == null) {
final dto = await apiService.assetApi.getAssetById(remote.remoteId!);
if (dto != null && dto.exifInfo != null) {
exif = ExifInfo.fromDto(dto.exifInfo!);
}
}
final file = await local.local!.originFile;
if (exif != null && file != null && exif.fileSize != null) {
final origSize = await file.length();
if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
final latLng = await local.local!.latlngAsync();
if (exif.lat == null &&
latLng.latitude != null &&
(remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
_sameExceptTimeZone(
remote.fileCreatedAt,
local.fileCreatedAt,
))) {
if (remote.type == AssetType.video) {
// it's very unlikely that a video of same length, filesize, name
// and date is wrong match. Cannot easily compare videos anyway
return true;
}
// for images: make sure they are pixel-wise identical
// (skip first few KBs containing metadata)
final Uint64List localImage =
_fakeDecodeImg(local, await file.readAsBytes());
final res = await apiService.assetApi
.downloadFileWithHttpInfo(remote.remoteId!);
final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
final eq = const ListEquality().equals(remoteImage, localImage);
return eq;
}
}
}
return false;
}
static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
const headerLength = 131072; // assume header is at most 128 KB
final start = bytes.length < headerLength * 2
? (bytes.length ~/ (4 * 8)) * 8
: headerLength;
return bytes.buffer.asUint64List(start);
}
static Future<List<Asset>> _getMatches(
QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
int ownerId,
List<Asset> assets,
int limit,
) =>
query
.ownerIdEqualTo(ownerId)
.anyOf(
assets,
(q, Asset a) => q
.fileNameEqualTo(a.fileName)
.and()
.durationInSecondsEqualTo(a.durationInSeconds)
.and()
.fileCreatedAtBetween(
a.fileCreatedAt.subtract(const Duration(hours: 12)),
a.fileCreatedAt.add(const Duration(hours: 12)),
)
.and()
.not()
.checksumEqualTo(a.checksum),
)
.sortByFileName()
.thenByFileCreatedAt()
.thenByFileModifiedAt()
.limit(limit)
.findAll();
static bool _sameExceptTimeZone(DateTime a, DateTime b) {
final ms = a.isAfter(b)
? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
: b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
final x = ms / (1000 * 60 * 30);
final y = ms ~/ (1000 * 60 * 30);
return y.toDouble() == x && y < 24;
}
}
final backupVerificationServiceProvider = Provider(
(ref) => BackupVerificationService(
ref.watch(dbProvider),
),
);