diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d6615b53b5..448e3208ad 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -9,6 +9,8 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; +import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; +import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; @@ -104,6 +106,8 @@ Future loadDb() async { AssetSchema, AlbumSchema, UserSchema, + BackupAlbumSchema, + DuplicatedAssetSchema, ], directory: dir.path, maxSizeMiB: 256, @@ -156,10 +160,12 @@ class ImmichAppState extends ConsumerState ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo(); - ref.watch(notificationPermissionProvider.notifier) - .getNotificationPermission(); - ref.watch(galleryPermissionNotifier.notifier) - .getGalleryPermissionStatus(); + ref + .watch(notificationPermissionProvider.notifier) + .getNotificationPermission(); + ref + .watch(galleryPermissionNotifier.notifier) + .getGalleryPermissionStatus(); ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index a846c50dd1..8fd42dde76 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -2,11 +2,9 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; -import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; -import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; +import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; +import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; @@ -24,27 +22,27 @@ final albumServiceProvider = Provider( (ref) => AlbumService( ref.watch(apiServiceProvider), ref.watch(userServiceProvider), - ref.watch(backgroundServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), + ref.watch(backupServiceProvider), ), ); class AlbumService { final ApiService _apiService; final UserService _userService; - final BackgroundService _backgroundService; final SyncService _syncService; final Isar _db; + final BackupService _backupService; Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); AlbumService( this._apiService, this._userService, - this._backgroundService, this._syncService, this._db, + this._backupService, ); /// Checks all selected device albums for changes of albums and their assets @@ -58,13 +56,11 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - if (!await _backgroundService.hasAccess) { - return false; - } - final HiveBackupAlbums? infos = - (await Hive.openBox(hiveBackupInfoBox)) - .get(backupInfoKey); - if (infos == null) { + final List excludedIds = + await _backupService.excludedAlbumsQuery().idProperty().findAll(); + final List selectedIds = + await _backupService.selectedAlbumsQuery().idProperty().findAll(); + if (selectedIds.isEmpty) { return false; } final List onDevice = @@ -72,11 +68,11 @@ class AlbumService { hasAll: true, filterOption: FilterOptionGroup(containsPathModified: true), ); - if (infos.excludedAlbumsIds.isNotEmpty) { + if (excludedIds.isNotEmpty) { // remove all excluded albums - onDevice.removeWhere((e) => infos.excludedAlbumsIds.contains(e.id)); + onDevice.removeWhere((e) => excludedIds.contains(e.id)); } - final hasAll = infos.selectedAlbumIds + final hasAll = selectedIds .map((id) => onDevice.firstWhereOrNull((a) => a.id == id)) .whereNotNull() .any((a) => a.isAll); @@ -85,7 +81,7 @@ class AlbumService { onDevice.removeWhere((e) => e.isAll); } else { // keep only the explicitly selected albums - onDevice.removeWhere((e) => !infos.selectedAlbumIds.contains(e.id)); + onDevice.removeWhere((e) => !selectedIds.contains(e.id)); } changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice); } finally { diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 7f67147bcc..a19423f781 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -4,21 +4,25 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/modules/backup/background_service/localization.dart'; +import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; -import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; -import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:immich_mobile/utils/diff.dart'; +import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -51,10 +55,6 @@ class BackgroundService { _Throttle(_updateProgress, notifyInterval); late final _Throttle _throttledDetailNotify = _Throttle(_updateDetailProgress, notifyInterval); - Completer _hasAccessCompleter = Completer(); - late Future _hasAccess = _hasAccessCompleter.future; - - Future get hasAccess => _hasAccess; bool get isBackgroundInitialized { return _isBackgroundInitialized; @@ -194,11 +194,6 @@ class BackgroundService { debugPrint("WARNING: [acquireLock] called more than once"); return true; } - if (_hasAccessCompleter.isCompleted) { - debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed"); - _hasAccessCompleter = Completer(); - _hasAccess = _hasAccessCompleter.future; - } final int lockTime = Timeline.now; _wantsLockTime = lockTime; final ReceivePort rp = ReceivePort(_portNameLock); @@ -217,7 +212,6 @@ class BackgroundService { } _hasLock = true; rp.listen(_heartbeatListener); - _hasAccessCompleter.complete(true); return true; } @@ -267,8 +261,6 @@ class BackgroundService { void releaseLock() { _wantsLockTime = 0; if (_hasLock) { - _hasAccessCompleter = Completer(); - _hasAccess = _hasAccessCompleter.future; IsolateNameServer.removePortNameMapping(_portNameLock); _waitingIsolate?.send(true); _waitingIsolate = null; @@ -339,29 +331,24 @@ class BackgroundService { } Future _onAssetsChanged() async { + final Isar db = await loadDb(); await Hive.initFlutter(); Hive.registerAdapter(HiveSavedLoginInfoAdapter()); - Hive.registerAdapter(HiveBackupAlbumsAdapter()); - Hive.registerAdapter(HiveDuplicatedAssetsAdapter()); await Future.wait([ Hive.openBox(userInfoBox), Hive.openBox(hiveLoginInfoBox), Hive.openBox(userSettingInfoBox), - Hive.openBox(backgroundBackupInfoBox), - Hive.openBox(duplicatedAssetsBox), - Hive.openBox(hiveBackupInfoBox), ]); ApiService apiService = ApiService(); apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); - BackupService backupService = BackupService(apiService); + BackupService backupService = BackupService(apiService, db); AppSettingsService settingsService = AppSettingsService(); - final Box box = - Hive.box(hiveBackupInfoBox); - final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); - if (backupAlbumInfo == null) { + final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); + final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + if (selectedAlbums.isEmpty) { return true; } @@ -371,18 +358,37 @@ class BackgroundService { final bool backupOk = await _runBackup( backupService, settingsService, - backupAlbumInfo, + selectedAlbums, + excludedAlbums, ); if (backupOk) { - await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince); - await box.put( - backupInfoKey, - backupAlbumInfo, - ); - } else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) == - null) { - Hive.box(backgroundBackupInfoBox) - .put(backupFailedSince, DateTime.now()); + await Store.delete(StoreKey.backupFailedSince); + final backupAlbums = [...selectedAlbums, ...excludedAlbums]; + backupAlbums.sortBy((e) => e.id); + db.writeTxnSync(() { + final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + db.backupAlbums.deleteAllSync(toDelete); + db.backupAlbums.putAllSync(toUpsert); + }); + } else if (Store.get(StoreKey.backupFailedSince) == null) { + Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; } // Android should check for new assets added while performing backup @@ -395,7 +401,8 @@ class BackgroundService { Future _runBackup( BackupService backupService, AppSettingsService settingsService, - HiveBackupAlbums backupAlbumInfo, + List selectedAlbums, + List excludedAlbums, ) async { _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService); final bool notifyTotalProgress = settingsService @@ -407,8 +414,10 @@ class BackgroundService { return false; } - List toUpload = - await backupService.buildUploadCandidates(backupAlbumInfo); + List toUpload = await backupService.buildUploadCandidates( + selectedAlbums, + excludedAlbums, + ); try { toUpload = await backupService.removeAlreadyUploadedAssets(toUpload); @@ -520,8 +529,7 @@ class BackgroundService { } else if (value == 5) { return false; } - final DateTime? failedSince = - Hive.box(backgroundBackupInfoBox).get(backupFailedSince); + final DateTime? failedSince = Store.get(StoreKey.backupFailedSince); if (failedSince == null) { return false; } diff --git a/mobile/lib/modules/backup/models/backup_album.model.dart b/mobile/lib/modules/backup/models/backup_album.model.dart new file mode 100644 index 0000000000..57bcde77fc --- /dev/null +++ b/mobile/lib/modules/backup/models/backup_album.model.dart @@ -0,0 +1,22 @@ +import 'package:immich_mobile/utils/hash.dart'; +import 'package:isar/isar.dart'; + +part 'backup_album.model.g.dart'; + +@Collection(inheritance: false) +class BackupAlbum { + String id; + DateTime lastBackup; + @Enumerated(EnumType.ordinal) + BackupSelection selection; + + BackupAlbum(this.id, this.lastBackup, this.selection); + + Id get isarId => fastHash(id); +} + +enum BackupSelection { + none, + select, + exclude; +} diff --git a/mobile/lib/modules/backup/models/backup_album.model.g.dart b/mobile/lib/modules/backup/models/backup_album.model.g.dart new file mode 100644 index 0000000000..87dfcab1cc Binary files /dev/null and b/mobile/lib/modules/backup/models/backup_album.model.g.dart differ diff --git a/mobile/lib/modules/backup/models/duplicated_asset.model.dart b/mobile/lib/modules/backup/models/duplicated_asset.model.dart new file mode 100644 index 0000000000..bc0302ed55 --- /dev/null +++ b/mobile/lib/modules/backup/models/duplicated_asset.model.dart @@ -0,0 +1,11 @@ +import 'package:immich_mobile/utils/hash.dart'; +import 'package:isar/isar.dart'; + +part 'duplicated_asset.model.g.dart'; + +@Collection(inheritance: false) +class DuplicatedAsset { + String id; + DuplicatedAsset(this.id); + Id get isarId => fastHash(id); +} diff --git a/mobile/lib/modules/backup/models/duplicated_asset.model.g.dart b/mobile/lib/modules/backup/models/duplicated_asset.model.g.dart new file mode 100644 index 0000000000..087925d3db Binary files /dev/null and b/mobile/lib/modules/backup/models/duplicated_asset.model.g.dart differ diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index ddb45a4ecb..78b20033ac 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -1,22 +1,26 @@ import 'package:cancellation_token_http/http.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; +import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; -import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; -import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart'; +import 'package:immich_mobile/utils/diff.dart'; +import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -29,6 +33,7 @@ class BackupNotifier extends StateNotifier { this._authState, this._backgroundService, this._galleryPermissionNotifier, + this._db, this.ref, ) : super( BackUpState( @@ -69,6 +74,7 @@ class BackupNotifier extends StateNotifier { final AuthenticationState _authState; final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; + final Isar _db; final Ref ref; /// @@ -157,11 +163,13 @@ class BackupNotifier extends StateNotifier { triggerMaxDelay: state.backupTriggerDelay * 10, ); if (success) { - final box = Hive.box(backgroundBackupInfoBox); await Future.wait([ - box.put(backupRequireWifi, state.backupRequireWifi), - box.put(backupRequireCharging, state.backupRequireCharging), - box.put(backupTriggerDelay, state.backupTriggerDelay), + Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi), + Store.put( + StoreKey.backupRequireCharging, + state.backupRequireCharging, + ), + Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay), ]); } else { state = state.copyWith( @@ -201,16 +209,16 @@ class BackupNotifier extends StateNotifier { for (AssetPathEntity album in albums) { AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); - var assetCountInAlbum = await album.assetCountAsync; + final assetCountInAlbum = await album.assetCountAsync; if (assetCountInAlbum > 0) { - var assetList = + final assetList = await album.getAssetListRange(start: 0, end: assetCountInAlbum); if (assetList.isNotEmpty) { - var thumbnailAsset = assetList.first; + final thumbnailAsset = assetList.first; try { - var thumbnailData = await thumbnailAsset + final thumbnailData = await thumbnailAsset .thumbnailDataWithSize(const ThumbnailSize(512, 512)); availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData); @@ -229,34 +237,17 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); - // Put persistent storage info into local state of the app - // Get local storage on selected backup album - Box backupAlbumInfoBox = - Hive.box(hiveBackupInfoBox); - HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get( - backupInfoKey, - defaultValue: HiveBackupAlbums( - selectedAlbumIds: [], - excludedAlbumsIds: [], - lastSelectedBackupTime: [], - lastExcludedBackupTime: [], - ), - ); - - if (backupAlbumInfo == null) { - log.severe( - "backupAlbumInfo == null", - "Failed to get Hive backup album information", - ); - return; - } + final List excludedBackupAlbums = + await _backupService.excludedAlbumsQuery().findAll(); + final List selectedBackupAlbums = + await _backupService.selectedAlbumsQuery().findAll(); // First time backup - set isAll album is the default one for backup. - if (backupAlbumInfo.selectedAlbumIds.isEmpty) { + if (selectedBackupAlbums.isEmpty) { log.info("First time backup; setup 'Recent(s)' album as default"); // Get album that contains all assets - var list = await PhotoManager.getAssetPathList( + final list = await PhotoManager.getAssetPathList( hasAll: true, onlyAll: true, type: RequestType.common, @@ -267,48 +258,29 @@ class BackupNotifier extends StateNotifier { } AssetPathEntity albumHasAllAssets = list.first; - backupAlbumInfoBox.put( - backupInfoKey, - HiveBackupAlbums( - selectedAlbumIds: [albumHasAllAssets.id], - excludedAlbumsIds: [], - lastSelectedBackupTime: [ - DateTime.fromMillisecondsSinceEpoch(0, isUtc: true) - ], - lastExcludedBackupTime: [], - ), + final ba = BackupAlbum( + albumHasAllAssets.id, + DateTime.fromMillisecondsSinceEpoch(0), + BackupSelection.select, ); - - backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey); + await _db.writeTxn(() => _db.backupAlbums.put(ba)); } // Generate AssetPathEntity from id to add to local state try { - Set selectedAlbums = {}; - for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) { - var albumAsset = - await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]); + final Set selectedAlbums = {}; + for (final BackupAlbum ba in selectedBackupAlbums) { + final albumAsset = await AssetPathEntity.fromId(ba.id); selectedAlbums.add( - AvailableAlbum( - albumEntity: albumAsset, - lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i - ? backupAlbumInfo.lastSelectedBackupTime[i] - : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), - ), + AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), ); } - Set excludedAlbums = {}; - for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) { - var albumAsset = - await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]); + final Set excludedAlbums = {}; + for (final BackupAlbum ba in excludedBackupAlbums) { + final albumAsset = await AssetPathEntity.fromId(ba.id); excludedAlbums.add( - AvailableAlbum( - albumEntity: albumAsset, - lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i - ? backupAlbumInfo.lastExcludedBackupTime[i] - : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), - ), + AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), ); } state = state.copyWith( @@ -328,36 +300,36 @@ class BackupNotifier extends StateNotifier { /// Those assets are unique and are used as the total assets /// Future _updateBackupAssetCount() async { - Set duplicatedAssetIds = _backupService.getDuplicatedAssetIds(); - Set assetsFromSelectedAlbums = {}; - Set assetsFromExcludedAlbums = {}; + final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); + final Set assetsFromSelectedAlbums = {}; + final Set assetsFromExcludedAlbums = {}; - for (var album in state.selectedBackupAlbums) { - var assets = await album.albumEntity.getAssetListRange( + for (final album in state.selectedBackupAlbums) { + final assets = await album.albumEntity.getAssetListRange( start: 0, end: await album.albumEntity.assetCountAsync, ); assetsFromSelectedAlbums.addAll(assets); } - for (var album in state.excludedBackupAlbums) { - var assets = await album.albumEntity.getAssetListRange( + for (final album in state.excludedBackupAlbums) { + final assets = await album.albumEntity.getAssetListRange( start: 0, end: await album.albumEntity.assetCountAsync, ); assetsFromExcludedAlbums.addAll(assets); } - Set allUniqueAssets = + final Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); - var allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); + final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { return; } // Find asset that were backup from selected albums - Set selectedAlbumsBackupAssets = + final Set selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id)); selectedAlbumsBackupAssets @@ -386,7 +358,7 @@ class BackupNotifier extends StateNotifier { } // Save to persistent storage - _updatePersistentAlbumsSelection(); + await _updatePersistentAlbumsSelection(); return; } @@ -395,7 +367,7 @@ class BackupNotifier extends StateNotifier { /// which albums are selected or excluded /// and then update the UI according to those information Future getBackupInfo() async { - var isEnabled = await _backgroundService.isBackgroundBackupEnabled(); + final isEnabled = await _backgroundService.isBackgroundBackupEnabled(); state = state.copyWith(backgroundBackup: isEnabled); @@ -406,25 +378,38 @@ class BackupNotifier extends StateNotifier { } } - /// Save user selection of selected albums and excluded albums to - /// Hive database - void _updatePersistentAlbumsSelection() { + /// Save user selection of selected albums and excluded albums to database + Future _updatePersistentAlbumsSelection() { final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - Box backupAlbumInfoBox = - Hive.box(hiveBackupInfoBox); - backupAlbumInfoBox.put( - backupInfoKey, - HiveBackupAlbums( - selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(), - excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(), - lastSelectedBackupTime: state.selectedBackupAlbums - .map((e) => e.lastBackup ?? epoch) - .toList(), - lastExcludedBackupTime: state.excludedBackupAlbums - .map((e) => e.lastBackup ?? epoch) - .toList(), - ), + final selected = state.selectedBackupAlbums.map( + (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select), ); + final excluded = state.excludedBackupAlbums.map( + (e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude), + ); + final backupAlbums = selected.followedBy(excluded).toList(); + backupAlbums.sortBy((e) => e.id); + return _db.writeTxn(() async { + final dbAlbums = await _db.backupAlbums.where().sortById().findAll(); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` the user just made + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + b.lastBackup = + a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup; + toUpsert.add(b); + return true; + }, + onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId), + onlySecond: (BackupAlbum b) => toUpsert.add(b), + ); + await _db.backupAlbums.deleteAll(toDelete); + await _db.backupAlbums.putAll(toUpsert); + }); } /// Invoke backup process @@ -447,7 +432,7 @@ class BackupNotifier extends StateNotifier { Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up - for (var assetId in state.allAssetsInDatabase) { + for (final assetId in state.allAssetsInDatabase) { assetsWillBeBackup.removeWhere((e) => e.id == assetId); } @@ -547,7 +532,7 @@ class BackupNotifier extends StateNotifier { } Future _updateServerInfo() async { - var serverInfo = await _serverInfoService.getServerInfo(); + final serverInfo = await _serverInfoService.getServerInfo(); // Update server info if (serverInfo != null) { @@ -559,7 +544,7 @@ class BackupNotifier extends StateNotifier { Future _resumeBackup() async { // Check if user is login - var accessKey = Hive.box(userInfoBox).get(accessTokenKey); + final accessKey = Hive.box(userInfoBox).get(accessTokenKey); // User has been logged out return if (accessKey == null || !_authState.isAuthenticated) { @@ -590,65 +575,56 @@ class BackupNotifier extends StateNotifier { } Future resumeBackup() async { - // assumes the background service is currently running - // if true, waits until it has stopped to update the app state from HiveDB - // before actually resuming backup by calling the internal `_resumeBackup` - final BackUpProgressEnum previous = state.backupProgress; - state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); - final bool hasLock = await _backgroundService.acquireLock(); - if (!hasLock) { - log.warning("WARNING [resumeBackup] failed to acquireLock"); - return; - } - - await Future.wait([ - Hive.openBox(hiveBackupInfoBox), - Hive.openBox(duplicatedAssetsBox), - Hive.openBox(backgroundBackupInfoBox), - ]); - final HiveBackupAlbums? albums = - Hive.box(hiveBackupInfoBox).get(backupInfoKey); + final List selectedBackupAlbums = await _db.backupAlbums + .filter() + .selectionEqualTo(BackupSelection.select) + .findAll(); + final List excludedBackupAlbums = await _db.backupAlbums + .filter() + .selectionEqualTo(BackupSelection.select) + .findAll(); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; - if (albums != null) { - if (selectedAlbums.isNotEmpty) { - selectedAlbums = _updateAlbumsBackupTime( - selectedAlbums, - albums.selectedAlbumIds, - albums.lastSelectedBackupTime, - ); - } - - if (excludedAlbums.isNotEmpty) { - excludedAlbums = _updateAlbumsBackupTime( - excludedAlbums, - albums.excludedAlbumsIds, - albums.lastExcludedBackupTime, - ); - } + if (selectedAlbums.isNotEmpty) { + selectedAlbums = _updateAlbumsBackupTime( + selectedAlbums, + selectedBackupAlbums, + ); } - final Box backgroundBox = Hive.box(backgroundBackupInfoBox); + + if (excludedAlbums.isNotEmpty) { + excludedAlbums = _updateAlbumsBackupTime( + excludedAlbums, + excludedBackupAlbums, + ); + } + final BackUpProgressEnum previous = state.backupProgress; state = state.copyWith( - backupProgress: previous, + backupProgress: BackUpProgressEnum.inBackground, selectedBackupAlbums: selectedAlbums, excludedBackupAlbums: excludedAlbums, - backupRequireWifi: backgroundBox.get(backupRequireWifi), - backupRequireCharging: backgroundBox.get(backupRequireCharging), - backupTriggerDelay: backgroundBox.get(backupTriggerDelay), + backupRequireWifi: Store.get(StoreKey.backupRequireWifi), + backupRequireCharging: Store.get(StoreKey.backupRequireCharging), + backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay), ); + // assumes the background service is currently running + // if true, waits until it has stopped to start the backup + final bool hasLock = await _backgroundService.acquireLock(); + if (hasLock) { + state = state.copyWith(backupProgress: previous); + } return _resumeBackup(); } Set _updateAlbumsBackupTime( Set albums, - List ids, - List times, + List backupAlbums, ) { Set result = {}; - for (int i = 0; i < ids.length; i++) { + for (BackupAlbum ba in backupAlbums) { try { - AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]); - result.add(a.copyWith(lastBackup: times[i])); + AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id); + result.add(a.copyWith(lastBackup: ba.lastBackup)); } on StateError { log.severe( "[_updateAlbumBackupTime] failed to find album in state", @@ -667,35 +643,6 @@ class BackupNotifier extends StateNotifier { AppStateEnum.detached, ]; if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) { - try { - if (Hive.isBoxOpen(hiveBackupInfoBox)) { - await Hive.box(hiveBackupInfoBox).close(); - } - } catch (error) { - log.info("[_notifyBackgroundServiceCanRun] failed to close box"); - } - try { - if (Hive.isBoxOpen(duplicatedAssetsBox)) { - await Hive.box(duplicatedAssetsBox).close(); - } - } catch (error, stackTrace) { - log.severe( - "[_notifyBackgroundServiceCanRun] failed to close box", - error, - stackTrace, - ); - } - try { - if (Hive.isBoxOpen(backgroundBackupInfoBox)) { - await Hive.box(backgroundBackupInfoBox).close(); - } - } catch (error, stackTrace) { - log.severe( - "[_notifyBackgroundServiceCanRun] failed to close box", - error, - stackTrace, - ); - } _backgroundService.releaseLock(); } } @@ -709,6 +656,7 @@ final backupProvider = ref.watch(authenticationProvider), ref.watch(backgroundServiceProvider), ref.watch(galleryPermissionNotifier.notifier), + ref.watch(dbProvider), ref, ); }); diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 0eac69ee31..b8f0bce837 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -8,31 +8,34 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; +import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; -import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/files_helper.dart'; +import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart' as p; import 'package:cancellation_token_http/http.dart' as http; -import '../models/hive_duplicated_assets.model.dart'; - final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), + ref.watch(dbProvider), ), ); class BackupService { final httpClient = http.Client(); final ApiService _apiService; + final Isar _db; - BackupService(this._apiService); + BackupService(this._apiService, this._db); Future?> getDeviceBackupAsset() async { String deviceId = Hive.box(userInfoBox).get(deviceIdKey); @@ -45,32 +48,28 @@ class BackupService { } } - void _saveDuplicatedAssetIdToLocalStorage(List deviceAssetIds) { - HiveDuplicatedAssets duplicatedAssets = - Hive.box(duplicatedAssetsBox) - .get(duplicatedAssetsKey) ?? - HiveDuplicatedAssets(duplicatedAssetIds: []); - - duplicatedAssets.duplicatedAssetIds = - {...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList(); - - Hive.box(duplicatedAssetsBox) - .put(duplicatedAssetsKey, duplicatedAssets); + Future _saveDuplicatedAssetIds(List deviceAssetIds) { + final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); + return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); } - /// Get duplicated asset id from Hive storage - Set getDuplicatedAssetIds() { - HiveDuplicatedAssets duplicatedAssets = - Hive.box(duplicatedAssetsBox) - .get(duplicatedAssetsKey) ?? - HiveDuplicatedAssets(duplicatedAssetIds: []); - - return duplicatedAssets.duplicatedAssetIds.toSet(); + /// Get duplicated asset id from database + Future> getDuplicatedAssetIds() async { + final duplicates = await _db.duplicatedAssets.where().findAll(); + return duplicates.map((e) => e.id).toSet(); } + QueryBuilder + selectedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); + QueryBuilder + excludedAlbumsQuery() => + _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); + /// Returns all assets newer than the last successful backup per album Future> buildUploadCandidates( - HiveBackupAlbums backupAlbums, + List selectedBackupAlbums, + List excludedBackupAlbums, ) async { final filter = FilterOptionGroup( containsPathModified: true, @@ -81,66 +80,55 @@ class BackupService { ); final now = DateTime.now(); final List selectedAlbums = - await _loadAlbumsWithTimeFilter( - backupAlbums.selectedAlbumIds, - backupAlbums.lastSelectedBackupTime, - filter, - now, - ); + await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now); if (selectedAlbums.every((e) => e == null)) { return []; } final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll); if (allIdx != -1) { final List excludedAlbums = - await _loadAlbumsWithTimeFilter( - backupAlbums.excludedAlbumsIds, - backupAlbums.lastExcludedBackupTime, - filter, - now, - ); + await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now); final List toAdd = await _fetchAssetsAndUpdateLastBackup( selectedAlbums.slice(allIdx, allIdx + 1), - backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1), + selectedBackupAlbums.slice(allIdx, allIdx + 1), now, ); final List toRemove = await _fetchAssetsAndUpdateLastBackup( excludedAlbums, - backupAlbums.lastExcludedBackupTime, + excludedBackupAlbums, now, ); return toAdd.toSet().difference(toRemove.toSet()).toList(); } else { return await _fetchAssetsAndUpdateLastBackup( selectedAlbums, - backupAlbums.lastSelectedBackupTime, + selectedBackupAlbums, now, ); } } Future> _loadAlbumsWithTimeFilter( - List albumIds, - List lastBackups, + List albums, FilterOptionGroup filter, DateTime now, ) async { - List result = List.filled(albumIds.length, null); - for (int i = 0; i < albumIds.length; i++) { + List result = []; + for (BackupAlbum a in albums) { try { final AssetPathEntity album = await AssetPathEntity.obtainPathFromProperties( - id: albumIds[i], + id: a.id, optionGroup: filter.copyWith( updateTimeCond: DateTimeCond( // subtract 2 seconds to prevent missing assets due to rounding issues - min: lastBackups[i].subtract(const Duration(seconds: 2)), + min: a.lastBackup.subtract(const Duration(seconds: 2)), max: now, ), ), maxDateTimeToNow: false, ); - result[i] = album; + result.add(album); } on StateError { // either there are no assets matching the filter criteria OR the album no longer exists } @@ -150,17 +138,18 @@ class BackupService { Future> _fetchAssetsAndUpdateLastBackup( List albums, - List lastBackup, + List backupAlbums, DateTime now, ) async { List result = []; for (int i = 0; i < albums.length; i++) { final AssetPathEntity? a = albums[i]; - if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) { + if (a != null && + a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) { result.addAll( await a.getAssetListRange(start: 0, end: await a.assetCountAsync), ); - lastBackup[i] = now; + backupAlbums[i].lastBackup = now; } } return result; @@ -173,7 +162,7 @@ class BackupService { if (candidates.isEmpty) { return candidates; } - final Set duplicatedAssetIds = getDuplicatedAssetIds(); + final Set duplicatedAssetIds = await getDuplicatedAssetIds(); candidates = duplicatedAssetIds.isEmpty ? candidates : candidates @@ -261,7 +250,8 @@ class BackupService { req.fields['deviceId'] = deviceId; req.fields['assetType'] = _getAssetType(entity.type); req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String(); - req.fields['fileModifiedAt'] = entity.modifiedDateTime.toIso8601String(); + req.fields['fileModifiedAt'] = + entity.modifiedDateTime.toIso8601String(); req.fields['isFavorite'] = entity.isFavorite.toString(); req.fields['fileExtension'] = fileExtension; req.fields['duration'] = entity.videoDuration.toString(); @@ -332,7 +322,7 @@ class BackupService { } } if (duplicatedAssetIds.isNotEmpty) { - _saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds); + await _saveDuplicatedAssetIds(duplicatedAssetIds); } return !anyErrors; } diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index d6a5c168dd..9df9a307ee 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -29,8 +29,8 @@ class BackupControllerPage extends HookConsumerWidget { AuthenticationState authenticationState = ref.watch(authenticationProvider); final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings; - final appRefreshDisabled = Platform.isIOS && - settings?.appRefreshEnabled != true; + final appRefreshDisabled = + Platform.isIOS && settings?.appRefreshEnabled != true; bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground; bool shouldBackup = backupState.allUniqueAssets.length - @@ -292,15 +292,13 @@ class BackupControllerPage extends HookConsumerWidget { dense: true, activeColor: activeColor, value: isWifiRequired, - onChanged: hasExclusiveAccess - ? (isChecked) => ref - .read(backupProvider.notifier) - .configureBackgroundBackup( - requireWifi: isChecked, - onError: showErrorToUser, - onBatteryInfo: showBatteryOptimizationInfoToUser, - ) - : null, + onChanged: (isChecked) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + requireWifi: isChecked, + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ), ), if (isBackgroundEnabled) SwitchListTile.adaptive( @@ -314,21 +312,18 @@ class BackupControllerPage extends HookConsumerWidget { dense: true, activeColor: activeColor, value: isChargingRequired, - onChanged: hasExclusiveAccess - ? (isChecked) => ref - .read(backupProvider.notifier) - .configureBackgroundBackup( - requireCharging: isChecked, - onError: showErrorToUser, - onBatteryInfo: showBatteryOptimizationInfoToUser, - ) - : null, + onChanged: (isChecked) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + requireCharging: isChecked, + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ), ), if (isBackgroundEnabled && Platform.isAndroid) ListTile( isThreeLine: false, dense: true, - enabled: hasExclusiveAccess, title: const Text( 'backup_controller_page_background_delay', style: TextStyle( @@ -339,9 +334,7 @@ class BackupControllerPage extends HookConsumerWidget { ), subtitle: Slider( value: triggerDelay.value, - onChanged: hasExclusiveAccess - ? (double v) => triggerDelay.value = v - : null, + onChanged: (double v) => triggerDelay.value = v, onChangeEnd: (double v) => ref .read(backupProvider.notifier) .configureBackgroundBackup( @@ -379,15 +372,13 @@ class BackupControllerPage extends HookConsumerWidget { if (isBackgroundEnabled && Platform.isIOS) FutureBuilder( future: ref - .read(backgroundServiceProvider) - .getIOSBackgroundAppRefreshEnabled(), + .read(backgroundServiceProvider) + .getIOSBackgroundAppRefreshEnabled(), builder: (context, snapshot) { final enabled = snapshot.data as bool?; // If it's not enabled, show them some kind of alert that says // background refresh is not enabled - if (enabled != null && !enabled) { - - } + if (enabled != null && !enabled) {} // If it's enabled, no need to bother them return Container(); }, @@ -395,7 +386,7 @@ class BackupControllerPage extends HookConsumerWidget { if (Platform.isIOS && isBackgroundEnabled && settings != null) IosDebugInfoTile( settings: settings, - ), + ), ], ); } @@ -403,7 +394,9 @@ class BackupControllerPage extends HookConsumerWidget { Widget buildBackgroundAppRefreshWarning() { return ListTile( isThreeLine: true, - leading: const Icon(Icons.task_outlined,), + leading: const Icon( + Icons.task_outlined, + ), title: const Text( 'backup_controller_page_background_app_refresh_disabled_title', style: TextStyle( @@ -420,7 +413,7 @@ class BackupControllerPage extends HookConsumerWidget { 'backup_controller_page_background_app_refresh_disabled_content', ).tr(), ), - ElevatedButton( + ElevatedButton( onPressed: () => openAppSettings(), child: const Text( 'backup_controller_page_background_app_refresh_enable_button_text', @@ -533,12 +526,9 @@ class BackupControllerPage extends HookConsumerWidget { ), ), trailing: ElevatedButton( - onPressed: hasExclusiveAccess - ? () { - AutoRouter.of(context) - .push(const BackupAlbumSelectionRoute()); - } - : null, + onPressed: () { + AutoRouter.of(context).push(const BackupAlbumSelectionRoute()); + }, child: const Text( "backup_controller_page_select", style: TextStyle( @@ -598,28 +588,12 @@ class BackupControllerPage extends HookConsumerWidget { } buildBackgroundBackupInfo() { - return hasExclusiveAccess - ? const SizedBox.shrink() - : Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), // if you need this - side: BorderSide( - color: isDarkMode - ? const Color.fromARGB(255, 56, 56, 56) - : Colors.black12, - width: 1, - ), - ), - elevation: 0, - borderOnForeground: false, - child: const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - "Background backup is currently running, some actions are disabled", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ); + return const ListTile( + leading: Icon(Icons.info_outline_rounded), + title: Text( + "Background backup is currently running, cannot start manual backup", + ), + ); } return Scaffold( @@ -652,7 +626,6 @@ class BackupControllerPage extends HookConsumerWidget { style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ).tr(), ), - buildBackgroundBackupInfo(), buildFolderSelectionTile(), BackupInfoCard( title: "backup_controller_page_total".tr(), @@ -681,22 +654,20 @@ class BackupControllerPage extends HookConsumerWidget { AnimatedSwitcher( duration: const Duration(milliseconds: 500), child: Platform.isIOS - ? ( - appRefreshDisabled - ? buildBackgroundAppRefreshWarning() - : buildBackgroundBackupController() - ) : buildBackgroundBackupController(), + ? (appRefreshDisabled + ? buildBackgroundAppRefreshWarning() + : buildBackgroundBackupController()) + : buildBackgroundBackupController(), ), const Divider(), buildStorageInformation(), const Divider(), const CurrentUploadingAssetInfoBox(), + if (!hasExclusiveAccess) buildBackgroundBackupInfo(), buildBackupButton() ], ), ), ); } - - } diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart index a349e7d600..858d13ad7b 100644 --- a/mobile/lib/shared/models/store.dart +++ b/mobile/lib/shared/models/store.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:isar/isar.dart'; import 'dart:convert'; @@ -9,7 +10,8 @@ part 'store.g.dart'; /// Can be used concurrently from multiple isolates class Store { static late final Isar _db; - static final List _cache = List.filled(StoreKey.values.length, null); + static final List _cache = + List.filled(StoreKey.values.map((e) => e.id).max + 1, null); /// Initializes the store (call exactly once per app start) static void init(Isar db) { @@ -70,23 +72,44 @@ class StoreValue { int? intValue; String? strValue; - T? _extract(StoreKey key) => key.isInt - ? (key.fromDb == null ? intValue : key.fromDb!.call(Store._db, intValue!)) - : (key.fromJson != null - ? key.fromJson!(json.decode(strValue!)) - : strValue); - static Future _of(dynamic value, StoreKey key) async => - StoreValue( - key.id, - intValue: key.isInt - ? (key.toDb == null - ? value - : await key.toDb!.call(Store._db, value)) - : null, - strValue: key.isInt + dynamic _extract(StoreKey key) { + switch (key.type) { + case int: + return key.fromDb == null + ? intValue + : key.fromDb!.call(Store._db, intValue!); + case bool: + return intValue == null ? null : intValue! == 1; + case DateTime: + return intValue == null ? null - : (key.fromJson == null ? value : json.encode(value.toJson())), - ); + : DateTime.fromMicrosecondsSinceEpoch(intValue!); + case String: + return key.fromJson != null + ? key.fromJson!.call(json.decode(strValue!)) + : strValue; + } + } + + static Future _of(dynamic value, StoreKey key) async { + int? i; + String? s; + switch (key.type) { + case int: + i = (key.toDb == null ? value : await key.toDb!.call(Store._db, value)); + break; + case bool: + i = value == null ? null : (value ? 1 : 0); + break; + case DateTime: + i = value == null ? null : (value as DateTime).microsecondsSinceEpoch; + break; + case String: + s = key.fromJson == null ? value : json.encode(value.toJson()); + break; + } + return StoreValue(key.id, intValue: i, strValue: s); + } } /// Key for each possible value in the `Store`. @@ -94,21 +117,24 @@ class StoreValue { enum StoreKey { userRemoteId(0), assetETag(1), - currentUser(2, isInt: true, fromDb: _getUser, toDb: _toUser), - deviceIdHash(3, isInt: true), + currentUser(2, type: int, fromDb: _getUser, toDb: _toUser), + deviceIdHash(3, type: int), deviceId(4), - ; + backupFailedSince(5, type: DateTime), + backupRequireWifi(6, type: bool), + backupRequireCharging(7, type: bool), + backupTriggerDelay(8, type: int); const StoreKey( this.id, { - this.isInt = false, + this.type = String, this.fromDb, this.toDb, // ignore: unused_element this.fromJson, }); final int id; - final bool isInt; + final Type type; final dynamic Function(Isar, int)? fromDb; final Future Function(Isar, dynamic)? toDb; final Function(dynamic)? fromJson; diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 8d44b4f7a3..87bc375cd3 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -4,22 +4,102 @@ import 'package:flutter/cupertino.dart'; import 'package:hive/hive.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; +import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; +import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'; +import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; +import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart'; +import 'package:isar/isar.dart'; Future migrateHiveToStoreIfNecessary() async { + await _migrateHiveBoxIfNecessary(userInfoBox, _migrateHiveUserInfoBox); + await _migrateHiveBoxIfNecessary( + backgroundBackupInfoBox, + _migrateHiveBackgroundBackupInfoBox, + ); + await _migrateHiveBoxIfNecessary(hiveBackupInfoBox, _migrateBackupInfoBox); + await _migrateHiveBoxIfNecessary( + duplicatedAssetsBox, + _migrateDuplicatedAssetsBox, + ); +} + +Future _migrateHiveUserInfoBox(Box box) async { + await _migrateKey(box, userIdKey, StoreKey.userRemoteId); + await _migrateKey(box, assetEtagKey, StoreKey.assetETag); +} + +Future _migrateHiveBackgroundBackupInfoBox(Box box) async { + await _migrateKey(box, backupFailedSince, StoreKey.backupFailedSince); + await _migrateKey(box, backupRequireWifi, StoreKey.backupRequireWifi); + await _migrateKey(box, backupRequireCharging, StoreKey.backupRequireCharging); + await _migrateKey(box, backupTriggerDelay, StoreKey.backupTriggerDelay); + return box.deleteFromDisk(); +} + +Future _migrateBackupInfoBox(Box box) async { + final Isar? db = Isar.getInstance(); + if (db == null) { + throw Exception("_migrateBackupInfoBox could not load database"); + } + final HiveBackupAlbums? infos = box.get(backupInfoKey); + if (infos != null) { + List albums = []; + for (int i = 0; i < infos.selectedAlbumIds.length; i++) { + final album = BackupAlbum( + infos.selectedAlbumIds[i], + infos.lastSelectedBackupTime[i], + BackupSelection.select, + ); + albums.add(album); + } + for (int i = 0; i < infos.excludedAlbumsIds.length; i++) { + final album = BackupAlbum( + infos.excludedAlbumsIds[i], + infos.lastExcludedBackupTime[i], + BackupSelection.exclude, + ); + albums.add(album); + } + await db.writeTxn(() => db.backupAlbums.putAll(albums)); + } else { + debugPrint("_migrateBackupInfoBox deletes empty box"); + } + return box.deleteFromDisk(); +} + +Future _migrateDuplicatedAssetsBox(Box box) async { + final Isar? db = Isar.getInstance(); + if (db == null) { + throw Exception("_migrateBackupInfoBox could not load database"); + } + final HiveDuplicatedAssets? duplicatedAssets = box.get(duplicatedAssetsKey); + if (duplicatedAssets != null) { + final duplicatedAssetIds = duplicatedAssets.duplicatedAssetIds + .map((id) => DuplicatedAsset(id)) + .toList(); + await db.writeTxn(() => db.duplicatedAssets.putAll(duplicatedAssetIds)); + } else { + debugPrint("_migrateDuplicatedAssetsBox deletes empty box"); + } + return box.deleteFromDisk(); +} + +Future _migrateHiveBoxIfNecessary( + String boxName, + Future Function(Box) migrate, +) async { try { - if (await Hive.boxExists(userInfoBox)) { - final Box box = await Hive.openBox(userInfoBox); - await _migrateSingleKey(box, userIdKey, StoreKey.userRemoteId); - await _migrateSingleKey(box, assetEtagKey, StoreKey.assetETag); + if (await Hive.boxExists(boxName)) { + await migrate(await Hive.openBox(boxName)); } } catch (e) { - debugPrint("Error while migrating userInfoBox $e"); + debugPrint("Error while migrating $boxName $e"); } } -_migrateSingleKey(Box box, String hiveKey, StoreKey key) async { +_migrateKey(Box box, String hiveKey, StoreKey key) async { final String? value = box.get(hiveKey); if (value != null) { await Store.put(key, value);