diff --git a/mobile/lib/modules/partner/views/partner_detail_page.dart b/mobile/lib/modules/partner/views/partner_detail_page.dart index a97e6a1d6c..995eebd3fd 100644 --- a/mobile/lib/modules/partner/views/partner_detail_page.dart +++ b/mobile/lib/modules/partner/views/partner_detail_page.dart @@ -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()), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4aef3beabb..bd385fc8e5 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1351,6 +1351,7 @@ class MemoryRouteArgs { } } +/// generated route for /// [MapPage] class MapRoute extends PageRouteInfo { const MapRoute() diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 1aaef3af51..c5bc4865b5 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -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 { diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 701b6c2dd7..40d329e3f8 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -417,17 +417,17 @@ enum AssetState { extension AssetsHelper on IsarCollection { Future deleteAllByRemoteId(Iterable ids) => - ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll(); + ids.isEmpty ? Future.value(0) : remote(ids).deleteAll(); Future deleteAllByLocalId(Iterable ids) => - ids.isEmpty ? Future.value(0) : _local(ids).deleteAll(); + ids.isEmpty ? Future.value(0) : local(ids).deleteAll(); Future> getAllByRemoteId(Iterable ids) => - ids.isEmpty ? Future.value([]) : _remote(ids).findAll(); + ids.isEmpty ? Future.value([]) : remote(ids).findAll(); Future> getAllByLocalId(Iterable ids) => - ids.isEmpty ? Future.value([]) : _local(ids).findAll(); + ids.isEmpty ? Future.value([]) : local(ids).findAll(); - QueryBuilder _remote(Iterable ids) => + QueryBuilder remote(Iterable ids) => where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e)); - QueryBuilder _local(Iterable ids) { + QueryBuilder local(Iterable ids) { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } diff --git a/mobile/lib/shared/models/etag.dart b/mobile/lib/shared/models/etag.dart index 2f13898992..2128b8fb5b 100644 --- a/mobile/lib/shared/models/etag.dart +++ b/mobile/lib/shared/models/etag.dart @@ -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; } diff --git a/mobile/lib/shared/models/etag.g.dart b/mobile/lib/shared/models/etag.g.dart index 0342531e1c..f1415f4408 100644 Binary files a/mobile/lib/shared/models/etag.g.dart and b/mobile/lib/shared/models/etag.g.dart differ diff --git a/mobile/lib/shared/providers/app_state.provider.dart b/mobile/lib/shared/providers/app_state.provider.dart index 3cebc4d2a8..e2813d86d8 100644 --- a/mobile/lib/shared/providers/app_state.provider.dart +++ b/mobile/lib/shared/providers/app_state.provider.dart @@ -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 { 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(); diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 284ca903ad..27e70298ab 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -27,6 +27,7 @@ class AssetNotifier extends StateNotifier { final log = Logger('AssetNotifier'); bool _getAllAssetInProgress = false; bool _deleteInProgress = false; + bool _getPartnerAssetsInProgress = false; AssetNotifier( this._assetService, @@ -49,15 +50,10 @@ class AssetNotifier extends StateNotifier { 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 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 { } } + Future getPartnerAssets([User? partner]) async { + if (_getPartnerAssetsInProgress) return; + try { + final stopwatch = Stopwatch()..start(); + _getPartnerAssetsInProgress = true; + if (partner == null) { + await _userService.refreshUsers(); + final List 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 clearAllAsset() { return clearAssetsAndAlbums(_db); } diff --git a/mobile/lib/shared/providers/tab.provider.dart b/mobile/lib/shared/providers/tab.provider.dart new file mode 100644 index 0000000000..2abed7c395 --- /dev/null +++ b/mobile/lib/shared/providers/tab.provider.dart @@ -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( + (ref) => TabEnum.home, +); diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index 3f6c309827..b19860cc89 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -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 resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 8e0fc1847f..b0eb9228a3 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -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 refreshRemoteAssets([User? user]) async { - user ??= Store.get(StoreKey.currentUser); + user ??= Store.get(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? toUpsert, List? 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?> _getRemoteAssets({ - required bool hasCache, - required User user, - }) async { + Future?> _getRemoteAssets(User user) async { try { - final etag = hasCache ? _db.eTags.getByIdSync(user.id)?.value : null; - final (List? assets, String? newETag) = - await _apiService.assetApi.getAllAssetsWithETag( - eTag: etag, - userId: user.id, - ); + final List? 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) { diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index 0bb2378361..2580ed9521 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -69,9 +69,17 @@ class SyncService { /// Returns `true` if there were any changes Future syncRemoteAssetsToDb( User user, - FutureOr?> Function() loadAssets, + Future<(List? toUpsert, List? toDelete)> Function( + User user, + DateTime since, + ) getChangedAssets, + FutureOr?> 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 _syncRemoteAssetsToDb( + /// Efficiently syncs assets via changes. Returns `null` when a full sync is required. + Future _syncRemoteAssetChanges( User user, - FutureOr?> Function() loadAssets, + Future<(List? toUpsert, List? toDelete)> Function( + User user, + DateTime since, + ) getChangedAssets, ) async { - final List? 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 _handleRemoteAssetRemoval(List 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 _syncRemoteAssetsFull( + User user, + FutureOr?> Function(User user) loadAssets, + ) async { + final DateTime now = DateTime.now(); + final List? 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 _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 _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; } } diff --git a/mobile/lib/shared/views/tab_controller_page.dart b/mobile/lib/shared/views/tab_controller_page.dart index cb5a3c3621..8e72ce900f 100644 --- a/mobile/lib/shared/views/tab_controller_page.dart +++ b/mobile/lib/shared/views/tab_controller_page.dart @@ -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( diff --git a/mobile/lib/utils/openapi_extensions.dart b/mobile/lib/utils/openapi_extensions.dart deleted file mode 100644 index bd4458e4a5..0000000000 --- a/mobile/lib/utils/openapi_extensions.dart +++ /dev/null @@ -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? 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', - ) as List) - .cast() - .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 _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; -} diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index 8f98e88b61..144ca2c299 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -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 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 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 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 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 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 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?, List?)> _failDiff(User user, DateTime time) => + Future.value((null, null)); + class MockHashService extends Mock implements HashService {}