diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 9fc7d17d5..03656ce0b 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart index 158638442..c5c63cad5 100644 --- a/mobile/lib/domain/models/user_metadata.model.dart +++ b/mobile/lib/domain/models/user_metadata.model.dart @@ -1,5 +1,12 @@ import 'dart:ui'; +enum UserMetadataKey { + // do not change this order! + onboarding, + preferences, + license, +} + enum AvatarColor { // do not change this order or reuse indices for other purposes, adding is OK primary("primary"), @@ -31,7 +38,45 @@ enum AvatarColor { }; } -class UserPreferences { +class Onboarding { + final bool isOnboarded; + + const Onboarding({required this.isOnboarded}); + + Onboarding copyWith({bool? isOnboarded}) { + return Onboarding(isOnboarded: isOnboarded ?? this.isOnboarded); + } + + Map toMap() { + final onboarding = {}; + onboarding["isOnboarded"] = isOnboarded; + return onboarding; + } + + factory Onboarding.fromMap(Map map) { + return Onboarding(isOnboarded: map["isOnboarded"] as bool); + } + + @override + String toString() { + return '''Onboarding { +isOnboarded: $isOnboarded, +}'''; + } + + @override + bool operator ==(covariant Onboarding other) { + if (identical(this, other)) return true; + + return isOnboarded == other.isOnboarded; + } + + @override + int get hashCode => isOnboarded.hashCode; +} + +// TODO: wait to be overwritten +class Preferences { final bool foldersEnabled; final bool memoriesEnabled; final bool peopleEnabled; @@ -41,7 +86,7 @@ class UserPreferences { final AvatarColor userAvatarColor; final bool showSupportBadge; - const UserPreferences({ + const Preferences({ this.foldersEnabled = false, this.memoriesEnabled = true, this.peopleEnabled = true, @@ -52,7 +97,7 @@ class UserPreferences { this.showSupportBadge = true, }); - UserPreferences copyWith({ + Preferences copyWith({ bool? foldersEnabled, bool? memoriesEnabled, bool? peopleEnabled, @@ -62,7 +107,7 @@ class UserPreferences { AvatarColor? userAvatarColor, bool? showSupportBadge, }) { - return UserPreferences( + return Preferences( foldersEnabled: foldersEnabled ?? this.foldersEnabled, memoriesEnabled: memoriesEnabled ?? this.memoriesEnabled, peopleEnabled: peopleEnabled ?? this.peopleEnabled, @@ -87,8 +132,8 @@ class UserPreferences { return preferences; } - factory UserPreferences.fromMap(Map map) { - return UserPreferences( + factory Preferences.fromMap(Map map) { + return Preferences( foldersEnabled: map["folders-Enabled"] as bool? ?? false, memoriesEnabled: map["memories-Enabled"] as bool? ?? true, peopleEnabled: map["people-Enabled"] as bool? ?? true, @@ -102,4 +147,173 @@ class UserPreferences { showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true, ); } + + @override + String toString() { + return '''Preferences: { +foldersEnabled: $foldersEnabled, +memoriesEnabled: $memoriesEnabled, +peopleEnabled: $peopleEnabled, +ratingsEnabled: $ratingsEnabled, +sharedLinksEnabled: $sharedLinksEnabled, +tagsEnabled: $tagsEnabled, +userAvatarColor: $userAvatarColor, +showSupportBadge: $showSupportBadge, +}'''; + } + + @override + bool operator ==(covariant Preferences other) { + if (identical(this, other)) return true; + + return other.foldersEnabled == foldersEnabled && + other.memoriesEnabled == memoriesEnabled && + other.peopleEnabled == peopleEnabled && + other.ratingsEnabled == ratingsEnabled && + other.sharedLinksEnabled == sharedLinksEnabled && + other.tagsEnabled == tagsEnabled && + other.userAvatarColor == userAvatarColor && + other.showSupportBadge == showSupportBadge; + } + + @override + int get hashCode { + return foldersEnabled.hashCode ^ + memoriesEnabled.hashCode ^ + peopleEnabled.hashCode ^ + ratingsEnabled.hashCode ^ + sharedLinksEnabled.hashCode ^ + tagsEnabled.hashCode ^ + userAvatarColor.hashCode ^ + showSupportBadge.hashCode; + } +} + +class License { + final DateTime activatedAt; + final String activationKey; + final String licenseKey; + + const License({ + required this.activatedAt, + required this.activationKey, + required this.licenseKey, + }); + + License copyWith({ + DateTime? activatedAt, + String? activationKey, + String? licenseKey, + }) { + return License( + activatedAt: activatedAt ?? this.activatedAt, + activationKey: activationKey ?? this.activationKey, + licenseKey: licenseKey ?? this.licenseKey, + ); + } + + Map toMap() { + final license = {}; + license["activatedAt"] = activatedAt; + license["activationKey"] = activationKey; + license["licenseKey"] = licenseKey; + return license; + } + + factory License.fromMap(Map map) { + return License( + activatedAt: map["activatedAt"] as DateTime, + activationKey: map["activationKey"] as String, + licenseKey: map["licenseKey"] as String, + ); + } + + @override + String toString() { + return '''License { +activatedAt: $activatedAt, +activationKey: $activationKey, +licenseKey: $licenseKey, +}'''; + } + + @override + bool operator ==(covariant License other) { + if (identical(this, other)) return true; + + return activatedAt == other.activatedAt && + activationKey == other.activationKey && + licenseKey == other.licenseKey; + } + + @override + int get hashCode => + activatedAt.hashCode ^ activationKey.hashCode ^ licenseKey.hashCode; +} + +// Model for a user metadata stored in the server +class UserMetadata { + final String userId; + final UserMetadataKey key; + final Onboarding? onboarding; + final Preferences? preferences; + final License? license; + + const UserMetadata({ + required this.userId, + required this.key, + this.onboarding, + this.preferences, + this.license, + }) : assert( + onboarding != null || preferences != null || license != null, + 'One of onboarding, preferences and license must be provided', + ); + + UserMetadata copyWith({ + String? userId, + UserMetadataKey? key, + Onboarding? onboarding, + Preferences? preferences, + License? license, + }) { + return UserMetadata( + userId: userId ?? this.userId, + key: key ?? this.key, + onboarding: onboarding ?? this.onboarding, + preferences: preferences ?? this.preferences, + license: license ?? this.license, + ); + } + + @override + String toString() { + return '''UserMetadata: { +userId: $userId, +key: $key, +onboarding: ${onboarding ?? ""}, +preferences: ${preferences ?? ""}, +license: ${license ?? ""}, +}'''; + } + + @override + bool operator ==(covariant UserMetadata other) { + if (identical(this, other)) return true; + + return other.userId == userId && + other.key == key && + other.onboarding == onboarding && + other.preferences == preferences && + other.license == license; + } + + @override + int get hashCode { + return userId.hashCode ^ + key.hashCode ^ + onboarding.hashCode ^ + preferences.hashCode ^ + license.hashCode; + } } diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 29066195f..48cf318a8 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -179,6 +179,14 @@ class SyncStreamService { data.cast(), debugLabel: 'partner', ); + case SyncEntityType.userMetadataV1: + return _syncStreamRepository.updateUserMetadatasV1( + data.cast(), + ); + case SyncEntityType.userMetadataDeleteV1: + return _syncStreamRepository.deleteUserMetadatasV1( + data.cast(), + ); default: _logger.warning("Unknown sync data type: $type"); } diff --git a/mobile/lib/infrastructure/entities/user_metadata.entity.dart b/mobile/lib/infrastructure/entities/user_metadata.entity.dart index 302a9ffce..f9a411e3d 100644 --- a/mobile/lib/infrastructure/entities/user_metadata.entity.dart +++ b/mobile/lib/infrastructure/entities/user_metadata.entity.dart @@ -8,14 +8,16 @@ class UserMetadataEntity extends Table with DriftDefaultsMixin { TextColumn get userId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); - TextColumn get preferences => text().map(userPreferenceConverter)(); + + IntColumn get key => intEnum()(); + + BlobColumn get value => blob().map(userMetadataConverter)(); @override - Set get primaryKey => {userId}; + Set get primaryKey => {userId, key}; } -final JsonTypeConverter2 - userPreferenceConverter = TypeConverter.json2( - fromJson: (json) => UserPreferences.fromMap(json as Map), - toJson: (pref) => pref.toMap(), +final JsonTypeConverter2, Uint8List, Object?> + userMetadataConverter = TypeConverter.jsonb( + fromJson: (json) => json as Map, ); diff --git a/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart b/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart index 95ab63ebf..a13ea5c04 100644 Binary files a/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart and b/mobile/lib/infrastructure/entities/user_metadata.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index d43f786a2..d1ecbd580 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -56,6 +56,7 @@ class SyncApiRepository { SyncRequestType.memoryToAssetsV1, SyncRequestType.stacksV1, SyncRequestType.partnerStacksV1, + SyncRequestType.userMetadataV1, ], ).toJson(), ); @@ -170,6 +171,8 @@ const _kResponseMap = { SyncEntityType.partnerStackV1: SyncStackV1.fromJson, SyncEntityType.partnerStackBackfillV1: SyncStackV1.fromJson, SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson, + SyncEntityType.userMetadataV1: SyncUserMetadataV1.fromJson, + SyncEntityType.userMetadataDeleteV1: SyncUserMetadataDeleteV1.fromJson, }; class _SyncAckV1 { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index dc3b466f0..f3f26bb01 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; @@ -14,6 +15,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole; @@ -461,6 +463,53 @@ class SyncStreamRepository extends DriftDatabaseRepository { rethrow; } } + + Future updateUserMetadatasV1( + Iterable data, + ) async { + try { + await _db.batch((batch) { + for (final userMetadata in data) { + final companion = UserMetadataEntityCompanion( + value: Value(userMetadata.value as Map), + ); + + batch.insert( + _db.userMetadataEntity, + companion.copyWith( + userId: Value(userMetadata.userId), + key: Value(userMetadata.key.toUserMetadataKey()), + ), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteUserMetadatasV1', error, stack); + rethrow; + } + } + + Future deleteUserMetadatasV1( + Iterable data, + ) async { + try { + await _db.batch((batch) { + for (final userMetadata in data) { + batch.delete( + _db.userMetadataEntity, + UserMetadataEntityCompanion( + userId: Value(userMetadata.userId), + key: Value(userMetadata.key.toUserMetadataKey()), + ), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteUserMetadatasV1', error, stack); + rethrow; + } + } } extension on AssetTypeEnum { @@ -506,6 +555,15 @@ extension on api.AssetVisibility { }; } +extension on String { + UserMetadataKey toUserMetadataKey() => switch (this) { + "onboarding" => UserMetadataKey.onboarding, + "preferences" => UserMetadataKey.preferences, + "license" => UserMetadataKey.license, + _ => throw Exception('Unknown UserMetadataKey value: $this'), + }; +} + extension on String { Duration? toDuration() { try { diff --git a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart new file mode 100644 index 000000000..19364afb4 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftUserMetadataRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftUserMetadataRepository(this._db) : super(_db); + + Future> getUserMetadata(String userId) { + final query = _db.userMetadataEntity.select() + ..where((e) => e.userId.equals(userId)); + + return query.map((userMetadata) { + return userMetadata.toDto(); + }).get(); + } +} + +extension on UserMetadataEntityData { + UserMetadata toDto() => switch (key) { + UserMetadataKey.onboarding => UserMetadata( + userId: userId, + key: key, + onboarding: Onboarding.fromMap(value), + ), + UserMetadataKey.preferences => UserMetadata( + userId: userId, + key: key, + preferences: Preferences.fromMap(value), + ), + UserMetadataKey.license => UserMetadata( + userId: userId, + key: key, + license: License.fromMap(value), + ), + }; +} diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart new file mode 100644 index 000000000..2e2ae7555 --- /dev/null +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -0,0 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final userMetadataRepository = Provider( + (ref) => DriftUserMetadataRepository(ref.watch(driftProvider)), +); diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 27cd8c5b2..c9fd8104e 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -101,6 +101,10 @@ void main() { debugLabel: any(named: 'debugLabel'), ), ).thenAnswer(successHandler); + when(() => mockSyncStreamRepo.updateUserMetadatasV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deleteUserMetadatasV1(any())) + .thenAnswer(successHandler); sut = SyncStreamService( syncApiRepository: mockSyncApiRepo,