Compare commits

...

2 Commits

Author SHA1 Message Date
Alex
630fcf3516 wip 2024-09-05 09:35:04 -05:00
Alex
c579e78413 feat(mobile): check hash before uploading 2024-09-04 17:08:02 -05:00
6 changed files with 278 additions and 32 deletions

View File

@@ -0,0 +1,167 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:collection/collection.dart';
class RejectResult {
final String localId;
final String remoteId;
RejectResult({
required this.localId,
required this.remoteId,
});
RejectResult copyWith({
String? localId,
String? remoteId,
}) {
return RejectResult(
localId: localId ?? this.localId,
remoteId: remoteId ?? this.remoteId,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'localId': localId,
'remoteId': remoteId,
};
}
factory RejectResult.fromMap(Map<String, dynamic> map) {
return RejectResult(
localId: map['localId'] as String,
remoteId: map['remoteId'] as String,
);
}
String toJson() => json.encode(toMap());
factory RejectResult.fromJson(String source) =>
RejectResult.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'RejectResult(localId: $localId, remoteId: $remoteId)';
@override
bool operator ==(covariant RejectResult other) {
if (identical(this, other)) return true;
return other.localId == localId && other.remoteId == remoteId;
}
@override
int get hashCode => localId.hashCode ^ remoteId.hashCode;
}
class AcceptResult {
final String localId;
AcceptResult({
required this.localId,
});
AcceptResult copyWith({
String? localId,
}) {
return AcceptResult(
localId: localId ?? this.localId,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'localId': localId,
};
}
factory AcceptResult.fromMap(Map<String, dynamic> map) {
return AcceptResult(
localId: map['localId'] as String,
);
}
String toJson() => json.encode(toMap());
factory AcceptResult.fromJson(String source) =>
AcceptResult.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'AcceptResult(localId: $localId)';
@override
bool operator ==(covariant AcceptResult other) {
if (identical(this, other)) return true;
return other.localId == localId;
}
@override
int get hashCode => localId.hashCode;
}
class BulkUploadCheckResult {
List<RejectResult> rejects;
List<AcceptResult> accepts;
BulkUploadCheckResult({
required this.rejects,
required this.accepts,
});
BulkUploadCheckResult copyWith({
List<RejectResult>? rejects,
List<AcceptResult>? accepts,
}) {
return BulkUploadCheckResult(
rejects: rejects ?? this.rejects,
accepts: accepts ?? this.accepts,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'rejects': rejects.map((x) => x.toMap()).toList(),
'accepts': accepts.map((x) => x.toMap()).toList(),
};
}
factory BulkUploadCheckResult.fromMap(Map<String, dynamic> map) {
return BulkUploadCheckResult(
rejects: List<RejectResult>.from(
(map['rejects'] as List<int>).map<RejectResult>(
(x) => RejectResult.fromMap(x as Map<String, dynamic>),
),
),
accepts: List<AcceptResult>.from(
(map['accepts'] as List<int>).map<AcceptResult>(
(x) => AcceptResult.fromMap(x as Map<String, dynamic>),
),
),
);
}
String toJson() => json.encode(toMap());
factory BulkUploadCheckResult.fromJson(String source) =>
BulkUploadCheckResult.fromMap(
json.decode(source) as Map<String, dynamic>,
);
@override
String toString() =>
'BulkUploadCheckResult(rejects: $rejects, accepts: $accepts)';
@override
bool operator ==(covariant BulkUploadCheckResult other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.rejects, rejects) &&
listEquals(other.accepts, accepts);
}
@override
int get hashCode => rejects.hashCode ^ accepts.hashCode;
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -462,36 +460,39 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return; return;
} }
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets); Set<BackupCandidate> candidates = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up // Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) { for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId); candidates.removeWhere((e) => e.asset.id == assetId);
} }
if (assetsWillBeBackup.isEmpty) { if (candidates.isEmpty) {
state = state.copyWith(backupProgress: BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
} }
// Perform Backup // Check with server for hash duplication
state = state.copyWith(cancelToken: CancellationToken()); final bulkCheckResult = await _backupService.checkBulkUpload(candidates);
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; // // Perform Backup
// state = state.copyWith(cancelToken: CancellationToken());
pmProgressHandler?.stream.listen((event) { // final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset( // pmProgressHandler?.stream.listen((event) {
assetsWillBeBackup, // final double progress = event.progress;
state.cancelToken, // state = state.copyWith(iCloudDownloadProgress: progress);
pmProgressHandler: pmProgressHandler, // });
onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress, // await _backupService.backupAsset(
onCurrentAsset: _onSetCurrentBackupAsset, // candidates,
onError: _onBackupError, // state.cancelToken,
); // pmProgressHandler: pmProgressHandler,
await notifyBackgroundServiceCanRun(); // onSuccess: _onAssetUploaded,
// onProgress: _onUploadProgress,
// onCurrentAsset: _onSetCurrentBackupAsset,
// onError: _onBackupError,
// );
// await notifyBackgroundServiceCanRun();
} else { } else {
openAppSettings(); openAppSettings();
} }

View File

@@ -361,8 +361,13 @@ class BackgroundService {
UserService(apiService, db, syncSerive, partnerService); UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService = AlbumService albumService =
AlbumService(apiService, userService, syncSerive, db); AlbumService(apiService, userService, syncSerive, db);
BackupService backupService = BackupService backupService = BackupService(
BackupService(apiService, db, settingService, albumService); apiService,
db,
settingService,
albumService,
hashService,
);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/bulk_upload_check_result.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
@@ -19,6 +20,7 @@ import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -32,6 +34,7 @@ final backupServiceProvider = Provider(
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(hashServiceProvider),
), ),
); );
@@ -42,14 +45,71 @@ class BackupService {
final Logger _log = Logger("BackupService"); final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting; final AppSettingsService _appSetting;
final AlbumService _albumService; final AlbumService _albumService;
final HashService _hashService;
BackupService( BackupService(
this._apiService, this._apiService,
this._db, this._db,
this._appSetting, this._appSetting,
this._albumService, this._albumService,
this._hashService,
); );
Future<BulkUploadCheckResult> checkBulkUpload(
Set<BackupCandidate> candidates,
) async {
List<AssetBulkUploadCheckItem> assets = [];
final assetEntities = candidates.map((c) => c.asset).toList();
final hashedDeviceAssets =
await _hashService.getHashedAssetsFromAssetEntity(assetEntities);
for (final hashedAsset in hashedDeviceAssets) {
final AssetBulkUploadCheckItem item = AssetBulkUploadCheckItem(
id: hashedAsset.id.toString(),
checksum: hashedAsset.checksum,
);
assets.add(item);
}
final response = await _apiService.assetsApi.checkBulkUpload(
AssetBulkUploadCheckDto(assets: assets),
);
if (response == null) {
return BulkUploadCheckResult(
rejects: [],
accepts: [],
);
}
final List<RejectResult> rejects = [];
final List<AcceptResult> accepts = [];
for (final result in response.results) {
if (result.action == AssetBulkUploadCheckResultActionEnum.reject) {
rejects.add(
RejectResult(
localId: result.id,
remoteId: result.assetId ?? "",
),
);
} else {
accepts.add(
AcceptResult(
localId: result.id,
),
);
}
}
return BulkUploadCheckResult(
rejects: rejects,
accepts: accepts,
);
}
Future<List<String>?> getDeviceBackupAsset() async { Future<List<String>?> getDeviceBackupAsset() async {
final String deviceId = Store.get(StoreKey.deviceId); final String deviceId = Store.get(StoreKey.deviceId);

View File

@@ -19,8 +19,20 @@ class HashService {
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final _log = Logger('HashService'); final _log = Logger('HashService');
Future<List<Asset>> getHashedAssetsFromAssetEntity(
List<AssetEntity> assets,
) async {
final ids = assets
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
.toList();
final List<DeviceAsset?> hashes = await lookupHashes(ids);
return _mapAllHashedAssets(assets, hashes);
}
/// Returns all assets that were successfully hashed /// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets( Future<List<Asset>> getHashedAssetsFromDeviceAlbum(
AssetPathEntity album, { AssetPathEntity album, {
int start = 0, int start = 0,
int end = 0x7fffffffffffffff, int end = 0x7fffffffffffffff,
@@ -44,7 +56,7 @@ class HashService {
final ids = assetEntities final ids = assetEntities
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id) .map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
.toList(); .toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids); final List<DeviceAsset?> hashes = await lookupHashes(ids);
final List<DeviceAsset> toAdd = []; final List<DeviceAsset> toAdd = [];
final List<String> toHash = []; final List<String> toHash = [];
@@ -90,7 +102,7 @@ class HashService {
} }
/// Lookup hashes of assets by their local ID /// Lookup hashes of assets by their local ID
Future<List<DeviceAsset?>> _lookupHashes(List<Object> ids) => Future<List<DeviceAsset?>> lookupHashes(List<Object> ids) =>
Platform.isAndroid Platform.isAndroid
? _db.androidDeviceAssets.getAll(ids.cast()) ? _db.androidDeviceAssets.getAll(ids.cast())
: _db.iOSDeviceAssets.getAllById(ids.cast()); : _db.iOSDeviceAssets.getAllById(ids.cast());

View File

@@ -566,8 +566,8 @@ class SyncService {
.findAll(); .findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice = await ape.assetCountAsync; final int assetCountOnDevice = await ape.assetCountAsync;
final List<Asset> onDevice = final List<Asset> onDevice = await _hashService
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); .getHashedAssetsFromDeviceAlbum(ape, excludedAssets: excludedAssets);
_removeDuplicates(onDevice); _removeDuplicates(onDevice);
// _removeDuplicates sorts `onDevice` by checksum // _removeDuplicates sorts `onDevice` by checksum
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb); final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
@@ -649,7 +649,8 @@ class SyncService {
if (modified == null) { if (modified == null) {
return false; return false;
} }
final List<Asset> newAssets = await _hashService.getHashedAssets(modified); final List<Asset> newAssets =
await _hashService.getHashedAssetsFromDeviceAlbum(modified);
if (totalOnDevice != lastKnownTotal + newAssets.length) { if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false; return false;
@@ -683,8 +684,8 @@ class SyncService {
]) async { ]) async {
_log.info("Syncing a new local album to DB: ${ape.name}"); _log.info("Syncing a new local album to DB: ${ape.name}");
final Album a = Album.local(ape); final Album a = Album.local(ape);
final assets = final assets = await _hashService.getHashedAssetsFromDeviceAlbum(ape,
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); excludedAssets: excludedAssets);
_removeDuplicates(assets); _removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets); final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info( _log.info(