diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 32ba52e36..493a34cc9 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/stack.model.dart b/mobile/lib/domain/models/stack.model.dart new file mode 100644 index 000000000..5404eb8f4 --- /dev/null +++ b/mobile/lib/domain/models/stack.model.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +// Model for a stack stored in the server +class Stack { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + + const Stack({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId, + }); + + Stack copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId, + }) { + return Stack( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + Map toMap() { + return { + 'id': id, + 'createdAt': createdAt.millisecondsSinceEpoch, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + 'ownerId': ownerId, + 'primaryAssetId': primaryAssetId, + }; + } + + factory Stack.fromMap(Map map) { + return Stack( + id: map['id'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int), + updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int), + ownerId: map['ownerId'] as String, + primaryAssetId: map['primaryAssetId'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory Stack.fromJson(String source) => + Stack.fromMap(json.decode(source) as Map); + + @override + String toString() { + return 'Stack(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, ownerId: $ownerId, primaryAssetId: $primaryAssetId)'; + } + + @override + bool operator ==(covariant Stack other) { + if (identical(this, other)) return true; + + return other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.ownerId == ownerId && + other.primaryAssetId == primaryAssetId; + } + + @override + int get hashCode { + return id.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + ownerId.hashCode ^ + primaryAssetId.hashCode; + } +} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index c4e40726b..ee0ec6c44 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -154,6 +154,25 @@ class SyncStreamService { return _syncStreamRepository.updateMemoryAssetsV1(data.cast()); case SyncEntityType.memoryToAssetDeleteV1: return _syncStreamRepository.deleteMemoryAssetsV1(data.cast()); + case SyncEntityType.stackV1: + return _syncStreamRepository.updateStacksV1(data.cast()); + case SyncEntityType.stackDeleteV1: + return _syncStreamRepository.deleteStacksV1(data.cast()); + case SyncEntityType.partnerStackV1: + return _syncStreamRepository.updateStacksV1( + data.cast(), + debugLabel: 'partner', + ); + case SyncEntityType.partnerStackBackfillV1: + return _syncStreamRepository.updateStacksV1( + data.cast(), + debugLabel: 'partner backfill', + ); + case SyncEntityType.partnerStackDeleteV1: + return _syncStreamRepository.deleteStacksV1( + data.cast(), + debugLabel: 'partner', + ); default: _logger.warning("Unknown sync data type: $type"); } diff --git a/mobile/lib/infrastructure/entities/stack.entity.dart b/mobile/lib/infrastructure/entities/stack.entity.dart new file mode 100644 index 000000000..92375f19d --- /dev/null +++ b/mobile/lib/infrastructure/entities/stack.entity.dart @@ -0,0 +1,22 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class StackEntity extends Table with DriftDefaultsMixin { + const StackEntity(); + + TextColumn get id => text()(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + TextColumn get ownerId => + text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get primaryAssetId => text().references(RemoteAssetEntity, #id)(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/stack.entity.drift.dart b/mobile/lib/infrastructure/entities/stack.entity.drift.dart new file mode 100644 index 000000000..c0d000e02 Binary files /dev/null and b/mobile/lib/infrastructure/entities/stack.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index e71598a99..a7920cf7b 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:isar/isar.dart'; @@ -50,6 +51,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { RemoteAlbumUserEntity, MemoryEntity, MemoryAssetEntity, + StackEntity, ], include: { 'package:immich_mobile/infrastructure/entities/merged_asset.drift', diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 925591def..15d445d22 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/stack.repository.dart b/mobile/lib/infrastructure/repositories/stack.repository.dart new file mode 100644 index 000000000..7f97f3d9a --- /dev/null +++ b/mobile/lib/infrastructure/repositories/stack.repository.dart @@ -0,0 +1,30 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; +import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftStackRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftStackRepository(this._db) : super(_db); + + Future> getAll(String userId) { + final query = _db.stackEntity.select() + ..where((e) => e.ownerId.equals(userId)); + + return query.map((stack) { + return stack.toDto(); + }).get(); + } +} + +extension on StackEntityData { + Stack toDto() { + return Stack( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + ownerId: ownerId, + primaryAssetId: primaryAssetId, + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 99199a7fc..d43f786a2 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -54,6 +54,8 @@ class SyncApiRepository { SyncRequestType.albumToAssetsV1, SyncRequestType.memoriesV1, SyncRequestType.memoryToAssetsV1, + SyncRequestType.stacksV1, + SyncRequestType.partnerStacksV1, ], ).toJson(), ); @@ -163,6 +165,11 @@ const _kResponseMap = { SyncEntityType.memoryDeleteV1: SyncMemoryDeleteV1.fromJson, SyncEntityType.memoryToAssetV1: SyncMemoryAssetV1.fromJson, SyncEntityType.memoryToAssetDeleteV1: SyncMemoryAssetDeleteV1.fromJson, + SyncEntityType.stackV1: SyncStackV1.fromJson, + SyncEntityType.stackDeleteV1: SyncStackDeleteV1.fromJson, + SyncEntityType.partnerStackV1: SyncStackV1.fromJson, + SyncEntityType.partnerStackBackfillV1: SyncStackV1.fromJson, + SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson, }; class _SyncAckV1 { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index b88083aa0..89f5c2f59 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity. import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory.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/repositories/db.repository.dart'; import 'package:logging/logging.dart'; @@ -69,8 +70,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: SyncPartnerDeleteV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: SyncPartnerDeleteV1', error, stack); rethrow; } } @@ -92,8 +93,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: SyncPartnerV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: SyncPartnerV1', error, stack); rethrow; } } @@ -104,10 +105,10 @@ class SyncStreamRepository extends DriftDatabaseRepository { }) async { try { await _db.remoteAssetEntity.deleteWhere( - (row) => row.id.isIn(data.map((error) => error.assetId)), + (row) => row.id.isIn(data.map((e) => e.assetId)), ); - } catch (error, stackTrace) { - _logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack); rethrow; } } @@ -142,8 +143,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: updateAssetsV1 - $debugLabel', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: updateAssetsV1 - $debugLabel', error, stack); rethrow; } } @@ -186,11 +187,11 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { + } catch (error, stack) { _logger.severe( 'Error: updateAssetsExifV1 - $debugLabel', error, - stackTrace, + stack, ); rethrow; } @@ -201,8 +202,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.remoteAlbumEntity.deleteWhere( (row) => row.id.isIn(data.map((e) => e.albumId)), ); - } catch (error, stackTrace) { - _logger.severe('Error: deleteAlbumsV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: deleteAlbumsV1', error, stack); rethrow; } } @@ -229,8 +230,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: updateAlbumsV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: updateAlbumsV1', error, stack); rethrow; } } @@ -248,8 +249,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: deleteAlbumUsersV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: deleteAlbumUsersV1', error, stack); rethrow; } } @@ -275,11 +276,11 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { + } catch (error, stack) { _logger.severe( 'Error: updateAlbumUsersV1 - $debugLabel', error, - stackTrace, + stack, ); rethrow; } @@ -300,8 +301,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: deleteAlbumToAssetsV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: deleteAlbumToAssetsV1', error, stack); rethrow; } } @@ -325,11 +326,11 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { + } catch (error, stack) { _logger.severe( 'Error: updateAlbumToAssetsV1 - $debugLabel', error, - stackTrace, + stack, ); rethrow; } @@ -359,8 +360,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: updateMemoriesV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: updateMemoriesV1', error, stack); rethrow; } } @@ -370,8 +371,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.memoryEntity.deleteWhere( (row) => row.id.isIn(data.map((e) => e.memoryId)), ); - } catch (error, stackTrace) { - _logger.severe('Error: deleteMemoriesV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: deleteMemoriesV1', error, stack); rethrow; } } @@ -392,8 +393,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: updateMemoryAssetsV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: updateMemoryAssetsV1', error, stack); rethrow; } } @@ -413,8 +414,49 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (error, stackTrace) { - _logger.severe('Error: deleteMemoryAssetsV1', error, stackTrace); + } catch (error, stack) { + _logger.severe('Error: deleteMemoryAssetsV1', error, stack); + rethrow; + } + } + + Future updateStacksV1( + Iterable data, { + String debugLabel = 'user', + }) async { + try { + await _db.batch((batch) { + for (final stack in data) { + final companion = StackEntityCompanion( + createdAt: Value(stack.createdAt), + updatedAt: Value(stack.updatedAt), + ownerId: Value(stack.ownerId), + primaryAssetId: Value(stack.primaryAssetId), + ); + + batch.insert( + _db.stackEntity, + companion.copyWith(id: Value(stack.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateStacksV1 - $debugLabel', error, stack); + rethrow; + } + } + + Future deleteStacksV1( + Iterable data, { + String debugLabel = 'user', + }) async { + try { + await _db.stackEntity.deleteWhere( + (row) => row.id.isIn(data.map((e) => e.stackId)), + ); + } catch (error, stack) { + _logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack); rethrow; } } @@ -467,7 +509,7 @@ extension on String { Duration? toDuration() { try { final parts = split(':') - .map((error) => double.parse(error).toInt()) + .map((e) => double.parse(e).toInt()) .toList(growable: false); return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]); diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index f10c042e1..e487d644e 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -66,6 +66,9 @@ final _features = [ await db.remoteAlbumEntity.deleteAll(); await db.remoteAlbumUserEntity.deleteAll(); await db.remoteAlbumAssetEntity.deleteAll(); + await db.memoryEntity.deleteAll(); + await db.memoryAssetEntity.deleteAll(); + await db.stackEntity.deleteAll(); }, ), _Feature( diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index f0a648fd5..e5745fa62 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -162,6 +162,10 @@ final _remoteStats = [ name: 'Memories Assets', load: (db) => db.managers.memoryAssetEntity.count(), ), + _Stat( + name: 'Stacks', + load: (db) => db.managers.stackEntity.count(), + ), ]; @RoutePage() diff --git a/mobile/lib/providers/stack.provider.dart b/mobile/lib/providers/stack.provider.dart new file mode 100644 index 000000000..71abd1e87 --- /dev/null +++ b/mobile/lib/providers/stack.provider.dart @@ -0,0 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final driftStackProvider = Provider( + (ref) => DriftStackRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index 3ad8e3458..4ee4d8c13 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -40,6 +40,9 @@ class AuthRepository extends DatabaseRepository { _drift.remoteAlbumEntity.deleteAll(), _drift.remoteAlbumAssetEntity.deleteAll(), _drift.remoteAlbumUserEntity.deleteAll(), + _drift.memoryEntity.deleteAll(), + _drift.memoryAssetEntity.deleteAll(), + _drift.stackEntity.deleteAll(), ]); }); } diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 28288422f..27cd8c5b2 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -89,6 +89,18 @@ void main() { .thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteMemoryAssetsV1(any())) .thenAnswer(successHandler); + when( + () => mockSyncStreamRepo.updateStacksV1( + any(), + debugLabel: any(named: 'debugLabel'), + ), + ).thenAnswer(successHandler); + when( + () => mockSyncStreamRepo.deleteStacksV1( + any(), + debugLabel: any(named: 'debugLabel'), + ), + ).thenAnswer(successHandler); sut = SyncStreamService( syncApiRepository: mockSyncApiRepo,