refactor(mobile): add Isar DB & Store class (#1574)

* refactor(mobile): add Isar DB & Store class

new Store: globally accessible key-value store like Hive (but based on Isar)

replace first few places of Hive usage with the new Store

* reduce max. DB size to prevent errors on older iOS devices

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Fynn Petersen-Frey
2023-02-09 18:32:08 +01:00
committed by GitHub
parent adb265794c
commit 911c35a7f1
11 changed files with 796 additions and 18 deletions

View File

@@ -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<dynamic> _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<void> 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<T>(StoreKey key, [T? defaultValue]) =>
_cache[key._id] ?? defaultValue;
/// Stores the value synchronously in the cache and asynchronously in the DB
static Future<void> put<T>(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<void> 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<StoreValue>? 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<T>(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;
}

View File

@@ -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<StoreValue> 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<int> offsets,
Map<Type, List<int>> 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<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeLong(offsets[0], object.intValue);
writer.writeString(offsets[1], object.strValue);
}
StoreValue _storeValueDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = StoreValue(
id,
intValue: reader.readLongOrNull(offsets[0]),
strValue: reader.readStringOrNull(offsets[1]),
);
return object;
}
P _storeValueDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> 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<IsarLinkBase<dynamic>> _storeValueGetLinks(StoreValue object) {
return [];
}
void _storeValueAttach(IsarCollection<dynamic> col, Id id, StoreValue object) {
object.id = id;
}
extension StoreValueQueryWhereSort
on QueryBuilder<StoreValue, StoreValue, QWhere> {
QueryBuilder<StoreValue, StoreValue, QAfterWhere> anyId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension StoreValueQueryWhere
on QueryBuilder<StoreValue, StoreValue, QWhereClause> {
QueryBuilder<StoreValue, StoreValue, QAfterWhereClause> idEqualTo(Id id) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: id,
upper: id,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterWhereClause> 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<StoreValue, StoreValue, QAfterWhereClause> idGreaterThan(Id id,
{bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: include),
);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterWhereClause> idLessThan(Id id,
{bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: include),
);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterWhereClause> 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<StoreValue, StoreValue, QFilterCondition> {
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> idEqualTo(
Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> idGreaterThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> idLessThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> 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<StoreValue, StoreValue, QAfterFilterCondition> intValueIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'intValue',
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition>
intValueIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'intValue',
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> intValueEqualTo(
int? value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'intValue',
value: value,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition>
intValueGreaterThan(
int? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'intValue',
value: value,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> intValueLessThan(
int? value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'intValue',
value: value,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> 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<StoreValue, StoreValue, QAfterFilterCondition> strValueIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'strValue',
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition>
strValueIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'strValue',
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> strValueEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'strValue',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition>
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<StoreValue, StoreValue, QAfterFilterCondition> 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<StoreValue, StoreValue, QAfterFilterCondition> 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<StoreValue, StoreValue, QAfterFilterCondition>
strValueStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'strValue',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> strValueEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'strValue',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> strValueContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'strValue',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition> strValueMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'strValue',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition>
strValueIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'strValue',
value: '',
));
});
}
QueryBuilder<StoreValue, StoreValue, QAfterFilterCondition>
strValueIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'strValue',
value: '',
));
});
}
}
extension StoreValueQueryObject
on QueryBuilder<StoreValue, StoreValue, QFilterCondition> {}
extension StoreValueQueryLinks
on QueryBuilder<StoreValue, StoreValue, QFilterCondition> {}
extension StoreValueQuerySortBy
on QueryBuilder<StoreValue, StoreValue, QSortBy> {
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> sortByIntValue() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'intValue', Sort.asc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> sortByIntValueDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'intValue', Sort.desc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> sortByStrValue() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'strValue', Sort.asc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> sortByStrValueDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'strValue', Sort.desc);
});
}
}
extension StoreValueQuerySortThenBy
on QueryBuilder<StoreValue, StoreValue, QSortThenBy> {
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> thenByIntValue() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'intValue', Sort.asc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> thenByIntValueDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'intValue', Sort.desc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> thenByStrValue() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'strValue', Sort.asc);
});
}
QueryBuilder<StoreValue, StoreValue, QAfterSortBy> thenByStrValueDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'strValue', Sort.desc);
});
}
}
extension StoreValueQueryWhereDistinct
on QueryBuilder<StoreValue, StoreValue, QDistinct> {
QueryBuilder<StoreValue, StoreValue, QDistinct> distinctByIntValue() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'intValue');
});
}
QueryBuilder<StoreValue, StoreValue, QDistinct> distinctByStrValue(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'strValue', caseSensitive: caseSensitive);
});
}
}
extension StoreValueQueryProperty
on QueryBuilder<StoreValue, StoreValue, QQueryProperty> {
QueryBuilder<StoreValue, int, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
QueryBuilder<StoreValue, int?, QQueryOperations> intValueProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'intValue');
});
}
QueryBuilder<StoreValue, String?, QQueryOperations> strValueProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'strValue');
});
}
}

View File

@@ -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<AssetsState> {
_getAllAssetInProgress = true;
bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start();
final Box box = Hive.box(userInfoBox);
if (isCacheValid && state.allAssets.isEmpty) {
final List<Asset>? cachedData = await _assetCacheService.get();
if (cachedData == null) {
@@ -122,7 +122,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
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<AssetsState> {
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<AssetsState> {
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);
}

View File

@@ -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<Isar>((_) => throw UnimplementedError());

View File

@@ -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<List<AssetResponseDto>, 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<HiveBackupAlbums>(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<AssetResponseDto?> updateAsset(Asset asset, UpdateAssetDto updateAssetDto) async {
return await _apiService.assetApi.updateAsset(asset.id, updateAssetDto);
Future<Asset?> updateAsset(
Asset asset,
UpdateAssetDto updateAssetDto,
) async {
final dto =
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
return dto == null ? null : Asset.remote(dto);
}
Future<AssetResponseDto?> changeFavoriteStatus(Asset asset, bool isFavorite) {
Future<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) {
return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite));
}
}