1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(mobile): efficient asset sync (#3945)

* feat(mobile): efficient asset sync
This commit is contained in:
Fynn Petersen-Frey 2023-09-10 14:51:18 +02:00 committed by GitHub
parent 4b11e925d9
commit 5d1011b482
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 226 additions and 135 deletions

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/user.dart';
@ -14,6 +15,14 @@ class PartnerDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(assetsProvider(partner.isarId));
useEffect(
() {
ref.read(assetProvider.notifier).getPartnerAssets(partner);
return null;
},
[],
);
return Scaffold(
appBar: AppBar(
title: Text("${partner.firstName} ${partner.lastName}"),
@ -30,7 +39,8 @@ class PartnerDetailPage extends HookConsumerWidget {
)
: ImmichAssetGrid(
renderList: renderList,
onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
onRefresh: () =>
ref.read(assetProvider.notifier).getPartnerAssets(partner),
),
error: (e, _) => Text("Error loading partners:\n$e"),
loading: () => const Center(child: ImmichLoadingIndicator()),

View File

@ -1351,6 +1351,7 @@ class MemoryRouteArgs {
}
}
/// generated route for
/// [MapPage]
class MapRoute extends PageRouteInfo<void> {
const MapRoute()

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class TabNavigationObserver extends AutoRouterObserver {
@ -42,6 +43,7 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'SharingRoute') {
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.read(assetProvider.notifier).getPartnerAssets();
}
if (route.name == 'LibraryRoute') {
@ -50,6 +52,7 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'HomeRoute') {
ref.invalidate(memoryFutureProvider);
Future(() => ref.read(assetProvider.notifier).getAllAsset());
// Update user info
try {

View File

@ -417,17 +417,17 @@ enum AssetState {
extension AssetsHelper on IsarCollection<Asset> {
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
ids.isEmpty ? Future.value(0) : remote(ids).deleteAll();
Future<int> deleteAllByLocalId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _local(ids).deleteAll();
ids.isEmpty ? Future.value(0) : local(ids).deleteAll();
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value([]) : _remote(ids).findAll();
ids.isEmpty ? Future.value([]) : remote(ids).findAll();
Future<List<Asset>> getAllByLocalId(Iterable<String> ids) =>
ids.isEmpty ? Future.value([]) : _local(ids).findAll();
ids.isEmpty ? Future.value([]) : local(ids).findAll();
QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
QueryBuilder<Asset, Asset, QAfterWhereClause> remote(Iterable<String> ids) =>
where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
QueryBuilder<Asset, Asset, QAfterWhereClause> local(Iterable<String> ids) {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
}
}

View File

@ -5,9 +5,10 @@ part 'etag.g.dart';
@Collection(inheritance: false)
class ETag {
ETag({required this.id, this.value});
ETag({required this.id, this.assetCount, this.time});
Id get isarId => fastHash(id);
@Index(unique: true, replace: true, type: IndexType.hash)
String id;
String? value;
int? assetCount;
DateTime? time;
}

Binary file not shown.

View File

@ -1,4 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
@ -11,6 +13,7 @@ import 'package:immich_mobile/modules/settings/providers/notification_permission
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/release_info.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/tab.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:permission_handler/permission_handler.dart';
@ -47,8 +50,18 @@ class AppStateNotiifer extends StateNotifier<AppStateEnum> {
if (isAuthenticated && (permission.isGranted || permission.isLimited)) {
ref.read(backupProvider.notifier).resumeBackup();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
ref.read(serverInfoProvider.notifier).getServerVersion();
switch (ref.read(tabProvider)) {
case TabEnum.home:
ref.read(assetProvider.notifier).getAllAsset();
case TabEnum.search:
// nothing to do
case TabEnum.sharing:
ref.read(assetProvider.notifier).getPartnerAssets();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
case TabEnum.library:
ref.read(albumProvider.notifier).getAllAlbums();
}
}
ref.watch(websocketProvider.notifier).connect();

View File

@ -27,6 +27,7 @@ class AssetNotifier extends StateNotifier<bool> {
final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
bool _getPartnerAssetsInProgress = false;
AssetNotifier(
this._assetService,
@ -49,15 +50,10 @@ class AssetNotifier extends StateNotifier<bool> {
await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db");
}
await _userService.refreshUsers();
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
final List<User> partners =
await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll();
for (User u in partners) {
await _assetService.refreshRemoteAssets(u);
}
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getAllAssetInProgress = false;
@ -65,6 +61,27 @@ class AssetNotifier extends StateNotifier<bool> {
}
}
Future<void> getPartnerAssets([User? partner]) async {
if (_getPartnerAssetsInProgress) return;
try {
final stopwatch = Stopwatch()..start();
_getPartnerAssetsInProgress = true;
if (partner == null) {
await _userService.refreshUsers();
final List<User> partners =
await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll();
for (User u in partners) {
await _assetService.refreshRemoteAssets(u);
}
} else {
await _assetService.refreshRemoteAssets(partner);
}
log.info("Load partner assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getPartnerAssetsInProgress = false;
}
}
Future<void> clearAllAsset() {
return clearAssetsAndAlbums(_db);
}

View File

@ -0,0 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum TabEnum {
home,
search,
sharing,
library,
}
/// Provides the currently active tab
final tabProvider = StateProvider<TabEnum>(
(ref) => TabEnum.home,
);

View File

@ -20,6 +20,7 @@ class ApiService {
late ServerInfoApi serverInfoApi;
late PartnerApi partnerApi;
late PersonApi personApi;
late AuditApi auditApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -43,6 +44,7 @@ class ApiService {
searchApi = SearchApi(_apiClient);
partnerApi = PartnerApi(_apiClient);
personApi = PersonApi(_apiClient);
auditApi = AuditApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View File

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@ -11,7 +10,6 @@ import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@ -39,37 +37,34 @@ class AssetService {
/// Checks the server for updated assets and updates the local database if
/// required. Returns `true` if there were any changes.
Future<bool> refreshRemoteAssets([User? user]) async {
user ??= Store.get(StoreKey.currentUser);
user ??= Store.get<User>(StoreKey.currentUser);
final Stopwatch sw = Stopwatch()..start();
final int numOwnedRemoteAssets = await _db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(user!.isarId)
.count();
final bool changes = await _syncService.syncRemoteAssetsToDb(
user,
() async => (await _getRemoteAssets(
hasCache: numOwnedRemoteAssets > 0,
user: user!,
)),
_getRemoteAssetChanges,
_getRemoteAssets,
);
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Returns `(null, null)` if changes are invalid -> requires full sync
Future<(List<Asset>? toUpsert, List<String>? toDelete)>
_getRemoteAssetChanges(User user, DateTime since) async {
final deleted = await _apiService.auditApi
.getAuditDeletes(EntityType.ASSET, since, userId: user.id);
if (deleted == null || deleted.needsFullSync) return (null, null);
final assetDto = await _apiService.assetApi
.getAllAssets(userId: user.id, updatedAfter: since);
if (assetDto == null) return (null, null);
return (assetDto.map(Asset.remote).toList(), deleted.ids);
}
/// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> _getRemoteAssets({
required bool hasCache,
required User user,
}) async {
Future<List<Asset>?> _getRemoteAssets(User user) async {
try {
final etag = hasCache ? _db.eTags.getByIdSync(user.id)?.value : null;
final (List<AssetResponseDto>? assets, String? newETag) =
await _apiService.assetApi.getAllAssetsWithETag(
eTag: etag,
userId: user.id,
);
final List<AssetResponseDto>? assets =
await _apiService.assetApi.getAllAssets(userId: user.id);
if (assets == null) {
return null;
} else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
@ -77,8 +72,6 @@ class AssetService {
" The server returned assets for user ${assets.first.ownerId}"
" while requesting assets of user ${user.id}");
return null;
} else if (newETag != etag) {
_db.writeTxn(() => _db.eTags.put(ETag(id: user.id, value: newETag)));
}
return assets.map(Asset.remote).toList();
} catch (error, stack) {

View File

@ -69,9 +69,17 @@ class SyncService {
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(
User user,
FutureOr<List<Asset>?> Function() loadAssets,
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
User user,
DateTime since,
) getChangedAssets,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) =>
_lock.run(() => _syncRemoteAssetsToDb(user, loadAssets));
_lock.run(
() async =>
await _syncRemoteAssetChanges(user, getChangedAssets) ??
await _syncRemoteAssetsFull(user, loadAssets),
);
/// Syncs remote albums to the database
/// returns `true` if there were any changes
@ -130,13 +138,59 @@ class SyncService {
return true;
}
/// Syncs remote assets to the databas
/// returns `true` if there were any changes
Future<bool> _syncRemoteAssetsToDb(
/// Efficiently syncs assets via changes. Returns `null` when a full sync is required.
Future<bool?> _syncRemoteAssetChanges(
User user,
FutureOr<List<Asset>?> Function() loadAssets,
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
User user,
DateTime since,
) getChangedAssets,
) async {
final List<Asset>? remote = await loadAssets();
final DateTime? since = _db.eTags.getByIdSync(user.id)?.time?.toUtc();
if (since == null) return null;
final DateTime now = DateTime.now();
final (toUpsert, toDelete) = await getChangedAssets(user, since);
if (toUpsert == null || toDelete == null) return null;
try {
if (toDelete.isNotEmpty) {
await _handleRemoteAssetRemoval(toDelete);
}
if (toUpsert.isNotEmpty) {
final (_, updated) = await _linkWithExistingFromDb(toUpsert);
await upsertAssetsWithExif(updated);
}
if (toUpsert.isNotEmpty || toDelete.isNotEmpty) {
await _updateUserAssetsETag(user, now);
return true;
}
return false;
} on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e");
}
return null;
}
/// Deletes remote-only assets, updates merged assets to be local-only
Future<void> _handleRemoteAssetRemoval(List<String> idsToDelete) {
return _db.writeTxn(() async {
await _db.assets.remote(idsToDelete).filter().localIdIsNull().deleteAll();
final onlyLocal = await _db.assets.remote(idsToDelete).findAll();
if (onlyLocal.isNotEmpty) {
for (final Asset a in onlyLocal) {
a.remoteId = null;
}
await _db.assets.putAll(onlyLocal);
}
});
}
/// Syncs assets by loading and comparing all assets from the server.
Future<bool> _syncRemoteAssetsFull(
User user,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) async {
final DateTime now = DateTime.now();
final List<Asset>? remote = await loadAssets(user);
if (remote == null) {
return false;
}
@ -150,6 +204,7 @@ class SyncService {
remote.sort(Asset.compareByChecksum);
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
await _updateUserAssetsETag(user, now);
return false;
}
final idsToDelete = toRemove.map((e) => e.id).toList();
@ -159,9 +214,13 @@ class SyncService {
} on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e");
}
await _updateUserAssetsETag(user, now);
return true;
}
Future<void> _updateUserAssetsETag(User user, DateTime time) =>
_db.writeTxn(() => _db.eTags.put(ETag(id: user.id, time: time)));
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> _syncRemoteAlbumsToDb(
@ -450,6 +509,14 @@ class SyncService {
_log.fine(
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
);
if (assetCountOnDevice !=
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
await _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
),
);
}
return false;
}
_log.fine(
@ -477,7 +544,7 @@ class SyncService {
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, value: assetCountOnDevice.toString()),
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
);
});
_log.info("Synced changes of local album ${ape.name} to DB");
@ -496,7 +563,7 @@ class SyncService {
}
final int totalOnDevice = await ape.assetCountAsync;
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.value?.toInt() ?? 0;
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
@ -523,9 +590,8 @@ class SyncService {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.albums.put(album);
await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, value: totalOnDevice.toString()),
);
await _db.eTags
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice));
});
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) {
@ -667,7 +733,7 @@ class SyncService {
a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
await a.assetCountAsync !=
(await _db.eTags.getById(a.eTagKeyAssetCount))?.value?.toInt();
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount;
}
}

View File

@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.pro
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/tab.provider.dart';
class TabControllerPage extends HookConsumerWidget {
const TabControllerPage({Key? key}) : super(key: key);
@ -51,6 +52,7 @@ class TabControllerPage extends HookConsumerWidget {
}
HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
},
selectedIconTheme: IconThemeData(
color: Theme.of(context).primaryColor,
@ -103,6 +105,7 @@ class TabControllerPage extends HookConsumerWidget {
}
HapticFeedback.selectionClick();
tabsRouter.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
},
destinations: [
NavigationDestination(

View File

@ -1,59 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:openapi/api.dart';
/// Extension methods to retrieve ETag together with the API call
extension WithETag on AssetApi {
/// Get all AssetEntity belong to the user
///
/// Parameters:
///
/// * [String] eTag:
/// ETag of data already cached on the client
Future<(List<AssetResponseDto>? assets, String? eTag)> getAllAssetsWithETag({
String? eTag,
String? userId,
bool? isFavorite,
bool? isArchived,
}) async {
final response = await getAllAssetsWithHttpInfo(
ifNoneMatch: eTag,
userId: userId,
isFavorite: isFavorite,
isArchived: isArchived,
);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty &&
response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
final etag = response.headers[HttpHeaders.etagHeader];
final data = (await apiClient.deserializeAsync(
responseBody,
'List<AssetResponseDto>',
) as List)
.cast<AssetResponseDto>()
.toList();
return (data, etag);
}
return (null, null);
}
}
/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json'
/// content type. Otherwise, returns the decoded body as decoded by dart:http package.
Future<String> _decodeBodyBytes(Response response) async {
final contentType = response.headers['content-type'];
return contentType != null &&
contentType.toLowerCase().startsWith('application/json')
? response.bodyBytes.isEmpty
? ''
: utf8.decode(response.bodyBytes)
: response.body;
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/etag.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
@ -17,7 +18,6 @@ void main() {
required String checksum,
String? localId,
String? remoteId,
int deviceId = 1,
int ownerId = 590700560494856554, // hash of "1"
}) {
final DateTime date = DateTime(2000);
@ -46,6 +46,7 @@ void main() {
UserSchema,
StoreValueSchema,
LoggerMessageSchema,
ETagSchema,
],
maxSizeMiB: 256,
directory: ".",
@ -73,8 +74,8 @@ void main() {
await Store.put(StoreKey.currentUser, owner);
});
final List<Asset> initialAssets = [
makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
makeAsset(checksum: "b", remoteId: "2-1", deviceId: 2),
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
makeAsset(checksum: "c", localId: "1", remoteId: "1-1"),
makeAsset(checksum: "d", localId: "2"),
makeAsset(checksum: "e", localId: "3"),
@ -88,12 +89,13 @@ void main() {
test('test inserting existing assets', () async {
SyncService s = SyncService(db, hs);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
makeAsset(checksum: "b", remoteId: "2-1", deviceId: 2),
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
makeAsset(checksum: "c", remoteId: "1-1"),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
final bool c1 =
await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets);
expect(c1, false);
expect(db.assets.countSync(), 5);
});
@ -101,15 +103,16 @@ void main() {
test('test inserting new assets', () async {
SyncService s = SyncService(db, hs);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
makeAsset(checksum: "b", remoteId: "2-1", deviceId: 2),
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
makeAsset(checksum: "c", remoteId: "1-1"),
makeAsset(checksum: "d", remoteId: "1-2"),
makeAsset(checksum: "f", remoteId: "1-4"),
makeAsset(checksum: "g", remoteId: "3-1", deviceId: 3),
makeAsset(checksum: "g", remoteId: "3-1"),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
final bool c1 =
await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 7);
});
@ -117,31 +120,56 @@ void main() {
test('test syncing duplicate assets', () async {
SyncService s = SyncService(db, hs);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "1-1"),
makeAsset(checksum: "c", remoteId: "2-1", deviceId: 2),
makeAsset(checksum: "h", remoteId: "2-1b", deviceId: 2),
makeAsset(checksum: "i", remoteId: "2-1c", deviceId: 2),
makeAsset(checksum: "j", remoteId: "2-1d", deviceId: 2),
makeAsset(checksum: "c", remoteId: "2-1"),
makeAsset(checksum: "h", remoteId: "2-1b"),
makeAsset(checksum: "i", remoteId: "2-1c"),
makeAsset(checksum: "j", remoteId: "2-1d"),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
final bool c1 =
await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 8);
final bool c2 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
final bool c2 =
await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets);
expect(c2, false);
expect(db.assets.countSync(), 8);
remoteAssets.removeAt(4);
final bool c3 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
final bool c3 =
await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets);
expect(c3, true);
expect(db.assets.countSync(), 7);
remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e", deviceId: 2));
remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2", deviceId: 2));
final bool c4 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e"));
remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2"));
final bool c4 =
await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets);
expect(c4, true);
expect(db.assets.countSync(), 9);
});
test('test efficient sync', () async {
SyncService s = SyncService(db, hs);
final List<Asset> toUpsert = [
makeAsset(checksum: "a", remoteId: "0-1"), // changed
makeAsset(checksum: "f", remoteId: "0-2"), // new
makeAsset(checksum: "g", remoteId: "0-3"), // new
];
toUpsert[0].isFavorite = true;
final List<String> toDelete = ["2-1", "1-1"];
final bool c = await s.syncRemoteAssetsToDb(
owner,
(user, since) async => (toUpsert, toDelete),
(user) => throw Exception(),
);
expect(c, true);
expect(db.assets.countSync(), 6);
});
});
}
Future<(List<Asset>?, List<String>?)> _failDiff(User user, DateTime time) =>
Future.value((null, null));
class MockHashService extends Mock implements HashService {}