Quellcode durchsuchen

feature(mobile): hash assets & sync via checksum (#2592)

* compare different sha1 implementations

* remove openssl sha1

* sync via checksum

* hash assets in batches

* hash in background, show spinner in tab

* undo tmp changes

* migrate by clearing assets

* ignore duplicate assets

* error handling

* trigger sync/merge after download and update view

* review feedback improvements

* hash in background isolate on iOS

* rework linking assets with existing from DB

* fine-grained errors on unique index violation

* hash lenth validation

* revert compute in background on iOS

* ignore duplicate assets on device

* fix bug with batching based on accumulated size

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Fynn Petersen-Frey vor 2 Jahren
Ursprung
Commit
73075c64d1
28 geänderte Dateien mit 2300 neuen und 492 gelöschten Zeilen
  1. 1 0
      mobile/android/app/build.gradle
  2. 38 1
      mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt
  3. 1 0
      mobile/android/build.gradle
  4. 3 0
      mobile/lib/main.dart
  5. 9 2
      mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart
  6. 1 9
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  7. 1 1
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  8. 11 0
      mobile/lib/modules/backup/background_service/background.service.dart
  9. 7 7
      mobile/lib/modules/home/views/home_page.dart
  10. 4 17
      mobile/lib/shared/models/album.dart
  11. 10 0
      mobile/lib/shared/models/android_device_asset.dart
  12. 493 0
      mobile/lib/shared/models/android_device_asset.g.dart
  13. 59 59
      mobile/lib/shared/models/asset.dart
  14. 430 217
      mobile/lib/shared/models/asset.g.dart
  15. 8 0
      mobile/lib/shared/models/device_asset.dart
  16. 14 0
      mobile/lib/shared/models/ios_device_asset.dart
  17. 780 0
      mobile/lib/shared/models/ios_device_asset.g.dart
  18. 10 21
      mobile/lib/shared/providers/asset.provider.dart
  19. 175 0
      mobile/lib/shared/services/hash.service.dart
  20. 136 107
      mobile/lib/shared/services/sync.service.dart
  21. 39 7
      mobile/lib/shared/views/tab_controller_page.dart
  22. 18 3
      mobile/lib/utils/builtin_extensions.dart
  23. 5 3
      mobile/lib/utils/migration.dart
  24. 5 5
      mobile/pubspec.lock
  25. 1 0
      mobile/pubspec.yaml
  26. 1 2
      mobile/test/asset_grid_data_structure_test.dart
  27. 6 1
      mobile/test/builtin_extensions_test.dart
  28. 34 30
      mobile/test/sync_service_test.dart

+ 1 - 0
mobile/android/app/build.gradle

@@ -84,6 +84,7 @@ flutter {
 
 dependencies {
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
+    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
     implementation "androidx.work:work-runtime-ktx:$work_version"
     implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
     implementation "com.google.guava:guava:$guava_version"

+ 38 - 1
mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt

@@ -1,10 +1,15 @@
 package app.alextran.immich
 
 import android.content.Context
+import android.util.Log
 import io.flutter.embedding.engine.plugins.FlutterPlugin
 import io.flutter.plugin.common.BinaryMessenger
 import io.flutter.plugin.common.MethodCall
 import io.flutter.plugin.common.MethodChannel
+import java.security.MessageDigest
+import java.io.File
+import java.io.FileInputStream
+import kotlinx.coroutines.*
 
 /**
  * Android plugin for Dart `BackgroundService`
@@ -16,6 +21,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
 
     private var methodChannel: MethodChannel? = null
     private var context: Context? = null
+    private val sha1: MessageDigest = MessageDigest.getInstance("SHA-1")
 
     override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
         onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -70,9 +76,40 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
             "isIgnoringBatteryOptimizations" -> {
                 result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
             }
+            "digestFiles" -> {
+                val args = call.arguments<ArrayList<String>>()!!
+                GlobalScope.launch(Dispatchers.IO) {
+                    val buf = ByteArray(BUFSIZE)
+                    val digest: MessageDigest = MessageDigest.getInstance("SHA-1")
+                    val hashes = arrayOfNulls<ByteArray>(args.size)
+                    for (i in args.indices) {
+                        val path = args[i]
+                        var len = 0
+                        try {
+                            val file = FileInputStream(path)
+                            try {
+                                while (true) {
+                                    len = file.read(buf)
+                                    if (len != BUFSIZE) break
+                                    digest.update(buf)
+                                }
+                            } finally {
+                                file.close()
+                            }
+                            digest.update(buf, 0, len)
+                            hashes[i] = digest.digest()
+                        } catch (e: Exception) {
+                            // skip this file
+                            Log.w(TAG, "Failed to hash file ${args[i]}: $e")
+                        }
+                    }
+                    result.success(hashes.asList())
+                }
+            }
             else -> result.notImplemented()
         }
     }
 }
 
-private const val TAG = "BackgroundServicePlugin"
+private const val TAG = "BackgroundServicePlugin"
+private const val BUFSIZE = 2*1024*1024;

+ 1 - 0
mobile/android/build.gradle

@@ -1,5 +1,6 @@
 buildscript {
     ext.kotlin_version = '1.8.20'
+    ext.kotlin_coroutines_version = '1.7.1'
     ext.work_version = '2.7.1'
     ext.concurrent_version = '1.1.0'
     ext.guava_version = '31.0.1-android'

+ 3 - 0
mobile/lib/main.dart

@@ -19,9 +19,11 @@ import 'package:immich_mobile/modules/settings/providers/notification_permission
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/tab_navigation_observer.dart';
 import 'package:immich_mobile/shared/models/album.dart';
+import 'package:immich_mobile/shared/models/android_device_asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/etag.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
+import 'package:immich_mobile/shared/models/ios_device_asset.dart';
 import 'package:immich_mobile/shared/models/logger_message.model.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/user.dart';
@@ -91,6 +93,7 @@ Future<Isar> loadDb() async {
       DuplicatedAssetSchema,
       LoggerMessageSchema,
       ETagSchema,
+      Platform.isAndroid ? AndroidDeviceAssetSchema : IOSDeviceAssetSchema,
     ],
     directory: dir.path,
     maxSizeMiB: 256,

+ 9 - 2
mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
 import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
@@ -12,9 +13,13 @@ import 'package:immich_mobile/shared/ui/share_dialog.dart';
 class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
   final ImageViewerService _imageViewerService;
   final ShareService _shareService;
+  final AlbumService _albumService;
 
-  ImageViewerStateNotifier(this._imageViewerService, this._shareService)
-      : super(
+  ImageViewerStateNotifier(
+    this._imageViewerService,
+    this._shareService,
+    this._albumService,
+  ) : super(
           ImageViewerPageState(
             downloadAssetStatus: DownloadAssetStatus.idle,
           ),
@@ -34,6 +39,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
         toastType: ToastType.success,
         gravity: ToastGravity.BOTTOM,
       );
+      _albumService.refreshDeviceAlbums();
     } else {
       state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
       ImmichToast.show(
@@ -66,5 +72,6 @@ final imageViewerStateProvider =
   ((ref) => ImageViewerStateNotifier(
         ref.watch(imageViewerServiceProvider),
         ref.watch(shareServiceProvider),
+        ref.watch(albumServiceProvider),
       )),
 );

+ 1 - 9
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -72,15 +72,7 @@ class TopControlAppBar extends HookConsumerWidget {
                     color: Colors.grey[200],
                   ),
           ),
-        if (!asset.isLocal)
-          IconButton(
-            onPressed: onDownloadPressed,
-            icon: Icon(
-              Icons.cloud_download_outlined,
-              color: Colors.grey[200],
-            ),
-          ),
-        if (asset.storage == AssetState.merged)
+        if (asset.storage == AssetState.remote)
           IconButton(
             onPressed: onDownloadPressed,
             icon: Icon(

+ 1 - 1
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -287,7 +287,7 @@ class GalleryViewerPage extends HookConsumerWidget {
             isFavorite: asset().isFavorite,
             onMoreInfoPressed: showInfo,
             onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null,
-            onDownloadPressed: asset().storage == AssetState.local
+            onDownloadPressed: asset().isLocal
                 ? null
                 : () =>
                     ref.watch(imageViewerStateProvider.notifier).downloadAsset(

+ 11 - 0
mobile/lib/modules/backup/background_service/background.service.dart

@@ -132,6 +132,17 @@ class BackgroundService {
     }
   }
 
+  Future<Uint8List?> digestFile(String path) {
+    return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
+  }
+
+  Future<List<Uint8List?>?> digestFiles(List<String> paths) {
+    return _foregroundChannel.invokeListMethod<Uint8List?>(
+      "digestFiles",
+      paths,
+    );
+  }
+
   /// Updates the notification shown by the background service
   Future<bool?> _updateNotification({
     String? title,

+ 7 - 7
mobile/lib/modules/home/views/home_page.dart

@@ -47,11 +47,11 @@ class HomePage extends HookConsumerWidget {
 
     useEffect(
       () {
-        ref.watch(websocketProvider.notifier).connect();
-        ref.watch(assetProvider.notifier).getAllAsset();
-        ref.watch(albumProvider.notifier).getAllAlbums();
-        ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
-        ref.watch(serverInfoProvider.notifier).getServerVersion();
+        ref.read(websocketProvider.notifier).connect();
+        Future(() => ref.read(assetProvider.notifier).getAllAsset());
+        ref.read(albumProvider.notifier).getAllAlbums();
+        ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
+        ref.read(serverInfoProvider.notifier).getServerVersion();
 
         selectionEnabledHook.addListener(() {
           multiselectEnabled.state = selectionEnabledHook.value;
@@ -144,7 +144,7 @@ class HomePage extends HookConsumerWidget {
           );
           if (remoteAssets.isNotEmpty) {
             await ref
-                .watch(assetProvider.notifier)
+                .read(assetProvider.notifier)
                 .toggleArchive(remoteAssets, true);
 
             final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
@@ -163,7 +163,7 @@ class HomePage extends HookConsumerWidget {
       void onDelete() async {
         processing.value = true;
         try {
-          await ref.watch(assetProvider.notifier).deleteAssets(selection.value);
+          await ref.read(assetProvider.notifier).deleteAssets(selection.value);
           selectionEnabledHook.value = false;
         } finally {
           processing.value = false;

+ 4 - 17
mobile/lib/shared/models/album.dart

@@ -166,23 +166,10 @@ extension AssetsHelper on IsarCollection<Album> {
   }
 }
 
-extension AssetPathEntityHelper on AssetPathEntity {
-  Future<List<Asset>> getAssets({
-    int start = 0,
-    int end = 0x7fffffffffffffff,
-    Set<String>? excludedAssets,
-  }) async {
-    final assetEntities = await getAssetListRange(start: start, end: end);
-    if (excludedAssets != null) {
-      return assetEntities
-          .where((e) => !excludedAssets.contains(e.id))
-          .map(Asset.local)
-          .toList();
-    }
-    return assetEntities.map(Asset.local).toList();
-  }
-}
-
 extension AlbumResponseDtoHelper on AlbumResponseDto {
   List<Asset> getAssets() => assets.map(Asset.remote).toList();
 }
+
+extension AssetPathEntityHelper on AssetPathEntity {
+  String get eTagKeyAssetCount => "device-album-$id-asset-count";
+}

+ 10 - 0
mobile/lib/shared/models/android_device_asset.dart

@@ -0,0 +1,10 @@
+import 'package:immich_mobile/shared/models/device_asset.dart';
+import 'package:isar/isar.dart';
+
+part 'android_device_asset.g.dart';
+
+@Collection()
+class AndroidDeviceAsset extends DeviceAsset {
+  AndroidDeviceAsset({required this.id, required super.hash});
+  Id id;
+}

+ 493 - 0
mobile/lib/shared/models/android_device_asset.g.dart

@@ -0,0 +1,493 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'android_device_asset.dart';
+
+// **************************************************************************
+// IsarCollectionGenerator
+// **************************************************************************
+
+// coverage:ignore-file
+// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
+
+extension GetAndroidDeviceAssetCollection on Isar {
+  IsarCollection<AndroidDeviceAsset> get androidDeviceAssets =>
+      this.collection();
+}
+
+const AndroidDeviceAssetSchema = CollectionSchema(
+  name: r'AndroidDeviceAsset',
+  id: -6758387181232899335,
+  properties: {
+    r'hash': PropertySchema(
+      id: 0,
+      name: r'hash',
+      type: IsarType.byteList,
+    )
+  },
+  estimateSize: _androidDeviceAssetEstimateSize,
+  serialize: _androidDeviceAssetSerialize,
+  deserialize: _androidDeviceAssetDeserialize,
+  deserializeProp: _androidDeviceAssetDeserializeProp,
+  idName: r'id',
+  indexes: {
+    r'hash': IndexSchema(
+      id: -7973251393006690288,
+      name: r'hash',
+      unique: false,
+      replace: false,
+      properties: [
+        IndexPropertySchema(
+          name: r'hash',
+          type: IndexType.hash,
+          caseSensitive: false,
+        )
+      ],
+    )
+  },
+  links: {},
+  embeddedSchemas: {},
+  getId: _androidDeviceAssetGetId,
+  getLinks: _androidDeviceAssetGetLinks,
+  attach: _androidDeviceAssetAttach,
+  version: '3.1.0+1',
+);
+
+int _androidDeviceAssetEstimateSize(
+  AndroidDeviceAsset object,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  var bytesCount = offsets.last;
+  bytesCount += 3 + object.hash.length;
+  return bytesCount;
+}
+
+void _androidDeviceAssetSerialize(
+  AndroidDeviceAsset object,
+  IsarWriter writer,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  writer.writeByteList(offsets[0], object.hash);
+}
+
+AndroidDeviceAsset _androidDeviceAssetDeserialize(
+  Id id,
+  IsarReader reader,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  final object = AndroidDeviceAsset(
+    hash: reader.readByteList(offsets[0]) ?? [],
+    id: id,
+  );
+  return object;
+}
+
+P _androidDeviceAssetDeserializeProp<P>(
+  IsarReader reader,
+  int propertyId,
+  int offset,
+  Map<Type, List<int>> allOffsets,
+) {
+  switch (propertyId) {
+    case 0:
+      return (reader.readByteList(offset) ?? []) as P;
+    default:
+      throw IsarError('Unknown property with id $propertyId');
+  }
+}
+
+Id _androidDeviceAssetGetId(AndroidDeviceAsset object) {
+  return object.id;
+}
+
+List<IsarLinkBase<dynamic>> _androidDeviceAssetGetLinks(
+    AndroidDeviceAsset object) {
+  return [];
+}
+
+void _androidDeviceAssetAttach(
+    IsarCollection<dynamic> col, Id id, AndroidDeviceAsset object) {
+  object.id = id;
+}
+
+extension AndroidDeviceAssetQueryWhereSort
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QWhere> {
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhere> anyId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(const IdWhereClause.any());
+    });
+  }
+}
+
+extension AndroidDeviceAssetQueryWhere
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QWhereClause> {
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      idEqualTo(Id id) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: id,
+        upper: id,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      idNotEqualTo(Id id) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: id, includeUpper: false),
+            )
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: id, includeLower: false),
+            );
+      } else {
+        return query
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: id, includeLower: false),
+            )
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: id, includeUpper: false),
+            );
+      }
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      idGreaterThan(Id id, {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.greaterThan(lower: id, includeLower: include),
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      idLessThan(Id id, {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.lessThan(upper: id, includeUpper: include),
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      idBetween(
+    Id lowerId,
+    Id upperId, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: lowerId,
+        includeLower: includeLower,
+        upper: upperId,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      hashEqualTo(List<int> hash) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'hash',
+        value: [hash],
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterWhereClause>
+      hashNotEqualTo(List<int> hash) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [],
+              upper: [hash],
+              includeUpper: false,
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [hash],
+              includeLower: false,
+              upper: [],
+            ));
+      } else {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [hash],
+              includeLower: false,
+              upper: [],
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [],
+              upper: [hash],
+              includeUpper: false,
+            ));
+      }
+    });
+  }
+}
+
+extension AndroidDeviceAssetQueryFilter
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QFilterCondition> {
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashElementEqualTo(int value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'hash',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashElementGreaterThan(
+    int value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'hash',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashElementLessThan(
+    int value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'hash',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashElementBetween(
+    int lower,
+    int upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'hash',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashLengthEqualTo(int length) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        length,
+        true,
+        length,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        0,
+        true,
+        0,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        0,
+        false,
+        999999,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashLengthLessThan(
+    int length, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        0,
+        true,
+        length,
+        include,
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashLengthGreaterThan(
+    int length, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        length,
+        include,
+        999999,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      hashLengthBetween(
+    int lower,
+    int upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        lower,
+        includeLower,
+        upper,
+        includeUpper,
+      );
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      idEqualTo(Id value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      idGreaterThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'id',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      idLessThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'id',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterFilterCondition>
+      idBetween(
+    Id lower,
+    Id upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'id',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+}
+
+extension AndroidDeviceAssetQueryObject
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QFilterCondition> {}
+
+extension AndroidDeviceAssetQueryLinks
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QFilterCondition> {}
+
+extension AndroidDeviceAssetQuerySortBy
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QSortBy> {}
+
+extension AndroidDeviceAssetQuerySortThenBy
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QSortThenBy> {
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterSortBy>
+      thenById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QAfterSortBy>
+      thenByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+}
+
+extension AndroidDeviceAssetQueryWhereDistinct
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QDistinct> {
+  QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QDistinct>
+      distinctByHash() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'hash');
+    });
+  }
+}
+
+extension AndroidDeviceAssetQueryProperty
+    on QueryBuilder<AndroidDeviceAsset, AndroidDeviceAsset, QQueryProperty> {
+  QueryBuilder<AndroidDeviceAsset, int, QQueryOperations> idProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'id');
+    });
+  }
+
+  QueryBuilder<AndroidDeviceAsset, List<int>, QQueryOperations> hashProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'hash');
+    });
+  }
+}

+ 59 - 59
mobile/lib/shared/models/asset.dart

@@ -1,3 +1,5 @@
+import 'dart:convert';
+
 import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/utils/hash.dart';
@@ -14,7 +16,7 @@ part 'asset.g.dart';
 class Asset {
   Asset.remote(AssetResponseDto remote)
       : remoteId = remote.id,
-        isLocal = false,
+        checksum = remote.checksum,
         fileCreatedAt = remote.fileCreatedAt,
         fileModifiedAt = remote.fileModifiedAt,
         updatedAt = remote.updatedAt,
@@ -24,23 +26,20 @@ class Asset {
         height = remote.exifInfo?.exifImageHeight?.toInt(),
         width = remote.exifInfo?.exifImageWidth?.toInt(),
         livePhotoVideoId = remote.livePhotoVideoId,
-        localId = remote.deviceAssetId,
-        deviceId = fastHash(remote.deviceId),
         ownerId = fastHash(remote.ownerId),
         exifInfo =
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
         isFavorite = remote.isFavorite,
         isArchived = remote.isArchived;
 
-  Asset.local(AssetEntity local)
+  Asset.local(AssetEntity local, List<int> hash)
       : localId = local.id,
-        isLocal = true,
+        checksum = base64.encode(hash),
         durationInSeconds = local.duration,
         type = AssetType.values[local.typeInt],
         height = local.height,
         width = local.width,
         fileName = local.title!,
-        deviceId = Store.get(StoreKey.deviceIdHash),
         ownerId = Store.get(StoreKey.currentUser).isarId,
         fileModifiedAt = local.modifiedDateTime,
         updatedAt = local.modifiedDateTime,
@@ -53,13 +52,15 @@ class Asset {
     if (local.latitude != null) {
       exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
     }
+    _local = local;
+    assert(hash.length == 20, "invalid SHA1 hash");
   }
 
   Asset({
     this.id = Isar.autoIncrement,
+    required this.checksum,
     this.remoteId,
     required this.localId,
-    required this.deviceId,
     required this.ownerId,
     required this.fileCreatedAt,
     required this.fileModifiedAt,
@@ -72,7 +73,6 @@ class Asset {
     this.livePhotoVideoId,
     this.exifInfo,
     required this.isFavorite,
-    required this.isLocal,
     required this.isArchived,
   });
 
@@ -83,7 +83,7 @@ class Asset {
   AssetEntity? get local {
     if (isLocal && _local == null) {
       _local = AssetEntity(
-        id: localId,
+        id: localId!,
         typeInt: isImage ? 1 : 2,
         width: width ?? 0,
         height: height ?? 0,
@@ -98,18 +98,21 @@ class Asset {
 
   Id id = Isar.autoIncrement;
 
-  @Index(unique: false, replace: false, type: IndexType.hash)
-  String? remoteId;
-
+  /// stores the raw SHA1 bytes as a base64 String
+  /// because Isar cannot sort lists of byte arrays
   @Index(
-    unique: false,
+    unique: true,
     replace: false,
     type: IndexType.hash,
-    composite: [CompositeIndex('deviceId')],
+    composite: [CompositeIndex("ownerId")],
   )
-  String localId;
+  String checksum;
+
+  @Index(unique: false, replace: false, type: IndexType.hash)
+  String? remoteId;
 
-  int deviceId;
+  @Index(unique: false, replace: false, type: IndexType.hash)
+  String? localId;
 
   int ownerId;
 
@@ -134,14 +137,15 @@ class Asset {
 
   bool isFavorite;
 
-  /// `true` if this [Asset] is present on the device
-  bool isLocal;
-
   bool isArchived;
 
   @ignore
   ExifInfo? exifInfo;
 
+  /// `true` if this [Asset] is present on the device
+  @ignore
+  bool get isLocal => localId != null;
+
   @ignore
   bool get isInDb => id != Isar.autoIncrement;
 
@@ -175,9 +179,9 @@ class Asset {
   bool operator ==(other) {
     if (other is! Asset) return false;
     return id == other.id &&
+        checksum == other.checksum &&
         remoteId == other.remoteId &&
         localId == other.localId &&
-        deviceId == other.deviceId &&
         ownerId == other.ownerId &&
         fileCreatedAt.isAtSameMomentAs(other.fileCreatedAt) &&
         fileModifiedAt.isAtSameMomentAs(other.fileModifiedAt) &&
@@ -197,9 +201,9 @@ class Asset {
   @ignore
   int get hashCode =>
       id.hashCode ^
+      checksum.hashCode ^
       remoteId.hashCode ^
       localId.hashCode ^
-      deviceId.hashCode ^
       ownerId.hashCode ^
       fileCreatedAt.hashCode ^
       fileModifiedAt.hashCode ^
@@ -217,8 +221,7 @@ class Asset {
   /// Returns `true` if this [Asset] can updated with values from parameter [a]
   bool canUpdate(Asset a) {
     assert(isInDb);
-    assert(localId == a.localId);
-    assert(deviceId == a.deviceId);
+    assert(checksum == a.checksum);
     assert(a.storage != AssetState.merged);
     return a.updatedAt.isAfter(updatedAt) ||
         a.isRemote && !isRemote ||
@@ -239,11 +242,18 @@ class Asset {
       if (a.isRemote) {
         return a._copyWith(
           id: id,
-          isLocal: isLocal,
+          localId: localId,
           width: a.width ?? width,
           height: a.height ?? height,
           exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
         );
+      } else if (isRemote) {
+        return _copyWith(
+          localId: localId ?? a.localId,
+          width: width ?? a.width,
+          height: height ?? a.height,
+          exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id),
+        );
       } else {
         return a._copyWith(
           id: id,
@@ -270,7 +280,7 @@ class Asset {
       } else {
         // add only missing values (and set isLocal to true)
         return _copyWith(
-          isLocal: true,
+          localId: localId ?? a.localId,
           width: width ?? a.width,
           height: height ?? a.height,
           exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id),
@@ -281,9 +291,9 @@ class Asset {
 
   Asset _copyWith({
     Id? id,
+    String? checksum,
     String? remoteId,
     String? localId,
-    int? deviceId,
     int? ownerId,
     DateTime? fileCreatedAt,
     DateTime? fileModifiedAt,
@@ -295,15 +305,14 @@ class Asset {
     String? fileName,
     String? livePhotoVideoId,
     bool? isFavorite,
-    bool? isLocal,
     bool? isArchived,
     ExifInfo? exifInfo,
   }) =>
       Asset(
         id: id ?? this.id,
+        checksum: checksum ?? this.checksum,
         remoteId: remoteId ?? this.remoteId,
         localId: localId ?? this.localId,
-        deviceId: deviceId ?? this.deviceId,
         ownerId: ownerId ?? this.ownerId,
         fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt,
         fileModifiedAt: fileModifiedAt ?? this.fileModifiedAt,
@@ -315,7 +324,6 @@ class Asset {
         fileName: fileName ?? this.fileName,
         livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
         isFavorite: isFavorite ?? this.isFavorite,
-        isLocal: isLocal ?? this.isLocal,
         isArchived: isArchived ?? this.isArchived,
         exifInfo: exifInfo ?? this.exifInfo,
       );
@@ -328,39 +336,36 @@ class Asset {
     }
   }
 
-  /// compares assets by [ownerId], [deviceId], [localId]
-  static int compareByOwnerDeviceLocalId(Asset a, Asset b) {
+  static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
+
+  static int compareByChecksum(Asset a, Asset b) =>
+      a.checksum.compareTo(b.checksum);
+
+  static int compareByOwnerChecksum(Asset a, Asset b) {
     final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
-    if (ownerIdOrder != 0) {
-      return ownerIdOrder;
-    }
-    final int deviceIdOrder = a.deviceId.compareTo(b.deviceId);
-    if (deviceIdOrder != 0) {
-      return deviceIdOrder;
-    }
-    final int localIdOrder = a.localId.compareTo(b.localId);
-    return localIdOrder;
+    if (ownerIdOrder != 0) return ownerIdOrder;
+    return compareByChecksum(a, b);
   }
 
-  /// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt]
-  static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) {
-    final int order = compareByOwnerDeviceLocalId(a, b);
-    return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt);
+  static int compareByOwnerChecksumCreatedModified(Asset a, Asset b) {
+    final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
+    if (ownerIdOrder != 0) return ownerIdOrder;
+    final int checksumOrder = compareByChecksum(a, b);
+    if (checksumOrder != 0) return checksumOrder;
+    final int createdOrder = a.fileCreatedAt.compareTo(b.fileCreatedAt);
+    if (createdOrder != 0) return createdOrder;
+    return a.fileModifiedAt.compareTo(b.fileModifiedAt);
   }
 
-  static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
-
-  static int compareByLocalId(Asset a, Asset b) =>
-      a.localId.compareTo(b.localId);
-
   @override
   String toString() {
     return """
 {
+  "id": ${id == Isar.autoIncrement ? '"N/A"' : id},
   "remoteId": "${remoteId ?? "N/A"}",
-  "localId": "$localId", 
-  "deviceId": "$deviceId", 
-  "ownerId": "$ownerId", 
+  "localId": "${localId ?? "N/A"}",
+  "checksum": "$checksum",
+  "ownerId": $ownerId, 
   "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
   "fileCreatedAt": "$fileCreatedAt",
   "fileModifiedAt": "$fileModifiedAt", 
@@ -369,9 +374,8 @@ class Asset {
   "type": "$type",
   "fileName": "$fileName", 
   "isFavorite": $isFavorite, 
-  "isLocal": $isLocal,
   "isRemote: $isRemote,
-  "storage": $storage,
+  "storage": "$storage",
   "width": ${width ?? "N/A"},
   "height": ${height ?? "N/A"},
   "isArchived": $isArchived
@@ -424,10 +428,6 @@ extension AssetsHelper on IsarCollection<Asset> {
   QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
       where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
   QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
-    return where().anyOf(
-      ids,
-      (q, String e) =>
-          q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)),
-    );
+    return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
   }
 }

+ 430 - 217
mobile/lib/shared/models/asset.g.dart

@@ -17,10 +17,10 @@ const AssetSchema = CollectionSchema(
   name: r'Asset',
   id: -2933289051367723566,
   properties: {
-    r'deviceId': PropertySchema(
+    r'checksum': PropertySchema(
       id: 0,
-      name: r'deviceId',
-      type: IsarType.long,
+      name: r'checksum',
+      type: IsarType.string,
     ),
     r'durationInSeconds': PropertySchema(
       id: 1,
@@ -57,44 +57,39 @@ const AssetSchema = CollectionSchema(
       name: r'isFavorite',
       type: IsarType.bool,
     ),
-    r'isLocal': PropertySchema(
-      id: 8,
-      name: r'isLocal',
-      type: IsarType.bool,
-    ),
     r'livePhotoVideoId': PropertySchema(
-      id: 9,
+      id: 8,
       name: r'livePhotoVideoId',
       type: IsarType.string,
     ),
     r'localId': PropertySchema(
-      id: 10,
+      id: 9,
       name: r'localId',
       type: IsarType.string,
     ),
     r'ownerId': PropertySchema(
-      id: 11,
+      id: 10,
       name: r'ownerId',
       type: IsarType.long,
     ),
     r'remoteId': PropertySchema(
-      id: 12,
+      id: 11,
       name: r'remoteId',
       type: IsarType.string,
     ),
     r'type': PropertySchema(
-      id: 13,
+      id: 12,
       name: r'type',
       type: IsarType.byte,
       enumMap: _AssettypeEnumValueMap,
     ),
     r'updatedAt': PropertySchema(
-      id: 14,
+      id: 13,
       name: r'updatedAt',
       type: IsarType.dateTime,
     ),
     r'width': PropertySchema(
-      id: 15,
+      id: 14,
       name: r'width',
       type: IsarType.int,
     )
@@ -105,6 +100,24 @@ const AssetSchema = CollectionSchema(
   deserializeProp: _assetDeserializeProp,
   idName: r'id',
   indexes: {
+    r'checksum_ownerId': IndexSchema(
+      id: 5611361749756160119,
+      name: r'checksum_ownerId',
+      unique: true,
+      replace: false,
+      properties: [
+        IndexPropertySchema(
+          name: r'checksum',
+          type: IndexType.hash,
+          caseSensitive: true,
+        ),
+        IndexPropertySchema(
+          name: r'ownerId',
+          type: IndexType.value,
+          caseSensitive: false,
+        )
+      ],
+    ),
     r'remoteId': IndexSchema(
       id: 6301175856541681032,
       name: r'remoteId',
@@ -118,9 +131,9 @@ const AssetSchema = CollectionSchema(
         )
       ],
     ),
-    r'localId_deviceId': IndexSchema(
-      id: 7649417350086526165,
-      name: r'localId_deviceId',
+    r'localId': IndexSchema(
+      id: 1199848425898359622,
+      name: r'localId',
       unique: false,
       replace: false,
       properties: [
@@ -128,11 +141,6 @@ const AssetSchema = CollectionSchema(
           name: r'localId',
           type: IndexType.hash,
           caseSensitive: true,
-        ),
-        IndexPropertySchema(
-          name: r'deviceId',
-          type: IndexType.value,
-          caseSensitive: false,
         )
       ],
     )
@@ -151,6 +159,7 @@ int _assetEstimateSize(
   Map<Type, List<int>> allOffsets,
 ) {
   var bytesCount = offsets.last;
+  bytesCount += 3 + object.checksum.length * 3;
   bytesCount += 3 + object.fileName.length * 3;
   {
     final value = object.livePhotoVideoId;
@@ -158,7 +167,12 @@ int _assetEstimateSize(
       bytesCount += 3 + value.length * 3;
     }
   }
-  bytesCount += 3 + object.localId.length * 3;
+  {
+    final value = object.localId;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   {
     final value = object.remoteId;
     if (value != null) {
@@ -174,7 +188,7 @@ void _assetSerialize(
   List<int> offsets,
   Map<Type, List<int>> allOffsets,
 ) {
-  writer.writeLong(offsets[0], object.deviceId);
+  writer.writeString(offsets[0], object.checksum);
   writer.writeLong(offsets[1], object.durationInSeconds);
   writer.writeDateTime(offsets[2], object.fileCreatedAt);
   writer.writeDateTime(offsets[3], object.fileModifiedAt);
@@ -182,14 +196,13 @@ void _assetSerialize(
   writer.writeInt(offsets[5], object.height);
   writer.writeBool(offsets[6], object.isArchived);
   writer.writeBool(offsets[7], object.isFavorite);
-  writer.writeBool(offsets[8], object.isLocal);
-  writer.writeString(offsets[9], object.livePhotoVideoId);
-  writer.writeString(offsets[10], object.localId);
-  writer.writeLong(offsets[11], object.ownerId);
-  writer.writeString(offsets[12], object.remoteId);
-  writer.writeByte(offsets[13], object.type.index);
-  writer.writeDateTime(offsets[14], object.updatedAt);
-  writer.writeInt(offsets[15], object.width);
+  writer.writeString(offsets[8], object.livePhotoVideoId);
+  writer.writeString(offsets[9], object.localId);
+  writer.writeLong(offsets[10], object.ownerId);
+  writer.writeString(offsets[11], object.remoteId);
+  writer.writeByte(offsets[12], object.type.index);
+  writer.writeDateTime(offsets[13], object.updatedAt);
+  writer.writeInt(offsets[14], object.width);
 }
 
 Asset _assetDeserialize(
@@ -199,7 +212,7 @@ Asset _assetDeserialize(
   Map<Type, List<int>> allOffsets,
 ) {
   final object = Asset(
-    deviceId: reader.readLong(offsets[0]),
+    checksum: reader.readString(offsets[0]),
     durationInSeconds: reader.readLong(offsets[1]),
     fileCreatedAt: reader.readDateTime(offsets[2]),
     fileModifiedAt: reader.readDateTime(offsets[3]),
@@ -208,15 +221,14 @@ Asset _assetDeserialize(
     id: id,
     isArchived: reader.readBool(offsets[6]),
     isFavorite: reader.readBool(offsets[7]),
-    isLocal: reader.readBool(offsets[8]),
-    livePhotoVideoId: reader.readStringOrNull(offsets[9]),
-    localId: reader.readString(offsets[10]),
-    ownerId: reader.readLong(offsets[11]),
-    remoteId: reader.readStringOrNull(offsets[12]),
-    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
+    livePhotoVideoId: reader.readStringOrNull(offsets[8]),
+    localId: reader.readStringOrNull(offsets[9]),
+    ownerId: reader.readLong(offsets[10]),
+    remoteId: reader.readStringOrNull(offsets[11]),
+    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
         AssetType.other,
-    updatedAt: reader.readDateTime(offsets[14]),
-    width: reader.readIntOrNull(offsets[15]),
+    updatedAt: reader.readDateTime(offsets[13]),
+    width: reader.readIntOrNull(offsets[14]),
   );
   return object;
 }
@@ -229,7 +241,7 @@ P _assetDeserializeProp<P>(
 ) {
   switch (propertyId) {
     case 0:
-      return (reader.readLong(offset)) as P;
+      return (reader.readString(offset)) as P;
     case 1:
       return (reader.readLong(offset)) as P;
     case 2:
@@ -245,21 +257,19 @@ P _assetDeserializeProp<P>(
     case 7:
       return (reader.readBool(offset)) as P;
     case 8:
-      return (reader.readBool(offset)) as P;
+      return (reader.readStringOrNull(offset)) as P;
     case 9:
       return (reader.readStringOrNull(offset)) as P;
     case 10:
-      return (reader.readString(offset)) as P;
-    case 11:
       return (reader.readLong(offset)) as P;
-    case 12:
+    case 11:
       return (reader.readStringOrNull(offset)) as P;
-    case 13:
+    case 12:
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
           AssetType.other) as P;
-    case 14:
+    case 13:
       return (reader.readDateTime(offset)) as P;
-    case 15:
+    case 14:
       return (reader.readIntOrNull(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
@@ -291,6 +301,94 @@ void _assetAttach(IsarCollection<dynamic> col, Id id, Asset object) {
   object.id = id;
 }
 
+extension AssetByIndex on IsarCollection<Asset> {
+  Future<Asset?> getByChecksumOwnerId(String checksum, int ownerId) {
+    return getByIndex(r'checksum_ownerId', [checksum, ownerId]);
+  }
+
+  Asset? getByChecksumOwnerIdSync(String checksum, int ownerId) {
+    return getByIndexSync(r'checksum_ownerId', [checksum, ownerId]);
+  }
+
+  Future<bool> deleteByChecksumOwnerId(String checksum, int ownerId) {
+    return deleteByIndex(r'checksum_ownerId', [checksum, ownerId]);
+  }
+
+  bool deleteByChecksumOwnerIdSync(String checksum, int ownerId) {
+    return deleteByIndexSync(r'checksum_ownerId', [checksum, ownerId]);
+  }
+
+  Future<List<Asset?>> getAllByChecksumOwnerId(
+      List<String> checksumValues, List<int> ownerIdValues) {
+    final len = checksumValues.length;
+    assert(ownerIdValues.length == len,
+        'All index values must have the same length');
+    final values = <List<dynamic>>[];
+    for (var i = 0; i < len; i++) {
+      values.add([checksumValues[i], ownerIdValues[i]]);
+    }
+
+    return getAllByIndex(r'checksum_ownerId', values);
+  }
+
+  List<Asset?> getAllByChecksumOwnerIdSync(
+      List<String> checksumValues, List<int> ownerIdValues) {
+    final len = checksumValues.length;
+    assert(ownerIdValues.length == len,
+        'All index values must have the same length');
+    final values = <List<dynamic>>[];
+    for (var i = 0; i < len; i++) {
+      values.add([checksumValues[i], ownerIdValues[i]]);
+    }
+
+    return getAllByIndexSync(r'checksum_ownerId', values);
+  }
+
+  Future<int> deleteAllByChecksumOwnerId(
+      List<String> checksumValues, List<int> ownerIdValues) {
+    final len = checksumValues.length;
+    assert(ownerIdValues.length == len,
+        'All index values must have the same length');
+    final values = <List<dynamic>>[];
+    for (var i = 0; i < len; i++) {
+      values.add([checksumValues[i], ownerIdValues[i]]);
+    }
+
+    return deleteAllByIndex(r'checksum_ownerId', values);
+  }
+
+  int deleteAllByChecksumOwnerIdSync(
+      List<String> checksumValues, List<int> ownerIdValues) {
+    final len = checksumValues.length;
+    assert(ownerIdValues.length == len,
+        'All index values must have the same length');
+    final values = <List<dynamic>>[];
+    for (var i = 0; i < len; i++) {
+      values.add([checksumValues[i], ownerIdValues[i]]);
+    }
+
+    return deleteAllByIndexSync(r'checksum_ownerId', values);
+  }
+
+  Future<Id> putByChecksumOwnerId(Asset object) {
+    return putByIndex(r'checksum_ownerId', object);
+  }
+
+  Id putByChecksumOwnerIdSync(Asset object, {bool saveLinks = true}) {
+    return putByIndexSync(r'checksum_ownerId', object, saveLinks: saveLinks);
+  }
+
+  Future<List<Id>> putAllByChecksumOwnerId(List<Asset> objects) {
+    return putAllByIndex(r'checksum_ownerId', objects);
+  }
+
+  List<Id> putAllByChecksumOwnerIdSync(List<Asset> objects,
+      {bool saveLinks = true}) {
+    return putAllByIndexSync(r'checksum_ownerId', objects,
+        saveLinks: saveLinks);
+  }
+}
+
 extension AssetQueryWhereSort on QueryBuilder<Asset, Asset, QWhere> {
   QueryBuilder<Asset, Asset, QAfterWhere> anyId() {
     return QueryBuilder.apply(this, (query) {
@@ -365,6 +463,145 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToAnyOwnerId(
+      String checksum) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'checksum_ownerId',
+        value: [checksum],
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause> checksumNotEqualToAnyOwnerId(
+      String checksum) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [],
+              upper: [checksum],
+              includeUpper: false,
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [checksum],
+              includeLower: false,
+              upper: [],
+            ));
+      } else {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [checksum],
+              includeLower: false,
+              upper: [],
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [],
+              upper: [checksum],
+              includeUpper: false,
+            ));
+      }
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause> checksumOwnerIdEqualTo(
+      String checksum, int ownerId) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'checksum_ownerId',
+        value: [checksum, ownerId],
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause>
+      checksumEqualToOwnerIdNotEqualTo(String checksum, int ownerId) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [checksum],
+              upper: [checksum, ownerId],
+              includeUpper: false,
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [checksum, ownerId],
+              includeLower: false,
+              upper: [checksum],
+            ));
+      } else {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [checksum, ownerId],
+              includeLower: false,
+              upper: [checksum],
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'checksum_ownerId',
+              lower: [checksum],
+              upper: [checksum, ownerId],
+              includeUpper: false,
+            ));
+      }
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause>
+      checksumEqualToOwnerIdGreaterThan(
+    String checksum,
+    int ownerId, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.between(
+        indexName: r'checksum_ownerId',
+        lower: [checksum, ownerId],
+        includeLower: include,
+        upper: [checksum],
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToOwnerIdLessThan(
+    String checksum,
+    int ownerId, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.between(
+        indexName: r'checksum_ownerId',
+        lower: [checksum],
+        upper: [checksum, ownerId],
+        includeUpper: include,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToOwnerIdBetween(
+    String checksum,
+    int lowerOwnerId,
+    int upperOwnerId, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.between(
+        indexName: r'checksum_ownerId',
+        lower: [checksum, lowerOwnerId],
+        includeLower: includeLower,
+        upper: [checksum, upperOwnerId],
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterWhereClause> remoteIdIsNull() {
     return QueryBuilder.apply(this, (query) {
       return query.addWhereClause(IndexWhereClause.equalTo(
@@ -430,29 +667,49 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdEqualToAnyDeviceId(
-      String localId) {
+  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdIsNull() {
     return QueryBuilder.apply(this, (query) {
       return query.addWhereClause(IndexWhereClause.equalTo(
-        indexName: r'localId_deviceId',
+        indexName: r'localId',
+        value: [null],
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.between(
+        indexName: r'localId',
+        lower: [null],
+        includeLower: false,
+        upper: [],
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdEqualTo(
+      String? localId) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'localId',
         value: [localId],
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdNotEqualToAnyDeviceId(
-      String localId) {
+  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdNotEqualTo(
+      String? localId) {
     return QueryBuilder.apply(this, (query) {
       if (query.whereSort == Sort.asc) {
         return query
             .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
+              indexName: r'localId',
               lower: [],
               upper: [localId],
               includeUpper: false,
             ))
             .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
+              indexName: r'localId',
               lower: [localId],
               includeLower: false,
               upper: [],
@@ -460,13 +717,13 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
       } else {
         return query
             .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
+              indexName: r'localId',
               lower: [localId],
               includeLower: false,
               upper: [],
             ))
             .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
+              indexName: r'localId',
               lower: [],
               upper: [localId],
               includeUpper: false,
@@ -474,151 +731,135 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
       }
     });
   }
+}
 
-  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdDeviceIdEqualTo(
-      String localId, int deviceId) {
+extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumEqualTo(
+    String value, {
+    bool caseSensitive = true,
+  }) {
     return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.equalTo(
-        indexName: r'localId_deviceId',
-        value: [localId, deviceId],
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'checksum',
+        value: value,
+        caseSensitive: caseSensitive,
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterWhereClause>
-      localIdEqualToDeviceIdNotEqualTo(String localId, int deviceId) {
-    return QueryBuilder.apply(this, (query) {
-      if (query.whereSort == Sort.asc) {
-        return query
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
-              lower: [localId],
-              upper: [localId, deviceId],
-              includeUpper: false,
-            ))
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
-              lower: [localId, deviceId],
-              includeLower: false,
-              upper: [localId],
-            ));
-      } else {
-        return query
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
-              lower: [localId, deviceId],
-              includeLower: false,
-              upper: [localId],
-            ))
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'localId_deviceId',
-              lower: [localId],
-              upper: [localId, deviceId],
-              includeUpper: false,
-            ));
-      }
-    });
-  }
-
-  QueryBuilder<Asset, Asset, QAfterWhereClause>
-      localIdEqualToDeviceIdGreaterThan(
-    String localId,
-    int deviceId, {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumGreaterThan(
+    String value, {
     bool include = false,
+    bool caseSensitive = true,
   }) {
     return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'localId_deviceId',
-        lower: [localId, deviceId],
-        includeLower: include,
-        upper: [localId],
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'checksum',
+        value: value,
+        caseSensitive: caseSensitive,
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdEqualToDeviceIdLessThan(
-    String localId,
-    int deviceId, {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumLessThan(
+    String value, {
     bool include = false,
+    bool caseSensitive = true,
   }) {
     return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'localId_deviceId',
-        lower: [localId],
-        upper: [localId, deviceId],
-        includeUpper: include,
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'checksum',
+        value: value,
+        caseSensitive: caseSensitive,
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterWhereClause> localIdEqualToDeviceIdBetween(
-    String localId,
-    int lowerDeviceId,
-    int upperDeviceId, {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumBetween(
+    String lower,
+    String upper, {
     bool includeLower = true,
     bool includeUpper = true,
+    bool caseSensitive = true,
   }) {
     return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'localId_deviceId',
-        lower: [localId, lowerDeviceId],
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'checksum',
+        lower: lower,
         includeLower: includeLower,
-        upper: [localId, upperDeviceId],
+        upper: upper,
         includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
       ));
     });
   }
-}
 
-extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
-  QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdEqualTo(int value) {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
     return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'deviceId',
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'checksum',
         value: value,
+        caseSensitive: caseSensitive,
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdGreaterThan(
-    int value, {
-    bool include = false,
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumEndsWith(
+    String value, {
+    bool caseSensitive = true,
   }) {
     return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.greaterThan(
-        include: include,
-        property: r'deviceId',
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'checksum',
         value: value,
+        caseSensitive: caseSensitive,
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdLessThan(
-    int value, {
-    bool include = false,
-  }) {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumContains(
+      String value,
+      {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.lessThan(
-        include: include,
-        property: r'deviceId',
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'checksum',
         value: value,
+        caseSensitive: caseSensitive,
       ));
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdBetween(
-    int lower,
-    int upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.between(
-        property: r'deviceId',
-        lower: lower,
-        includeLower: includeLower,
-        upper: upper,
-        includeUpper: includeUpper,
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'checksum',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'checksum',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'checksum',
+        value: '',
       ));
     });
   }
@@ -1053,15 +1294,6 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterFilterCondition> isLocalEqualTo(bool value) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'isLocal',
-        value: value,
-      ));
-    });
-  }
-
   QueryBuilder<Asset, Asset, QAfterFilterCondition> livePhotoVideoIdIsNull() {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(const FilterCondition.isNull(
@@ -1210,8 +1442,24 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> localIdIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'localId',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> localIdIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'localId',
+      ));
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterFilterCondition> localIdEqualTo(
-    String value, {
+    String? value, {
     bool caseSensitive = true,
   }) {
     return QueryBuilder.apply(this, (query) {
@@ -1224,7 +1472,7 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
   }
 
   QueryBuilder<Asset, Asset, QAfterFilterCondition> localIdGreaterThan(
-    String value, {
+    String? value, {
     bool include = false,
     bool caseSensitive = true,
   }) {
@@ -1239,7 +1487,7 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
   }
 
   QueryBuilder<Asset, Asset, QAfterFilterCondition> localIdLessThan(
-    String value, {
+    String? value, {
     bool include = false,
     bool caseSensitive = true,
   }) {
@@ -1254,8 +1502,8 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
   }
 
   QueryBuilder<Asset, Asset, QAfterFilterCondition> localIdBetween(
-    String lower,
-    String upper, {
+    String? lower,
+    String? upper, {
     bool includeLower = true,
     bool includeUpper = true,
     bool caseSensitive = true,
@@ -1718,15 +1966,15 @@ extension AssetQueryObject on QueryBuilder<Asset, Asset, QFilterCondition> {}
 extension AssetQueryLinks on QueryBuilder<Asset, Asset, QFilterCondition> {}
 
 extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
-  QueryBuilder<Asset, Asset, QAfterSortBy> sortByDeviceId() {
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByChecksum() {
     return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'deviceId', Sort.asc);
+      return query.addSortBy(r'checksum', Sort.asc);
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterSortBy> sortByDeviceIdDesc() {
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByChecksumDesc() {
     return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'deviceId', Sort.desc);
+      return query.addSortBy(r'checksum', Sort.desc);
     });
   }
 
@@ -1814,18 +2062,6 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsLocal() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'isLocal', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsLocalDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'isLocal', Sort.desc);
-    });
-  }
-
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByLivePhotoVideoId() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'livePhotoVideoId', Sort.asc);
@@ -1912,15 +2148,15 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
 }
 
 extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
-  QueryBuilder<Asset, Asset, QAfterSortBy> thenByDeviceId() {
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByChecksum() {
     return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'deviceId', Sort.asc);
+      return query.addSortBy(r'checksum', Sort.asc);
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterSortBy> thenByDeviceIdDesc() {
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByChecksumDesc() {
     return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'deviceId', Sort.desc);
+      return query.addSortBy(r'checksum', Sort.desc);
     });
   }
 
@@ -2020,18 +2256,6 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
     });
   }
 
-  QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsLocal() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'isLocal', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsLocalDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'isLocal', Sort.desc);
-    });
-  }
-
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByLivePhotoVideoId() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'livePhotoVideoId', Sort.asc);
@@ -2118,9 +2342,10 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
 }
 
 extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
-  QueryBuilder<Asset, Asset, QDistinct> distinctByDeviceId() {
+  QueryBuilder<Asset, Asset, QDistinct> distinctByChecksum(
+      {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
-      return query.addDistinctBy(r'deviceId');
+      return query.addDistinctBy(r'checksum', caseSensitive: caseSensitive);
     });
   }
 
@@ -2167,12 +2392,6 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
     });
   }
 
-  QueryBuilder<Asset, Asset, QDistinct> distinctByIsLocal() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addDistinctBy(r'isLocal');
-    });
-  }
-
   QueryBuilder<Asset, Asset, QDistinct> distinctByLivePhotoVideoId(
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
@@ -2227,9 +2446,9 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
     });
   }
 
-  QueryBuilder<Asset, int, QQueryOperations> deviceIdProperty() {
+  QueryBuilder<Asset, String, QQueryOperations> checksumProperty() {
     return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'deviceId');
+      return query.addPropertyName(r'checksum');
     });
   }
 
@@ -2275,19 +2494,13 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
     });
   }
 
-  QueryBuilder<Asset, bool, QQueryOperations> isLocalProperty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'isLocal');
-    });
-  }
-
   QueryBuilder<Asset, String?, QQueryOperations> livePhotoVideoIdProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'livePhotoVideoId');
     });
   }
 
-  QueryBuilder<Asset, String, QQueryOperations> localIdProperty() {
+  QueryBuilder<Asset, String?, QQueryOperations> localIdProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'localId');
     });

+ 8 - 0
mobile/lib/shared/models/device_asset.dart

@@ -0,0 +1,8 @@
+import 'package:isar/isar.dart';
+
+class DeviceAsset {
+  DeviceAsset({required this.hash});
+
+  @Index(unique: false, type: IndexType.hash)
+  List<byte> hash;
+}

+ 14 - 0
mobile/lib/shared/models/ios_device_asset.dart

@@ -0,0 +1,14 @@
+import 'package:immich_mobile/shared/models/device_asset.dart';
+import 'package:immich_mobile/utils/hash.dart';
+import 'package:isar/isar.dart';
+
+part 'ios_device_asset.g.dart';
+
+@Collection()
+class IOSDeviceAsset extends DeviceAsset {
+  IOSDeviceAsset({required this.id, required super.hash});
+
+  @Index(replace: true, unique: true, type: IndexType.hash)
+  String id;
+  Id get isarId => fastHash(id);
+}

+ 780 - 0
mobile/lib/shared/models/ios_device_asset.g.dart

@@ -0,0 +1,780 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'ios_device_asset.dart';
+
+// **************************************************************************
+// IsarCollectionGenerator
+// **************************************************************************
+
+// coverage:ignore-file
+// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
+
+extension GetIOSDeviceAssetCollection on Isar {
+  IsarCollection<IOSDeviceAsset> get iOSDeviceAssets => this.collection();
+}
+
+const IOSDeviceAssetSchema = CollectionSchema(
+  name: r'IOSDeviceAsset',
+  id: -1671546753821948030,
+  properties: {
+    r'hash': PropertySchema(
+      id: 0,
+      name: r'hash',
+      type: IsarType.byteList,
+    ),
+    r'id': PropertySchema(
+      id: 1,
+      name: r'id',
+      type: IsarType.string,
+    )
+  },
+  estimateSize: _iOSDeviceAssetEstimateSize,
+  serialize: _iOSDeviceAssetSerialize,
+  deserialize: _iOSDeviceAssetDeserialize,
+  deserializeProp: _iOSDeviceAssetDeserializeProp,
+  idName: r'isarId',
+  indexes: {
+    r'id': IndexSchema(
+      id: -3268401673993471357,
+      name: r'id',
+      unique: true,
+      replace: true,
+      properties: [
+        IndexPropertySchema(
+          name: r'id',
+          type: IndexType.hash,
+          caseSensitive: true,
+        )
+      ],
+    ),
+    r'hash': IndexSchema(
+      id: -7973251393006690288,
+      name: r'hash',
+      unique: false,
+      replace: false,
+      properties: [
+        IndexPropertySchema(
+          name: r'hash',
+          type: IndexType.hash,
+          caseSensitive: false,
+        )
+      ],
+    )
+  },
+  links: {},
+  embeddedSchemas: {},
+  getId: _iOSDeviceAssetGetId,
+  getLinks: _iOSDeviceAssetGetLinks,
+  attach: _iOSDeviceAssetAttach,
+  version: '3.1.0+1',
+);
+
+int _iOSDeviceAssetEstimateSize(
+  IOSDeviceAsset object,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  var bytesCount = offsets.last;
+  bytesCount += 3 + object.hash.length;
+  bytesCount += 3 + object.id.length * 3;
+  return bytesCount;
+}
+
+void _iOSDeviceAssetSerialize(
+  IOSDeviceAsset object,
+  IsarWriter writer,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  writer.writeByteList(offsets[0], object.hash);
+  writer.writeString(offsets[1], object.id);
+}
+
+IOSDeviceAsset _iOSDeviceAssetDeserialize(
+  Id id,
+  IsarReader reader,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  final object = IOSDeviceAsset(
+    hash: reader.readByteList(offsets[0]) ?? [],
+    id: reader.readString(offsets[1]),
+  );
+  return object;
+}
+
+P _iOSDeviceAssetDeserializeProp<P>(
+  IsarReader reader,
+  int propertyId,
+  int offset,
+  Map<Type, List<int>> allOffsets,
+) {
+  switch (propertyId) {
+    case 0:
+      return (reader.readByteList(offset) ?? []) as P;
+    case 1:
+      return (reader.readString(offset)) as P;
+    default:
+      throw IsarError('Unknown property with id $propertyId');
+  }
+}
+
+Id _iOSDeviceAssetGetId(IOSDeviceAsset object) {
+  return object.isarId;
+}
+
+List<IsarLinkBase<dynamic>> _iOSDeviceAssetGetLinks(IOSDeviceAsset object) {
+  return [];
+}
+
+void _iOSDeviceAssetAttach(
+    IsarCollection<dynamic> col, Id id, IOSDeviceAsset object) {}
+
+extension IOSDeviceAssetByIndex on IsarCollection<IOSDeviceAsset> {
+  Future<IOSDeviceAsset?> getById(String id) {
+    return getByIndex(r'id', [id]);
+  }
+
+  IOSDeviceAsset? getByIdSync(String id) {
+    return getByIndexSync(r'id', [id]);
+  }
+
+  Future<bool> deleteById(String id) {
+    return deleteByIndex(r'id', [id]);
+  }
+
+  bool deleteByIdSync(String id) {
+    return deleteByIndexSync(r'id', [id]);
+  }
+
+  Future<List<IOSDeviceAsset?>> getAllById(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return getAllByIndex(r'id', values);
+  }
+
+  List<IOSDeviceAsset?> getAllByIdSync(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return getAllByIndexSync(r'id', values);
+  }
+
+  Future<int> deleteAllById(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return deleteAllByIndex(r'id', values);
+  }
+
+  int deleteAllByIdSync(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return deleteAllByIndexSync(r'id', values);
+  }
+
+  Future<Id> putById(IOSDeviceAsset object) {
+    return putByIndex(r'id', object);
+  }
+
+  Id putByIdSync(IOSDeviceAsset object, {bool saveLinks = true}) {
+    return putByIndexSync(r'id', object, saveLinks: saveLinks);
+  }
+
+  Future<List<Id>> putAllById(List<IOSDeviceAsset> objects) {
+    return putAllByIndex(r'id', objects);
+  }
+
+  List<Id> putAllByIdSync(List<IOSDeviceAsset> objects,
+      {bool saveLinks = true}) {
+    return putAllByIndexSync(r'id', objects, saveLinks: saveLinks);
+  }
+}
+
+extension IOSDeviceAssetQueryWhereSort
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QWhere> {
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhere> anyIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(const IdWhereClause.any());
+    });
+  }
+}
+
+extension IOSDeviceAssetQueryWhere
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QWhereClause> {
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> isarIdEqualTo(
+      Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: isarId,
+        upper: isarId,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
+      isarIdNotEqualTo(Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            )
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            );
+      } else {
+        return query
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            )
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            );
+      }
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
+      isarIdGreaterThan(Id isarId, {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.greaterThan(lower: isarId, includeLower: include),
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
+      isarIdLessThan(Id isarId, {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.lessThan(upper: isarId, includeUpper: include),
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> isarIdBetween(
+    Id lowerIsarId,
+    Id upperIsarId, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: lowerIsarId,
+        includeLower: includeLower,
+        upper: upperIsarId,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> idEqualTo(
+      String id) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'id',
+        value: [id],
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> idNotEqualTo(
+      String id) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [],
+              upper: [id],
+              includeUpper: false,
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [id],
+              includeLower: false,
+              upper: [],
+            ));
+      } else {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [id],
+              includeLower: false,
+              upper: [],
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [],
+              upper: [id],
+              includeUpper: false,
+            ));
+      }
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause> hashEqualTo(
+      List<int> hash) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'hash',
+        value: [hash],
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterWhereClause>
+      hashNotEqualTo(List<int> hash) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [],
+              upper: [hash],
+              includeUpper: false,
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [hash],
+              includeLower: false,
+              upper: [],
+            ));
+      } else {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [hash],
+              includeLower: false,
+              upper: [],
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'hash',
+              lower: [],
+              upper: [hash],
+              includeUpper: false,
+            ));
+      }
+    });
+  }
+}
+
+extension IOSDeviceAssetQueryFilter
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QFilterCondition> {
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashElementEqualTo(int value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'hash',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashElementGreaterThan(
+    int value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'hash',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashElementLessThan(
+    int value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'hash',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashElementBetween(
+    int lower,
+    int upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'hash',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashLengthEqualTo(int length) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        length,
+        true,
+        length,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        0,
+        true,
+        0,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        0,
+        false,
+        999999,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashLengthLessThan(
+    int length, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        0,
+        true,
+        length,
+        include,
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashLengthGreaterThan(
+    int length, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        length,
+        include,
+        999999,
+        true,
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      hashLengthBetween(
+    int lower,
+    int upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.listLength(
+        r'hash',
+        lower,
+        includeLower,
+        upper,
+        includeUpper,
+      );
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition> idEqualTo(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idGreaterThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idLessThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition> idBetween(
+    String lower,
+    String upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'id',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idContains(String value, {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition> idMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'id',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      idIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      isarIdEqualTo(Id value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      isarIdGreaterThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      isarIdLessThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterFilterCondition>
+      isarIdBetween(
+    Id lower,
+    Id upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'isarId',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+}
+
+extension IOSDeviceAssetQueryObject
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QFilterCondition> {}
+
+extension IOSDeviceAssetQueryLinks
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QFilterCondition> {}
+
+extension IOSDeviceAssetQuerySortBy
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QSortBy> {
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> sortById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> sortByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+}
+
+extension IOSDeviceAssetQuerySortThenBy
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QSortThenBy> {
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> thenById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> thenByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy> thenByIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.asc);
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QAfterSortBy>
+      thenByIsarIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.desc);
+    });
+  }
+}
+
+extension IOSDeviceAssetQueryWhereDistinct
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QDistinct> {
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QDistinct> distinctByHash() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'hash');
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QDistinct> distinctById(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
+    });
+  }
+}
+
+extension IOSDeviceAssetQueryProperty
+    on QueryBuilder<IOSDeviceAsset, IOSDeviceAsset, QQueryProperty> {
+  QueryBuilder<IOSDeviceAsset, int, QQueryOperations> isarIdProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isarId');
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, List<int>, QQueryOperations> hashProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'hash');
+    });
+  }
+
+  QueryBuilder<IOSDeviceAsset, String, QQueryOperations> idProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'id');
+    });
+  }
+}

+ 10 - 21
mobile/lib/shared/providers/asset.provider.dart

@@ -18,11 +18,7 @@ import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
-/// State does not contain archived assets.
-/// Use database provider if you want to access the isArchived assets
-class AssetsState {}
-
-class AssetNotifier extends StateNotifier<AssetsState> {
+class AssetNotifier extends StateNotifier<bool> {
   final AssetService _assetService;
   final AlbumService _albumService;
   final UserService _userService;
@@ -38,7 +34,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     this._userService,
     this._syncService,
     this._db,
-  ) : super(AssetsState());
+  ) : super(false);
 
   Future<void> getAllAsset({bool clear = false}) async {
     if (_getAllAssetInProgress || _deleteInProgress) {
@@ -48,14 +44,15 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     final stopwatch = Stopwatch()..start();
     try {
       _getAllAssetInProgress = true;
+      state = true;
       if (clear) {
         await clearAssetsAndAlbums(_db);
         log.info("Manual refresh requested, cleared assets and albums from db");
       }
+      await _userService.refreshUsers();
       final bool newRemote = await _assetService.refreshRemoteAssets();
       final bool newLocal = await _albumService.refreshDeviceAlbums();
       debugPrint("newRemote: $newRemote, newLocal: $newLocal");
-      await _userService.refreshUsers();
       final List<User> partners =
           await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll();
       for (User u in partners) {
@@ -64,6 +61,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
     } finally {
       _getAllAssetInProgress = false;
+      state = false;
     }
   }
 
@@ -79,6 +77,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
 
   Future<void> deleteAssets(Set<Asset> deleteAssets) async {
     _deleteInProgress = true;
+    state = true;
     try {
       final localDeleted = await _deleteLocalAssets(deleteAssets);
       final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
@@ -91,24 +90,14 @@ class AssetNotifier extends StateNotifier<AssetsState> {
       }
     } finally {
       _deleteInProgress = false;
+      state = false;
     }
   }
 
   Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
-    final int deviceId = Store.get(StoreKey.deviceIdHash);
-    final List<String> local = [];
+    final List<String> local =
+        assetsToDelete.where((a) => a.isLocal).map((a) => a.localId!).toList();
     // Delete asset from device
-    for (final Asset asset in assetsToDelete) {
-      if (asset.isLocal) {
-        local.add(asset.localId);
-      } else if (asset.deviceId == deviceId) {
-        // Delete asset on device if it is still present
-        var localAsset = await AssetEntity.fromId(asset.localId);
-        if (localAsset != null) {
-          local.add(localAsset.id);
-        }
-      }
-    }
     if (local.isNotEmpty) {
       try {
         return await PhotoManager.editor.deleteWithIds(local);
@@ -153,7 +142,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
   }
 }
 
-final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
+final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
   return AssetNotifier(
     ref.watch(assetServiceProvider),
     ref.watch(albumServiceProvider),

+ 175 - 0
mobile/lib/shared/services/hash.service.dart

@@ -0,0 +1,175 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:crypto/crypto.dart';
+import 'package:flutter/foundation.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
+import 'package:immich_mobile/shared/models/android_device_asset.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/device_asset.dart';
+import 'package:immich_mobile/shared/models/ios_device_asset.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/utils/builtin_extensions.dart';
+import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
+import 'package:photo_manager/photo_manager.dart';
+
+class HashService {
+  HashService(this._db, this._backgroundService);
+  final Isar _db;
+  final BackgroundService _backgroundService;
+  final _log = Logger('HashService');
+
+  /// Returns all assets that were successfully hashed
+  Future<List<Asset>> getHashedAssets(
+    AssetPathEntity album, {
+    int start = 0,
+    int end = 0x7fffffffffffffff,
+    Set<String>? excludedAssets,
+  }) async {
+    final entities = await album.getAssetListRange(start: start, end: end);
+    final filtered = excludedAssets == null
+        ? entities
+        : entities.where((e) => !excludedAssets.contains(e.id)).toList();
+    return _hashAssets(filtered);
+  }
+
+  /// Converts a list of [AssetEntity]s to [Asset]s including only those
+  /// that were successfully hashed. Hashes are looked up in a DB table
+  /// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
+  /// entries are newly hashed and added to the DB table.
+  Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async {
+    const int batchFileCount = 128;
+    const int batchDataSize = 1024 * 1024 * 1024; // 1GB
+
+    final ids = assetEntities
+        .map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
+        .toList();
+    final List<DeviceAsset?> hashes = await _lookupHashes(ids);
+    final List<DeviceAsset> toAdd = [];
+    final List<String> toHash = [];
+
+    int bytes = 0;
+
+    for (int i = 0; i < assetEntities.length; i++) {
+      if (hashes[i] != null) {
+        continue;
+      }
+      final file = await assetEntities[i].originFile;
+      if (file == null) {
+        _log.warning(
+          "Failed to get file for asset ${assetEntities[i].id}, skipping",
+        );
+        continue;
+      }
+      bytes += await file.length();
+      toHash.add(file.path);
+      final deviceAsset = Platform.isAndroid
+          ? AndroidDeviceAsset(id: ids[i] as int, hash: const [])
+          : IOSDeviceAsset(id: ids[i] as String, hash: const []);
+      toAdd.add(deviceAsset);
+      hashes[i] = deviceAsset;
+      if (toHash.length == batchFileCount || bytes >= batchDataSize) {
+        await _processBatch(toHash, toAdd);
+        toAdd.clear();
+        toHash.clear();
+        bytes = 0;
+      }
+    }
+    if (toHash.isNotEmpty) {
+      await _processBatch(toHash, toAdd);
+    }
+    return _mapAllHashedAssets(assetEntities, hashes);
+  }
+
+  /// Lookup hashes of assets by their local ID
+  Future<List<DeviceAsset?>> _lookupHashes(List<Object> ids) =>
+      Platform.isAndroid
+          ? _db.androidDeviceAssets.getAll(ids.cast())
+          : _db.iOSDeviceAssets.getAllById(ids.cast());
+
+  /// Processes a batch of files and saves any successfully hashed
+  /// values to the DB table.
+  Future<void> _processBatch(
+    final List<String> toHash,
+    final List<DeviceAsset> toAdd,
+  ) async {
+    final hashes = await _hashFiles(toHash);
+    bool anyNull = false;
+    for (int j = 0; j < hashes.length; j++) {
+      if (hashes[j]?.length == 20) {
+        toAdd[j].hash = hashes[j]!;
+      } else {
+        _log.warning("Failed to hash file ${toHash[j]}, skipping");
+        anyNull = true;
+      }
+    }
+    final validHashes = anyNull
+        ? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
+        : toAdd;
+    await _db.writeTxn(
+      () => Platform.isAndroid
+          ? _db.androidDeviceAssets.putAll(validHashes.cast())
+          : _db.iOSDeviceAssets.putAll(validHashes.cast()),
+    );
+    _log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
+  }
+
+  /// Hashes the given files and returns a list of the same length
+  /// files that could not be hashed have a `null` value
+  Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
+    if (Platform.isAndroid) {
+      final List<Uint8List?>? hashes =
+          await _backgroundService.digestFiles(paths);
+      if (hashes == null) {
+        throw Exception("Hashing ${paths.length} files failed");
+      }
+      return hashes;
+    } else if (Platform.isIOS) {
+      final List<Uint8List?> result = List.filled(paths.length, null);
+      for (int i = 0; i < paths.length; i++) {
+        result[i] = await _hashAssetDart(File(paths[i]));
+      }
+      return result;
+    } else {
+      throw Exception("_hashFiles implementation missing");
+    }
+  }
+
+  /// Hashes a single file using Dart's crypto package
+  Future<Uint8List?> _hashAssetDart(File f) async {
+    late Digest output;
+    final sink = sha1.startChunkedConversion(
+      ChunkedConversionSink<Digest>.withCallback((accumulated) {
+        output = accumulated.first;
+      }),
+    );
+    await for (final chunk in f.openRead()) {
+      sink.add(chunk);
+    }
+    sink.close();
+    return Uint8List.fromList(output.bytes);
+  }
+
+  /// Converts [AssetEntity]s that were successfully hashed to [Asset]s
+  List<Asset> _mapAllHashedAssets(
+    List<AssetEntity> assets,
+    List<DeviceAsset?> hashes,
+  ) {
+    final List<Asset> result = [];
+    for (int i = 0; i < assets.length; i++) {
+      if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
+        result.add(Asset.local(assets[i], hashes[i]!.hash));
+      }
+    }
+    return result;
+  }
+}
+
+final hashServiceProvider = Provider(
+  (ref) => HashService(
+    ref.watch(dbProvider),
+    ref.watch(backgroundServiceProvider),
+  ),
+);

+ 136 - 107
mobile/lib/shared/services/sync.service.dart

@@ -4,10 +4,12 @@ import 'package:collection/collection.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/etag.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/services/hash.service.dart';
 import 'package:immich_mobile/utils/async_mutex.dart';
 import 'package:immich_mobile/utils/builtin_extensions.dart';
 import 'package:immich_mobile/utils/diff.dart';
@@ -16,15 +18,17 @@ import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
-final syncServiceProvider =
-    Provider((ref) => SyncService(ref.watch(dbProvider)));
+final syncServiceProvider = Provider(
+  (ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)),
+);
 
 class SyncService {
   final Isar _db;
+  final HashService _hashService;
   final AsyncMutex _lock = AsyncMutex();
   final Logger _log = Logger('SyncService');
 
-  SyncService(this._db);
+  SyncService(this._db, this._hashService);
 
   // public methods:
 
@@ -33,6 +37,7 @@ class SyncService {
   Future<bool> syncUsersFromServer(List<User> users) async {
     users.sortBy((u) => u.id);
     final dbUsers = await _db.users.where().sortById().findAll();
+    assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!");
     final List<int> toDelete = [];
     final List<User> toUpsert = [];
     final changes = diffSortedListsSync(
@@ -108,40 +113,16 @@ class SyncService {
   // private methods:
 
   /// Syncs a new asset to the db. Returns `true` if successful
-  Future<bool> _syncNewAssetToDb(Asset newAsset) async {
-    final List<Asset> inDb = await _db.assets
-        .where()
-        .localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
-        .findAll();
-    Asset? match;
-    if (inDb.length == 1) {
-      // exactly one match: trivial case
-      match = inDb.first;
-    } else if (inDb.length > 1) {
-      // TODO instead of this heuristics: match by checksum once available
-      for (Asset a in inDb) {
-        if (a.ownerId == newAsset.ownerId &&
-            a.fileModifiedAt.isAtSameMomentAs(newAsset.fileModifiedAt)) {
-          assert(match == null);
-          match = a;
-        }
-      }
-      if (match == null) {
-        for (Asset a in inDb) {
-          if (a.ownerId == newAsset.ownerId) {
-            assert(match == null);
-            match = a;
-          }
-        }
-      }
-    }
-    if (match != null) {
+  Future<bool> _syncNewAssetToDb(Asset a) async {
+    final Asset? inDb =
+        await _db.assets.getByChecksumOwnerId(a.checksum, a.ownerId);
+    if (inDb != null) {
       // unify local/remote assets by replacing the
       // local-only asset in the DB with a local&remote asset
-      newAsset = match.updatedCopy(newAsset);
+      a = inDb.updatedCopy(a);
     }
     try {
-      await _db.writeTxn(() => newAsset.put(_db));
+      await _db.writeTxn(() => a.put(_db));
     } on IsarError catch (e) {
       _log.severe("Failed to put new asset into db: $e");
       return false;
@@ -162,11 +143,11 @@ class SyncService {
     final List<Asset> inDb = await _db.assets
         .filter()
         .ownerIdEqualTo(user.isarId)
-        .sortByDeviceId()
-        .thenByLocalId()
-        .thenByFileModifiedAt()
+        .sortByChecksum()
         .findAll();
-    remote.sort(Asset.compareByOwnerDeviceLocalIdModified);
+    assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
+
+    remote.sort(Asset.compareByChecksum);
     final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
     if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
       return false;
@@ -199,6 +180,7 @@ class SyncService {
       query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
     }
     final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
+    assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!");
 
     final List<Asset> toDelete = [];
     final List<Asset> existing = [];
@@ -245,16 +227,16 @@ class SyncService {
     if (dto.assetCount != dto.assets.length) {
       return false;
     }
-    final assetsInDb = await album.assets
-        .filter()
-        .sortByOwnerId()
-        .thenByDeviceId()
-        .thenByLocalId()
-        .thenByFileModifiedAt()
-        .findAll();
+    final assetsInDb =
+        await album.assets.filter().sortByOwnerId().thenByChecksum().findAll();
+    assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
     final List<Asset> assetsOnRemote = dto.getAssets();
-    assetsOnRemote.sort(Asset.compareByOwnerDeviceLocalIdModified);
-    final (toAdd, toUpdate, toUnlink) = _diffAssets(assetsOnRemote, assetsInDb);
+    assetsOnRemote.sort(Asset.compareByOwnerChecksum);
+    final (toAdd, toUpdate, toUnlink) = _diffAssets(
+      assetsOnRemote,
+      assetsInDb,
+      compare: Asset.compareByOwnerChecksum,
+    );
 
     // update shared users
     final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
@@ -297,6 +279,7 @@ class SyncService {
         await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
         await _db.albums.put(album);
       });
+      _log.info("Synced changes of remote album ${album.name} to DB");
     } on IsarError catch (e) {
       _log.severe("Failed to sync remote album to database $e");
     }
@@ -382,10 +365,11 @@ class SyncService {
     Set<String>? excludedAssets,
   ]) async {
     onDevice.sort((a, b) => a.id.compareTo(b.id));
-    final List<Album> inDb =
+    final inDb =
         await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
     final List<Asset> deleteCandidates = [];
     final List<Asset> existing = [];
+    assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!");
     final bool anyChanges = await diffSortedLists(
       onDevice,
       inDb,
@@ -447,14 +431,15 @@ class SyncService {
     final inDb = await album.assets
         .filter()
         .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
-        .deviceIdEqualTo(Store.get(StoreKey.deviceIdHash))
-        .sortByLocalId()
+        .sortByChecksum()
         .findAll();
+    assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
+    final int assetCountOnDevice = await ape.assetCountAsync;
     final List<Asset> onDevice =
-        await ape.getAssets(excludedAssets: excludedAssets);
-    onDevice.sort(Asset.compareByLocalId);
-    final (toAdd, toUpdate, toDelete) =
-        _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
+        await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
+    _removeDuplicates(onDevice);
+    // _removeDuplicates sorts `onDevice` by checksum
+    final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
     if (toAdd.isEmpty &&
         toUpdate.isEmpty &&
         toDelete.isEmpty &&
@@ -491,6 +476,9 @@ class SyncService {
         await _db.albums.put(album);
         album.thumbnail.value ??= await album.assets.filter().findFirst();
         await album.thumbnail.save();
+        await _db.eTags.put(
+          ETag(id: ape.eTagKeyAssetCount, value: assetCountOnDevice.toString()),
+        );
       });
       _log.info("Synced changes of local album ${ape.name} to DB");
     } on IsarError catch (e) {
@@ -503,8 +491,13 @@ class SyncService {
   /// fast path for common case: only new assets were added to device album
   /// returns `true` if successfull, else `false`
   Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
+    if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
+      return false;
+    }
     final int totalOnDevice = await ape.assetCountAsync;
-    final AssetPathEntity? modified = totalOnDevice > album.assetCount
+    final int lastKnownTotal =
+        (await _db.eTags.getById(ape.eTagKeyAssetCount))?.value?.toInt() ?? 0;
+    final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
         ? await ape.fetchPathProperties(
             filterOptionGroup: FilterOptionGroup(
               updateTimeCond: DateTimeCond(
@@ -517,17 +510,22 @@ class SyncService {
     if (modified == null) {
       return false;
     }
-    final List<Asset> newAssets = await modified.getAssets();
-    if (totalOnDevice != album.assets.length + newAssets.length) {
+    final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
+
+    if (totalOnDevice != lastKnownTotal + newAssets.length) {
       return false;
     }
     album.modifiedAt = ape.lastModified ?? DateTime.now();
+    _removeDuplicates(newAssets);
     final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
     try {
       await _db.writeTxn(() async {
         await _db.assets.putAll(updated);
         await album.assets.update(link: existingInDb + updated);
         await _db.albums.put(album);
+        await _db.eTags.put(
+          ETag(id: ape.eTagKeyAssetCount, value: totalOnDevice.toString()),
+        );
       });
       _log.info("Fast synced local album ${ape.name} to DB");
     } on IsarError catch (e) {
@@ -547,7 +545,9 @@ class SyncService {
   ]) async {
     _log.info("Syncing a new local album to DB: ${ape.name}");
     final Album a = Album.local(ape);
-    final assets = await ape.getAssets(excludedAssets: excludedAssets);
+    final assets =
+        await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
+    _removeDuplicates(assets);
     final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
     _log.info(
       "${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
@@ -570,44 +570,29 @@ class SyncService {
   Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
     List<Asset> assets,
   ) async {
-    if (assets.isEmpty) {
-      return ([].cast<Asset>(), [].cast<Asset>());
-    }
-    final List<Asset> inDb = await _db.assets
-        .where()
-        .anyOf(
-          assets,
-          (q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
-        )
-        .sortByOwnerId()
-        .thenByDeviceId()
-        .thenByLocalId()
-        .thenByFileModifiedAt()
-        .findAll();
-    assets.sort(Asset.compareByOwnerDeviceLocalIdModified);
-    final List<Asset> existing = [], toUpsert = [];
-    diffSortedListsSync(
-      inDb,
-      assets,
-      // do not compare by modified date because for some assets dates differ on
-      // client and server, thus never reaching "both" case below
-      compare: Asset.compareByOwnerDeviceLocalId,
-      both: (Asset a, Asset b) {
-        if (a.canUpdate(b)) {
-          toUpsert.add(a.updatedCopy(b));
-          return true;
-        } else {
-          existing.add(a);
-          return false;
-        }
-      },
-      onlyFirst: (Asset a) => _log.finer(
-        "_linkWithExistingFromDb encountered asset only in DB: $a",
-        null,
-        StackTrace.current,
-      ),
-      onlySecond: (Asset b) => toUpsert.add(b),
+    if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
+
+    final List<Asset?> inDb = await _db.assets.getAllByChecksumOwnerId(
+      assets.map((a) => a.checksum).toList(growable: false),
+      assets.map((a) => a.ownerId).toInt64List(),
     );
+    assert(inDb.length == assets.length);
+    final List<Asset> existing = [], toUpsert = [];
+    for (int i = 0; i < assets.length; i++) {
+      final Asset? b = inDb[i];
+      if (b == null) {
+        toUpsert.add(assets[i]);
+        continue;
+      }
+      if (b.canUpdate(assets[i])) {
+        final updated = b.updatedCopy(assets[i]);
+        assert(updated.id != Isar.autoIncrement);
+        toUpsert.add(updated);
+      } else {
+        existing.add(b);
+      }
+    }
+    assert(existing.length + toUpsert.length == assets.length);
     return (existing, toUpsert);
   }
 
@@ -627,10 +612,62 @@ class SyncService {
       });
       _log.info("Upserted ${assets.length} assets into the DB");
     } on IsarError catch (e) {
-      _log.warning(
+      _log.severe(
         "Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
       );
+      // give details on the errors
+      assets.sort(Asset.compareByOwnerChecksum);
+      final inDb = await _db.assets.getAllByChecksumOwnerId(
+        assets.map((e) => e.checksum).toList(growable: false),
+        assets.map((e) => e.ownerId).toInt64List(),
+      );
+      for (int i = 0; i < assets.length; i++) {
+        final Asset a = assets[i];
+        final Asset? b = inDb[i];
+        if (b == null) {
+          if (a.id != Isar.autoIncrement) {
+            _log.warning(
+              "Trying to update an asset that does not exist in DB:\n$a",
+            );
+          }
+        } else if (a.id != b.id) {
+          _log.warning(
+            "Trying to insert another asset with the same checksum+owner. In DB:\n$b\nTo insert:\n$a",
+          );
+        }
+      }
+      for (int i = 1; i < assets.length; i++) {
+        if (Asset.compareByOwnerChecksum(assets[i - 1], assets[i]) == 0) {
+          _log.warning(
+            "Trying to insert duplicate assets:\n${assets[i - 1]}\n${assets[i]}",
+          );
+        }
+      }
+    }
+  }
+
+  List<Asset> _removeDuplicates(List<Asset> assets) {
+    final int before = assets.length;
+    assets.sort(Asset.compareByOwnerChecksumCreatedModified);
+    assets.uniqueConsecutive(
+      compare: Asset.compareByOwnerChecksum,
+      onDuplicate: (a, b) =>
+          _log.info("Ignoring duplicate assets on device:\n$a\n$b"),
+    );
+    final int duplicates = before - assets.length;
+    if (duplicates > 0) {
+      _log.warning("Ignored $duplicates duplicate assets on device");
     }
+    return assets;
+  }
+
+  /// returns `true` if the albums differ on the surface
+  Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
+    return a.name != b.name ||
+        a.lastModified == null ||
+        !a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
+        await a.assetCountAsync !=
+            (await _db.eTags.getById(a.eTagKeyAssetCount))?.value?.toInt();
   }
 }
 
@@ -639,7 +676,7 @@ class SyncService {
   List<Asset> assets,
   List<Asset> inDb, {
   bool? remote,
-  int Function(Asset, Asset) compare = Asset.compareByOwnerDeviceLocalId,
+  int Function(Asset, Asset) compare = Asset.compareByChecksum,
 }) {
   final List<Asset> toAdd = [];
   final List<Asset> toUpdate = [];
@@ -663,7 +700,7 @@ class SyncService {
         }
       } else if (remote == false && a.isRemote) {
         if (a.isLocal) {
-          a.isLocal = false;
+          a.localId = null;
           toUpdate.add(a);
         }
       } else {
@@ -685,9 +722,9 @@ class SyncService {
     return const ([], []);
   }
   deleteCandidates.sort(Asset.compareById);
-  deleteCandidates.uniqueConsecutive((a) => a.id);
+  deleteCandidates.uniqueConsecutive(compare: Asset.compareById);
   existing.sort(Asset.compareById);
-  existing.uniqueConsecutive((a) => a.id);
+  existing.uniqueConsecutive(compare: Asset.compareById);
   final (tooAdd, toUpdate, toRemove) = _diffAssets(
     existing,
     deleteCandidates,
@@ -698,14 +735,6 @@ class SyncService {
   return (toRemove.map((e) => e.id).toList(), toUpdate);
 }
 
-/// returns `true` if the albums differ on the surface
-Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
-  return a.name != b.name ||
-      a.lastModified == null ||
-      !a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
-      await a.assetCountAsync != b.assetCount;
-}
-
 /// returns `true` if the albums differ on the surface
 bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
   return dto.assetCount != a.assetCount ||

+ 39 - 7
mobile/lib/shared/views/tab_controller_page.dart

@@ -6,12 +6,39 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
 import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
 
-class TabControllerPage extends ConsumerWidget {
+class TabControllerPage extends HookConsumerWidget {
   const TabControllerPage({Key? key}) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final refreshing = ref.watch(assetProvider);
+
+    Widget buildIcon(Widget icon) {
+      if (!refreshing) return icon;
+      return Stack(
+        alignment: Alignment.center,
+        clipBehavior: Clip.none,
+        children: [
+          icon,
+          Positioned(
+            right: -14,
+            child: SizedBox(
+              height: 12,
+              width: 12,
+              child: CircularProgressIndicator(
+                strokeWidth: 2,
+                valueColor: AlwaysStoppedAnimation<Color>(
+                  Theme.of(context).primaryColor,
+                ),
+              ),
+            ),
+          ),
+        ],
+      );
+    }
+
     navigationRail(TabsRouter tabsRouter) {
       return NavigationRail(
         labelType: NavigationRailLabelType.all,
@@ -83,9 +110,12 @@ class TabControllerPage extends ConsumerWidget {
             icon: const Icon(
               Icons.photo_library_outlined,
             ),
-            selectedIcon: Icon(
-              Icons.photo_library,
-              color: Theme.of(context).primaryColor,
+            selectedIcon: buildIcon(
+              Icon(
+                size: 24,
+                Icons.photo_library,
+                color: Theme.of(context).primaryColor,
+              ),
             ),
           ),
           NavigationDestination(
@@ -113,9 +143,11 @@ class TabControllerPage extends ConsumerWidget {
             icon: const Icon(
               Icons.photo_album_outlined,
             ),
-            selectedIcon: Icon(
-              Icons.photo_album_rounded,
-              color: Theme.of(context).primaryColor,
+            selectedIcon: buildIcon(
+              Icon(
+                Icons.photo_album_rounded,
+                color: Theme.of(context).primaryColor,
+              ),
             ),
           )
         ],

+ 18 - 3
mobile/lib/utils/builtin_extensions.dart

@@ -1,3 +1,5 @@
+import 'dart:typed_data';
+
 import 'package:collection/collection.dart';
 
 extension DurationExtension on String {
@@ -22,15 +24,20 @@ extension DurationExtension on String {
 }
 
 extension ListExtension<E> on List<E> {
-  List<E> uniqueConsecutive<T>([T Function(E element)? key]) {
-    key ??= (E e) => e as T;
+  List<E> uniqueConsecutive({
+    int Function(E a, E b)? compare,
+    void Function(E a, E b)? onDuplicate,
+  }) {
+    compare ??= (E a, E b) => a == b ? 0 : 1;
     int i = 1, j = 1;
     for (; i < length; i++) {
-      if (key(this[i]) != key(this[i - 1])) {
+      if (compare(this[i - 1], this[i]) != 0) {
         if (i != j) {
           this[j] = this[i];
         }
         j++;
+      } else if (onDuplicate != null) {
+        onDuplicate(this[i - 1], this[i]);
       }
     }
     length = length == 0 ? 0 : j;
@@ -45,3 +52,11 @@ extension ListExtension<E> on List<E> {
     return ListSlice<E>(this, start, end);
   }
 }
+
+extension IntListExtension on Iterable<int> {
+  Int64List toInt64List() {
+    final list = Int64List(length);
+    list.setAll(0, this);
+    return list;
+  }
+}

+ 5 - 3
mobile/lib/utils/migration.dart

@@ -8,11 +8,13 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
   final int version = Store.get(StoreKey.version, 1);
   switch (version) {
     case 1:
-      await _migrateV1ToV2(db);
+      await _migrateTo(db, 2);
+    case 2:
+      await _migrateTo(db, 3);
   }
 }
 
-Future<void> _migrateV1ToV2(Isar db) async {
+Future<void> _migrateTo(Isar db, int version) async {
   await clearAssetsAndAlbums(db);
-  await Store.put(StoreKey.version, 2);
+  await Store.put(StoreKey.version, version);
 }

+ 5 - 5
mobile/pubspec.lock

@@ -242,13 +242,13 @@ packages:
     source: hosted
     version: "0.3.3+4"
   crypto:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: crypto
-      sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67
+      sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
       url: "https://pub.dev"
     source: hosted
-    version: "3.0.2"
+    version: "3.0.3"
   csslib:
     dependency: transitive
     description:
@@ -333,10 +333,10 @@ packages:
     dependency: transitive
     description:
       name: ffi
-      sha256: a38574032c5f1dd06c4aee541789906c12ccaab8ba01446e800d9c5b79c4a978
+      sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99
       url: "https://pub.dev"
     source: hosted
-    version: "2.0.1"
+    version: "2.0.2"
   file:
     dependency: transitive
     description:

+ 1 - 0
mobile/pubspec.yaml

@@ -45,6 +45,7 @@ dependencies:
   isar_flutter_libs: *isar_version # contains Isar Core
   permission_handler: ^10.2.0
   device_info_plus: ^8.1.0
+  crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
 
   openapi:
     path: openapi

+ 1 - 2
mobile/test/asset_grid_data_structure_test.dart

@@ -13,8 +13,8 @@ void main() {
 
     testAssets.add(
       Asset(
+        checksum: "",
         localId: '$i',
-        deviceId: 1,
         ownerId: 1,
         fileCreatedAt: date,
         fileModifiedAt: date,
@@ -23,7 +23,6 @@ void main() {
         type: AssetType.image,
         fileName: '',
         isFavorite: false,
-        isLocal: false,
         isArchived: false,
       ),
     );

+ 6 - 1
mobile/test/builtin_extensions_text.dart → mobile/test/builtin_extensions_test.dart

@@ -43,7 +43,12 @@ void main() {
 
     test('withKey', () {
       final a = ["a", "bb", "cc", "ddd"];
-      expect(a.uniqueConsecutive((s) => s.length), ["a", "bb", "ddd"]);
+      expect(
+        a.uniqueConsecutive(
+          compare: (s1, s2) => s1.length.compareTo(s2.length),
+        ),
+        ["a", "bb", "ddd"],
+      );
     });
   });
 }

+ 34 - 30
mobile/test/sync_service_test.dart

@@ -6,32 +6,33 @@ import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/logger_message.model.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/services/hash.service.dart';
 import 'package:immich_mobile/shared/services/immich_logger.service.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
 import 'package:isar/isar.dart';
+import 'package:mockito/mockito.dart';
 
 void main() {
   Asset makeAsset({
-    required String localId,
+    required String checksum,
+    String? localId,
     String? remoteId,
     int deviceId = 1,
     int ownerId = 590700560494856554, // hash of "1"
-    bool isLocal = false,
   }) {
     final DateTime date = DateTime(2000);
     return Asset(
+      checksum: checksum,
       localId: localId,
       remoteId: remoteId,
-      deviceId: deviceId,
       ownerId: ownerId,
       fileCreatedAt: date,
       fileModifiedAt: date,
       updatedAt: date,
       durationInSeconds: 0,
       type: AssetType.image,
-      fileName: localId,
+      fileName: localId ?? remoteId ?? "",
       isFavorite: false,
-      isLocal: isLocal,
       isArchived: false,
     );
   }
@@ -53,6 +54,7 @@ void main() {
 
   group('Test SyncService grouped', () {
     late final Isar db;
+    final MockHashService hs = MockHashService();
     final owner = User(
       id: "1",
       updatedAt: DateTime.now(),
@@ -71,11 +73,11 @@ void main() {
       await Store.put(StoreKey.currentUser, owner);
     });
     final List<Asset> initialAssets = [
-      makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
-      makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
-      makeAsset(localId: "1", remoteId: "1-1", isLocal: true),
-      makeAsset(localId: "2", isLocal: true),
-      makeAsset(localId: "3", isLocal: true),
+      makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
+      makeAsset(checksum: "b", remoteId: "2-1", deviceId: 2),
+      makeAsset(checksum: "c", localId: "1", remoteId: "1-1"),
+      makeAsset(checksum: "d", localId: "2"),
+      makeAsset(checksum: "e", localId: "3"),
     ];
     setUp(() {
       db.writeTxnSync(() {
@@ -84,11 +86,11 @@ void main() {
       });
     });
     test('test inserting existing assets', () async {
-      SyncService s = SyncService(db);
+      SyncService s = SyncService(db, hs);
       final List<Asset> remoteAssets = [
-        makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
-        makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
-        makeAsset(localId: "1", remoteId: "1-1"),
+        makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
+        makeAsset(checksum: "b", remoteId: "2-1", deviceId: 2),
+        makeAsset(checksum: "c", remoteId: "1-1"),
       ];
       expect(db.assets.countSync(), 5);
       final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
@@ -97,14 +99,14 @@ void main() {
     });
 
     test('test inserting new assets', () async {
-      SyncService s = SyncService(db);
+      SyncService s = SyncService(db, hs);
       final List<Asset> remoteAssets = [
-        makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
-        makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
-        makeAsset(localId: "1", remoteId: "1-1"),
-        makeAsset(localId: "2", remoteId: "1-2"),
-        makeAsset(localId: "4", remoteId: "1-4"),
-        makeAsset(localId: "1", remoteId: "3-1", deviceId: 3),
+        makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
+        makeAsset(checksum: "b", remoteId: "2-1", deviceId: 2),
+        makeAsset(checksum: "c", remoteId: "1-1"),
+        makeAsset(checksum: "d", remoteId: "1-2"),
+        makeAsset(checksum: "f", remoteId: "1-4"),
+        makeAsset(checksum: "g", remoteId: "3-1", deviceId: 3),
       ];
       expect(db.assets.countSync(), 5);
       final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
@@ -113,14 +115,14 @@ void main() {
     });
 
     test('test syncing duplicate assets', () async {
-      SyncService s = SyncService(db);
+      SyncService s = SyncService(db, hs);
       final List<Asset> remoteAssets = [
-        makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
-        makeAsset(localId: "1", remoteId: "1-1"),
-        makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
-        makeAsset(localId: "1", remoteId: "2-1b", deviceId: 2),
-        makeAsset(localId: "1", remoteId: "2-1c", deviceId: 2),
-        makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2),
+        makeAsset(checksum: "a", remoteId: "0-1", deviceId: 0),
+        makeAsset(checksum: "b", remoteId: "1-1"),
+        makeAsset(checksum: "c", remoteId: "2-1", deviceId: 2),
+        makeAsset(checksum: "h", remoteId: "2-1b", deviceId: 2),
+        makeAsset(checksum: "i", remoteId: "2-1c", deviceId: 2),
+        makeAsset(checksum: "j", remoteId: "2-1d", deviceId: 2),
       ];
       expect(db.assets.countSync(), 5);
       final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
@@ -133,11 +135,13 @@ void main() {
       final bool c3 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c3, true);
       expect(db.assets.countSync(), 7);
-      remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2));
-      remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2));
+      remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e", deviceId: 2));
+      remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2", deviceId: 2));
       final bool c4 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c4, true);
       expect(db.assets.countSync(), 9);
     });
   });
 }
+
+class MockHashService extends Mock implements HashService {}