import 'package:collection/collection.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; 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 final Logger _log = Logger("Store"); static late final Isar _db; static final List _cache = List.filled(StoreKey.values.map((e) => e.id).max + 1, null); /// Initializes the store (call exactly once per app start) static void init(Isar db) { _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 if null the [defaultValue] /// Throws a [StoreKeyNotFoundException] if both are null static T get(StoreKey key, [T? defaultValue]) { final value = _cache[key.id] ?? defaultValue; if (value == null) { throw StoreKeyNotFoundException(key); } return value; } /// Watches a specific key for changes static Stream watch(StoreKey key) => _db.storeValues.watchObject(key.id).map((e) => e?._extract(key)); /// Returns the stored value for the given key (possibly null) static T? tryGet(StoreKey key) => _cache[key.id]; /// Stores the value synchronously in the cache and asynchronously in the DB static Future put(StoreKey key, T value) { if (_cache[key.id] == value) return Future.value(); _cache[key.id] = value; return _db.writeTxn( () async => _db.storeValues.put(await StoreValue._of(value, key)), ); } /// Removes the value synchronously from the cache and asynchronously from the DB static Future delete(StoreKey key) { if (_cache[key.id] == null) return Future.value(); _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) { final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id); if (key != null) { _cache[value.id] = value._extract(key); } else { _log.warning("No key available for value id - ${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) { switch (key.type) { case const (int): return intValue as T?; case const (bool): return intValue == null ? null : (intValue! == 1) as T; case const (DateTime): return intValue == null ? null : DateTime.fromMicrosecondsSinceEpoch(intValue!) as T; case const (String): return strValue as T?; default: if (key.fromDb != null) { return key.fromDb!.call(Store._db, intValue!); } } throw TypeError(); } static Future _of(T? value, StoreKey key) async { int? i; String? s; switch (key.type) { case const (int): i = value as int?; break; case const (bool): i = value == null ? null : (value == true ? 1 : 0); break; case const (DateTime): i = value == null ? null : (value as DateTime).microsecondsSinceEpoch; break; case const (String): s = value as String?; break; default: if (key.toDb != null) { i = await key.toDb!.call(Store._db, value); break; } throw TypeError(); } return StoreValue(key.id, intValue: i, strValue: s); } } class StoreKeyNotFoundException implements Exception { final StoreKey key; StoreKeyNotFoundException(this.key); @override String toString() => "Key '${key.name}' not found in Store"; } /// Key for each possible value in the `Store`. /// Defines the data type for each value enum StoreKey { version(0, type: int), assetETag(1, type: String), currentUser(2, type: User, fromDb: _getUser, toDb: _toUser), deviceIdHash(3, type: int), deviceId(4, type: String), backupFailedSince(5, type: DateTime), backupRequireWifi(6, type: bool), backupRequireCharging(7, type: bool), backupTriggerDelay(8, type: int), serverUrl(10, type: String), accessToken(11, type: String), serverEndpoint(12, type: String), autoBackup(13, type: bool), backgroundBackup(14, type: bool), // user settings from [AppSettingsEnum] below: loadPreview(100, type: bool), loadOriginal(101, type: bool), themeMode(102, type: String), tilesPerRow(103, type: int), dynamicLayout(104, type: bool), groupAssetsBy(105, type: int), uploadErrorNotificationGracePeriod(106, type: int), backgroundBackupTotalProgress(107, type: bool), backgroundBackupSingleProgress(108, type: bool), storageIndicator(109, type: bool), thumbnailCacheSize(110, type: int), imageCacheSize(111, type: int), albumThumbnailCacheSize(112, type: int), selectedAlbumSortOrder(113, type: int), advancedTroubleshooting(114, type: bool), logLevel(115, type: int), preferRemoteImage(116, type: bool), // map related settings mapShowFavoriteOnly(118, type: bool), mapRelativeDate(119, type: int), selfSignedCert(120, type: bool), mapIncludeArchived(121, type: bool), ignoreIcloudAssets(122, type: bool), selectedAlbumSortReverse(123, type: bool), mapThemeMode(124, type: int), mapwithPartners(125, type: bool), ; const StoreKey( this.id, { required this.type, this.fromDb, this.toDb, }); final int id; final Type type; final T? Function(Isar, int)? fromDb; final Future Function(Isar, T)? toDb; } T? _getUser(Isar db, int i) { final User? u = db.users.getSync(i); return u as T?; } Future _toUser(Isar db, T u) { if (u is User) { return db.users.put(u); } throw TypeError(); }