Browse Source

feature(mobile): no longer wait for background backup in settings (#1984)

* feature(mobile): no longer wait for background backup in settings

migrate all Hive boxes required for the backup process to Isar

* add final modifier
Fynn Petersen-Frey 2 years ago
parent
commit
05cf5d57a9

+ 10 - 4
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<Isar> loadDb() async {
       AssetSchema,
       AlbumSchema,
       UserSchema,
+      BackupAlbumSchema,
+      DuplicatedAssetSchema,
     ],
     directory: dir.path,
     maxSizeMiB: 256,
@@ -156,10 +160,12 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 
         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();
 

+ 14 - 18
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<bool> _localCompleter = Completer()..complete(false);
   Completer<bool> _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<HiveBackupAlbums>(hiveBackupInfoBox))
-              .get(backupInfoKey);
-      if (infos == null) {
+      final List<String> excludedIds =
+          await _backupService.excludedAlbumsQuery().idProperty().findAll();
+      final List<String> selectedIds =
+          await _backupService.selectedAlbumsQuery().idProperty().findAll();
+      if (selectedIds.isEmpty) {
         return false;
       }
       final List<AssetPathEntity> 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 {

+ 47 - 39
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<bool> _hasAccessCompleter = Completer();
-  late Future<bool> _hasAccess = _hasAccessCompleter.future;
-
-  Future<bool> 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<bool> _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<HiveSavedLoginInfo>(hiveLoginInfoBox),
       Hive.openBox(userSettingInfoBox),
-      Hive.openBox(backgroundBackupInfoBox),
-      Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
-      Hive.openBox<HiveBackupAlbums>(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<HiveBackupAlbums> box =
-        Hive.box<HiveBackupAlbums>(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<int> toDelete = [];
+          final List<BackupAlbum> 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<bool> _runBackup(
     BackupService backupService,
     AppSettingsService settingsService,
-    HiveBackupAlbums backupAlbumInfo,
+    List<BackupAlbum> selectedAlbums,
+    List<BackupAlbum> excludedAlbums,
   ) async {
     _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
     final bool notifyTotalProgress = settingsService
@@ -407,8 +414,10 @@ class BackgroundService {
       return false;
     }
 
-    List<AssetEntity> toUpload =
-        await backupService.buildUploadCandidates(backupAlbumInfo);
+    List<AssetEntity> 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;
     }

+ 22 - 0
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;
+}

+ 653 - 0
mobile/lib/modules/backup/models/backup_album.model.g.dart

@@ -0,0 +1,653 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'backup_album.model.dart';
+
+// **************************************************************************
+// IsarCollectionGenerator
+// **************************************************************************
+
+// coverage:ignore-file
+// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
+
+extension GetBackupAlbumCollection on Isar {
+  IsarCollection<BackupAlbum> get backupAlbums => this.collection();
+}
+
+const BackupAlbumSchema = CollectionSchema(
+  name: r'BackupAlbum',
+  id: 8308487201128361847,
+  properties: {
+    r'id': PropertySchema(
+      id: 0,
+      name: r'id',
+      type: IsarType.string,
+    ),
+    r'lastBackup': PropertySchema(
+      id: 1,
+      name: r'lastBackup',
+      type: IsarType.dateTime,
+    ),
+    r'selection': PropertySchema(
+      id: 2,
+      name: r'selection',
+      type: IsarType.byte,
+      enumMap: _BackupAlbumselectionEnumValueMap,
+    )
+  },
+  estimateSize: _backupAlbumEstimateSize,
+  serialize: _backupAlbumSerialize,
+  deserialize: _backupAlbumDeserialize,
+  deserializeProp: _backupAlbumDeserializeProp,
+  idName: r'isarId',
+  indexes: {},
+  links: {},
+  embeddedSchemas: {},
+  getId: _backupAlbumGetId,
+  getLinks: _backupAlbumGetLinks,
+  attach: _backupAlbumAttach,
+  version: '3.0.5',
+);
+
+int _backupAlbumEstimateSize(
+  BackupAlbum object,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  var bytesCount = offsets.last;
+  bytesCount += 3 + object.id.length * 3;
+  return bytesCount;
+}
+
+void _backupAlbumSerialize(
+  BackupAlbum object,
+  IsarWriter writer,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  writer.writeString(offsets[0], object.id);
+  writer.writeDateTime(offsets[1], object.lastBackup);
+  writer.writeByte(offsets[2], object.selection.index);
+}
+
+BackupAlbum _backupAlbumDeserialize(
+  Id id,
+  IsarReader reader,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  final object = BackupAlbum(
+    reader.readString(offsets[0]),
+    reader.readDateTime(offsets[1]),
+    _BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
+        BackupSelection.none,
+  );
+  return object;
+}
+
+P _backupAlbumDeserializeProp<P>(
+  IsarReader reader,
+  int propertyId,
+  int offset,
+  Map<Type, List<int>> allOffsets,
+) {
+  switch (propertyId) {
+    case 0:
+      return (reader.readString(offset)) as P;
+    case 1:
+      return (reader.readDateTime(offset)) as P;
+    case 2:
+      return (_BackupAlbumselectionValueEnumMap[
+              reader.readByteOrNull(offset)] ??
+          BackupSelection.none) as P;
+    default:
+      throw IsarError('Unknown property with id $propertyId');
+  }
+}
+
+const _BackupAlbumselectionEnumValueMap = {
+  'none': 0,
+  'select': 1,
+  'exclude': 2,
+};
+const _BackupAlbumselectionValueEnumMap = {
+  0: BackupSelection.none,
+  1: BackupSelection.select,
+  2: BackupSelection.exclude,
+};
+
+Id _backupAlbumGetId(BackupAlbum object) {
+  return object.isarId;
+}
+
+List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
+  return [];
+}
+
+void _backupAlbumAttach(
+    IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
+
+extension BackupAlbumQueryWhereSort
+    on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhere> anyIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(const IdWhereClause.any());
+    });
+  }
+}
+
+extension BackupAlbumQueryWhere
+    on QueryBuilder<BackupAlbum, BackupAlbum, QWhereClause> {
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdEqualTo(
+      Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: isarId,
+        upper: isarId,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdNotEqualTo(
+      Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            )
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            );
+      } else {
+        return query
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            )
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            );
+      }
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdGreaterThan(
+      Id isarId,
+      {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.greaterThan(lower: isarId, includeLower: include),
+      );
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdLessThan(
+      Id isarId,
+      {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.lessThan(upper: isarId, includeUpper: include),
+      );
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdBetween(
+    Id lowerIsarId,
+    Id upperIsarId, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: lowerIsarId,
+        includeLower: includeLower,
+        upper: upperIsarId,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+}
+
+extension BackupAlbumQueryFilter
+    on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEqualTo(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idGreaterThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idLessThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idBetween(
+    String lower,
+    String upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'id',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idContains(
+      String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'id',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdEqualTo(
+      Id value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      isarIdGreaterThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdLessThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdBetween(
+    Id lower,
+    Id upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'isarId',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      lastBackupEqualTo(DateTime value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'lastBackup',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      lastBackupGreaterThan(
+    DateTime value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'lastBackup',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      lastBackupLessThan(
+    DateTime value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'lastBackup',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      lastBackupBetween(
+    DateTime lower,
+    DateTime upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'lastBackup',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      selectionEqualTo(BackupSelection value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'selection',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      selectionGreaterThan(
+    BackupSelection value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'selection',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      selectionLessThan(
+    BackupSelection value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'selection',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      selectionBetween(
+    BackupSelection lower,
+    BackupSelection upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'selection',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+}
+
+extension BackupAlbumQueryObject
+    on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
+
+extension BackupAlbumQueryLinks
+    on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
+
+extension BackupAlbumQuerySortBy
+    on QueryBuilder<BackupAlbum, BackupAlbum, QSortBy> {
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackup() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastBackup', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackupDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastBackup', Sort.desc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelection() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'selection', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelectionDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'selection', Sort.desc);
+    });
+  }
+}
+
+extension BackupAlbumQuerySortThenBy
+    on QueryBuilder<BackupAlbum, BackupAlbum, QSortThenBy> {
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIsarIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.desc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackup() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastBackup', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackupDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastBackup', Sort.desc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelection() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'selection', Sort.asc);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelectionDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'selection', Sort.desc);
+    });
+  }
+}
+
+extension BackupAlbumQueryWhereDistinct
+    on QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> {
+  QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctById(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByLastBackup() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'lastBackup');
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctBySelection() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'selection');
+    });
+  }
+}
+
+extension BackupAlbumQueryProperty
+    on QueryBuilder<BackupAlbum, BackupAlbum, QQueryProperty> {
+  QueryBuilder<BackupAlbum, int, QQueryOperations> isarIdProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isarId');
+    });
+  }
+
+  QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'id');
+    });
+  }
+
+  QueryBuilder<BackupAlbum, DateTime, QQueryOperations> lastBackupProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'lastBackup');
+    });
+  }
+
+  QueryBuilder<BackupAlbum, BackupSelection, QQueryOperations>
+      selectionProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'selection');
+    });
+  }
+}

+ 11 - 0
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);
+}

+ 443 - 0
mobile/lib/modules/backup/models/duplicated_asset.model.g.dart

@@ -0,0 +1,443 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'duplicated_asset.model.dart';
+
+// **************************************************************************
+// IsarCollectionGenerator
+// **************************************************************************
+
+// coverage:ignore-file
+// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
+
+extension GetDuplicatedAssetCollection on Isar {
+  IsarCollection<DuplicatedAsset> get duplicatedAssets => this.collection();
+}
+
+const DuplicatedAssetSchema = CollectionSchema(
+  name: r'DuplicatedAsset',
+  id: -2679334728174694496,
+  properties: {
+    r'id': PropertySchema(
+      id: 0,
+      name: r'id',
+      type: IsarType.string,
+    )
+  },
+  estimateSize: _duplicatedAssetEstimateSize,
+  serialize: _duplicatedAssetSerialize,
+  deserialize: _duplicatedAssetDeserialize,
+  deserializeProp: _duplicatedAssetDeserializeProp,
+  idName: r'isarId',
+  indexes: {},
+  links: {},
+  embeddedSchemas: {},
+  getId: _duplicatedAssetGetId,
+  getLinks: _duplicatedAssetGetLinks,
+  attach: _duplicatedAssetAttach,
+  version: '3.0.5',
+);
+
+int _duplicatedAssetEstimateSize(
+  DuplicatedAsset object,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  var bytesCount = offsets.last;
+  bytesCount += 3 + object.id.length * 3;
+  return bytesCount;
+}
+
+void _duplicatedAssetSerialize(
+  DuplicatedAsset object,
+  IsarWriter writer,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  writer.writeString(offsets[0], object.id);
+}
+
+DuplicatedAsset _duplicatedAssetDeserialize(
+  Id id,
+  IsarReader reader,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  final object = DuplicatedAsset(
+    reader.readString(offsets[0]),
+  );
+  return object;
+}
+
+P _duplicatedAssetDeserializeProp<P>(
+  IsarReader reader,
+  int propertyId,
+  int offset,
+  Map<Type, List<int>> allOffsets,
+) {
+  switch (propertyId) {
+    case 0:
+      return (reader.readString(offset)) as P;
+    default:
+      throw IsarError('Unknown property with id $propertyId');
+  }
+}
+
+Id _duplicatedAssetGetId(DuplicatedAsset object) {
+  return object.isarId;
+}
+
+List<IsarLinkBase<dynamic>> _duplicatedAssetGetLinks(DuplicatedAsset object) {
+  return [];
+}
+
+void _duplicatedAssetAttach(
+    IsarCollection<dynamic> col, Id id, DuplicatedAsset object) {}
+
+extension DuplicatedAssetQueryWhereSort
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhere> {
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhere> anyIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(const IdWhereClause.any());
+    });
+  }
+}
+
+extension DuplicatedAssetQueryWhere
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhereClause> {
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
+      isarIdEqualTo(Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: isarId,
+        upper: isarId,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
+      isarIdNotEqualTo(Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            )
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            );
+      } else {
+        return query
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            )
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            );
+      }
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
+      isarIdGreaterThan(Id isarId, {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.greaterThan(lower: isarId, includeLower: include),
+      );
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
+      isarIdLessThan(Id isarId, {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.lessThan(upper: isarId, includeUpper: include),
+      );
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
+      isarIdBetween(
+    Id lowerIsarId,
+    Id upperIsarId, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: lowerIsarId,
+        includeLower: includeLower,
+        upper: upperIsarId,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+}
+
+extension DuplicatedAssetQueryFilter
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idEqualTo(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idGreaterThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idLessThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idBetween(
+    String lower,
+    String upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'id',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idContains(String value, {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idMatches(String pattern, {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'id',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      idIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      isarIdEqualTo(Id value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      isarIdGreaterThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      isarIdLessThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
+      isarIdBetween(
+    Id lower,
+    Id upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'isarId',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+}
+
+extension DuplicatedAssetQueryObject
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
+
+extension DuplicatedAssetQueryLinks
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
+
+extension DuplicatedAssetQuerySortBy
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortBy> {
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+}
+
+extension DuplicatedAssetQuerySortThenBy
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortThenBy> {
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.asc);
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy>
+      thenByIsarIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.desc);
+    });
+  }
+}
+
+extension DuplicatedAssetQueryWhereDistinct
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> {
+  QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> distinctById(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
+    });
+  }
+}
+
+extension DuplicatedAssetQueryProperty
+    on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QQueryProperty> {
+  QueryBuilder<DuplicatedAsset, int, QQueryOperations> isarIdProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isarId');
+    });
+  }
+
+  QueryBuilder<DuplicatedAsset, String, QQueryOperations> idProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'id');
+    });
+  }
+}

+ 117 - 169
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<BackUpState> {
     this._authState,
     this._backgroundService,
     this._galleryPermissionNotifier,
+    this._db,
     this.ref,
   ) : super(
           BackUpState(
@@ -69,6 +74,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
   final AuthenticationState _authState;
   final BackgroundService _backgroundService;
   final GalleryPermissionNotifier _galleryPermissionNotifier;
+  final Isar _db;
   final Ref ref;
 
   ///
@@ -157,11 +163,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
             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<BackUpState> {
     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<BackUpState> {
 
     state = state.copyWith(availableAlbums: availableAlbums);
 
-    // Put persistent storage info into local state of the app
-    // Get local storage on selected backup album
-    Box<HiveBackupAlbums> backupAlbumInfoBox =
-        Hive.box<HiveBackupAlbums>(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<BackupAlbum> excludedBackupAlbums =
+        await _backupService.excludedAlbumsQuery().findAll();
+    final List<BackupAlbum> 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<BackUpState> {
       }
       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<AvailableAlbum> selectedAlbums = {};
-      for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
-        var albumAsset =
-            await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
+      final Set<AvailableAlbum> 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<AvailableAlbum> excludedAlbums = {};
-      for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
-        var albumAsset =
-            await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
+      final Set<AvailableAlbum> 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<BackUpState> {
   /// Those assets are unique and are used as the total assets
   ///
   Future<void> _updateBackupAssetCount() async {
-    Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
-    Set<AssetEntity> assetsFromSelectedAlbums = {};
-    Set<AssetEntity> assetsFromExcludedAlbums = {};
+    final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
+    final Set<AssetEntity> assetsFromSelectedAlbums = {};
+    final Set<AssetEntity> 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<AssetEntity> allUniqueAssets =
+    final Set<AssetEntity> 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<String> selectedAlbumsBackupAssets =
+    final Set<String> selectedAlbumsBackupAssets =
         Set.from(allUniqueAssets.map((e) => e.id));
 
     selectedAlbumsBackupAssets
@@ -386,7 +358,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     }
 
     // Save to persistent storage
-    _updatePersistentAlbumsSelection();
+    await _updatePersistentAlbumsSelection();
 
     return;
   }
@@ -395,7 +367,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
   /// which albums are selected or excluded
   /// and then update the UI according to those information
   Future<void> 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<BackUpState> {
     }
   }
 
-  /// 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<void> _updatePersistentAlbumsSelection() {
     final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
-    Box<HiveBackupAlbums> backupAlbumInfoBox =
-        Hive.box<HiveBackupAlbums>(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<int> toDelete = [];
+      final List<BackupAlbum> 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<BackUpState> {
 
       Set<AssetEntity> 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<BackUpState> {
   }
 
   Future<void> _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<BackUpState> {
 
   Future<void> _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<BackUpState> {
   }
 
   Future<void> 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<HiveBackupAlbums>(hiveBackupInfoBox),
-      Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
-      Hive.openBox(backgroundBackupInfoBox),
-    ]);
-    final HiveBackupAlbums? albums =
-        Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
+    final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
+        .filter()
+        .selectionEqualTo(BackupSelection.select)
+        .findAll();
+    final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
+        .filter()
+        .selectionEqualTo(BackupSelection.select)
+        .findAll();
     Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
     Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
-    if (albums != null) {
-      if (selectedAlbums.isNotEmpty) {
-        selectedAlbums = _updateAlbumsBackupTime(
-          selectedAlbums,
-          albums.selectedAlbumIds,
-          albums.lastSelectedBackupTime,
-        );
-      }
+    if (selectedAlbums.isNotEmpty) {
+      selectedAlbums = _updateAlbumsBackupTime(
+        selectedAlbums,
+        selectedBackupAlbums,
+      );
+    }
 
-      if (excludedAlbums.isNotEmpty) {
-        excludedAlbums = _updateAlbumsBackupTime(
-          excludedAlbums,
-          albums.excludedAlbumsIds,
-          albums.lastExcludedBackupTime,
-        );
-      }
+    if (excludedAlbums.isNotEmpty) {
+      excludedAlbums = _updateAlbumsBackupTime(
+        excludedAlbums,
+        excludedBackupAlbums,
+      );
     }
-    final Box backgroundBox = Hive.box(backgroundBackupInfoBox);
+    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<AvailableAlbum> _updateAlbumsBackupTime(
     Set<AvailableAlbum> albums,
-    List<String> ids,
-    List<DateTime> times,
+    List<BackupAlbum> backupAlbums,
   ) {
     Set<AvailableAlbum> 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<BackUpState> {
       AppStateEnum.detached,
     ];
     if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
-      try {
-        if (Hive.isBoxOpen(hiveBackupInfoBox)) {
-          await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
-        }
-      } catch (error) {
-        log.info("[_notifyBackgroundServiceCanRun] failed to close box");
-      }
-      try {
-        if (Hive.isBoxOpen(duplicatedAssetsBox)) {
-          await Hive.box<HiveDuplicatedAssets>(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,
   );
 });

+ 42 - 52
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<List<String>?> getDeviceBackupAsset() async {
     String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
@@ -45,32 +48,28 @@ class BackupService {
     }
   }
 
-  void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
-    HiveDuplicatedAssets duplicatedAssets =
-        Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
-                .get(duplicatedAssetsKey) ??
-            HiveDuplicatedAssets(duplicatedAssetIds: []);
-
-    duplicatedAssets.duplicatedAssetIds =
-        {...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
-
-    Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
-        .put(duplicatedAssetsKey, duplicatedAssets);
+  Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
+    final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
+    return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
   }
 
-  /// Get duplicated asset id from Hive storage
-  Set<String> getDuplicatedAssetIds() {
-    HiveDuplicatedAssets duplicatedAssets =
-        Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
-                .get(duplicatedAssetsKey) ??
-            HiveDuplicatedAssets(duplicatedAssetIds: []);
-
-    return duplicatedAssets.duplicatedAssetIds.toSet();
+  /// Get duplicated asset id from database
+  Future<Set<String>> getDuplicatedAssetIds() async {
+    final duplicates = await _db.duplicatedAssets.where().findAll();
+    return duplicates.map((e) => e.id).toSet();
   }
 
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      selectedAlbumsQuery() =>
+          _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
+  QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
+      excludedAlbumsQuery() =>
+          _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
+
   /// Returns all assets newer than the last successful backup per album
   Future<List<AssetEntity>> buildUploadCandidates(
-    HiveBackupAlbums backupAlbums,
+    List<BackupAlbum> selectedBackupAlbums,
+    List<BackupAlbum> excludedBackupAlbums,
   ) async {
     final filter = FilterOptionGroup(
       containsPathModified: true,
@@ -81,66 +80,55 @@ class BackupService {
     );
     final now = DateTime.now();
     final List<AssetPathEntity?> 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<AssetPathEntity?> excludedAlbums =
-          await _loadAlbumsWithTimeFilter(
-        backupAlbums.excludedAlbumsIds,
-        backupAlbums.lastExcludedBackupTime,
-        filter,
-        now,
-      );
+          await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
       final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
         selectedAlbums.slice(allIdx, allIdx + 1),
-        backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
+        selectedBackupAlbums.slice(allIdx, allIdx + 1),
         now,
       );
       final List<AssetEntity> 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<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
-    List<String> albumIds,
-    List<DateTime> lastBackups,
+    List<BackupAlbum> albums,
     FilterOptionGroup filter,
     DateTime now,
   ) async {
-    List<AssetPathEntity?> result = List.filled(albumIds.length, null);
-    for (int i = 0; i < albumIds.length; i++) {
+    List<AssetPathEntity?> 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<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
     List<AssetPathEntity?> albums,
-    List<DateTime> lastBackup,
+    List<BackupAlbum> backupAlbums,
     DateTime now,
   ) async {
     List<AssetEntity> 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<String> duplicatedAssetIds = getDuplicatedAssetIds();
+    final Set<String> 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;
   }

+ 39 - 68
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()
           ],
         ),
       ),
     );
   }
-
-
 }

+ 48 - 22
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<dynamic> _cache = List.filled(StoreKey.values.length, null);
+  static final List<dynamic> _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<T>(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<StoreValue> _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<StoreValue> _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<int> Function(Isar, dynamic)? toDb;
   final Function(dynamic)? fromJson;

+ 86 - 6
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<void> migrateHiveToStoreIfNecessary() async {
+  await _migrateHiveBoxIfNecessary(userInfoBox, _migrateHiveUserInfoBox);
+  await _migrateHiveBoxIfNecessary(
+    backgroundBackupInfoBox,
+    _migrateHiveBackgroundBackupInfoBox,
+  );
+  await _migrateHiveBoxIfNecessary(hiveBackupInfoBox, _migrateBackupInfoBox);
+  await _migrateHiveBoxIfNecessary(
+    duplicatedAssetsBox,
+    _migrateDuplicatedAssetsBox,
+  );
+}
+
+Future<void> _migrateHiveUserInfoBox(Box box) async {
+  await _migrateKey(box, userIdKey, StoreKey.userRemoteId);
+  await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
+}
+
+Future<void> _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<void> _migrateBackupInfoBox(Box<HiveBackupAlbums> 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<BackupAlbum> 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<void> _migrateDuplicatedAssetsBox(Box<HiveDuplicatedAssets> 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<void> _migrateHiveBoxIfNecessary<T>(
+  String boxName,
+  Future<void> Function(Box<T>) 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<T>(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);