From e0fa3cdbc75817226bebe6eb58dd9a069e112d39 Mon Sep 17 00:00:00 2001
From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com>
Date: Tue, 24 Sep 2024 08:24:48 +0200
Subject: [PATCH] refactor(mobile): more repositories (#12879)

 * ExifInfoRepository
 * ActivityApiRepository
 * initial AssetApiRepository
---
 mobile/analysis_options.yaml                  |  9 +--
 .../interfaces/activity_api.interface.dart    | 16 ++++
 mobile/lib/interfaces/asset.interface.dart    | 12 +++
 .../lib/interfaces/asset_api.interface.dart   | 16 ++++
 .../lib/interfaces/exif_info.interface.dart   |  9 +++
 .../lib/models/activities/activity.model.dart | 17 ++--
 .../providers/activity_service.provider.dart  |  4 +-
 .../activity_statistics.provider.dart         |  2 +-
 .../repositories/activity_api.repository.dart | 67 +++++++++++++++
 .../repositories/album_api.repository.dart    | 25 +++---
 mobile/lib/repositories/asset.repository.dart | 80 ++++++++++++++++++
 .../repositories/asset_api.repository.dart    | 25 ++++++
 .../lib/repositories/base_api.repository.dart | 11 +++
 .../repositories/exif_info.repository.dart    | 28 +++++++
 mobile/lib/services/activity.service.dart     | 48 ++++-------
 mobile/lib/services/asset.service.dart        | 52 ++++++++++++
 .../services/asset_description.service.dart   | 66 ---------------
 .../services/backup_verification.service.dart | 81 +++++++------------
 .../asset_viewer/description_input.dart       | 10 ++-
 .../activity_statistics_provider_test.dart    |  7 +-
 20 files changed, 392 insertions(+), 193 deletions(-)
 create mode 100644 mobile/lib/interfaces/activity_api.interface.dart
 create mode 100644 mobile/lib/interfaces/asset_api.interface.dart
 create mode 100644 mobile/lib/interfaces/exif_info.interface.dart
 create mode 100644 mobile/lib/repositories/activity_api.repository.dart
 create mode 100644 mobile/lib/repositories/asset_api.repository.dart
 create mode 100644 mobile/lib/repositories/base_api.repository.dart
 create mode 100644 mobile/lib/repositories/exif_info.repository.dart
 delete mode 100644 mobile/lib/services/asset_description.service.dart

diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index 8f9d41d736..e996a54372 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -64,7 +64,7 @@ custom_lint:
       allowed:
         # required / wanted
         - lib/entities/*.entity.dart
-        - lib/repositories/{album,asset,backup,user}.repository.dart
+        - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart
         # acceptable exceptions for the time being
         - integration_test/test_utils/general_helper.dart
         - lib/main.dart
@@ -75,7 +75,7 @@ custom_lint:
         - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart
         - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart
         - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart
-        - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart
+        - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart
         - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart
 
     - import_rule_openapi:
@@ -83,13 +83,12 @@ custom_lint:
       restrict: package:openapi
       allowed:
         # requried / wanted
-        - lib/repositories/album_api.repository.dart
+        - lib/repositories/*_api.repository.dart
         # acceptable exceptions for the time being
         - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities
         - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
         - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
         # refactor
-        - lib/models/activities/activity.model.dart
         - lib/models/map/map_marker.model.dart
         - lib/models/search/search_filter.model.dart
         - lib/models/server_info/server_{config,disk_info,features,version}.model.dart
@@ -102,7 +101,7 @@ custom_lint:
         - lib/providers/search/{people,search,search_filter}.provider.dart
         - lib/providers/websocket.provider.dart
         - lib/routing/auth_guard.dart
-        - lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart
+        - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart
         - lib/widgets/album/album_thumbnail_listtile.dart
         - lib/widgets/forms/login/login_form.dart
         - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart
diff --git a/mobile/lib/interfaces/activity_api.interface.dart b/mobile/lib/interfaces/activity_api.interface.dart
new file mode 100644
index 0000000000..99aef6f4d4
--- /dev/null
+++ b/mobile/lib/interfaces/activity_api.interface.dart
@@ -0,0 +1,16 @@
+import 'package:immich_mobile/models/activities/activity.model.dart';
+
+abstract interface class IActivityApiRepository {
+  Future<List<Activity>> getAll(
+    String albumId, {
+    String? assetId,
+  });
+  Future<Activity> create(
+    String albumId,
+    ActivityType type, {
+    String? assetId,
+    String? comment,
+  });
+  Future<void> delete(String id);
+  Future<ActivityStats> getStats(String albumId, {String? assetId});
+}
diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart
index 2574e52112..98f4c7687c 100644
--- a/mobile/lib/interfaces/asset.interface.dart
+++ b/mobile/lib/interfaces/asset.interface.dart
@@ -7,4 +7,16 @@ abstract interface class IAssetRepository {
   Future<List<Asset>> getAllByRemoteId(Iterable<String> ids);
   Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy});
   Future<void> deleteById(List<int> ids);
+  Future<List<Asset>> getAll({
+    required int ownerId,
+    bool? remote,
+    int limit = 100,
+  });
+
+  Future<List<Asset>> getMatches({
+    required List<Asset> assets,
+    required int ownerId,
+    bool? remote,
+    int limit = 100,
+  });
 }
diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart
new file mode 100644
index 0000000000..201c85cea7
--- /dev/null
+++ b/mobile/lib/interfaces/asset_api.interface.dart
@@ -0,0 +1,16 @@
+import 'package:immich_mobile/entities/asset.entity.dart';
+
+abstract interface class IAssetApiRepository {
+  // Future<Asset> get(String id);
+
+  // Future<List<Asset>> getAll();
+
+  // Future<Asset> create(Asset asset);
+
+  Future<Asset> update(
+    String id, {
+    String? description,
+  });
+
+  // Future<void> delete(String id);
+}
diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart
new file mode 100644
index 0000000000..fa8ca08f9d
--- /dev/null
+++ b/mobile/lib/interfaces/exif_info.interface.dart
@@ -0,0 +1,9 @@
+import 'package:immich_mobile/entities/exif_info.entity.dart';
+
+abstract interface class IExifInfoRepository {
+  Future<ExifInfo?> get(int id);
+
+  Future<ExifInfo> update(ExifInfo exifInfo);
+
+  Future<void> delete(int id);
+}
diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart
index 6adb80dca9..4702753f41 100644
--- a/mobile/lib/models/activities/activity.model.dart
+++ b/mobile/lib/models/activities/activity.model.dart
@@ -1,5 +1,4 @@
 import 'package:immich_mobile/entities/user.entity.dart';
-import 'package:openapi/api.dart';
 
 enum ActivityType { comment, like }
 
@@ -38,16 +37,6 @@ class Activity {
     );
   }
 
-  Activity.fromDto(ActivityResponseDto dto)
-      : id = dto.id,
-        assetId = dto.assetId,
-        comment = dto.comment,
-        createdAt = dto.createdAt,
-        type = dto.type == ReactionType.comment
-            ? ActivityType.comment
-            : ActivityType.like,
-        user = User.fromSimpleUserDto(dto.user);
-
   @override
   String toString() {
     return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
@@ -75,3 +64,9 @@ class Activity {
         user.hashCode;
   }
 }
+
+class ActivityStats {
+  final int comments;
+
+  const ActivityStats({required this.comments});
+}
diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart
index dcfaac883f..6bd139c565 100644
--- a/mobile/lib/providers/activity_service.provider.dart
+++ b/mobile/lib/providers/activity_service.provider.dart
@@ -1,9 +1,9 @@
+import 'package:immich_mobile/repositories/activity_api.repository.dart';
 import 'package:immich_mobile/services/activity.service.dart';
-import 'package:immich_mobile/providers/api.provider.dart';
 import 'package:riverpod_annotation/riverpod_annotation.dart';
 
 part 'activity_service.provider.g.dart';
 
 @riverpod
 ActivityService activityService(ActivityServiceRef ref) =>
-    ActivityService(ref.watch(apiServiceProvider));
+    ActivityService(ref.watch(activityApiRepositoryProvider));
diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart
index afb43e8cba..b1d2b4b987 100644
--- a/mobile/lib/providers/activity_statistics.provider.dart
+++ b/mobile/lib/providers/activity_statistics.provider.dart
@@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics {
     ref
         .watch(activityServiceProvider)
         .getStatistics(albumId, assetId: assetId)
-        .then((comments) => state = comments);
+        .then((stats) => state = stats.comments);
     return 0;
   }
 
diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart
new file mode 100644
index 0000000000..0b1b4d99f3
--- /dev/null
+++ b/mobile/lib/repositories/activity_api.repository.dart
@@ -0,0 +1,67 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/entities/user.entity.dart';
+import 'package:immich_mobile/interfaces/activity_api.interface.dart';
+import 'package:immich_mobile/models/activities/activity.model.dart';
+import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/repositories/base_api.repository.dart';
+import 'package:openapi/api.dart';
+
+final activityApiRepositoryProvider = Provider(
+  (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi),
+);
+
+class ActivityApiRepository extends BaseApiRepository
+    implements IActivityApiRepository {
+  final ActivitiesApi _api;
+
+  ActivityApiRepository(this._api);
+
+  @override
+  Future<List<Activity>> getAll(String albumId, {String? assetId}) async {
+    final response =
+        await checkNull(_api.getActivities(albumId, assetId: assetId));
+    return response.map(_toActivity).toList();
+  }
+
+  @override
+  Future<Activity> create(
+    String albumId,
+    ActivityType type, {
+    String? assetId,
+    String? comment,
+  }) async {
+    final dto = ActivityCreateDto(
+      albumId: albumId,
+      type: type == ActivityType.comment
+          ? ReactionType.comment
+          : ReactionType.like,
+      assetId: assetId,
+      comment: comment,
+    );
+    final response = await checkNull(_api.createActivity(dto));
+    return _toActivity(response);
+  }
+
+  @override
+  Future<void> delete(String id) {
+    return checkNull(_api.deleteActivity(id));
+  }
+
+  @override
+  Future<ActivityStats> getStats(String albumId, {String? assetId}) async {
+    final response =
+        await checkNull(_api.getActivityStatistics(albumId, assetId: assetId));
+    return ActivityStats(comments: response.comments);
+  }
+
+  static Activity _toActivity(ActivityResponseDto dto) => Activity(
+        id: dto.id,
+        createdAt: dto.createdAt,
+        type: dto.type == ReactionType.comment
+            ? ActivityType.comment
+            : ActivityType.like,
+        user: User.fromSimpleUserDto(dto.user),
+        assetId: dto.assetId,
+        comment: dto.comment,
+      );
+}
diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart
index 6b7865f8e4..0e27e44684 100644
--- a/mobile/lib/repositories/album_api.repository.dart
+++ b/mobile/lib/repositories/album_api.repository.dart
@@ -1,30 +1,31 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/errors.dart';
 import 'package:immich_mobile/entities/album.entity.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
 import 'package:immich_mobile/entities/user.entity.dart';
 import 'package:immich_mobile/interfaces/album_api.interface.dart';
 import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/repositories/base_api.repository.dart';
 import 'package:openapi/api.dart';
 
 final albumApiRepositoryProvider = Provider(
   (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
 );
 
-class AlbumApiRepository implements IAlbumApiRepository {
+class AlbumApiRepository extends BaseApiRepository
+    implements IAlbumApiRepository {
   final AlbumsApi _api;
 
   AlbumApiRepository(this._api);
 
   @override
   Future<Album> get(String id) async {
-    final dto = await _checkNull(_api.getAlbumInfo(id));
+    final dto = await checkNull(_api.getAlbumInfo(id));
     return _toAlbum(dto);
   }
 
   @override
   Future<List<Album>> getAll({bool? shared}) async {
-    final dtos = await _checkNull(_api.getAllAlbums(shared: shared));
+    final dtos = await checkNull(_api.getAllAlbums(shared: shared));
     return dtos.map(_toAlbum).toList().cast();
   }
 
@@ -37,7 +38,7 @@ class AlbumApiRepository implements IAlbumApiRepository {
     final users = sharedUserIds.map(
       (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor),
     );
-    final responseDto = await _checkNull(
+    final responseDto = await checkNull(
       _api.createAlbum(
         CreateAlbumDto(
           albumName: name,
@@ -57,7 +58,7 @@ class AlbumApiRepository implements IAlbumApiRepository {
     String? description,
     bool? activityEnabled,
   }) async {
-    final response = await _checkNull(
+    final response = await checkNull(
       _api.updateAlbumInfo(
         albumId,
         UpdateAlbumDto(
@@ -81,7 +82,7 @@ class AlbumApiRepository implements IAlbumApiRepository {
     String albumId,
     Iterable<String> assetIds,
   ) async {
-    final response = await _checkNull(
+    final response = await checkNull(
       _api.addAssetsToAlbum(
         albumId,
         BulkIdsDto(ids: assetIds.toList()),
@@ -106,7 +107,7 @@ class AlbumApiRepository implements IAlbumApiRepository {
     String albumId,
     Iterable<String> assetIds,
   ) async {
-    final response = await _checkNull(
+    final response = await checkNull(
       _api.removeAssetFromAlbum(
         albumId,
         BulkIdsDto(ids: assetIds.toList()),
@@ -127,7 +128,7 @@ class AlbumApiRepository implements IAlbumApiRepository {
   Future<Album> addUsers(String albumId, Iterable<String> userIds) async {
     final albumUsers =
         userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
-    final response = await _checkNull(
+    final response = await checkNull(
       _api.addUsersToAlbum(
         albumId,
         AddUsersDto(albumUsers: albumUsers),
@@ -141,12 +142,6 @@ class AlbumApiRepository implements IAlbumApiRepository {
     return _api.removeUserFromAlbum(albumId, userId);
   }
 
-  static Future<T> _checkNull<T>(Future<T?> future) async {
-    final response = await future;
-    if (response == null) throw NoResponseDtoError();
-    return response;
-  }
-
   static Album _toAlbum(AlbumResponseDto dto) {
     final Album album = Album(
       remoteId: dto.id,
diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart
index 8ec028f728..c6012af371 100644
--- a/mobile/lib/repositories/asset.repository.dart
+++ b/mobile/lib/repositories/asset.repository.dart
@@ -35,4 +35,84 @@ class AssetRepository implements IAssetRepository {
   @override
   Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
       _db.assets.getAllByRemoteId(ids);
+
+  @override
+  Future<List<Asset>> getAll({
+    required int ownerId,
+    bool? remote,
+    int limit = 100,
+  }) {
+    if (remote == null) {
+      return _db.assets
+          .where()
+          .ownerIdEqualToAnyChecksum(ownerId)
+          .limit(limit)
+          .findAll();
+    }
+    final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
+    if (remote) {
+      query = _db.assets
+          .where()
+          .localIdIsNull()
+          .filter()
+          .remoteIdIsNotNull()
+          .ownerIdEqualTo(ownerId);
+    } else {
+      query = _db.assets
+          .where()
+          .remoteIdIsNull()
+          .filter()
+          .localIdIsNotNull()
+          .ownerIdEqualTo(ownerId);
+    }
+
+    return query.limit(limit).findAll();
+  }
+
+  @override
+  Future<List<Asset>> getMatches({
+    required List<Asset> assets,
+    required int ownerId,
+    bool? remote,
+    int limit = 100,
+  }) {
+    final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
+    if (remote == null) {
+      query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull();
+    } else if (remote) {
+      query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull();
+    } else {
+      query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull();
+    }
+    return _getMatchesImpl(query, ownerId, assets, limit);
+  }
 }
+
+Future<List<Asset>> _getMatchesImpl(
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
+  int ownerId,
+  List<Asset> assets,
+  int limit,
+) =>
+    query
+        .ownerIdEqualTo(ownerId)
+        .anyOf(
+          assets,
+          (q, Asset a) => q
+              .fileNameEqualTo(a.fileName)
+              .and()
+              .durationInSecondsEqualTo(a.durationInSeconds)
+              .and()
+              .fileCreatedAtBetween(
+                a.fileCreatedAt.subtract(const Duration(hours: 12)),
+                a.fileCreatedAt.add(const Duration(hours: 12)),
+              )
+              .and()
+              .not()
+              .checksumEqualTo(a.checksum),
+        )
+        .sortByFileName()
+        .thenByFileCreatedAt()
+        .thenByFileModifiedAt()
+        .limit(limit)
+        .findAll();
diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart
new file mode 100644
index 0000000000..3ad0e1cba0
--- /dev/null
+++ b/mobile/lib/repositories/asset_api.repository.dart
@@ -0,0 +1,25 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:immich_mobile/interfaces/asset_api.interface.dart';
+import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/repositories/base_api.repository.dart';
+import 'package:openapi/api.dart';
+
+final assetApiRepositoryProvider = Provider(
+  (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi),
+);
+
+class AssetApiRepository extends BaseApiRepository
+    implements IAssetApiRepository {
+  final AssetsApi _api;
+
+  AssetApiRepository(this._api);
+
+  @override
+  Future<Asset> update(String id, {String? description}) async {
+    final response = await checkNull(
+      _api.updateAsset(id, UpdateAssetDto(description: description)),
+    );
+    return Asset.remote(response);
+  }
+}
diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/base_api.repository.dart
new file mode 100644
index 0000000000..418cba84f8
--- /dev/null
+++ b/mobile/lib/repositories/base_api.repository.dart
@@ -0,0 +1,11 @@
+import 'package:flutter/foundation.dart';
+import 'package:immich_mobile/constants/errors.dart';
+
+abstract class BaseApiRepository {
+  @protected
+  Future<T> checkNull<T>(Future<T?> future) async {
+    final response = await future;
+    if (response == null) throw NoResponseDtoError();
+    return response;
+  }
+}
diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart
new file mode 100644
index 0000000000..a165e98bdb
--- /dev/null
+++ b/mobile/lib/repositories/exif_info.repository.dart
@@ -0,0 +1,28 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/entities/exif_info.entity.dart';
+import 'package:immich_mobile/interfaces/exif_info.interface.dart';
+import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:isar/isar.dart';
+
+final exifInfoRepositoryProvider =
+    Provider((ref) => ExifInfoRepository(ref.watch(dbProvider)));
+
+class ExifInfoRepository implements IExifInfoRepository {
+  final Isar _db;
+
+  ExifInfoRepository(
+    this._db,
+  );
+
+  @override
+  Future<void> delete(int id) => _db.exifInfos.delete(id);
+
+  @override
+  Future<ExifInfo?> get(int id) => _db.exifInfos.get(id);
+
+  @override
+  Future<ExifInfo> update(ExifInfo exifInfo) async {
+    await _db.writeTxn(() => _db.exifInfos.put(exifInfo));
+    return exifInfo;
+  }
+}
diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart
index 58af26e204..5496041416 100644
--- a/mobile/lib/services/activity.service.dart
+++ b/mobile/lib/services/activity.service.dart
@@ -1,41 +1,31 @@
-import 'package:immich_mobile/constants/errors.dart';
+import 'package:immich_mobile/interfaces/activity_api.interface.dart';
 import 'package:immich_mobile/mixins/error_logger.mixin.dart';
 import 'package:immich_mobile/models/activities/activity.model.dart';
-import 'package:immich_mobile/services/api.service.dart';
 import 'package:logging/logging.dart';
-import 'package:openapi/api.dart';
 
 class ActivityService with ErrorLoggerMixin {
-  final ApiService _apiService;
+  final IActivityApiRepository _activityApiRepository;
 
   @override
   final Logger logger = Logger("ActivityService");
 
-  ActivityService(this._apiService);
+  ActivityService(this._activityApiRepository);
 
   Future<List<Activity>> getAllActivities(
     String albumId, {
     String? assetId,
   }) async {
     return logError(
-      () async {
-        final list = await _apiService.activitiesApi
-            .getActivities(albumId, assetId: assetId);
-        return list != null ? list.map(Activity.fromDto).toList() : [];
-      },
+      () => _activityApiRepository.getAll(albumId, assetId: assetId),
       defaultValue: [],
       errorMessage: "Failed to get all activities for album $albumId",
     );
   }
 
-  Future<int> getStatistics(String albumId, {String? assetId}) async {
+  Future<ActivityStats> getStatistics(String albumId, {String? assetId}) async {
     return logError(
-      () async {
-        final dto = await _apiService.activitiesApi
-            .getActivityStatistics(albumId, assetId: assetId);
-        return dto?.comments ?? 0;
-      },
-      defaultValue: 0,
+      () => _activityApiRepository.getStats(albumId, assetId: assetId),
+      defaultValue: const ActivityStats(comments: 0),
       errorMessage: "Failed to statistics for album $albumId",
     );
   }
@@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin {
   Future<bool> removeActivity(String id) async {
     return logError(
       () async {
-        await _apiService.activitiesApi.deleteActivity(id);
+        await _activityApiRepository.delete(id);
         return true;
       },
       defaultValue: false,
@@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin {
     String? comment,
   }) async {
     return guardError(
-      () async {
-        final dto = await _apiService.activitiesApi.createActivity(
-          ActivityCreateDto(
-            albumId: albumId,
-            type: type == ActivityType.comment
-                ? ReactionType.comment
-                : ReactionType.like,
-            assetId: assetId,
-            comment: comment,
-          ),
-        );
-        if (dto != null) {
-          return Activity.fromDto(dto);
-        }
-        throw NoResponseDtoError();
-      },
+      () => _activityApiRepository.create(
+        albumId,
+        type,
+        assetId: assetId,
+        comment: comment,
+      ),
       errorMessage: "Failed to create $type for album $albumId",
     );
   }
diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart
index 90c46ae90a..262040026e 100644
--- a/mobile/lib/services/asset.service.dart
+++ b/mobile/lib/services/asset.service.dart
@@ -9,9 +9,13 @@ import 'package:immich_mobile/entities/asset.entity.dart';
 import 'package:immich_mobile/entities/etag.entity.dart';
 import 'package:immich_mobile/entities/exif_info.entity.dart';
 import 'package:immich_mobile/entities/user.entity.dart';
+import 'package:immich_mobile/interfaces/asset_api.interface.dart';
+import 'package:immich_mobile/interfaces/exif_info.interface.dart';
 import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
 import 'package:immich_mobile/providers/api.provider.dart';
 import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/repositories/asset_api.repository.dart';
+import 'package:immich_mobile/repositories/exif_info.repository.dart';
 import 'package:immich_mobile/services/album.service.dart';
 import 'package:immich_mobile/services/api.service.dart';
 import 'package:immich_mobile/services/backup.service.dart';
@@ -24,6 +28,8 @@ import 'package:openapi/api.dart';
 
 final assetServiceProvider = Provider(
   (ref) => AssetService(
+    ref.watch(assetApiRepositoryProvider),
+    ref.watch(exifInfoRepositoryProvider),
     ref.watch(apiServiceProvider),
     ref.watch(syncServiceProvider),
     ref.watch(userServiceProvider),
@@ -34,6 +40,8 @@ final assetServiceProvider = Provider(
 );
 
 class AssetService {
+  final IAssetApiRepository _assetApiRepository;
+  final IExifInfoRepository _exifInfoRepository;
   final ApiService _apiService;
   final SyncService _syncService;
   final UserService _userService;
@@ -43,6 +51,8 @@ class AssetService {
   final Isar _db;
 
   AssetService(
+    this._assetApiRepository,
+    this._exifInfoRepository,
     this._apiService,
     this._syncService,
     this._userService,
@@ -342,4 +352,46 @@ class AssetService {
       log.severe("Error while syncing uploaded asset to albums", error, stack);
     }
   }
+
+  Future<void> setDescription(
+    Asset asset,
+    String newDescription,
+  ) async {
+    final remoteAssetId = asset.remoteId;
+    final localExifId = asset.exifInfo?.id;
+
+    // Guard [remoteAssetId] and [localExifId] null
+    if (remoteAssetId == null || localExifId == null) {
+      return;
+    }
+
+    final result = await _assetApiRepository.update(
+      remoteAssetId,
+      description: newDescription,
+    );
+
+    final description = result.exifInfo?.description;
+
+    if (description != null) {
+      var exifInfo = await _exifInfoRepository.get(localExifId);
+
+      if (exifInfo != null) {
+        exifInfo.description = description;
+        await _exifInfoRepository.update(exifInfo);
+      }
+    }
+  }
+
+  Future<String> getDescription(Asset asset) async {
+    final localExifId = asset.exifInfo?.id;
+
+    // Guard [remoteAssetId] and [localExifId] null
+    if (localExifId == null) {
+      return "";
+    }
+
+    final exifInfo = await _exifInfoRepository.get(localExifId);
+
+    return exifInfo?.description ?? "";
+  }
 }
diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart
deleted file mode 100644
index 196e29dc6a..0000000000
--- a/mobile/lib/services/asset_description.service.dart
+++ /dev/null
@@ -1,66 +0,0 @@
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/entities/asset.entity.dart';
-import 'package:immich_mobile/entities/exif_info.entity.dart';
-import 'package:immich_mobile/providers/api.provider.dart';
-import 'package:immich_mobile/providers/db.provider.dart';
-import 'package:immich_mobile/services/api.service.dart';
-import 'package:isar/isar.dart';
-import 'package:openapi/api.dart';
-
-class AssetDescriptionService {
-  AssetDescriptionService(this._db, this._api);
-
-  final Isar _db;
-  final ApiService _api;
-
-  Future<void> setDescription(
-    Asset asset,
-    String newDescription,
-  ) async {
-    final remoteAssetId = asset.remoteId;
-    final localExifId = asset.exifInfo?.id;
-
-    // Guard [remoteAssetId] and [localExifId] null
-    if (remoteAssetId == null || localExifId == null) {
-      return;
-    }
-
-    final result = await _api.assetsApi.updateAsset(
-      remoteAssetId,
-      UpdateAssetDto(description: newDescription),
-    );
-
-    final description = result?.exifInfo?.description;
-
-    if (description != null) {
-      var exifInfo = await _db.exifInfos.get(localExifId);
-
-      if (exifInfo != null) {
-        exifInfo.description = description;
-        await _db.writeTxn(
-          () => _db.exifInfos.put(exifInfo),
-        );
-      }
-    }
-  }
-
-  String getAssetDescription(Asset asset) {
-    final localExifId = asset.exifInfo?.id;
-
-    // Guard [remoteAssetId] and [localExifId] null
-    if (localExifId == null) {
-      return "";
-    }
-
-    final exifInfo = _db.exifInfos.getSync(localExifId);
-
-    return exifInfo?.description ?? "";
-  }
-}
-
-final assetDescriptionServiceProvider = Provider(
-  (ref) => AssetDescriptionService(
-    ref.watch(dbProvider),
-    ref.watch(apiServiceProvider),
-  ),
-);
diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart
index 66a61d2914..da9d8da164 100644
--- a/mobile/lib/services/backup_verification.service.dart
+++ b/mobile/lib/services/backup_verification.service.dart
@@ -8,41 +8,46 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/entities/asset.entity.dart';
 import 'package:immich_mobile/entities/exif_info.entity.dart';
 import 'package:immich_mobile/entities/store.entity.dart';
+import 'package:immich_mobile/interfaces/asset.interface.dart';
+import 'package:immich_mobile/interfaces/exif_info.interface.dart';
 import 'package:immich_mobile/interfaces/file_media.interface.dart';
-import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/repositories/asset.repository.dart';
+import 'package:immich_mobile/repositories/exif_info.repository.dart';
 import 'package:immich_mobile/repositories/file_media.repository.dart';
 import 'package:immich_mobile/services/api.service.dart';
 import 'package:immich_mobile/utils/diff.dart';
-import 'package:isar/isar.dart';
 
 /// Finds duplicates originating from missing EXIF information
 class BackupVerificationService {
-  final Isar _db;
   final IFileMediaRepository _fileMediaRepository;
+  final IAssetRepository _assetRepository;
+  final IExifInfoRepository _exifInfoRepository;
 
-  BackupVerificationService(this._db, this._fileMediaRepository);
+  BackupVerificationService(
+    this._fileMediaRepository,
+    this._assetRepository,
+    this._exifInfoRepository,
+  );
 
   /// Returns at most [limit] assets that were backed up without exif
   Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
     final owner = Store.get(StoreKey.currentUser).isarId;
-    final List<Asset> onlyLocal = await _db.assets
-        .where()
-        .remoteIdIsNull()
-        .filter()
-        .ownerIdEqualTo(owner)
-        .localIdIsNotNull()
-        .findAll();
-    final List<Asset> remoteMatches = await _getMatches(
-      _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
-      owner,
-      onlyLocal,
-      limit,
+    final List<Asset> onlyLocal = await _assetRepository.getAll(
+      ownerId: owner,
+      remote: false,
+      limit: limit,
     );
-    final List<Asset> localMatches = await _getMatches(
-      _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
-      owner,
-      remoteMatches,
-      limit,
+    final List<Asset> remoteMatches = await _assetRepository.getMatches(
+      assets: onlyLocal,
+      ownerId: owner,
+      remote: true,
+      limit: limit,
+    );
+    final List<Asset> localMatches = await _assetRepository.getMatches(
+      assets: remoteMatches,
+      ownerId: owner,
+      remote: false,
+      limit: limit,
     );
 
     final List<Asset> deleteCandidates = [], originals = [];
@@ -52,7 +57,7 @@ class BackupVerificationService {
       localMatches,
       compare: (a, b) => a.fileName.compareTo(b.fileName),
       both: (a, b) async {
-        a.exifInfo = await _db.exifInfos.get(a.id);
+        a.exifInfo = await _exifInfoRepository.get(a.id);
         deleteCandidates.add(a);
         originals.add(b);
         return false;
@@ -192,35 +197,6 @@ class BackupVerificationService {
     return bytes.buffer.asUint64List(start);
   }
 
-  static Future<List<Asset>> _getMatches(
-    QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
-    int ownerId,
-    List<Asset> assets,
-    int limit,
-  ) =>
-      query
-          .ownerIdEqualTo(ownerId)
-          .anyOf(
-            assets,
-            (q, Asset a) => q
-                .fileNameEqualTo(a.fileName)
-                .and()
-                .durationInSecondsEqualTo(a.durationInSeconds)
-                .and()
-                .fileCreatedAtBetween(
-                  a.fileCreatedAt.subtract(const Duration(hours: 12)),
-                  a.fileCreatedAt.add(const Duration(hours: 12)),
-                )
-                .and()
-                .not()
-                .checksumEqualTo(a.checksum),
-          )
-          .sortByFileName()
-          .thenByFileCreatedAt()
-          .thenByFileModifiedAt()
-          .limit(limit)
-          .findAll();
-
   static bool _sameExceptTimeZone(DateTime a, DateTime b) {
     final ms = a.isAfter(b)
         ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
@@ -233,7 +209,8 @@ class BackupVerificationService {
 
 final backupVerificationServiceProvider = Provider(
   (ref) => BackupVerificationService(
-    ref.watch(dbProvider),
     ref.watch(fileMediaRepositoryProvider),
+    ref.watch(assetRepositoryProvider),
+    ref.watch(exifInfoRepositoryProvider),
   ),
 );
diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart
index 18ef394e2d..3fdd40130a 100644
--- a/mobile/lib/widgets/asset_viewer/description_input.dart
+++ b/mobile/lib/widgets/asset_viewer/description_input.dart
@@ -8,7 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
 import 'package:immich_mobile/extensions/theme_extensions.dart';
 import 'package:immich_mobile/providers/asset.provider.dart';
 import 'package:immich_mobile/providers/user.provider.dart';
-import 'package:immich_mobile/services/asset_description.service.dart';
+import 'package:immich_mobile/services/asset.service.dart';
 import 'package:immich_mobile/widgets/common/immich_toast.dart';
 import 'package:logging/logging.dart';
 
@@ -29,14 +29,16 @@ class DescriptionInput extends HookConsumerWidget {
     final focusNode = useFocusNode();
     final isFocus = useState(false);
     final isTextEmpty = useState(controller.text.isEmpty);
-    final descriptionProvider = ref.watch(assetDescriptionServiceProvider);
+    final assetService = ref.watch(assetServiceProvider);
     final owner = ref.watch(currentUserProvider);
     final hasError = useState(false);
     final assetWithExif = ref.watch(assetDetailProvider(asset));
 
     useEffect(
       () {
-        controller.text = descriptionProvider.getAssetDescription(asset);
+        assetService
+            .getDescription(asset)
+            .then((value) => controller.text = value);
         return null;
       },
       [assetWithExif.value],
@@ -45,7 +47,7 @@ class DescriptionInput extends HookConsumerWidget {
     submitDescription(String description) async {
       hasError.value = false;
       try {
-        await descriptionProvider.setDescription(
+        await assetService.setDescription(
           asset,
           description,
         );
diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart
index 9edabcc0d0..0216528ddd 100644
--- a/mobile/test/modules/activity/activity_statistics_provider_test.dart
+++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart
@@ -1,5 +1,6 @@
 import 'package:flutter_test/flutter_test.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/models/activities/activity.model.dart';
 import 'package:immich_mobile/providers/activity_service.provider.dart';
 import 'package:immich_mobile/providers/activity_statistics.provider.dart';
 import 'package:mocktail/mocktail.dart';
@@ -25,7 +26,7 @@ void main() {
   test('Returns the proper count family', () async {
     when(
       () => activityMock.getStatistics('test-album', assetId: 'test-asset'),
-    ).thenAnswer((_) async => 5);
+    ).thenAnswer((_) async => const ActivityStats(comments: 5));
 
     // Read here to make the getStatistics call
     container.read(activityStatisticsProvider('test-album', 'test-asset'));
@@ -50,7 +51,7 @@ void main() {
   test('Adds activity', () async {
     when(
       () => activityMock.getStatistics('test-album'),
-    ).thenAnswer((_) async => 10);
+    ).thenAnswer((_) async => const ActivityStats(comments: 10));
 
     final provider = activityStatisticsProvider('test-album');
     container.listen(
@@ -71,7 +72,7 @@ void main() {
   test('Removes activity', () async {
     when(
       () => activityMock.getStatistics('new-album', assetId: 'test-asset'),
-    ).thenAnswer((_) async => 10);
+    ).thenAnswer((_) async => const ActivityStats(comments: 10));
 
     final provider = activityStatisticsProvider('new-album', 'test-asset');
     container.listen(