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>
This commit is contained in:
Fynn Petersen-Frey 2023-06-10 20:13:59 +02:00 committed by GitHub
parent 053a0482b4
commit 73075c64d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 2315 additions and 507 deletions

View file

@ -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"

View file

@ -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;

View file

@ -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'

View file

@ -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,

View file

@ -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),
)),
);

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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;

View file

@ -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";
}

View file

@ -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;
}

View file

@ -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');
});
}
}

View file

@ -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;
/// stores the raw SHA1 bytes as a base64 String
/// because Isar cannot sort lists of byte arrays
@Index(
unique: true,
replace: false,
type: IndexType.hash,
composite: [CompositeIndex("ownerId")],
)
String checksum;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@Index(
unique: false,
replace: false,
type: IndexType.hash,
composite: [CompositeIndex('deviceId')],
)
String localId;
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) {
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;
}
/// 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 compareById(Asset a, Asset b) => a.id.compareTo(b.id);
static int compareByLocalId(Asset a, Asset b) =>
a.localId.compareTo(b.localId);
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;
return compareByChecksum(a, b);
}
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);
}
@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));
}
}

View file

@ -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) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'localId_deviceId',
value: [localId, deviceId],
));
});
}
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, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'localId_deviceId',
lower: [localId, deviceId],
includeLower: include,
upper: [localId],
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> localIdEqualToDeviceIdLessThan(
String localId,
int deviceId, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'localId_deviceId',
lower: [localId],
upper: [localId, deviceId],
includeUpper: include,
));
});
}
QueryBuilder<Asset, Asset, QAfterWhereClause> localIdEqualToDeviceIdBetween(
String localId,
int lowerDeviceId,
int upperDeviceId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'localId_deviceId',
lower: [localId, lowerDeviceId],
includeLower: includeLower,
upper: [localId, upperDeviceId],
includeUpper: includeUpper,
));
});
}
}
extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdEqualTo(int value) {
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'deviceId',
property: r'checksum',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdGreaterThan(
int value, {
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'deviceId',
property: r'checksum',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdLessThan(
int value, {
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'deviceId',
property: r'checksum',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> deviceIdBetween(
int lower,
int upper, {
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.addFilterCondition(FilterCondition.between(
property: r'deviceId',
property: r'checksum',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'checksum',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'checksum',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'checksum',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> checksumMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
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');
});

View file

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

View file

@ -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);
}

View file

@ -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');
});
}
}

View file

@ -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),

View file

@ -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),
),
);

View file

@ -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,11 +612,63 @@ 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();
}
}
/// Returns a triple(toAdd, toUpdate, toRemove)
@ -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 ||

View file

@ -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,
),
),
)
],

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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:

View file

@ -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

View file

@ -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,
),
);

View file

@ -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"],
);
});
});
}

View file

@ -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 {}