From bcfb5bee1f3fea428af336b5a7939853acd871a8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 4 Sep 2025 13:44:10 -0500 Subject: [PATCH] feat: album info sync (#21103) * wip * album creation * fix: album api repository no invalidating after logging out * add linkedRemoteAlbumId column and migration * link/unlink remote album * logic to find and add new assets to album * pr feedback * add toggle option to backup option page * refactor: provider > service * rename * Handle page pop manually * UI feedback for user creation and sync linked album * uncomment migration * remove unused method --- i18n/en.json | 2 + .../drift_schemas/main/drift_schema_v9.json | Bin 0 -> 34917 bytes .../models/album/local_album.model.dart | 11 +- .../domain/services/local_album.service.dart | 12 + .../domain/services/remote_album.service.dart | 5 +- .../services/sync_linked_album.service.dart | 101 +++++++ mobile/lib/domain/utils/background_sync.dart | 6 + .../lib/domain/utils/sync_linked_album.dart | 11 + .../entities/local_album.entity.dart | 19 ++ .../entities/local_album.entity.drift.dart | Bin 22352 -> 31632 bytes .../entities/local_asset.entity.dart | 2 +- .../repositories/backup.repository.dart | 3 +- .../repositories/db.repository.dart | 5 +- .../repositories/db.repository.drift.dart | Bin 12257 -> 12519 bytes .../repositories/db.repository.steps.dart | Bin 102950 -> 114037 bytes .../repositories/local_album.repository.dart | 22 +- .../repositories/remote_album.repository.dart | 45 +++ .../drift_backup_album_selection.page.dart | 277 ++++++++++-------- .../providers/app_life_cycle.provider.dart | 6 + mobile/lib/providers/websocket.provider.dart | 7 +- mobile/lib/utils/database.utils.dart | 31 -- mobile/lib/utils/migration.dart | 10 +- .../drift_backup_settings.dart | 138 ++++++++- .../beta_sync_settings.dart | 87 ++++-- mobile/test/drift/main/generated/schema.dart | Bin 1138 -> 1222 bytes .../test/drift/main/generated/schema_v9.dart | Bin 0 -> 217097 bytes 26 files changed, 601 insertions(+), 199 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v9.json create mode 100644 mobile/lib/domain/services/sync_linked_album.service.dart create mode 100644 mobile/lib/domain/utils/sync_linked_album.dart delete mode 100644 mobile/lib/utils/database.utils.dart create mode 100644 mobile/test/drift/main/generated/schema_v9.dart diff --git a/i18n/en.json b/i18n/en.json index 9e7b6fae6..f8a51eb5f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1417,6 +1417,8 @@ "open_the_search_filters": "Open the search filters", "options": "Options", "or": "or", + "organize_into_albums": "Organize into albums", + "organize_into_albums_description": "Put existing photos into albums using current sync settings", "organize_your_library": "Organize your library", "original": "original", "other": "Other", diff --git a/mobile/drift_schemas/main/drift_schema_v9.json b/mobile/drift_schemas/main/drift_schema_v9.json new file mode 100644 index 0000000000000000000000000000000000000000..5b08a752ec16b986530ec4b28d32b89ba03ca6c8 GIT binary patch literal 34917 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 getCount() { return _repository.getCount(); } + + Future unlinkRemoteAlbum(String id) async { + return _repository.unlinkRemoteAlbum(id); + } + + Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async { + return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId); + } + + Future> getBackupAlbums() { + return _repository.getBackupAlbums(); + } } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 4d85119b7..cc28dfafd 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -26,6 +26,10 @@ class RemoteAlbumService { return _repository.get(albumId); } + Future getByName(String albumName, String ownerId) { + return _repository.getByName(albumName, ownerId); + } + Future> sortAlbums( List albums, RemoteAlbumSortMode sortMode, { @@ -80,7 +84,6 @@ class RemoteAlbumService { Future createAlbum({required String title, required List assetIds, String? description}) async { final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds); - await _repository.create(album, assetIds); return album; diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart new file mode 100644 index 000000000..37e52e6c1 --- /dev/null +++ b/mobile/lib/domain/services/sync_linked_album.service.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; + +final syncLinkedAlbumServiceProvider = Provider( + (ref) => SyncLinkedAlbumService( + ref.watch(localAlbumRepository), + ref.watch(remoteAlbumRepository), + ref.watch(driftAlbumApiRepositoryProvider), + ), +); + +class SyncLinkedAlbumService { + final DriftLocalAlbumRepository _localAlbumRepository; + final DriftRemoteAlbumRepository _remoteAlbumRepository; + final DriftAlbumApiRepository _albumApiRepository; + + const SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository); + + Future syncLinkedAlbums(String userId) async { + final selectedAlbums = await _localAlbumRepository.getBackupAlbums(); + + await Future.wait( + selectedAlbums.map((localAlbum) async { + final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId; + if (linkedRemoteAlbumId == null) { + return; + } + + final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId); + if (remoteAlbum == null) { + return; + } + + // get assets that are uploaded but not in the remote album + final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId); + + if (assetIds.isNotEmpty) { + final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds); + await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added); + } + }), + ); + } + + Future manageLinkedAlbums(List localAlbums, String ownerId) async { + for (final album in localAlbums) { + await _processLocalAlbum(album, ownerId); + } + } + + /// Processes a single local album to ensure proper linking with remote albums + Future _processLocalAlbum(LocalAlbum localAlbum, String ownerId) { + final hasLinkedRemoteAlbum = localAlbum.linkedRemoteAlbumId != null; + + if (hasLinkedRemoteAlbum) { + return _handleLinkedAlbum(localAlbum); + } else { + return _handleUnlinkedAlbum(localAlbum, ownerId); + } + } + + /// Handles albums that are already linked to a remote album + Future _handleLinkedAlbum(LocalAlbum localAlbum) async { + final remoteAlbumId = localAlbum.linkedRemoteAlbumId!; + final remoteAlbum = await _remoteAlbumRepository.get(remoteAlbumId); + + final remoteAlbumExists = remoteAlbum != null; + if (!remoteAlbumExists) { + return _localAlbumRepository.unlinkRemoteAlbum(localAlbum.id); + } + } + + /// Handles albums that are not linked to any remote album + Future _handleUnlinkedAlbum(LocalAlbum localAlbum, String ownerId) async { + final existingRemoteAlbum = await _remoteAlbumRepository.getByName(localAlbum.name, ownerId); + + if (existingRemoteAlbum != null) { + return _linkToExistingRemoteAlbum(localAlbum, existingRemoteAlbum); + } else { + return _createAndLinkNewRemoteAlbum(localAlbum); + } + } + + /// Links a local album to an existing remote album + Future _linkToExistingRemoteAlbum(LocalAlbum localAlbum, dynamic existingRemoteAlbum) { + return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id); + } + + /// Creates a new remote album and links it to the local album + Future _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async { + debugPrint("Creating new remote album for local album: ${localAlbum.name}"); + final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []); + await _remoteAlbumRepository.create(newRemoteAlbum, []); + return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id); + } +} diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index d8042c707..1cb6820ab 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:immich_mobile/domain/utils/sync_linked_album.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/utils/isolate.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -155,6 +156,11 @@ class BackgroundSyncManager { _syncWebsocketTask = null; }); } + + Future syncLinkedAlbum() { + final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated); + return task.future; + } } Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => runInIsolateGentle( diff --git a/mobile/lib/domain/utils/sync_linked_album.dart b/mobile/lib/domain/utils/sync_linked_album.dart new file mode 100644 index 000000000..9df69799a --- /dev/null +++ b/mobile/lib/domain/utils/sync_linked_album.dart @@ -0,0 +1,11 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; + +Future syncLinkedAlbumsIsolated(ProviderContainer ref) { + final user = ref.read(currentUserProvider); + if (user == null) { + return Future.value(); + } + return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id); +} diff --git a/mobile/lib/infrastructure/entities/local_album.entity.dart b/mobile/lib/infrastructure/entities/local_album.entity.dart index c796a1295..707d3326a 100644 --- a/mobile/lib/infrastructure/entities/local_album.entity.dart +++ b/mobile/lib/infrastructure/entities/local_album.entity.dart @@ -1,5 +1,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; class LocalAlbumEntity extends Table with DriftDefaultsMixin { @@ -11,9 +13,26 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin { IntColumn get backupSelection => intEnum()(); BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))(); + // // Linked album for putting assets to the remote album after finished uploading + TextColumn get linkedRemoteAlbumId => + text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.setNull).nullable()(); + // Used for mark & sweep BoolColumn get marker_ => boolean().nullable()(); @override Set get primaryKey => {id}; } + +extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { + LocalAlbum toDto({int assetCount = 0}) { + return LocalAlbum( + id: id, + name: name, + updatedAt: updatedAt, + assetCount: assetCount, + backupSelection: backupSelection, + linkedRemoteAlbumId: linkedRemoteAlbumId, + ); + } +} diff --git a/mobile/lib/infrastructure/entities/local_album.entity.drift.dart b/mobile/lib/infrastructure/entities/local_album.entity.drift.dart index 5be349c8e09674eb0cc4606042afd0e3dd3d9207..07038442911964ec88731505fa0a834e8da46511 100644 GIT binary patch literal 31632 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 S6&>WT>?fU-%hORe8~+PMTPj@u delta 402 zcmbR6o$YsI%Yi2F6mW?PY$ zER$_T6ei!d656~^)(^sw)#TZ{L~SGEcWCgr4Rlz>Q09feE-J*ULt$^VQb88s(c z8QZYrmFDD37K{?xywY+vBQwxFlkKcjC#SQvazGph6j*5OwE3^~ERM}f+`cky-sJI* zW%Dk-k1U&gLWG$ow}hYEoE@Rav{@?p7}I9=xO}F`EQzlt^Ra?lDYrQxiHULZ{p1p+ z%@5LMGEUx=ag;qbu_!yWD1K6y)aIR;sf?QwvppF%-^@{AoZOYKJ~<}u+vcwPX^fi# m3d0yTvlL4)0?lIE98jXgxcOM=Po~YmmB1L-mEp=%%LM>O36Yxs diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 3130e41db..337a6d728 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -20,7 +20,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { Set get primaryKey => {id}; } -extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { +extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { LocalAsset toDto() => LocalAsset( id: id, name: name, diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index d98067d5f..057c7a7bf 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -4,9 +4,10 @@ import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import "package:immich_mobile/utils/database.utils.dart"; final backupRepositoryProvider = Provider( (ref) => DriftBackupRepository(ref.watch(driftProvider)), diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 386de2269..f8de114f8 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -68,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository { : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); @override - int get schemaVersion => 8; + int get schemaVersion => 9; @override MigrationStrategy get migration => MigrationStrategy( @@ -123,6 +123,9 @@ class Drift extends $Drift implements IDatabaseRepository { from7To8: (m, v8) async { await m.create(v8.storeEntity); }, + from8To9: (m, v9) async { + await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 456296e2d0fedb6a7b07afe6ebb4e656c05ee3d8..035f7b0c034bfdeae6da0fbbe39555836d6df584 100644 GIT binary patch delta 375 zcmYjMF-XHu6eK?>n*W0}woR+H7_e&7PeKE2Y(TJ}gF=T2>LjS6MGEdJimQrn;OHcD zDikuh2<~>!#YGVWUBv3-BKVDiK6v-;z2o5CYuKrbr==d7y0(it+k7IdSwgra^d#NbMA z66_j{;Bx3pLOs*KLtMkn@Ig?IzeG?I7MAVs|n0yQ^i(=Yl=yFZ+@RrNsI%kG# zR^%&Ma{_m)CAsWmu|QLJwVZ!k3;or6O7?}mPh;4225^{9{_PV6I{6qjISaq2a1A}& SRNR6v^fLn$ZU*5+YtF-bc#7_CI}Xibq+GgwY*knggWM}Ze4&i|H;c_NE ziMO~MRyZH;oQ_lCK}3SWX2V3pkD_WRxaJk!6LdNSR4f&@aTOiD7a76ABQFzAVn41j z6W9@4q9;O$nZ%1=5S7Gn#z$3jh&DtI6_H1cH`tQS9kWyeS%ve+2P%Q+;{8~<`FeA4bk!d+!Nl&xW0 z6$C8U0?sW7Q&xhqPLD~r8;xSojN!+UnG#a=GT{zvgYH|lM$g$Nzf_-`zl(eNI~zux$!A`2 zO+N6_Y5M+n#!Hjqc2#XxN@P63zFFbyJw_%&%gGn{B^eE;KP+W5oh&c#1!%xRHsQ^0 zKC?4zH^^a}#<~4NFyjl($pM=Lx6f^4yv)dGIlZu%Q5C2zu|8$8p2Dul4tM#cZ!l)e zpR5q_a&mwok6=z_UUq6qP-<>|NvdN`QfcnwhaS?x5YhM|uyA}LSlqzSdh!N6uF1lo zv6B~63IGk=sO*4M<;SXJlR3q>C%5$pOrHE$X1iWHqY=~erVd6kAxnkgZp YWPC5D05{SK&eGvhP|(!j(zNCR0J(jUj{pDw delta 41 zcmV+^0M`HY`Ua+?27t5yEr+)nu>mszx0ka4PX)J}xdDU%mukEL?FK0<3Mo4ZZOji& diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 0c2976888..923d6e0a6 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -1,11 +1,12 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/utils/database.utils.dart'; import 'package:platform/platform.dart'; enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } @@ -49,6 +50,13 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get(); } + Future> getBackupAlbums() async { + final query = _db.localAlbumEntity.select() + ..where((row) => row.backupSelection.equalsValue(BackupSelection.selected)); + + return query.map((row) => row.toDto()).get(); + } + Future delete(String albumId) => transaction(() async { // Remove all assets that are only in this particular album // We cannot remove all assets in the album because they might be in other albums in iOS @@ -335,4 +343,16 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { Future getCount() { return _db.managers.localAlbumEntity.count(); } + + Future unlinkRemoteAlbum(String id) async { + return _db.localAlbumEntity.update() + ..where((row) => row.id.equals(id)) + ..write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null))); + } + + Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async { + return _db.localAlbumEntity.update() + ..where((row) => row.id.equals(localAlbumId)) + ..write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId))); + } } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 44a288787..41f167b3e 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -113,6 +113,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { .getSingleOrNull(); } + Future getByName(String albumName, String ownerId) { + final query = _db.remoteAlbumEntity.select() + ..where((row) => row.name.equals(albumName) & row.ownerId.equals(ownerId)) + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) + ..limit(1); + + return query.map((row) => row.toDto(ownerName: '', isShared: false)).getSingleOrNull(); + } + Future create(RemoteAlbum album, List assetIds) async { await _db.transaction(() async { final entity = RemoteAlbumEntityCompanion( @@ -321,6 +330,42 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { Future getCount() { return _db.managers.remoteAlbumEntity.count(); } + + Future> getLinkedAssetIds(String userId, String localAlbumId, String remoteAlbumId) async { + // Find remote asset ids that: + // 1. Belong to the provided local album (via local_album_asset_entity) + // 2. Have been uploaded (i.e. a matching remote asset exists for the same checksum & owner) + // 3. Are NOT already in the remote album (remote_album_asset_entity) + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([_db.remoteAssetEntity.id]) + ..join([ + innerJoin( + _db.localAssetEntity, + _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), + useColumns: false, + ), + innerJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + useColumns: false, + ), + // Left join remote album assets to exclude those already in the remote album + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id) & + _db.remoteAlbumAssetEntity.albumId.equals(remoteAlbumId), + useColumns: false, + ), + ]) + ..where( + _db.remoteAssetEntity.ownerId.equals(userId) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.localAlbumAssetEntity.albumId.equals(localAlbumId) & + _db.remoteAlbumAssetEntity.assetId.isNull(), // only those not yet linked + ); + + return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get(); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 865845525..e734dc300 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -26,10 +27,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState _enableSyncUploadAlbum; late TextEditingController _searchController; late FocusNode _searchFocusNode; + Future? _handleLinkedAlbumFuture; @override void initState() { @@ -44,6 +45,36 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount)); } + Future _handlePagePopped() async { + final user = ref.read(currentUserProvider); + if (user == null) { + return; + } + + final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + final selectedAlbums = ref + .read(backupAlbumProvider) + .where((a) => a.backupSelection == BackupSelection.selected) + .toList(); + + if (enableSyncUploadAlbum && selectedAlbums.isNotEmpty) { + setState(() { + _handleLinkedAlbumFuture = ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedAlbums, user.id); + }); + await _handleLinkedAlbumFuture; + } + + // Restart backup if total count changed and backup is enabled + final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); + final totalChanged = currentTotalAssetCount != _initialTotalAssetCount; + final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + + if (totalChanged && isBackupEnabled) { + await ref.read(driftBackupProvider.notifier).cancel(); + await ref.read(driftBackupProvider.notifier).startBackup(user.id); + } + } + @override void dispose() { _enableSyncUploadAlbum.dispose(); @@ -65,42 +96,12 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState album.backupSelection == BackupSelection.selected).toList(); final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); - // handleSyncAlbumToggle(bool isEnable) async { - // if (isEnable) { - // await ref.read(albumProvider.notifier).refreshRemoteAlbums(); - // for (final album in selectedBackupAlbums) { - // await ref.read(albumProvider.notifier).createSyncAlbum(album.name); - // } - // } - // } - return PopScope( - onPopInvokedWithResult: (didPop, result) async { - // There is an issue with Flutter where the pop event - // can be triggered multiple times, so we guard it with _hasPopped - if (didPop && !_hasPopped) { - _hasPopped = true; - - final currentUser = ref.read(currentUserProvider); - if (currentUser == null) { - return; - } - - await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); - final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); - - if (currentTotalAssetCount != _initialTotalAssetCount) { - final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); - - if (!isBackupEnabled) { - return; - } - final backupNotifier = ref.read(driftBackupProvider.notifier); - - backupNotifier.cancel().then((_) { - backupNotifier.startBackup(currentUser.id); - }); - } + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (!didPop) { + await _handlePagePopped(); + Navigator.of(context).pop(); } }, child: Scaffold( @@ -139,103 +140,123 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState 600) { + return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery); + } else { + return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery); + } + }, + ), + ], + ), + if (_handleLinkedAlbumFuture != null) + FutureBuilder( + future: _handleLinkedAlbumFuture, + builder: (context, snapshot) { + return SizedBox( + height: double.infinity, + width: double.infinity, + child: Container( + color: context.scaffoldBackgroundColor.withValues(alpha: 0.8), + child: Center( + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + const CircularProgressIndicator(strokeWidth: 4), + Text("Creating linked albums...", style: context.textTheme.labelLarge), + ], + ), + ), + ), + ); + }, ), - ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (constraints.crossAxisExtent > 600) { - return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery); - } else { - return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery); - } - }, - ), ], ), ), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index ff5dda79c..d7cb7dbaa 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -105,6 +105,8 @@ class AppLifeCycleNotifier extends StateNotifier { ]).then((_) async { final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + if (isEnableBackup) { final currentUser = _ref.read(currentUserProvider); if (currentUser == null) { @@ -113,6 +115,10 @@ class AppLifeCycleNotifier extends StateNotifier { await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); } + + if (isAlbumLinkedSyncEnable) { + await backgroundManager.syncLinkedAlbum(); + } }); } catch (e, stackTrace) { Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace); diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index fdc21592b..3b0d5daab 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; -// import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -323,7 +322,11 @@ class WebsocketNotifier extends StateNotifier { } try { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList())); + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) { + return _ref.read(backgroundSyncProvider).syncLinkedAlbum(); + }), + ); } catch (error) { _log.severe("Error processing batched AssetUploadReadyV1 events: $error"); } diff --git a/mobile/lib/utils/database.utils.dart b/mobile/lib/utils/database.utils.dart deleted file mode 100644 index 446b92db1..000000000 --- a/mobile/lib/utils/database.utils.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:immich_mobile/domain/models/album/local_album.model.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; - -extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { - LocalAlbum toDto({int assetCount = 0}) { - return LocalAlbum( - id: id, - name: name, - updatedAt: updatedAt, - assetCount: assetCount, - backupSelection: backupSelection, - ); - } -} - -extension LocalAssetEntityDataHelper on LocalAssetEntityData { - LocalAsset toDto() { - return LocalAsset( - id: id, - name: name, - checksum: checksum, - type: type, - createdAt: createdAt, - updatedAt: updatedAt, - durationInSeconds: durationInSeconds, - isFavorite: isFavorite, - ); - } -} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 9816986b9..0a786fed0 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -23,8 +23,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -268,11 +270,17 @@ Future> runNewSync(WidgetRef ref, {bool full = false}) { ref.read(backupProvider.notifier).cancelBackup(); final backgroundManager = ref.read(backgroundSyncProvider); + final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); + return Future.wait([ backgroundManager.syncLocal(full: full).then((_) { Logger("runNewSync").fine("Hashing assets after syncLocal"); return backgroundManager.hashAssets(); }), - backgroundManager.syncRemote(), + backgroundManager.syncRemote().then((_) { + if (isAlbumLinkedSyncEnable) { + return backgroundManager.syncLinkedAlbum(); + } + }), ]); } diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index 553eb939c..ac9866d4d 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -1,19 +1,153 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -class DriftBackupSettings extends StatelessWidget { +class DriftBackupSettings extends ConsumerWidget { const DriftBackupSettings({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + return const SettingsSubPageScaffold( + settings: [ + _UseWifiForUploadVideosButton(), + _UseWifiForUploadPhotosButton(), + Divider(indent: 16, endIndent: 16), + _AlbumSyncActionButton(), + ], + ); + } +} + +class _AlbumSyncActionButton extends ConsumerStatefulWidget { + const _AlbumSyncActionButton(); + + @override + ConsumerState<_AlbumSyncActionButton> createState() => _AlbumSyncActionButtonState(); +} + +class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> { + bool isAlbumSyncInProgress = false; + + Future _manualSyncAlbums() async { + setState(() { + isAlbumSyncInProgress = true; + }); + + try { + await ref.read(backgroundSyncProvider).syncLinkedAlbum(); + await ref.read(backgroundSyncProvider).syncRemote(); + } catch (_) { + } finally { + Future.delayed(const Duration(seconds: 1), () { + setState(() { + isAlbumSyncInProgress = false; + }); + }); + } + } + + Future _manageLinkedAlbums() async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + final localAlbums = ref.read(backupAlbumProvider); + final selectedBackupAlbums = localAlbums + .where((album) => album.backupSelection == BackupSelection.selected) + .toList(); + + await ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedBackupAlbums, currentUser.id); + } + @override Widget build(BuildContext context) { - return const SettingsSubPageScaffold(settings: [_UseWifiForUploadVideosButton(), _UseWifiForUploadPhotosButton()]); + return ListView( + shrinkWrap: true, + children: [ + StreamBuilder( + stream: Store.watch(StoreKey.syncAlbums), + initialData: Store.tryGet(StoreKey.syncAlbums) ?? false, + builder: (context, snapshot) { + final albumSyncEnable = snapshot.data ?? false; + return Column( + children: [ + ListTile( + title: Text( + "sync_albums".t(context: context), + style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor), + ), + subtitle: Text( + "sync_upload_album_setting_subtitle".t(context: context), + style: context.textTheme.labelLarge, + ), + trailing: Switch( + value: albumSyncEnable, + onChanged: (bool newValue) async { + await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue); + + if (newValue == true) { + await _manageLinkedAlbums(); + } + }, + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: albumSyncEnable ? 1.0 : 0.0, + child: albumSyncEnable + ? ListTile( + onTap: _manualSyncAlbums, + contentPadding: const EdgeInsets.only(left: 32, right: 16), + title: Text( + "organize_into_albums".t(context: context), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.normal, + ), + ), + subtitle: Text( + "organize_into_albums_description".t(context: context), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + trailing: isAlbumSyncInProgress + ? const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : IconButton( + onPressed: _manualSyncAlbums, + icon: const Icon(Icons.sync_rounded), + color: context.colorScheme.onSurface.withValues(alpha: 0.7), + iconSize: 20, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ) + : const SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ], + ); } } diff --git a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart index 8916fdd92..e5c65a9c6 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/beta_sync_settings.dart @@ -109,6 +109,37 @@ class BetaSyncSettings extends HookConsumerWidget { await ref.read(storageRepositoryProvider).clearCache(); } + Future resetSqliteDb(BuildContext context, Future Function() resetDatabase) { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("reset_sqlite".t(context: context)), + content: Text("reset_sqlite_confirmation".t(context: context)), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text("cancel".t(context: context)), + ), + TextButton( + onPressed: () async { + await resetDatabase(); + context.pop(); + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text("reset_sqlite_success".t(context: context))), + ); + }, + child: Text( + "confirm".t(context: context), + style: TextStyle(color: context.colorScheme.error), + ), + ), + ], + ); + }, + ); + } + return FutureBuilder>( future: loadCounts(), builder: (context, snapshot) { @@ -116,6 +147,33 @@ class BetaSyncSettings extends HookConsumerWidget { return const CircularProgressIndicator(); } + if (snapshot.hasError) { + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + "Error occur, reset the local database by tapping the button below", + style: context.textTheme.bodyLarge, + ), + ), + ), + + ListTile( + title: Text( + "reset_sqlite".t(context: context), + style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), + ), + leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), + onTap: () async { + await resetSqliteDb(context, resetDatabase); + }, + ), + ], + ); + } + final assetCounts = snapshot.data![0]! as (int, int); final localAssetCount = assetCounts.$1; final remoteAssetCount = assetCounts.$2; @@ -270,34 +328,7 @@ class BetaSyncSettings extends HookConsumerWidget { ), leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), onTap: () async { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text("reset_sqlite".t(context: context)), - content: Text("reset_sqlite_confirmation".t(context: context)), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text("cancel".t(context: context)), - ), - TextButton( - onPressed: () async { - await resetDatabase(); - context.pop(); - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text("reset_sqlite_success".t(context: context))), - ); - }, - child: Text( - "confirm".t(context: context), - style: TextStyle(color: context.colorScheme.error), - ), - ), - ], - ); - }, - ); + await resetSqliteDb(context, resetDatabase); }, ), ], diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 746206e453eced6a56f266847e3f980880bc5bcf..413b4408c4ccd1a2c237c1a91ad45110ea6a8ac7 100644 GIT binary patch delta 94 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 SRL*GIMTg{`McOvpo|4 literal 0 HcmV?d00001