forked from immich-app/immich
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(Android): find & delete corrupt asset backups (immich-app#2963)
* feat(mobile): find & delete corrupt asset backups * show backup fix only for advanced troubleshooting
- Loading branch information
Showing
6 changed files
with
385 additions
and
3 deletions.
There are no files selected for viewing
232 changes: 232 additions & 0 deletions
232
mobile/lib/modules/backup/services/backup_verification.service.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,232 @@ | ||
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), | ||
), | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.