Compare commits

...

1 Commits

Author SHA1 Message Date
shenlong-tanwen 5ca2c5effc refactor: migrate app metadata 2026-06-17 18:51:01 +05:30
40 changed files with 15677 additions and 236 deletions
File diff suppressed because it is too large Load Diff
@@ -34,14 +34,14 @@ void main() {
server = await FakeImmichServer.start();
await ApiService().resolveAndSetEndpoint(server.endpoint);
await drift.delete(drift.userEntity).go();
await Store.delete(StoreKey.syncMigrationStatus);
await Store.delete(StoreKey.legacySyncMigrationStatus);
});
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.legacyServerEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
await Store.delete(StoreKey.legacySyncMigrationStatus);
});
void sendUser(SyncStream stream, String id, String name) {
@@ -0,0 +1,20 @@
import 'package:immich_mobile/domain/models/value_codec.dart';
const int kCurrentVersion = 29;
enum AppMetadataKey<T> {
version<int>(kCurrentVersion),
syncMigrationStatus<List<String>>([], codec: ListCodec(PrimitiveCodec.string)),
manageLocalMediaAndroid<bool>(false);
const AppMetadataKey(this.defaultValue, {ValueCodec<T>? codec}) : _codecOverride = codec;
final T defaultValue;
final ValueCodec<T>? _codecOverride;
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
+3 -5
View File
@@ -1,14 +1,12 @@
/// Key for each possible value in the `Store`.
/// Defines the data type for each value
enum StoreKey<T> {
version<int>._(0),
deviceId<String>._(4),
manageLocalMediaAndroid<bool>._(137),
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacyVersion<int>._(0),
legacyManageLocalMediaAndroid<bool>._(137),
legacySyncMigrationStatus<String>._(1013),
legacyAdvancedTroubleshooting<bool>._(114),
legacyEnableHapticFeedback<bool>._(126),
legacyReadonlyModeEnabled<bool>._(138),
+3 -3
View File
@@ -104,19 +104,19 @@ final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
return const [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
return const [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
return const [];
}
}
}
@@ -5,9 +5,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
@@ -28,6 +27,7 @@ class LocalSyncService {
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final AppMetadataRepository _appMetadataRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService");
@@ -38,6 +38,7 @@ class LocalSyncService {
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._appMetadataRepository,
this._cancellation,
}) {
_cancellation?.future.then((_) => _nativeSyncApi.cancelSync().onError(_log.warning));
@@ -48,7 +49,7 @@ class LocalSyncService {
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) {
final hasPermission = await _permissionRepository.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
@@ -1,13 +1,11 @@
// ignore_for_file: constant_identifier_names
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
@@ -38,6 +36,7 @@ class SyncStreamService {
final IPermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final AppMetadataRepository _appMetadataRepository;
final Completer<void>? _cancellation;
SyncStreamService({
@@ -49,10 +48,12 @@ class SyncStreamService {
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
required this._appMetadataRepository,
this._cancellation,
});
bool get isCancelled => _cancellation?.isCompleted ?? false;
bool _manageLocalMediaAndroid = false;
Future<bool> sync() async {
_logger.info("Remote sync request for user");
@@ -64,16 +65,17 @@ class SyncStreamService {
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
final migrations = (jsonDecode(value) as List).cast<String>();
final migrations = (await _appMetadataRepository.get(.syncMigrationStatus)).toList();
int previousLength = migrations.length;
await _runPreSyncTasks(migrations, serverSemVer);
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
await _appMetadataRepository.set(.syncMigrationStatus, migrations);
}
_manageLocalMediaAndroid = CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid);
// Start the sync stream and handle events
bool shouldReset = false;
await _syncApiRepository.streamChanges(
@@ -96,7 +98,7 @@ class SyncStreamService {
if (migrations.length != previousLength) {
_logger.info("Updated pre-sync migration status: $migrations");
await Store.put(StoreKey.syncMigrationStatus, jsonEncode(migrations));
await _appMetadataRepository.set(.syncMigrationStatus, migrations);
}
return true;
@@ -106,10 +108,10 @@ class SyncStreamService {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetExifV1,
SyncEntityType.partnerAssetExifV1,
SyncEntityType.albumAssetExifCreateV1,
SyncEntityType.albumAssetExifUpdateV1,
.assetExifV1,
.partnerAssetExifV1,
.albumAssetExifCreateV1,
.albumAssetExifUpdateV1,
]);
migrations.add(SyncMigrationTask.v20260128_ResetExifV1.name);
}
@@ -117,12 +119,7 @@ class SyncStreamService {
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
SyncEntityType.partnerAssetV1,
SyncEntityType.albumAssetCreateV1,
SyncEntityType.albumAssetUpdateV1,
]);
await _syncApiRepository.deleteSyncAck([.assetV1, .partnerAssetV1, .albumAssetCreateV1, .albumAssetUpdateV1]);
migrations.add(SyncMigrationTask.v20260128_ResetAssetV1.name);
@@ -134,7 +131,7 @@ class SyncStreamService {
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
await _syncApiRepository.deleteSyncAck([.assetV1, .assetV2]);
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);
}
}
@@ -197,20 +194,20 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (_manageLocalMediaAndroid) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetV2:
final remoteSyncAssets = data.cast<SyncAssetV2>();
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (_manageLocalMediaAndroid) {
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
}
return;
case SyncEntityType.assetDeleteV1:
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (_manageLocalMediaAndroid) {
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
}
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
@@ -0,0 +1,18 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class AppMetadataEntity extends Table with DriftDefaultsMixin {
const AppMetadataEntity();
TextColumn get key => text()();
TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};
@override
String get tableName => "app_metadata";
}
@@ -0,0 +1,434 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$AppMetadataEntityTableCreateCompanionBuilder =
i1.AppMetadataEntityCompanion Function({
required String key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
typedef $$AppMetadataEntityTableUpdateCompanionBuilder =
i1.AppMetadataEntityCompanion Function({
i0.Value<String> key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
class $$AppMetadataEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
$$AppMetadataEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$AppMetadataEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
$$AppMetadataEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$AppMetadataEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AppMetadataEntityTable> {
$$AppMetadataEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get key =>
$composableBuilder(column: $table.key, builder: (column) => column);
i0.GeneratedColumn<String> get value =>
$composableBuilder(column: $table.value, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$AppMetadataEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData,
i1.$$AppMetadataEntityTableFilterComposer,
i1.$$AppMetadataEntityTableOrderingComposer,
i1.$$AppMetadataEntityTableAnnotationComposer,
$$AppMetadataEntityTableCreateCompanionBuilder,
$$AppMetadataEntityTableUpdateCompanionBuilder,
(
i1.AppMetadataEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData
>,
),
i1.AppMetadataEntityData,
i0.PrefetchHooks Function()
> {
$$AppMetadataEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$AppMetadataEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$AppMetadataEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () => i1
.$$AppMetadataEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$AppMetadataEntityTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.AppMetadataEntityCompanion(
key: key,
value: value,
updatedAt: updatedAt,
),
createCompanionCallback:
({
required String key,
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.AppMetadataEntityCompanion.insert(
key: key,
value: value,
updatedAt: updatedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$AppMetadataEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData,
i1.$$AppMetadataEntityTableFilterComposer,
i1.$$AppMetadataEntityTableOrderingComposer,
i1.$$AppMetadataEntityTableAnnotationComposer,
$$AppMetadataEntityTableCreateCompanionBuilder,
$$AppMetadataEntityTableUpdateCompanionBuilder,
(
i1.AppMetadataEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AppMetadataEntityTable,
i1.AppMetadataEntityData
>,
),
i1.AppMetadataEntityData,
i0.PrefetchHooks Function()
>;
class $AppMetadataEntityTable extends i2.AppMetadataEntity
with i0.TableInfo<$AppMetadataEntityTable, i1.AppMetadataEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$AppMetadataEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
@override
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
'key',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
'value',
);
@override
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
);
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime,
);
@override
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'app_metadata';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.AppMetadataEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('key')) {
context.handle(
_keyMeta,
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
);
} else if (isInserting) {
context.missing(_keyMeta);
}
if (data.containsKey('value')) {
context.handle(
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {key};
@override
i1.AppMetadataEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.AppMetadataEntityData(
key: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}key'],
)!,
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
),
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
);
}
@override
$AppMetadataEntityTable createAlias(String alias) {
return $AppMetadataEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class AppMetadataEntityData extends i0.DataClass
implements i0.Insertable<i1.AppMetadataEntityData> {
final String key;
final String? value;
final DateTime updatedAt;
const AppMetadataEntityData({
required this.key,
this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
factory AppMetadataEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return AppMetadataEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String?>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String?>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.AppMetadataEntityData copyWith({
String? key,
i0.Value<String?> value = const i0.Value.absent(),
DateTime? updatedAt,
}) => i1.AppMetadataEntityData(
key: key ?? this.key,
value: value.present ? value.value : this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
AppMetadataEntityData copyWithCompanion(i1.AppMetadataEntityCompanion data) {
return AppMetadataEntityData(
key: data.key.present ? data.key.value : this.key,
value: data.value.present ? data.value.value : this.value,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('AppMetadataEntityData(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(key, value, updatedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.AppMetadataEntityData &&
other.key == this.key &&
other.value == this.value &&
other.updatedAt == this.updatedAt);
}
class AppMetadataEntityCompanion
extends i0.UpdateCompanion<i1.AppMetadataEntityData> {
final i0.Value<String> key;
final i0.Value<String?> value;
final i0.Value<DateTime> updatedAt;
const AppMetadataEntityCompanion({
this.key = const i0.Value.absent(),
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
});
AppMetadataEntityCompanion.insert({
required String key,
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key);
static i0.Insertable<i1.AppMetadataEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
i0.Expression<DateTime>? updatedAt,
}) {
return i0.RawValuesInsertable({
if (key != null) 'key': key,
if (value != null) 'value': value,
if (updatedAt != null) 'updated_at': updatedAt,
});
}
i1.AppMetadataEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String?>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.AppMetadataEntityCompanion(
key: key ?? this.key,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (key.present) {
map['key'] = i0.Variable<String>(key.value);
}
if (value.present) {
map['value'] = i0.Variable<String>(value.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('AppMetadataEntityCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
}
@@ -0,0 +1,28 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class AppMetadataRepository {
final Drift _db;
const AppMetadataRepository(this._db);
Future<T> get<T>(AppMetadataKey<T> key) async {
final row = await (_db.select(_db.appMetadataEntity)..where((row) => row.key.equals(key.name))).getSingleOrNull();
final value = row?.value;
return value == null ? key.defaultValue : key.decode(value);
}
Future<void> set<T, U extends T>(AppMetadataKey<T> key, U value) async {
await _db
.into(_db.appMetadataEntity)
.insertOnConflictUpdate(
AppMetadataEntityCompanion.insert(
key: key.name,
value: Value(key.encode(value)),
updatedAt: Value(DateTime.now()),
),
);
}
}
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
import 'package:drift/src/runtime/executor/stream_queries.dart' show StreamQueryStore;
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
@@ -69,6 +70,7 @@ import 'package:sqlite_async/sqlite_async.dart';
SettingsEntity,
AssetOcrEntity,
SessionEntity,
AppMetadataEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -122,7 +124,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 31;
int get schemaVersion => 32;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -316,6 +318,14 @@ class Drift extends $Drift {
from30To31: (m, v31) async {
await m.createTable(v31.session);
},
from31To32: (m, v32) async {
await m.createTable(v32.appMetadata);
await customStatement(
"INSERT INTO app_metadata (key, value) "
"SELECT 'version', CAST(int_value AS TEXT) FROM store_entity "
"WHERE id = 0 AND int_value IS NOT NULL",
);
},
),
);
@@ -49,9 +49,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dar
as i23;
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
as i24;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart'
as i25;
import 'package:drift/internal/modular.dart' as i26;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i26;
import 'package:drift/internal/modular.dart' as i27;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -104,9 +106,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i24.$SessionEntityTable sessionEntity = i24.$SessionEntityTable(
this,
);
i25.MergedAssetDrift get mergedAssetDrift => i26.ReadDatabaseContainer(
late final i25.$AppMetadataEntityTable appMetadataEntity = i25
.$AppMetadataEntityTable(this);
i26.MergedAssetDrift get mergedAssetDrift => i27.ReadDatabaseContainer(
this,
).accessor<i25.MergedAssetDrift>(i25.MergedAssetDrift.new);
).accessor<i26.MergedAssetDrift>(i26.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -146,6 +150,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
settingsEntity,
assetOcrEntity,
sessionEntity,
appMetadataEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -422,4 +427,6 @@ class $DriftManager {
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
i24.$$SessionEntityTableTableManager get sessionEntity =>
i24.$$SessionEntityTableTableManager(_db, _db.sessionEntity);
i25.$$AppMetadataEntityTableTableManager get appMetadataEntity =>
i25.$$AppMetadataEntityTableTableManager(_db, _db.appMetadataEntity);
}
@@ -16513,6 +16513,610 @@ final class Schema31 extends i0.VersionedSchema {
);
}
final class Schema32 extends i0.VersionedSchema {
Schema32({required super.database}) : super(version: 32);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
session,
appMetadata,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 session = Shape49(
source: i0.VersionedTable(
entityName: 'session',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 appMetadata = Shape49(
source: i0.VersionedTable(
entityName: 'app_metadata',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -16544,6 +17148,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -16697,6 +17302,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
case 31:
final schema = Schema32(database: database);
final migrator = i1.Migrator(database, schema);
await from31To32(migrator, schema);
return 32;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -16734,6 +17344,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -16766,5 +17377,6 @@ i1.OnUpgrade stepByStep({
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
from31To32: from31To32,
),
);
@@ -72,9 +72,6 @@ class MapStateNotifier extends Notifier<MapState> {
}
void switchTheme(ThemeMode mode) {
// TODO: Remove this line when map theme provider is removed
// Until then, keep both in sync as MapThemeOverride uses map state provider
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
state = state.copyWith(themeMode: mode);
}
@@ -1,4 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
final appSettingsServiceProvider = Provider((_) => const AppSettingsService());
@@ -0,0 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final appMetadataRepositoryProvider = Provider<AppMetadataRepository>(
(ref) => AppMetadataRepository(ref.watch(driftProvider)),
);
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_migration.reposit
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@@ -26,6 +27,7 @@ final syncStreamServiceProvider = Provider(
permissionRepository: ref.watch(permissionRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
appMetadataRepository: ref.watch(appMetadataRepositoryProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -42,6 +44,7 @@ final localSyncServiceProvider = Provider(
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
appMetadataRepository: ref.watch(appMetadataRepositoryProvider),
cancellation: ref.watch(cancellationProvider),
),
);
+6 -3
View File
@@ -6,15 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -38,6 +38,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
ref.watch(appMetadataRepositoryProvider),
),
);
@@ -51,6 +52,7 @@ class ActionService {
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
final AppMetadataRepository _appMetadataRepository;
const ActionService(
this._assetApiRepository,
@@ -62,6 +64,7 @@ class ActionService {
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
this._appMetadataRepository,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@@ -318,7 +321,7 @@ class ActionService {
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && await _appMetadataRepository.get(.manageLocalMediaAndroid)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
@@ -1,23 +0,0 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
final StoreKey<T> storeKey;
final String? hiveKey;
final T defaultValue;
}
class AppSettingsService {
const AppSettingsService();
T getSetting<T>(AppSettingsEnum<T> setting) {
return Store.get(setting.storeKey, setting.defaultValue);
}
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
}
@@ -1,13 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
ValueNotifier<T> useAppSettingsState<T>(AppSettingsEnum<T> key) {
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));
// Listen to changes to the notifier and update app settings
useValueChanged(notifier.value, (_, __) => Store.put(key.storeKey, notifier.value));
return notifier;
}
+99 -75
View File
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
@@ -13,8 +14,10 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
@@ -22,10 +25,10 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 28;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
final metadataRepository = AppMetadataRepository(drift);
final int version = await metadataRepository.get(AppMetadataKey.version);
if (version < 25) {
await _migrateTo25();
@@ -43,26 +46,30 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo28(drift);
}
await Store.put(StoreKey.version, targetVersion);
if (version < 29) {
await _migrateTo29(drift);
}
await metadataRepository.set(AppMetadataKey.version, kCurrentVersion);
return;
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.legacyAccessToken);
final accessToken = Store.tryGet(.legacyAccessToken);
if (accessToken == null || accessToken.isEmpty) {
return;
}
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.legacyServerEndpoint);
final serverEndpoint = Store.tryGet(.legacyServerEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final localEndpoint = Store.tryGet(StoreKey.legacyLocalEndpoint);
final localEndpoint = Store.tryGet(.legacyLocalEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
final externalJson = Store.tryGet(StoreKey.legacyExternalEndpointList);
final externalJson = Store.tryGet(.legacyExternalEndpointList);
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
@@ -76,7 +83,7 @@ Future<void> _migrateTo25() async {
return;
}
final customHeadersStr = Store.get(StoreKey.legacyCustomHeaders, "");
final customHeadersStr = Store.get(.legacyCustomHeaders, "");
final headers = customHeadersStr.isEmpty
? const <String, String>{}
: (jsonDecode(customHeadersStr) as Map).cast<String, String>();
@@ -86,75 +93,67 @@ Future<void> _migrateTo25() async {
Future<void> _migrateTo26(Drift drift) async {
final migrator = _StoreMigrator.settings(drift);
await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, SettingsKey.logLevel, LogLevel.values);
await migrator.migrateEnumIndex(.legacyLogLevel, .logLevel, LogLevel.values);
// Theme
await migrator.migrateEnumName(StoreKey.legacyThemeMode, SettingsKey.themeMode, ThemeMode.values);
await migrator.migrateEnumName(StoreKey.legacyPrimaryColor, SettingsKey.themePrimaryColor, ImmichColorPreset.values);
await migrator.migrateBool(StoreKey.legacyDynamicTheme, SettingsKey.themeDynamic);
await migrator.migrateBool(StoreKey.legacyColorfulInterface, SettingsKey.themeColorfulInterface);
await migrator.migrateEnumName(.legacyThemeMode, .themeMode, ThemeMode.values);
await migrator.migrateEnumName(.legacyPrimaryColor, .themePrimaryColor, ImmichColorPreset.values);
await migrator.migrateBool(.legacyDynamicTheme, .themeDynamic);
await migrator.migrateBool(.legacyColorfulInterface, .themeColorfulInterface);
// Cleanup
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(.legacyCleanupKeepAlbumIds);
if (cleanupKeepAlbumIds != null) {
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
migrator.stage(StoreKey.legacyCleanupKeepAlbumIds, SettingsKey.cleanupKeepAlbumIds, ids);
migrator.stage(.legacyCleanupKeepAlbumIds, .cleanupKeepAlbumIds, ids);
}
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, SettingsKey.cleanupKeepFavorites);
await migrator.migrateEnumIndex(
StoreKey.legacyCleanupKeepMediaType,
SettingsKey.cleanupKeepMediaType,
AssetKeepType.values,
);
await migrator.migrateInt(StoreKey.legacyCleanupCutoffDaysAgo, SettingsKey.cleanupCutoffDaysAgo);
await migrator.migrateBool(StoreKey.legacyCleanupDefaultsInitialized, SettingsKey.cleanupDefaultsInitialized);
await migrator.migrateBool(.legacyCleanupKeepFavorites, .cleanupKeepFavorites);
await migrator.migrateEnumIndex(.legacyCleanupKeepMediaType, .cleanupKeepMediaType, AssetKeepType.values);
await migrator.migrateInt(.legacyCleanupCutoffDaysAgo, .cleanupCutoffDaysAgo);
await migrator.migrateBool(.legacyCleanupDefaultsInitialized, .cleanupDefaultsInitialized);
// Map
await migrator.migrateBool(StoreKey.legacyMapShowFavoriteOnly, SettingsKey.mapShowFavoriteOnly);
await migrator.migrateInt(StoreKey.legacyMapRelativeDate, SettingsKey.mapRelativeDate);
await migrator.migrateBool(StoreKey.legacyMapIncludeArchived, SettingsKey.mapIncludeArchived);
await migrator.migrateEnumIndex(StoreKey.legacyMapThemeMode, SettingsKey.mapThemeMode, ThemeMode.values);
await migrator.migrateBool(StoreKey.legacyMapwithPartners, SettingsKey.mapWithPartners);
await migrator.migrateBool(.legacyMapShowFavoriteOnly, .mapShowFavoriteOnly);
await migrator.migrateInt(.legacyMapRelativeDate, .mapRelativeDate);
await migrator.migrateBool(.legacyMapIncludeArchived, .mapIncludeArchived);
await migrator.migrateEnumIndex(.legacyMapThemeMode, .mapThemeMode, ThemeMode.values);
await migrator.migrateBool(.legacyMapwithPartners, .mapWithPartners);
// Timeline
await migrator.migrateInt(StoreKey.legacyTilesPerRow, SettingsKey.timelineTilesPerRow);
await migrator.migrateEnumIndex(
StoreKey.legacyGroupAssetsBy,
SettingsKey.timelineGroupAssetsBy,
GroupAssetsBy.values,
);
await migrator.migrateBool(StoreKey.legacyStorageIndicator, SettingsKey.timelineStorageIndicator);
await migrator.migrateInt(.legacyTilesPerRow, .timelineTilesPerRow);
await migrator.migrateEnumIndex(.legacyGroupAssetsBy, .timelineGroupAssetsBy, GroupAssetsBy.values);
await migrator.migrateBool(.legacyStorageIndicator, .timelineStorageIndicator);
// Image
await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, SettingsKey.imagePreferRemote);
await migrator.migrateBool(StoreKey.legacyLoadOriginal, SettingsKey.imageLoadOriginal);
await migrator.migrateBool(.legacyPreferRemoteImage, .imagePreferRemote);
await migrator.migrateBool(.legacyLoadOriginal, .imageLoadOriginal);
// Viewer
await migrator.migrateBool(StoreKey.legacyLoopVideo, SettingsKey.viewerLoopVideo);
await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, SettingsKey.viewerLoadOriginalVideo);
await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, SettingsKey.viewerAutoPlayVideo);
await migrator.migrateBool(StoreKey.legacyTapToNavigate, SettingsKey.viewerTapToNavigate);
await migrator.migrateBool(.legacyLoopVideo, .viewerLoopVideo);
await migrator.migrateBool(.legacyLoadOriginalVideo, .viewerLoadOriginalVideo);
await migrator.migrateBool(.legacyAutoPlayVideo, .viewerAutoPlayVideo);
await migrator.migrateBool(.legacyTapToNavigate, .viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, SettingsKey.networkAutoEndpointSwitching);
final preferredWifiName = await migrator.readLegacyStoreString(StoreKey.legacyPreferredWifiName.id);
migrator.stage(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName, preferredWifiName);
final localEndpoint = await migrator.readLegacyStoreString(StoreKey.legacyLocalEndpoint.id);
migrator.stage(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint, localEndpoint);
await migrator.migrateBool(.legacyAutoEndpointSwitching, .networkAutoEndpointSwitching);
final preferredWifiName = await migrator.readLegacyStoreString(.legacyPreferredWifiName);
migrator.stage(.legacyPreferredWifiName, .networkPreferredWifiName, preferredWifiName);
final localEndpoint = await migrator.readLegacyStoreString(.legacyLocalEndpoint);
migrator.stage(.legacyLocalEndpoint, .networkLocalEndpoint, localEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
await _migrateAlbumSortMode(migrator);
await migrator.migrateBool(StoreKey.legacySelectedAlbumSortReverse, SettingsKey.albumIsReverse);
await migrator.migrateBool(StoreKey.legacyAlbumGridView, SettingsKey.albumIsGrid);
await migrator.migrateBool(.legacySelectedAlbumSortReverse, .albumIsReverse);
await migrator.migrateBool(.legacyAlbumGridView, .albumIsGrid);
// Backup
await migrator.migrateBool(StoreKey.legacyEnableBackup, SettingsKey.backupEnabled);
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadVideos, SettingsKey.backupUseCellularForVideos);
await migrator.migrateBool(StoreKey.legacyUseWifiForUploadPhotos, SettingsKey.backupUseCellularForPhotos);
await migrator.migrateBool(StoreKey.legacyBackupRequireCharging, SettingsKey.backupRequireCharging);
await migrator.migrateInt(StoreKey.legacyBackupTriggerDelay, SettingsKey.backupTriggerDelay);
await migrator.migrateBool(StoreKey.legacySyncAlbums, SettingsKey.backupSyncAlbums);
await migrator.migrateBool(.legacyEnableBackup, .backupEnabled);
await migrator.migrateBool(.legacyUseWifiForUploadVideos, .backupUseCellularForVideos);
await migrator.migrateBool(.legacyUseWifiForUploadPhotos, .backupUseCellularForPhotos);
await migrator.migrateBool(.legacyBackupRequireCharging, .backupRequireCharging);
await migrator.migrateInt(.legacyBackupTriggerDelay, .backupTriggerDelay);
await migrator.migrateBool(.legacySyncAlbums, .backupSyncAlbums);
await migrator.complete();
}
Future<void> _migrateTo27(Drift drift) async {
final migrator = _StoreMigrator.session(drift);
await migrator.migrateString(StoreKey.legacyServerUrl, SessionKey.serverUrl);
await migrator.migrateString(StoreKey.legacyAccessToken, SessionKey.accessToken);
await migrator.migrateString(StoreKey.legacyServerEndpoint, SessionKey.serverEndpoint);
await migrator.migrateString(.legacyServerUrl, .serverUrl);
await migrator.migrateString(.legacyAccessToken, .accessToken);
await migrator.migrateString(.legacyServerEndpoint, .serverEndpoint);
await migrator.complete();
await SessionRepository.instance.refresh();
@@ -162,26 +161,40 @@ Future<void> _migrateTo27(Drift drift) async {
Future<void> _migrateTo28(Drift drift) async {
final migrator = _StoreMigrator.settings(drift);
await migrator.migrateBool(StoreKey.legacyAdvancedTroubleshooting, SettingsKey.advancedTroubleshooting);
await migrator.migrateBool(StoreKey.legacyEnableHapticFeedback, SettingsKey.advancedEnableHapticFeedback);
await migrator.migrateBool(StoreKey.legacyReadonlyModeEnabled, SettingsKey.advancedReadonlyModeEnabled);
await migrator.migrateBool(.legacyAdvancedTroubleshooting, .advancedTroubleshooting);
await migrator.migrateBool(.legacyEnableHapticFeedback, .advancedEnableHapticFeedback);
await migrator.migrateBool(.legacyReadonlyModeEnabled, .advancedReadonlyModeEnabled);
await migrator.complete();
await SettingsRepository.instance.refresh();
}
Future<void> _migrateTo29(Drift drift) async {
final migrator = _StoreMigrator.appMetadata(drift);
final rawStatus = await migrator.readLegacyStoreString(.legacySyncMigrationStatus);
if (rawStatus != null) {
final decoded = jsonDecode(rawStatus);
final migrations = decoded is List ? decoded.whereType<String>().toList() : <String>[];
migrator.stage(.legacySyncMigrationStatus, .syncMigrationStatus, migrations);
}
await migrator.migrateBool(.legacyManageLocalMediaAndroid, .manageLocalMediaAndroid);
await migrator.complete();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator<SettingsKey> migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
final raw = await migrator.readLegacyStoreInt(.legacySelectedAlbumSortOrder);
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
if (mode == null) {
return;
}
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, SettingsKey.albumSortMode, mode);
migrator.stage(.legacySelectedAlbumSortOrder, .albumSortMode, mode);
}
Future<void> _migrateExternalEndpointList(_StoreMigrator<SettingsKey> migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
final raw = await migrator.readLegacyStoreString(.legacyExternalEndpointList);
if (raw == null) {
return;
}
@@ -205,7 +218,7 @@ Future<void> _migrateExternalEndpointList(_StoreMigrator<SettingsKey> migrator)
}
Future<void> _migrateCustomHeaders(_StoreMigrator<SettingsKey> migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
final raw = await migrator.readLegacyStoreString(.legacyCustomHeaders);
if (raw == null) {
return;
}
@@ -252,6 +265,17 @@ class _StoreMigrator<K extends Enum> {
),
);
static _StoreMigrator<AppMetadataKey> appMetadata(Drift db) => _StoreMigrator<AppMetadataKey>._(
db,
encode: (key, value) => key.encode(value),
readDefault: (_) => null,
insertRow: (batch, name, value) => batch.insert(
db.appMetadataEntity,
AppMetadataEntityCompanion(key: Value(name), value: Value(value)),
mode: InsertMode.insertOrReplace,
),
);
final Drift _db;
final String Function(K key, Object value) encode;
final Object? Function(K key) readDefault;
@@ -260,7 +284,7 @@ class _StoreMigrator<K extends Enum> {
final List<int> _migratedStoreIds = [];
Future<void> migrateEnumIndex<T extends Enum>(StoreKey<int> legacyKey, K newKey, List<T> values) async {
final index = await readLegacyStoreInt(legacyKey.id);
final index = await readLegacyStoreInt(legacyKey);
if (index == null) {
return;
}
@@ -275,7 +299,7 @@ class _StoreMigrator<K extends Enum> {
}
Future<void> migrateEnumName<T extends Enum>(StoreKey<String> legacyKey, K newKey, List<T> values) async {
final name = await readLegacyStoreString(legacyKey.id);
final name = await readLegacyStoreString(legacyKey);
if (name == null) {
return;
}
@@ -290,7 +314,7 @@ class _StoreMigrator<K extends Enum> {
}
Future<void> migrateBool(StoreKey<bool> legacyKey, K newKey) async {
final intValue = await readLegacyStoreInt(legacyKey.id);
final intValue = await readLegacyStoreInt(legacyKey);
if (intValue == null) {
return;
}
@@ -300,7 +324,7 @@ class _StoreMigrator<K extends Enum> {
}
Future<void> migrateInt(StoreKey<int> legacyKey, K newKey) async {
final intValue = await readLegacyStoreInt(legacyKey.id);
final intValue = await readLegacyStoreInt(legacyKey);
if (intValue == null) {
return;
}
@@ -310,7 +334,7 @@ class _StoreMigrator<K extends Enum> {
}
Future<void> migrateString(StoreKey<String> legacyKey, K newKey) async {
final value = await readLegacyStoreString(legacyKey.id);
final value = await readLegacyStoreString(legacyKey);
if (value == null || value.isEmpty) {
return;
}
@@ -320,7 +344,7 @@ class _StoreMigrator<K extends Enum> {
}
Future<void> migrateNullableString(StoreKey<String> legacyKey, K newKey) async {
_cache[newKey] = await readLegacyStoreString(legacyKey.id);
_cache[newKey] = await readLegacyStoreString(legacyKey);
_migratedStoreIds.add(legacyKey.id);
}
@@ -343,13 +367,13 @@ class _StoreMigrator<K extends Enum> {
await deleteLegacyStoreRows(_migratedStoreIds);
}
Future<String?> readLegacyStoreString(int id) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
Future<String?> readLegacyStoreString(StoreKey key) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(key.id))).getSingleOrNull();
return row?.stringValue;
}
Future<int?> readLegacyStoreInt(int id) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
Future<int?> readLegacyStoreInt(StoreKey key) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(key.id))).getSingleOrNull();
return row?.intValue;
}
@@ -11,14 +11,14 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
@@ -242,7 +242,8 @@ class LoginForm extends HookConsumerWidget {
}
}
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
Future<bool> isSyncRemoteDeletionsMode() async =>
Platform.isAndroid && await ref.read(appMetadataRepositoryProvider).get(AppMetadataKey.manageLocalMediaAndroid);
login() async {
TextInput.finishAutofillContext();
@@ -257,7 +258,7 @@ class LoginForm extends HookConsumerWidget {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
if (await isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
@@ -345,7 +346,7 @@ class LoginForm extends HookConsumerWidget {
if (isSuccess) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
if (await isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
@@ -5,15 +5,15 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@@ -32,7 +32,7 @@ class AdvancedSettings extends HookConsumerWidget {
advancedTroubleshooting.value,
(_, __) => ref.read(settingsProvider).write(.advancedTroubleshooting, advancedTroubleshooting.value),
);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final manageLocalMediaAndroid = useState(false);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
@@ -61,6 +61,9 @@ class AdvancedSettings extends HookConsumerWidget {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageLocalMediaAndroid.value = await ref
.read(appMetadataRepositoryProvider)
.get(AppMetadataKey.manageLocalMediaAndroid);
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
}
}();
@@ -87,6 +90,9 @@ class AdvancedSettings extends HookConsumerWidget {
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
await ref.read(appMetadataRepositoryProvider).set(AppMetadataKey.manageLocalMediaAndroid, result);
} else {
await ref.read(appMetadataRepositoryProvider).set(AppMetadataKey.manageLocalMediaAndroid, false);
}
},
),
@@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
@@ -20,7 +19,6 @@ class GroupSettings extends HookConsumerWidget {
Future<void> updateAppSettings(GroupAssetsBy groupBy) async {
await ref.read(settingsProvider).write(.timelineGroupAssetsBy, groupBy);
ref.invalidate(appSettingsServiceProvider);
ref.invalidate(timelineServiceProvider);
}
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@@ -32,9 +31,6 @@ class LayoutSettings extends HookConsumerWidget {
maxValue: 6,
minValue: 2,
noDivisons: 4,
onChangeEnd: (value) {
ref.invalidate(appSettingsServiceProvider);
},
),
],
);
@@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart';
@@ -22,7 +21,6 @@ class AssetListSettings extends HookConsumerWidget {
title: 'theme_setting_asset_list_storage_indicator_title'.tr(),
onChanged: (value) {
ref.read(settingsProvider).write(.timelineStorageIndicator, value);
ref.invalidate(appSettingsServiceProvider);
ref.invalidate(settingsProvider);
},
),
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -29,7 +28,6 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
valueNotifier: isOriginal,
title: "setting_image_viewer_original_title".t(context: context),
subtitle: "setting_image_viewer_original_subtitle".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);
@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/app_metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@@ -17,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
@@ -219,7 +219,7 @@ class _SyncStatsCounts extends ConsumerWidget {
final localAlbumService = ref.watch(localAlbumServiceProvider);
final remoteAlbumService = ref.watch(remoteAlbumServiceProvider);
final memoryService = ref.watch(driftMemoryServiceProvider);
final appSettingsService = ref.watch(appSettingsServiceProvider);
final appMetadataRepository = ref.watch(appMetadataRepositoryProvider);
Future<List<dynamic>> loadCounts() async {
final assetCounts = assetService.getAssetCounts();
@@ -227,8 +227,16 @@ class _SyncStatsCounts extends ConsumerWidget {
final remoteAlbumCounts = remoteAlbumService.getCount();
final memoryCount = memoryService.getCount();
final getLocalHashedCount = assetService.getLocalHashedCount();
final manageLocalMediaAndroid = appMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid);
return await Future.wait([assetCounts, localAlbumCounts, remoteAlbumCounts, memoryCount, getLocalHashedCount]);
return await Future.wait([
assetCounts,
localAlbumCounts,
remoteAlbumCounts,
memoryCount,
getLocalHashedCount,
manageLocalMediaAndroid,
]);
}
return FutureBuilder(
@@ -254,14 +262,15 @@ class _SyncStatsCounts extends ConsumerWidget {
);
}
final assetCounts = snapshot.data![0]! as (int, int);
final assetCounts = snapshot.data![0] as (int, int);
final localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2;
final localAlbumCount = snapshot.data![1]! as int;
final remoteAlbumCount = snapshot.data![2]! as int;
final memoryCount = snapshot.data![3]! as int;
final localHashedCount = snapshot.data![4]! as int;
final localAlbumCount = snapshot.data![1] as int;
final remoteAlbumCount = snapshot.data![2] as int;
final memoryCount = snapshot.data![3] as int;
final localHashedCount = snapshot.data![4] as int;
final manageLocalMediaAndroid = snapshot.data![5] as bool;
return Column(
mainAxisAlignment: MainAxisAlignment.start,
@@ -354,8 +363,7 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
if (CurrentPlatform.isAndroid && manageLocalMediaAndroid) ...[
SettingGroupTitle(title: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {
-3
View File
@@ -2,7 +2,6 @@ import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {}
@@ -11,6 +10,4 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
@@ -2,8 +2,8 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -29,6 +29,7 @@ void main() {
late AssetMediaRepository mockAssetMediaRepository;
late MockPermissionRepository mockPermissionRepository;
late MockNativeSyncApi mockNativeSyncApi;
late MockAppMetadataRepository mockAppMetadataRepository;
late Drift db;
setUpAll(() async {
@@ -52,6 +53,7 @@ void main() {
mockAssetMediaRepository = MockAssetMediaRepository();
mockPermissionRepository = MockPermissionRepository();
mockNativeSyncApi = MockNativeSyncApi();
mockAppMetadataRepository = MockAppMetadataRepository();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
when(() => mockNativeSyncApi.getMediaChanges()).thenAnswer(
@@ -75,15 +77,16 @@ void main() {
assetMediaRepository: mockAssetMediaRepository,
permissionRepository: mockPermissionRepository,
nativeSyncApi: mockNativeSyncApi,
appMetadataRepository: mockAppMetadataRepository,
);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
});
group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
@@ -93,7 +96,7 @@ void main() {
});
test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
@@ -102,7 +105,7 @@ void main() {
});
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
@@ -114,7 +117,7 @@ void main() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
@@ -22,14 +22,14 @@ void main() {
mockDriftStoreRepo = MockDriftStoreRepository();
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.legacyAccessToken);
registerFallbackValue(StoreKey.version);
registerFallbackValue(StoreKey.legacyVersion);
registerFallbackValue(StoreKey.legacyAdvancedTroubleshooting);
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.legacyAccessToken, _kAccessToken),
const StoreDto(StoreKey.legacyAdvancedTroubleshooting, _kAdvancedTroubleshooting),
const StoreDto(StoreKey.version, _kVersion),
const StoreDto(StoreKey.legacyVersion, _kVersion),
],
);
when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream);
@@ -47,7 +47,7 @@ void main() {
verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.legacyAccessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.legacyAdvancedTroubleshooting), _kAdvancedTroubleshooting);
expect(sut.tryGet(StoreKey.version), _kVersion);
expect(sut.tryGet(StoreKey.legacyVersion), _kVersion);
// Other keys should be null
expect(sut.tryGet(StoreKey.deviceId), isNull);
});
@@ -148,7 +148,7 @@ void main() {
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.legacyAccessToken), isNull);
expect(sut.tryGet(StoreKey.legacyAdvancedTroubleshooting), isNull);
expect(sut.tryGet(StoreKey.version), isNull);
expect(sut.tryGet(StoreKey.legacyVersion), isNull);
});
});
}
@@ -4,8 +4,8 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
@@ -36,7 +36,6 @@ class _AbortCallbackWrapper {
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
void main() {
late SyncStreamService sut;
late SyncStreamRepository mockSyncStreamRepo;
@@ -51,6 +50,7 @@ void main() {
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
late _MockAbortCallbackWrapper mockAbortCallbackWrapper;
late _MockAbortCallbackWrapper mockResetCallbackWrapper;
late MockAppMetadataRepository mockAppMetadataRepository;
late Drift db;
late bool hasManageMediaPermission;
@@ -59,6 +59,8 @@ void main() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
registerFallbackValue(AppMetadataKey.syncMigrationStatus);
registerFallbackValue(const <String>[]);
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
@@ -84,6 +86,7 @@ void main() {
mockApi = MockApiService();
mockServerApi = MockServerApi();
mockSyncMigrationRepo = MockSyncMigrationRepository();
mockAppMetadataRepository = MockAppMetadataRepository();
when(() => mockAbortCallbackWrapper()).thenReturn(false);
@@ -159,6 +162,7 @@ void main() {
permissionRepository: mockPermissionRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
appMetadataRepository: mockAppMetadataRepository,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
@@ -172,7 +176,11 @@ void main() {
return ids;
});
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false);
when(
() => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus),
).thenAnswer((_) async => const <String>[]);
when(() => mockAppMetadataRepository.set<List<String>, List<String>>(any(), any())).thenAnswer((_) async {});
});
Future<void> simulateEvents(List<SyncEvent> events) async {
@@ -243,6 +251,7 @@ void main() {
cancellation: cancellation,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
appMetadataRepository: mockAppMetadataRepository,
);
await sut.sync();
@@ -283,6 +292,7 @@ void main() {
cancellation: cancellation,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
appMetadataRepository: mockAppMetadataRepository,
);
await sut.sync();
@@ -394,12 +404,12 @@ void main() {
group("SyncStreamService - remote trash & restore", () {
setUp(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => true);
hasManageMediaPermission = true;
});
tearDown(() async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockAppMetadataRepository.get(AppMetadataKey.manageLocalMediaAndroid)).thenAnswer((_) async => false);
hasManageMediaPermission = false;
});
@@ -552,7 +562,9 @@ void main() {
group('SyncStreamService - Sync Migration', () {
test('ensure that <2.5.0 migrations run', () async {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus),
).thenAnswer((_) async => const <String>[]);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
@@ -580,7 +592,9 @@ void main() {
);
});
test('ensure that >=2.5.0 migrations run', () async {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus),
).thenAnswer((_) async => const <String>[]);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null));
@@ -606,10 +620,9 @@ void main() {
});
test('ensure that migrations do not re-run', () async {
await Store.put(
StoreKey.syncMigrationStatus,
'["${SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name}"]',
);
when(
() => mockAppMetadataRepository.get(AppMetadataKey.syncMigrationStatus),
).thenAnswer((_) async => [SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name]);
when(
() => mockServerApi.getServerVersion(),
+4
View File
@@ -35,6 +35,7 @@ import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
import 'schema_v32.dart' as v32;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -102,6 +103,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
case 32:
return v32.DatabaseAtV32(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -139,5 +142,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
29,
30,
31,
32,
];
}
File diff suppressed because it is too large Load Diff
+34 -4
View File
@@ -1,13 +1,11 @@
// dart format width=80
// ignore_for_file: unused_local_variable, unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
import 'generated/schema_v2.dart' as v2;
import 'generated/schema_v31.dart' as v31;
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
@@ -35,4 +33,36 @@ void main() {
});
}
});
group('data migrations', () {
test('v31->v32 backfills the migration', () async {
final schema = await verifier.schemaAt(31);
final oldDb = v31.DatabaseAtV31(schema.newConnection());
await oldDb.into(oldDb.storeEntity).insert(v31.StoreEntityCompanion.insert(id: 0, intValue: const Value(28)));
await oldDb.close();
final db = Drift(schema.newConnection());
await verifier.migrateAndValidate(db, 32);
final cursor = await (db.appMetadataEntity.select()..where((tbl) => tbl.key.equals(AppMetadataKey.version.name)))
.map((row) => row.value)
.getSingleOrNull();
expect(cursor, '28');
await db.close();
});
test('v31->v32 writes no row when the legacy store has none', () async {
final schema = await verifier.schemaAt(31);
final db = Drift(schema.newConnection());
await verifier.migrateAndValidate(db, 32);
final rows = await db.appMetadataEntity.select().get();
expect(rows, isEmpty);
await db.close();
});
});
}
@@ -33,7 +33,7 @@ Future<void> _populateStore(Drift db) async {
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.version.id),
id: Value(StoreKey.legacyVersion.id),
intValue: const Value(_kTestVersion),
stringValue: const Value(null),
),
@@ -56,10 +56,10 @@ void main() {
group('Store Repository converters:', () {
test('converts int', () async {
int? version = await sut.tryGet(StoreKey.version);
int? version = await sut.tryGet(StoreKey.legacyVersion);
expect(version, isNull);
await sut.upsert(StoreKey.version, _kTestVersion);
version = await sut.tryGet(StoreKey.version);
await sut.upsert(StoreKey.legacyVersion, _kTestVersion);
version = await sut.tryGet(StoreKey.legacyVersion);
expect(version, _kTestVersion);
});
@@ -107,10 +107,10 @@ void main() {
});
test('upsert()', () async {
int? version = await sut.tryGet(StoreKey.version);
int? version = await sut.tryGet(StoreKey.legacyVersion);
expect(version, _kTestVersion);
await sut.upsert(StoreKey.version, _kTestVersion + 10);
version = await sut.tryGet(StoreKey.version);
await sut.upsert(StoreKey.legacyVersion, _kTestVersion + 10);
version = await sut.tryGet(StoreKey.legacyVersion);
expect(version, _kTestVersion + 10);
});
});
@@ -121,10 +121,10 @@ void main() {
});
test('watch()', () async {
final stream = sut.watch(StoreKey.version);
final stream = sut.watch(StoreKey.legacyVersion);
unawaited(expectLater(stream, emitsInOrder([_kTestVersion, _kTestVersion + 10])));
await pumpEventQueue();
await sut.upsert(StoreKey.version, _kTestVersion + 10);
await sut.upsert(StoreKey.legacyVersion, _kTestVersion + 10);
});
test('watchAll()', () async {
@@ -134,19 +134,19 @@ void main() {
stream,
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
const StoreDto<Object>(StoreKey.legacyVersion, _kTestVersion),
const StoreDto<Object>(StoreKey.legacyAccessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.legacyAdvancedTroubleshooting, _kTestAdvancedTroubleshooting),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
const StoreDto<Object>(StoreKey.legacyVersion, _kTestVersion + 10),
const StoreDto<Object>(StoreKey.legacyAccessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.legacyAdvancedTroubleshooting, _kTestAdvancedTroubleshooting),
],
]),
),
);
await sut.upsert(StoreKey.version, _kTestVersion + 10);
await sut.upsert(StoreKey.legacyVersion, _kTestVersion + 10);
});
});
}
@@ -1,3 +1,4 @@
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -22,6 +23,8 @@ class MockDriftStoreRepository extends Mock implements DriftStoreRepository {}
class MockSettingsRepository extends Mock implements SettingsRepository {}
class MockAppMetadataRepository extends Mock implements AppMetadataRepository {}
class MockLogRepository extends Mock implements LogRepository {}
class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
@@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/app_metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/app_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/app_metadata.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late AppMetadataRepository sut;
setUpAll(() {
ctx = MediumRepositoryContext();
sut = AppMetadataRepository(ctx.db);
});
tearDownAll(() async {
await ctx.dispose();
});
setUp(() async {
await ctx.db.delete(ctx.db.appMetadataEntity).go();
});
group('get', () {
test('a stored NULL value column resolves to the key default', () async {
await ctx.db
.into(ctx.db.appMetadataEntity)
.insert(
AppMetadataEntityCompanion.insert(
key: AppMetadataKey.manageLocalMediaAndroid.name,
value: const .new(null),
updatedAt: .new(DateTime.now()),
),
);
expect(await sut.get(.manageLocalMediaAndroid), false);
});
});
group('defaults', () {
test('falls back to the key default when the value is absent', () async {
expect(await sut.get(.version), kCurrentVersion);
expect(await sut.get(.syncMigrationStatus), const <String>[]);
expect(await sut.get(.manageLocalMediaAndroid), false);
});
test('a stored value takes precedence over the default', () async {
await sut.set(.version, 5);
await sut.set(.syncMigrationStatus, const ['task']);
await sut.set(.manageLocalMediaAndroid, true);
expect(await sut.get(.version), 5);
expect(await sut.get(.syncMigrationStatus), const ['task']);
expect(await sut.get(.manageLocalMediaAndroid), true);
});
});
group('set', () {
test('round-trips int, List and bool values to their typed form', () async {
await sut.set(.version, 42);
await sut.set(.syncMigrationStatus, const ['task']);
await sut.set(.manageLocalMediaAndroid, true);
expect(await sut.get(.version), 42);
expect(await sut.get(.syncMigrationStatus), const ['task']);
expect(await sut.get(.manageLocalMediaAndroid), true);
});
test('overwrites the existing value and keeps a single row per key', () async {
await sut.set(.version, 1);
await sut.set(.version, 2);
expect(await sut.get(.version), 2);
expect(await ctx.db.select(ctx.db.appMetadataEntity).get(), hasLength(1));
});
});
group('cache-less reads', () {
test('observes a value mutated directly in the DB', () async {
await sut.set(.version, 10);
await (ctx.db.update(ctx.db.appMetadataEntity)..where((r) => r.key.equals(AppMetadataKey.version.name))).write(
AppMetadataEntityCompanion(value: .new(AppMetadataKey.version.encode(99))),
);
expect(await sut.get(.version), 99);
});
});
}
-3
View File
@@ -1,10 +1,7 @@
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/network.service.dart';
import 'package:mocktail/mocktail.dart';
class MockApiService extends Mock implements ApiService {}
class MockNetworkService extends Mock implements NetworkService {}
class MockAppSettingService extends Mock implements AppSettingsService {}
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -28,6 +27,7 @@ void main() {
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
late MockAppMetadataRepository appMetadataRepository;
late Drift db;
@@ -55,6 +55,7 @@ void main() {
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
appMetadataRepository = MockAppMetadataRepository();
sut = ActionService(
assetApiRepository,
@@ -66,7 +67,10 @@ void main() {
assetMediaRepository,
downloadRepository,
tagService,
appMetadataRepository,
);
when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => false);
});
tearDown(() async {
@@ -144,7 +148,7 @@ void main() {
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => true);
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
@@ -159,7 +163,7 @@ void main() {
});
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => false);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
@@ -174,7 +178,7 @@ void main() {
});
test('short-circuits when nothing was deleted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => appMetadataRepository.get(.manageLocalMediaAndroid)).thenAnswer((_) async => true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);