diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index 0ce776ce97..24b98c9617 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -1,7 +1,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:isar/isar.dart'; // ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; import 'package:immich_mobile/main.dart' as app; @@ -34,8 +36,12 @@ class ImmichTestHelper { // Clear all data from Hive await Hive.deleteFromDisk(); await app.openBoxes(); + // Clear all data from Isar (reuse existing instance if available) + final db = Isar.getInstance() ?? await app.loadDb(); + await Store.clear(); + await db.writeTxn(() => db.clear()); // Load main Widget - await tester.pumpWidget(app.getMainWidget()); + await tester.pumpWidget(app.getMainWidget(db)); // Post run tasks await tester.pumpAndSettle(); await EasyLocalization.ensureInitialized(); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 262612e44d..fe96a5d274 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -17,8 +17,10 @@ import 'package:immich_mobile/modules/login/providers/authentication.provider.da import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/shared/models/immich_logger_message.model.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/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/release_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; @@ -26,11 +28,16 @@ import 'package:immich_mobile/shared/services/immich_logger.service.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/version_announcement_overlay.dart'; import 'package:immich_mobile/utils/immich_app_theme.dart'; +import 'package:immich_mobile/utils/migration.dart'; +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; import 'constants/hive_box.dart'; void main() async { await initApp(); - runApp(getMainWidget()); + final db = await loadDb(); + await migrateHiveToStoreIfNecessary(); + runApp(getMainWidget(db)); } Future openBoxes() async { @@ -70,13 +77,27 @@ Future initApp() async { ImmichLogger().init(); } -Widget getMainWidget() { +Future loadDb() async { + final dir = await getApplicationDocumentsDirectory(); + Isar db = await Isar.open( + [StoreValueSchema], + directory: dir.path, + maxSizeMiB: 256, + ); + Store.init(db); + return db; +} + +Widget getMainWidget(Isar db) { return EasyLocalization( supportedLocales: locales, path: translationsPath, useFallbackTranslations: true, fallbackLocale: locales.first, - child: const ProviderScope(child: ImmichApp()), + child: ProviderScope( + overrides: [dbProvider.overrideWithValue(db)], + child: const ImmichApp(), + ), ); } diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index f5f8481c59..88b49a4b10 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -4,6 +4,7 @@ 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/album/services/album_cache.service.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; @@ -94,7 +95,8 @@ class AuthenticationNotifier extends StateNotifier { await Future.wait([ _apiService.authenticationApi.logout(), Hive.box(userInfoBox).delete(accessTokenKey), - Hive.box(userInfoBox).delete(assetEtagKey), + Store.delete(StoreKey.assetETag), + Store.delete(StoreKey.userRemoteId), _assetCacheService.invalidate(), _albumCacheService.invalidate(), _sharedAlbumCacheService.invalidate(), @@ -153,7 +155,7 @@ class AuthenticationNotifier extends StateNotifier { var deviceInfo = await _deviceInfoService.getDeviceInfo(); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(accessTokenKey, accessToken); - userInfoHiveBox.put(userIdKey, userResponseDto.id); + Store.put(StoreKey.userRemoteId, userResponseDto.id); state = state.copyWith( isAuthenticated: true, diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart new file mode 100644 index 0000000000..537ac5443b --- /dev/null +++ b/mobile/lib/shared/models/store.dart @@ -0,0 +1,96 @@ +import 'package:isar/isar.dart'; +import 'dart:convert'; + +part 'store.g.dart'; + +/// Key-value store for individual items enumerated in StoreKey. +/// Supports String, int and JSON-serializable Objects +/// Can be used concurrently from multiple isolates +class Store { + static late final Isar _db; + static final List _cache = List.filled(StoreKey.values.length, null); + + /// Initializes the store (call exactly once per app start) + static void init(Isar db) { + _db = db; + _populateCache(); + _db.storeValues.where().build().watch().listen(_onChangeListener); + } + + /// clears all values from this store (cache and DB), only for testing! + static Future clear() { + _cache.fillRange(0, _cache.length, null); + return _db.writeTxn(() => _db.storeValues.clear()); + } + + /// Returns the stored value for the given key, or the default value if null + static T? get(StoreKey key, [T? defaultValue]) => + _cache[key._id] ?? defaultValue; + + /// Stores the value synchronously in the cache and asynchronously in the DB + static Future put(StoreKey key, T value) { + _cache[key._id] = value; + return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key))); + } + + /// Removes the value synchronously from the cache and asynchronously from the DB + static Future delete(StoreKey key) { + _cache[key._id] = null; + return _db.writeTxn(() => _db.storeValues.delete(key._id)); + } + + /// Fills the cache with the values from the DB + static _populateCache() { + for (StoreKey key in StoreKey.values) { + final StoreValue? value = _db.storeValues.getSync(key._id); + if (value != null) { + _cache[key._id] = value._extract(key); + } + } + } + + /// updates the state if a value is updated in any isolate + static void _onChangeListener(List? data) { + if (data != null) { + for (StoreValue value in data) { + _cache[value.id] = value._extract(StoreKey.values[value.id]); + } + } + } +} + +/// Internal class for `Store`, do not use elsewhere. +@Collection(inheritance: false) +class StoreValue { + StoreValue(this.id, {this.intValue, this.strValue}); + Id id; + int? intValue; + String? strValue; + + T? _extract(StoreKey key) => key._isInt + ? intValue + : (key._fromJson != null + ? key._fromJson!(json.decode(strValue!)) + : strValue); + static StoreValue _of(dynamic value, StoreKey key) => StoreValue( + key._id, + intValue: key._isInt ? value : null, + strValue: key._isInt + ? null + : (key._fromJson == null ? value : json.encode(value.toJson())), + ); +} + +/// Key for each possible value in the `Store`. +/// Defines the data type (int, String, JSON) for each value +enum StoreKey { + userRemoteId(0), + assetETag(1), + ; + + // ignore: unused_element + const StoreKey(this._id, [this._isInt = false, this._fromJson]); + final int _id; + final bool _isInt; + final Function(dynamic)? _fromJson; +} diff --git a/mobile/lib/shared/models/store.g.dart b/mobile/lib/shared/models/store.g.dart new file mode 100644 index 0000000000..6370573a68 --- /dev/null +++ b/mobile/lib/shared/models/store.g.dart @@ -0,0 +1,574 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'store.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 GetStoreValueCollection on Isar { + IsarCollection get storeValues => this.collection(); +} + +const StoreValueSchema = CollectionSchema( + name: r'StoreValue', + id: 902899285492123510, + properties: { + r'intValue': PropertySchema( + id: 0, + name: r'intValue', + type: IsarType.long, + ), + r'strValue': PropertySchema( + id: 1, + name: r'strValue', + type: IsarType.string, + ) + }, + estimateSize: _storeValueEstimateSize, + serialize: _storeValueSerialize, + deserialize: _storeValueDeserialize, + deserializeProp: _storeValueDeserializeProp, + idName: r'id', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _storeValueGetId, + getLinks: _storeValueGetLinks, + attach: _storeValueAttach, + version: '3.0.5', +); + +int _storeValueEstimateSize( + StoreValue object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.strValue; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + return bytesCount; +} + +void _storeValueSerialize( + StoreValue object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeLong(offsets[0], object.intValue); + writer.writeString(offsets[1], object.strValue); +} + +StoreValue _storeValueDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = StoreValue( + id, + intValue: reader.readLongOrNull(offsets[0]), + strValue: reader.readStringOrNull(offsets[1]), + ); + return object; +} + +P _storeValueDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readLongOrNull(offset)) as P; + case 1: + return (reader.readStringOrNull(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _storeValueGetId(StoreValue object) { + return object.id; +} + +List> _storeValueGetLinks(StoreValue object) { + return []; +} + +void _storeValueAttach(IsarCollection col, Id id, StoreValue object) { + object.id = id; +} + +extension StoreValueQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension StoreValueQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan(Id id, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } +} + +extension StoreValueQueryFilter + on QueryBuilder { + QueryBuilder idEqualTo( + Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder intValueIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'intValue', + )); + }); + } + + QueryBuilder + intValueIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'intValue', + )); + }); + } + + QueryBuilder intValueEqualTo( + int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'intValue', + value: value, + )); + }); + } + + QueryBuilder + intValueGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'intValue', + value: value, + )); + }); + } + + QueryBuilder intValueLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'intValue', + value: value, + )); + }); + } + + QueryBuilder intValueBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'intValue', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder strValueIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'strValue', + )); + }); + } + + QueryBuilder + strValueIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'strValue', + )); + }); + } + + QueryBuilder strValueEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'strValue', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + strValueGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'strValue', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder strValueLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'strValue', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder strValueBetween( + 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'strValue', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + strValueStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'strValue', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder strValueEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'strValue', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder strValueContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'strValue', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder strValueMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'strValue', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + strValueIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'strValue', + value: '', + )); + }); + } + + QueryBuilder + strValueIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'strValue', + value: '', + )); + }); + } +} + +extension StoreValueQueryObject + on QueryBuilder {} + +extension StoreValueQueryLinks + on QueryBuilder {} + +extension StoreValueQuerySortBy + on QueryBuilder { + QueryBuilder sortByIntValue() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'intValue', Sort.asc); + }); + } + + QueryBuilder sortByIntValueDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'intValue', Sort.desc); + }); + } + + QueryBuilder sortByStrValue() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'strValue', Sort.asc); + }); + } + + QueryBuilder sortByStrValueDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'strValue', Sort.desc); + }); + } +} + +extension StoreValueQuerySortThenBy + on QueryBuilder { + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByIntValue() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'intValue', Sort.asc); + }); + } + + QueryBuilder thenByIntValueDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'intValue', Sort.desc); + }); + } + + QueryBuilder thenByStrValue() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'strValue', Sort.asc); + }); + } + + QueryBuilder thenByStrValueDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'strValue', Sort.desc); + }); + } +} + +extension StoreValueQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByIntValue() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'intValue'); + }); + } + + QueryBuilder distinctByStrValue( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'strValue', caseSensitive: caseSensitive); + }); + } +} + +extension StoreValueQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder intValueProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'intValue'); + }); + } + + QueryBuilder strValueProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'strValue'); + }); + } +} diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 09da33f9c4..1f90f07763 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.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/shared/models/store.dart'; import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; @@ -106,7 +107,6 @@ class AssetNotifier extends StateNotifier { _getAllAssetInProgress = true; bool isCacheValid = await _assetCacheService.isValid(); stopwatch.start(); - final Box box = Hive.box(userInfoBox); if (isCacheValid && state.allAssets.isEmpty) { final List? cachedData = await _assetCacheService.get(); if (cachedData == null) { @@ -122,7 +122,7 @@ class AssetNotifier extends StateNotifier { } final localTask = _assetService.getLocalAssets(urgent: !isCacheValid); final remoteTask = _assetService.getRemoteAssets( - etag: isCacheValid ? box.get(assetEtagKey) : null, + etag: isCacheValid ? Store.get(StoreKey.assetETag) : null, ); int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote); @@ -151,7 +151,7 @@ class AssetNotifier extends StateNotifier { log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms"); - box.put(assetEtagKey, remoteResult.second); + Store.put(StoreKey.assetETag, remoteResult.second); } finally { _getAllAssetInProgress = false; } @@ -279,8 +279,7 @@ class AssetNotifier extends StateNotifier { final index = state.allAssets.indexWhere((a) => asset.id == a.id); if (index > 0) { - state.allAssets.removeAt(index); - state.allAssets.insert(index, Asset.remote(newAsset)); + state.allAssets[index] = newAsset; _updateAssetsState(state.allAssets); } diff --git a/mobile/lib/shared/providers/db.provider.dart b/mobile/lib/shared/providers/db.provider.dart new file mode 100644 index 0000000000..e03e037f36 --- /dev/null +++ b/mobile/lib/shared/providers/db.provider.dart @@ -0,0 +1,5 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:isar/isar.dart'; + +// overwritten in main.dart due to async loading +final dbProvider = Provider((_) => throw UnimplementedError()); diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 0cc04936fb..91e3a015db 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/backup/background_service/background.servi import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/openapi_extensions.dart'; @@ -37,7 +38,7 @@ class AssetService { final Pair, String?>? remote = await _apiService.assetApi.getAllAssetsWithETag(eTag: etag); if (remote == null) { - return const Pair(null, null); + return Pair(null, etag); } return Pair( remote.first.map(Asset.remote).toList(growable: false), @@ -45,7 +46,7 @@ class AssetService { ); } catch (e, stack) { log.severe('Error while getting remote assets', e, stack); - return const Pair(null, null); + return Pair(null, etag); } } @@ -62,7 +63,7 @@ class AssetService { } final box = await Hive.openBox(hiveBackupInfoBox); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); - final String userId = Hive.box(userInfoBox).get(userIdKey); + final String userId = Store.get(StoreKey.userRemoteId); if (backupAlbumInfo != null) { return (await _backupService .buildUploadCandidates(backupAlbumInfo.deepCopy())) @@ -105,12 +106,16 @@ class AssetService { } } - Future updateAsset(Asset asset, UpdateAssetDto updateAssetDto) async { - return await _apiService.assetApi.updateAsset(asset.id, updateAssetDto); + Future updateAsset( + Asset asset, + UpdateAssetDto updateAssetDto, + ) async { + final dto = + await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto); + return dto == null ? null : Asset.remote(dto); } - Future changeFavoriteStatus(Asset asset, bool isFavorite) { + Future changeFavoriteStatus(Asset asset, bool isFavorite) { return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite)); } - } diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart new file mode 100644 index 0000000000..d74dff9027 --- /dev/null +++ b/mobile/lib/utils/migration.dart @@ -0,0 +1,24 @@ +import 'package:flutter/cupertino.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/shared/models/store.dart'; + +Future migrateHiveToStoreIfNecessary() 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); + } + } catch (e) { + debugPrint("Error while migrating userInfoBox $e"); + } +} + +_migrateSingleKey(Box box, String hiveKey, StoreKey key) async { + final String? value = box.get(hiveKey); + if (value != null) { + await Store.put(key, value); + await box.delete(hiveKey); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 54345703c2..24e49e8190 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -239,6 +239,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.3" + dartx: + dependency: transitive + description: + name: dartx + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" easy_image_viewer: dependency: "direct main" description: @@ -547,6 +554,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" + isar: + dependency: "direct main" + description: + name: isar + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" + isar_flutter_libs: + dependency: "direct main" + description: + name: isar_flutter_libs + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" + isar_generator: + dependency: "direct dev" + description: + name: isar_generator + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" js: dependency: transitive description: @@ -1063,6 +1091,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.12" + time: + dependency: transitive + description: + name: time + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" timing: dependency: transitive description: @@ -1301,6 +1336,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.0" + xxh3: + dependency: transitive + description: + name: xxh3 + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" yaml: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 786d2342e1..1fd192fa2c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -3,6 +3,7 @@ description: Immich - selfhosted backup media file on mobile phone publish_to: "none" version: 1.45.0+68 +isar_version: &isar_version 3.0.5 environment: sdk: ">=2.17.0 <3.0.0" @@ -41,6 +42,8 @@ dependencies: http_parser: ^4.0.1 flutter_web_auth: ^0.5.0 easy_image_viewer: ^1.2.0 + isar: *isar_version + isar_flutter_libs: *isar_version # contains Isar Core openapi: path: openapi @@ -58,6 +61,7 @@ dev_dependencies: auto_route_generator: ^5.0.2 flutter_launcher_icons: "^0.9.2" flutter_native_splash: ^2.2.16 + isar_generator: *isar_version integration_test: sdk: flutter