mirror of
https://github.com/immich-app/immich.git
synced 2025-06-01 04:36:19 -04:00
refactor(mobile): device asset entity to use modified time (#17064)
* refactor: device asset entity to use modified time * chore: cleanup * refactor: remove album media dependency from hashservice * refactor: return updated copy of asset * add hash service tests * chore: rename hash batch constants * chore: log the number of assets processed during migration * chore: more logs * refactor: use lookup and more tests * use sort approach * refactor hash service to use for loop instead * refactor: rename to getByIds --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
1d4815d4f9
commit
69d75107d4
@ -61,6 +61,7 @@ custom_lint:
|
|||||||
# refactor to make the providers and services testable
|
# refactor to make the providers and services testable
|
||||||
- lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler
|
- lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler
|
||||||
- lib/services/{background,backup}.service.dart # uses only PMProgressHandler
|
- lib/services/{background,backup}.service.dart # uses only PMProgressHandler
|
||||||
|
- test/**.dart
|
||||||
- import_rule_isar:
|
- import_rule_isar:
|
||||||
message: isar must only be used in entities and repositories
|
message: isar must only be used in entities and repositories
|
||||||
restrict: package:isar
|
restrict: package:isar
|
||||||
@ -150,7 +151,6 @@ dart_code_metrics:
|
|||||||
- avoid-unnecessary-continue
|
- avoid-unnecessary-continue
|
||||||
- avoid-unnecessary-nullable-return-type: false
|
- avoid-unnecessary-nullable-return-type: false
|
||||||
- binary-expression-operand-order
|
- binary-expression-operand-order
|
||||||
- move-variable-outside-iteration
|
|
||||||
- pattern-fields-ordering
|
- pattern-fields-ordering
|
||||||
- prefer-abstract-final-static-class
|
- prefer-abstract-final-static-class
|
||||||
- prefer-commenting-future-delayed
|
- prefer-commenting-future-delayed
|
||||||
|
@ -4,3 +4,6 @@ const double downloadFailed = -2;
|
|||||||
|
|
||||||
// Number of log entries to retain on app start
|
// Number of log entries to retain on app start
|
||||||
const int kLogTruncateLimit = 250;
|
const int kLogTruncateLimit = 250;
|
||||||
|
|
||||||
|
const int kBatchHashFileLimit = 128;
|
||||||
|
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||||
|
12
mobile/lib/domain/interfaces/device_asset.interface.dart
Normal file
12
mobile/lib/domain/interfaces/device_asset.interface.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IDeviceAssetRepository implements IDatabaseRepository {
|
||||||
|
Future<bool> updateAll(List<DeviceAsset> assetHash);
|
||||||
|
|
||||||
|
Future<List<DeviceAsset>> getByIds(List<String> localIds);
|
||||||
|
|
||||||
|
Future<void> deleteIds(List<String> ids);
|
||||||
|
}
|
44
mobile/lib/domain/models/device_asset.model.dart
Normal file
44
mobile/lib/domain/models/device_asset.model.dart
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class DeviceAsset {
|
||||||
|
final String assetId;
|
||||||
|
final Uint8List hash;
|
||||||
|
final DateTime modifiedTime;
|
||||||
|
|
||||||
|
const DeviceAsset({
|
||||||
|
required this.assetId,
|
||||||
|
required this.hash,
|
||||||
|
required this.modifiedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(covariant DeviceAsset other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other.assetId == assetId &&
|
||||||
|
other.hash == hash &&
|
||||||
|
other.modifiedTime == modifiedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return assetId.hashCode ^ hash.hashCode ^ modifiedTime.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DeviceAsset(assetId: $assetId, hash: $hash, modifiedTime: $modifiedTime)';
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAsset copyWith({
|
||||||
|
String? assetId,
|
||||||
|
Uint8List? hash,
|
||||||
|
DateTime? modifiedTime,
|
||||||
|
}) {
|
||||||
|
return DeviceAsset(
|
||||||
|
assetId: assetId ?? this.assetId,
|
||||||
|
hash: hash ?? this.hash,
|
||||||
|
modifiedTime: modifiedTime ?? this.modifiedTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
||||||
as entity;
|
as entity;
|
||||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||||
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@ -358,7 +359,7 @@ class Asset {
|
|||||||
// take most values from newer asset
|
// take most values from newer asset
|
||||||
// keep vales that can never be set by the asset not in DB
|
// keep vales that can never be set by the asset not in DB
|
||||||
if (a.isRemote) {
|
if (a.isRemote) {
|
||||||
return a._copyWith(
|
return a.copyWith(
|
||||||
id: id,
|
id: id,
|
||||||
localId: localId,
|
localId: localId,
|
||||||
width: a.width ?? width,
|
width: a.width ?? width,
|
||||||
@ -366,7 +367,7 @@ class Asset {
|
|||||||
exifInfo: a.exifInfo?.copyWith(assetId: id) ?? exifInfo,
|
exifInfo: a.exifInfo?.copyWith(assetId: id) ?? exifInfo,
|
||||||
);
|
);
|
||||||
} else if (isRemote) {
|
} else if (isRemote) {
|
||||||
return _copyWith(
|
return copyWith(
|
||||||
localId: localId ?? a.localId,
|
localId: localId ?? a.localId,
|
||||||
width: width ?? a.width,
|
width: width ?? a.width,
|
||||||
height: height ?? a.height,
|
height: height ?? a.height,
|
||||||
@ -374,7 +375,7 @@ class Asset {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// TODO: Revisit this and remove all bool field assignments
|
// TODO: Revisit this and remove all bool field assignments
|
||||||
return a._copyWith(
|
return a.copyWith(
|
||||||
id: id,
|
id: id,
|
||||||
remoteId: remoteId,
|
remoteId: remoteId,
|
||||||
livePhotoVideoId: livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
@ -394,7 +395,7 @@ class Asset {
|
|||||||
// fill in potentially missing values, i.e. merge assets
|
// fill in potentially missing values, i.e. merge assets
|
||||||
if (a.isRemote) {
|
if (a.isRemote) {
|
||||||
// values from remote take precedence
|
// values from remote take precedence
|
||||||
return _copyWith(
|
return copyWith(
|
||||||
remoteId: a.remoteId,
|
remoteId: a.remoteId,
|
||||||
width: a.width,
|
width: a.width,
|
||||||
height: a.height,
|
height: a.height,
|
||||||
@ -416,7 +417,7 @@ class Asset {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// add only missing values (and set isLocal to true)
|
// add only missing values (and set isLocal to true)
|
||||||
return _copyWith(
|
return copyWith(
|
||||||
localId: localId ?? a.localId,
|
localId: localId ?? a.localId,
|
||||||
width: width ?? a.width,
|
width: width ?? a.width,
|
||||||
height: height ?? a.height,
|
height: height ?? a.height,
|
||||||
@ -427,7 +428,7 @@ class Asset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Asset _copyWith({
|
Asset copyWith({
|
||||||
Id? id,
|
Id? id,
|
||||||
String? checksum,
|
String? checksum,
|
||||||
String? remoteId,
|
String? remoteId,
|
||||||
@ -488,6 +489,9 @@ class Asset {
|
|||||||
|
|
||||||
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
||||||
|
|
||||||
|
static int compareByLocalId(Asset a, Asset b) =>
|
||||||
|
compareToNullable(a.localId, b.localId);
|
||||||
|
|
||||||
static int compareByChecksum(Asset a, Asset b) =>
|
static int compareByChecksum(Asset a, Asset b) =>
|
||||||
a.checksum.compareTo(b.checksum);
|
a.checksum.compareTo(b.checksum);
|
||||||
|
|
||||||
|
36
mobile/lib/infrastructure/entities/device_asset.entity.dart
Normal file
36
mobile/lib/infrastructure/entities/device_asset.entity.dart
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
part 'device_asset.entity.g.dart';
|
||||||
|
|
||||||
|
@Collection(inheritance: false)
|
||||||
|
class DeviceAssetEntity {
|
||||||
|
Id get id => fastHash(assetId);
|
||||||
|
|
||||||
|
@Index(replace: true, unique: true, type: IndexType.hash)
|
||||||
|
final String assetId;
|
||||||
|
@Index(unique: false, type: IndexType.hash)
|
||||||
|
final List<byte> hash;
|
||||||
|
final DateTime modifiedTime;
|
||||||
|
|
||||||
|
const DeviceAssetEntity({
|
||||||
|
required this.assetId,
|
||||||
|
required this.hash,
|
||||||
|
required this.modifiedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
DeviceAsset toModel() => DeviceAsset(
|
||||||
|
assetId: assetId,
|
||||||
|
hash: Uint8List.fromList(hash),
|
||||||
|
modifiedTime: modifiedTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
static DeviceAssetEntity fromDto(DeviceAsset dto) => DeviceAssetEntity(
|
||||||
|
assetId: dto.assetId,
|
||||||
|
hash: dto.hash,
|
||||||
|
modifiedTime: dto.modifiedTime,
|
||||||
|
);
|
||||||
|
}
|
895
mobile/lib/infrastructure/entities/device_asset.entity.g.dart
generated
Normal file
895
mobile/lib/infrastructure/entities/device_asset.entity.g.dart
generated
Normal file
@ -0,0 +1,895 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'device_asset.entity.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 GetDeviceAssetEntityCollection on Isar {
|
||||||
|
IsarCollection<DeviceAssetEntity> get deviceAssetEntitys => this.collection();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceAssetEntitySchema = CollectionSchema(
|
||||||
|
name: r'DeviceAssetEntity',
|
||||||
|
id: 6967030785073446271,
|
||||||
|
properties: {
|
||||||
|
r'assetId': PropertySchema(
|
||||||
|
id: 0,
|
||||||
|
name: r'assetId',
|
||||||
|
type: IsarType.string,
|
||||||
|
),
|
||||||
|
r'hash': PropertySchema(
|
||||||
|
id: 1,
|
||||||
|
name: r'hash',
|
||||||
|
type: IsarType.byteList,
|
||||||
|
),
|
||||||
|
r'modifiedTime': PropertySchema(
|
||||||
|
id: 2,
|
||||||
|
name: r'modifiedTime',
|
||||||
|
type: IsarType.dateTime,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
estimateSize: _deviceAssetEntityEstimateSize,
|
||||||
|
serialize: _deviceAssetEntitySerialize,
|
||||||
|
deserialize: _deviceAssetEntityDeserialize,
|
||||||
|
deserializeProp: _deviceAssetEntityDeserializeProp,
|
||||||
|
idName: r'id',
|
||||||
|
indexes: {
|
||||||
|
r'assetId': IndexSchema(
|
||||||
|
id: 174362542210192109,
|
||||||
|
name: r'assetId',
|
||||||
|
unique: true,
|
||||||
|
replace: true,
|
||||||
|
properties: [
|
||||||
|
IndexPropertySchema(
|
||||||
|
name: r'assetId',
|
||||||
|
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: _deviceAssetEntityGetId,
|
||||||
|
getLinks: _deviceAssetEntityGetLinks,
|
||||||
|
attach: _deviceAssetEntityAttach,
|
||||||
|
version: '3.1.8',
|
||||||
|
);
|
||||||
|
|
||||||
|
int _deviceAssetEntityEstimateSize(
|
||||||
|
DeviceAssetEntity object,
|
||||||
|
List<int> offsets,
|
||||||
|
Map<Type, List<int>> allOffsets,
|
||||||
|
) {
|
||||||
|
var bytesCount = offsets.last;
|
||||||
|
bytesCount += 3 + object.assetId.length * 3;
|
||||||
|
bytesCount += 3 + object.hash.length;
|
||||||
|
return bytesCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deviceAssetEntitySerialize(
|
||||||
|
DeviceAssetEntity object,
|
||||||
|
IsarWriter writer,
|
||||||
|
List<int> offsets,
|
||||||
|
Map<Type, List<int>> allOffsets,
|
||||||
|
) {
|
||||||
|
writer.writeString(offsets[0], object.assetId);
|
||||||
|
writer.writeByteList(offsets[1], object.hash);
|
||||||
|
writer.writeDateTime(offsets[2], object.modifiedTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAssetEntity _deviceAssetEntityDeserialize(
|
||||||
|
Id id,
|
||||||
|
IsarReader reader,
|
||||||
|
List<int> offsets,
|
||||||
|
Map<Type, List<int>> allOffsets,
|
||||||
|
) {
|
||||||
|
final object = DeviceAssetEntity(
|
||||||
|
assetId: reader.readString(offsets[0]),
|
||||||
|
hash: reader.readByteList(offsets[1]) ?? [],
|
||||||
|
modifiedTime: reader.readDateTime(offsets[2]),
|
||||||
|
);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
P _deviceAssetEntityDeserializeProp<P>(
|
||||||
|
IsarReader reader,
|
||||||
|
int propertyId,
|
||||||
|
int offset,
|
||||||
|
Map<Type, List<int>> allOffsets,
|
||||||
|
) {
|
||||||
|
switch (propertyId) {
|
||||||
|
case 0:
|
||||||
|
return (reader.readString(offset)) as P;
|
||||||
|
case 1:
|
||||||
|
return (reader.readByteList(offset) ?? []) as P;
|
||||||
|
case 2:
|
||||||
|
return (reader.readDateTime(offset)) as P;
|
||||||
|
default:
|
||||||
|
throw IsarError('Unknown property with id $propertyId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Id _deviceAssetEntityGetId(DeviceAssetEntity object) {
|
||||||
|
return object.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<IsarLinkBase<dynamic>> _deviceAssetEntityGetLinks(
|
||||||
|
DeviceAssetEntity object) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deviceAssetEntityAttach(
|
||||||
|
IsarCollection<dynamic> col, Id id, DeviceAssetEntity object) {}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityByIndex on IsarCollection<DeviceAssetEntity> {
|
||||||
|
Future<DeviceAssetEntity?> getByAssetId(String assetId) {
|
||||||
|
return getByIndex(r'assetId', [assetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAssetEntity? getByAssetIdSync(String assetId) {
|
||||||
|
return getByIndexSync(r'assetId', [assetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> deleteByAssetId(String assetId) {
|
||||||
|
return deleteByIndex(r'assetId', [assetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool deleteByAssetIdSync(String assetId) {
|
||||||
|
return deleteByIndexSync(r'assetId', [assetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DeviceAssetEntity?>> getAllByAssetId(List<String> assetIdValues) {
|
||||||
|
final values = assetIdValues.map((e) => [e]).toList();
|
||||||
|
return getAllByIndex(r'assetId', values);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DeviceAssetEntity?> getAllByAssetIdSync(List<String> assetIdValues) {
|
||||||
|
final values = assetIdValues.map((e) => [e]).toList();
|
||||||
|
return getAllByIndexSync(r'assetId', values);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteAllByAssetId(List<String> assetIdValues) {
|
||||||
|
final values = assetIdValues.map((e) => [e]).toList();
|
||||||
|
return deleteAllByIndex(r'assetId', values);
|
||||||
|
}
|
||||||
|
|
||||||
|
int deleteAllByAssetIdSync(List<String> assetIdValues) {
|
||||||
|
final values = assetIdValues.map((e) => [e]).toList();
|
||||||
|
return deleteAllByIndexSync(r'assetId', values);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Id> putByAssetId(DeviceAssetEntity object) {
|
||||||
|
return putByIndex(r'assetId', object);
|
||||||
|
}
|
||||||
|
|
||||||
|
Id putByAssetIdSync(DeviceAssetEntity object, {bool saveLinks = true}) {
|
||||||
|
return putByIndexSync(r'assetId', object, saveLinks: saveLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Id>> putAllByAssetId(List<DeviceAssetEntity> objects) {
|
||||||
|
return putAllByIndex(r'assetId', objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Id> putAllByAssetIdSync(List<DeviceAssetEntity> objects,
|
||||||
|
{bool saveLinks = true}) {
|
||||||
|
return putAllByIndexSync(r'assetId', objects, saveLinks: saveLinks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQueryWhereSort
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QWhere> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhere> anyId() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addWhereClause(const IdWhereClause.any());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQueryWhere
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QWhereClause> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
|
||||||
|
idEqualTo(Id id) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addWhereClause(IdWhereClause.between(
|
||||||
|
lower: id,
|
||||||
|
upper: id,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
|
||||||
|
idGreaterThan(Id id, {bool include = false}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addWhereClause(
|
||||||
|
IdWhereClause.greaterThan(lower: id, includeLower: include),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
|
||||||
|
idLessThan(Id id, {bool include = false}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addWhereClause(
|
||||||
|
IdWhereClause.lessThan(upper: id, includeUpper: include),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
|
||||||
|
assetIdEqualTo(String assetId) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||||
|
indexName: r'assetId',
|
||||||
|
value: [assetId],
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
|
||||||
|
assetIdNotEqualTo(String assetId) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
if (query.whereSort == Sort.asc) {
|
||||||
|
return query
|
||||||
|
.addWhereClause(IndexWhereClause.between(
|
||||||
|
indexName: r'assetId',
|
||||||
|
lower: [],
|
||||||
|
upper: [assetId],
|
||||||
|
includeUpper: false,
|
||||||
|
))
|
||||||
|
.addWhereClause(IndexWhereClause.between(
|
||||||
|
indexName: r'assetId',
|
||||||
|
lower: [assetId],
|
||||||
|
includeLower: false,
|
||||||
|
upper: [],
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return query
|
||||||
|
.addWhereClause(IndexWhereClause.between(
|
||||||
|
indexName: r'assetId',
|
||||||
|
lower: [assetId],
|
||||||
|
includeLower: false,
|
||||||
|
upper: [],
|
||||||
|
))
|
||||||
|
.addWhereClause(IndexWhereClause.between(
|
||||||
|
indexName: r'assetId',
|
||||||
|
lower: [],
|
||||||
|
upper: [assetId],
|
||||||
|
includeUpper: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
|
||||||
|
hashEqualTo(List<int> hash) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||||
|
indexName: r'hash',
|
||||||
|
value: [hash],
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, 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 DeviceAssetEntityQueryFilter
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QFilterCondition> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdEqualTo(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'assetId',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdGreaterThan(
|
||||||
|
String value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
include: include,
|
||||||
|
property: r'assetId',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdLessThan(
|
||||||
|
String value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.lessThan(
|
||||||
|
include: include,
|
||||||
|
property: r'assetId',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdBetween(
|
||||||
|
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'assetId',
|
||||||
|
lower: lower,
|
||||||
|
includeLower: includeLower,
|
||||||
|
upper: upper,
|
||||||
|
includeUpper: includeUpper,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdStartsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.startsWith(
|
||||||
|
property: r'assetId',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdEndsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.endsWith(
|
||||||
|
property: r'assetId',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdContains(String value, {bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.contains(
|
||||||
|
property: r'assetId',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdMatches(String pattern, {bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.matches(
|
||||||
|
property: r'assetId',
|
||||||
|
wildcard: pattern,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdIsEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'assetId',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
assetIdIsNotEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
property: r'assetId',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
hashElementEqualTo(int value) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'hash',
|
||||||
|
value: value,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
hashLengthEqualTo(int length) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.listLength(
|
||||||
|
r'hash',
|
||||||
|
length,
|
||||||
|
true,
|
||||||
|
length,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
hashIsEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.listLength(
|
||||||
|
r'hash',
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
hashIsNotEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.listLength(
|
||||||
|
r'hash',
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
999999,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
hashLengthLessThan(
|
||||||
|
int length, {
|
||||||
|
bool include = false,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.listLength(
|
||||||
|
r'hash',
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
length,
|
||||||
|
include,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
hashLengthGreaterThan(
|
||||||
|
int length, {
|
||||||
|
bool include = false,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.listLength(
|
||||||
|
r'hash',
|
||||||
|
length,
|
||||||
|
include,
|
||||||
|
999999,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
idEqualTo(Id value) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'id',
|
||||||
|
value: value,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, 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<DeviceAssetEntity, DeviceAssetEntity, 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,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
modifiedTimeEqualTo(DateTime value) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'modifiedTime',
|
||||||
|
value: value,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
modifiedTimeGreaterThan(
|
||||||
|
DateTime value, {
|
||||||
|
bool include = false,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
include: include,
|
||||||
|
property: r'modifiedTime',
|
||||||
|
value: value,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
modifiedTimeLessThan(
|
||||||
|
DateTime value, {
|
||||||
|
bool include = false,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.lessThan(
|
||||||
|
include: include,
|
||||||
|
property: r'modifiedTime',
|
||||||
|
value: value,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
|
||||||
|
modifiedTimeBetween(
|
||||||
|
DateTime lower,
|
||||||
|
DateTime upper, {
|
||||||
|
bool includeLower = true,
|
||||||
|
bool includeUpper = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.between(
|
||||||
|
property: r'modifiedTime',
|
||||||
|
lower: lower,
|
||||||
|
includeLower: includeLower,
|
||||||
|
upper: upper,
|
||||||
|
includeUpper: includeUpper,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQueryObject
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QFilterCondition> {}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQueryLinks
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QFilterCondition> {}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQuerySortBy
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QSortBy> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
sortByAssetId() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'assetId', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
sortByAssetIdDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'assetId', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
sortByModifiedTime() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'modifiedTime', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
sortByModifiedTimeDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'modifiedTime', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQuerySortThenBy
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QSortThenBy> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
thenByAssetId() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'assetId', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
thenByAssetIdDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'assetId', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy> thenById() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'id', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
thenByIdDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'id', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
thenByModifiedTime() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'modifiedTime', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
|
||||||
|
thenByModifiedTimeDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'modifiedTime', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQueryWhereDistinct
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct>
|
||||||
|
distinctByAssetId({bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addDistinctBy(r'assetId', caseSensitive: caseSensitive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct>
|
||||||
|
distinctByHash() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addDistinctBy(r'hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct>
|
||||||
|
distinctByModifiedTime() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addDistinctBy(r'modifiedTime');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DeviceAssetEntityQueryProperty
|
||||||
|
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QQueryProperty> {
|
||||||
|
QueryBuilder<DeviceAssetEntity, int, QQueryOperations> idProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, String, QQueryOperations> assetIdProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'assetId');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, List<int>, QQueryOperations> hashProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<DeviceAssetEntity, DateTime, QQueryOperations>
|
||||||
|
modifiedTimeProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'modifiedTime');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
class IsarDeviceAssetRepository extends IsarDatabaseRepository
|
||||||
|
implements IDeviceAssetRepository {
|
||||||
|
final Isar _db;
|
||||||
|
|
||||||
|
const IsarDeviceAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteIds(List<String> ids) {
|
||||||
|
return transaction(() async {
|
||||||
|
await _db.deviceAssetEntitys.deleteAllByAssetId(ids.toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DeviceAsset>> getByIds(List<String> localIds) {
|
||||||
|
return _db.deviceAssetEntitys
|
||||||
|
.where()
|
||||||
|
.anyOf(localIds, (query, id) => query.assetIdEqualTo(id))
|
||||||
|
.findAll()
|
||||||
|
.then((value) => value.map((e) => e.toModel()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> updateAll(List<DeviceAsset> assetHash) {
|
||||||
|
return transaction(() async {
|
||||||
|
await _db.deviceAssetEntitys
|
||||||
|
.putAll(assetHash.map(DeviceAssetEntity.fromDto).toList());
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/device_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/interfaces/database.interface.dart';
|
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||||
|
|
||||||
abstract interface class IAssetRepository implements IDatabaseRepository {
|
abstract interface class IAssetRepository implements IDatabaseRepository {
|
||||||
@ -50,10 +49,6 @@ abstract interface class IAssetRepository implements IDatabaseRepository {
|
|||||||
int limit = 100,
|
int limit = 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids);
|
|
||||||
|
|
||||||
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets);
|
|
||||||
|
|
||||||
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets);
|
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets);
|
||||||
|
|
||||||
Future<List<String>> getAllDuplicatedAssetIds();
|
Future<List<String>> getAllDuplicatedAssetIds();
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
|
||||||
|
final deviceAssetRepositoryProvider = Provider<IDeviceAssetRepository>(
|
||||||
|
(ref) => IsarDeviceAssetRepository(ref.watch(isarProvider)),
|
||||||
|
);
|
@ -1,12 +1,7 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/device_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
@ -158,19 +153,6 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
|||||||
return _getMatchesImpl(query, fastHash(ownerId), assets, limit);
|
return _getMatchesImpl(query, fastHash(ownerId), assets, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids) =>
|
|
||||||
Platform.isAndroid
|
|
||||||
? db.androidDeviceAssets.getAll(ids.cast())
|
|
||||||
: db.iOSDeviceAssets.getAllById(ids.cast());
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets) => txn(
|
|
||||||
() => Platform.isAndroid
|
|
||||||
? db.androidDeviceAssets.putAll(deviceAssets.cast())
|
|
||||||
: db.iOSDeviceAssets.putAll(deviceAssets.cast()),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Asset> update(Asset asset) async {
|
Future<Asset> update(Asset asset) async {
|
||||||
await txn(() => asset.put(db));
|
await txn(() => asset.put(db));
|
||||||
|
@ -1,172 +1,205 @@
|
|||||||
|
// ignore_for_file: avoid-unsafe-collection-methods
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
|
||||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
|
||||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/device_asset.entity.dart';
|
import 'package:immich_mobile/providers/infrastructure/device_asset.provider.dart';
|
||||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class HashService {
|
class HashService {
|
||||||
HashService(
|
HashService({
|
||||||
this._assetRepository,
|
required IDeviceAssetRepository deviceAssetRepository,
|
||||||
this._backgroundService,
|
required BackgroundService backgroundService,
|
||||||
this._albumMediaRepository,
|
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||||
);
|
this.batchFileLimit = kBatchHashFileLimit,
|
||||||
final IAssetRepository _assetRepository;
|
}) : _deviceAssetRepository = deviceAssetRepository,
|
||||||
final BackgroundService _backgroundService;
|
_backgroundService = backgroundService;
|
||||||
final IAlbumMediaRepository _albumMediaRepository;
|
|
||||||
final _log = Logger('HashService');
|
|
||||||
|
|
||||||
/// Returns all assets that were successfully hashed
|
final IDeviceAssetRepository _deviceAssetRepository;
|
||||||
Future<List<Asset>> getHashedAssets(
|
final BackgroundService _backgroundService;
|
||||||
Album album, {
|
final int batchSizeLimit;
|
||||||
int start = 0,
|
final int batchFileLimit;
|
||||||
int end = 0x7fffffffffffffff,
|
final _log = Logger('HashService');
|
||||||
DateTime? modifiedFrom,
|
|
||||||
DateTime? modifiedUntil,
|
|
||||||
Set<String>? excludedAssets,
|
|
||||||
}) async {
|
|
||||||
final entities = await _albumMediaRepository.getAssets(
|
|
||||||
album.localId!,
|
|
||||||
start: start,
|
|
||||||
end: end,
|
|
||||||
modifiedFrom: modifiedFrom,
|
|
||||||
modifiedUntil: modifiedUntil,
|
|
||||||
);
|
|
||||||
final filtered = excludedAssets == null
|
|
||||||
? entities
|
|
||||||
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
|
|
||||||
return _hashAssets(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processes a list of local [Asset]s, storing their hash and returning only those
|
/// Processes a list of local [Asset]s, storing their hash and returning only those
|
||||||
/// that were successfully hashed. Hashes are looked up in a DB table
|
/// that were successfully hashed. Hashes are looked up in a DB table
|
||||||
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
|
/// [DeviceAsset] by local id. Only missing entries are newly hashed and added to the DB table.
|
||||||
/// entries are newly hashed and added to the DB table.
|
Future<List<Asset>> hashAssets(List<Asset> assets) async {
|
||||||
Future<List<Asset>> _hashAssets(List<Asset> assets) async {
|
assets.sort(Asset.compareByLocalId);
|
||||||
const int batchFileCount = 128;
|
|
||||||
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
|
|
||||||
|
|
||||||
final ids = assets
|
// Get and sort DB entries - guaranteed to be a subset of assets
|
||||||
.map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
|
final hashesInDB = await _deviceAssetRepository.getByIds(
|
||||||
.toList();
|
assets.map((a) => a.localId!).toList(),
|
||||||
final List<DeviceAsset?> hashes =
|
);
|
||||||
await _assetRepository.getDeviceAssetsById(ids);
|
hashesInDB.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||||
final List<DeviceAsset> toAdd = [];
|
|
||||||
final List<String> toHash = [];
|
|
||||||
|
|
||||||
int bytes = 0;
|
int dbIndex = 0;
|
||||||
|
int bytesProcessed = 0;
|
||||||
|
final hashedAssets = <Asset>[];
|
||||||
|
final toBeHashed = <_AssetPath>[];
|
||||||
|
final toBeDeleted = <String>[];
|
||||||
|
|
||||||
for (int i = 0; i < assets.length; i++) {
|
for (int assetIndex = 0; assetIndex < assets.length; assetIndex++) {
|
||||||
if (hashes[i] != null) {
|
final asset = assets[assetIndex];
|
||||||
|
DeviceAsset? matchingDbEntry;
|
||||||
|
|
||||||
|
if (dbIndex < hashesInDB.length) {
|
||||||
|
final deviceAsset = hashesInDB[dbIndex];
|
||||||
|
if (deviceAsset.assetId == asset.localId) {
|
||||||
|
matchingDbEntry = deviceAsset;
|
||||||
|
dbIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingDbEntry != null &&
|
||||||
|
matchingDbEntry.hash.isNotEmpty &&
|
||||||
|
matchingDbEntry.modifiedTime.isAtSameMomentAs(asset.fileModifiedAt)) {
|
||||||
|
// Reuse the existing hash
|
||||||
|
hashedAssets.add(
|
||||||
|
asset.copyWith(checksum: base64.encode(matchingDbEntry.hash)),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
File? file;
|
final file = await _tryGetAssetFile(asset);
|
||||||
|
|
||||||
try {
|
|
||||||
file = await assets[i].local!.originFile;
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
_log.warning(
|
|
||||||
"Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping",
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
final fileName = assets[i].fileName;
|
// Can't access file, delete any DB entry
|
||||||
|
if (matchingDbEntry != null) {
|
||||||
_log.warning(
|
toBeDeleted.add(matchingDbEntry.assetId);
|
||||||
"Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
|
}
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bytes += await file.length();
|
|
||||||
toHash.add(file.path);
|
bytesProcessed += await file.length();
|
||||||
final deviceAsset = Platform.isAndroid
|
toBeHashed.add(_AssetPath(asset: asset, path: file.path));
|
||||||
? AndroidDeviceAsset(id: ids[i] as int, hash: const [])
|
|
||||||
: IOSDeviceAsset(id: ids[i] as String, hash: const []);
|
if (_shouldProcessBatch(toBeHashed.length, bytesProcessed)) {
|
||||||
toAdd.add(deviceAsset);
|
hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted));
|
||||||
hashes[i] = deviceAsset;
|
toBeHashed.clear();
|
||||||
if (toHash.length == batchFileCount || bytes >= batchDataSize) {
|
toBeDeleted.clear();
|
||||||
await _processBatch(toHash, toAdd);
|
bytesProcessed = 0;
|
||||||
toAdd.clear();
|
|
||||||
toHash.clear();
|
|
||||||
bytes = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toHash.isNotEmpty) {
|
assert(dbIndex == hashesInDB.length, "All hashes should've been processed");
|
||||||
await _processBatch(toHash, toAdd);
|
|
||||||
|
// Process any remaining files
|
||||||
|
if (toBeHashed.isNotEmpty) {
|
||||||
|
hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted));
|
||||||
}
|
}
|
||||||
return _getHashedAssets(assets, hashes);
|
|
||||||
|
// Clean up deleted references
|
||||||
|
if (toBeDeleted.isNotEmpty) {
|
||||||
|
await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashedAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a batch of files and saves any successfully hashed
|
bool _shouldProcessBatch(int assetCount, int bytesProcessed) =>
|
||||||
/// values to the DB table.
|
assetCount >= batchFileLimit || bytesProcessed >= batchSizeLimit;
|
||||||
Future<void> _processBatch(
|
|
||||||
final List<String> toHash,
|
Future<File?> _tryGetAssetFile(Asset asset) async {
|
||||||
final List<DeviceAsset> toAdd,
|
try {
|
||||||
|
final file = await asset.local!.originFile;
|
||||||
|
if (file == null) {
|
||||||
|
_log.warning(
|
||||||
|
"Failed to get file for asset ${asset.localId ?? '<N/A>'}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.warning(
|
||||||
|
"Error getting file to hash for asset ${asset.localId ?? '<N/A>'}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping",
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a batch of files and returns a list of successfully hashed assets after saving
|
||||||
|
/// them in [DeviceAssetToHash] for future retrieval
|
||||||
|
Future<List<Asset>> _processBatch(
|
||||||
|
List<_AssetPath> toBeHashed,
|
||||||
|
List<String> toBeDeleted,
|
||||||
) async {
|
) async {
|
||||||
final hashes = await _hashFiles(toHash);
|
_log.info("Hashing ${toBeHashed.length} files");
|
||||||
bool anyNull = false;
|
final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList());
|
||||||
for (int j = 0; j < hashes.length; j++) {
|
assert(
|
||||||
if (hashes[j]?.length == 20) {
|
hashes.length == toBeHashed.length,
|
||||||
toAdd[j].hash = hashes[j]!;
|
"Number of Hashes returned from platform should be the same as the input",
|
||||||
|
);
|
||||||
|
|
||||||
|
final hashedAssets = <Asset>[];
|
||||||
|
final toBeAdded = <DeviceAsset>[];
|
||||||
|
|
||||||
|
for (final (index, hash) in hashes.indexed) {
|
||||||
|
final asset = toBeHashed.elementAtOrNull(index)?.asset;
|
||||||
|
if (asset != null && hash?.length == 20) {
|
||||||
|
hashedAssets.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||||
|
toBeAdded.add(
|
||||||
|
DeviceAsset(
|
||||||
|
assetId: asset.localId!,
|
||||||
|
hash: hash,
|
||||||
|
modifiedTime: asset.fileModifiedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_log.warning("Failed to hash file ${toHash[j]}, skipping");
|
_log.warning("Failed to hash file ${asset?.localId ?? '<null>'}");
|
||||||
anyNull = true;
|
if (asset != null) {
|
||||||
|
toBeDeleted.add(asset.localId!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final validHashes = anyNull
|
|
||||||
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
|
|
||||||
: toAdd;
|
|
||||||
|
|
||||||
await _assetRepository
|
// Update the DB for future retrieval
|
||||||
.transaction(() => _assetRepository.upsertDeviceAssets(validHashes));
|
await _deviceAssetRepository.transaction(() async {
|
||||||
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
|
await _deviceAssetRepository.updateAll(toBeAdded);
|
||||||
|
await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||||
|
});
|
||||||
|
|
||||||
|
_log.fine("Hashed ${hashedAssets.length}/${toBeHashed.length} assets");
|
||||||
|
return hashedAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hashes the given files and returns a list of the same length
|
/// Hashes the given files and returns a list of the same length.
|
||||||
/// files that could not be hashed have a `null` value
|
/// Files that could not be hashed will have a `null` value
|
||||||
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
|
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
|
||||||
final List<Uint8List?>? hashes =
|
try {
|
||||||
await _backgroundService.digestFiles(paths);
|
final hashes = await _backgroundService.digestFiles(paths);
|
||||||
if (hashes == null) {
|
if (hashes != null) {
|
||||||
throw Exception("Hashing ${paths.length} files failed");
|
return hashes;
|
||||||
}
|
|
||||||
return hashes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns all successfully hashed [Asset]s with their hash value set
|
|
||||||
List<Asset> _getHashedAssets(
|
|
||||||
List<Asset> assets,
|
|
||||||
List<DeviceAsset?> hashes,
|
|
||||||
) {
|
|
||||||
final List<Asset> result = [];
|
|
||||||
for (int i = 0; i < assets.length; i++) {
|
|
||||||
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
|
|
||||||
assets[i].byteHash = hashes[i]!.hash;
|
|
||||||
result.add(assets[i]);
|
|
||||||
}
|
}
|
||||||
|
_log.severe("Hashing ${paths.length} files failed");
|
||||||
|
} catch (e, s) {
|
||||||
|
_log.severe("Error occurred while hashing assets", e, s);
|
||||||
}
|
}
|
||||||
return result;
|
return List.filled(paths.length, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetPath {
|
||||||
|
final Asset asset;
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const _AssetPath({required this.asset, required this.path});
|
||||||
|
|
||||||
|
_AssetPath copyWith({Asset? asset, String? path}) {
|
||||||
|
return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final hashServiceProvider = Provider(
|
final hashServiceProvider = Provider(
|
||||||
(ref) => HashService(
|
(ref) => HashService(
|
||||||
ref.watch(assetRepositoryProvider),
|
deviceAssetRepository: ref.watch(deviceAssetRepositoryProvider),
|
||||||
ref.watch(backgroundServiceProvider),
|
backgroundService: ref.watch(backgroundServiceProvider),
|
||||||
ref.watch(albumMediaRepositoryProvider),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -577,15 +577,18 @@ class SyncService {
|
|||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
]) async {
|
]) async {
|
||||||
|
_log.info("Syncing a local album to DB: ${deviceAlbum.name}");
|
||||||
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
|
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
|
||||||
_log.fine(
|
_log.info(
|
||||||
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
|
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
_log.info("Local album ${deviceAlbum.name} has changed. Syncing...");
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
excludedAssets == null &&
|
excludedAssets == null &&
|
||||||
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
|
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
|
||||||
|
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||||
@ -598,7 +601,7 @@ class SyncService {
|
|||||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||||
final int assetCountOnDevice =
|
final int assetCountOnDevice =
|
||||||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||||
final List<Asset> onDevice = await _hashService.getHashedAssets(
|
final List<Asset> onDevice = await _getHashedAssets(
|
||||||
deviceAlbum,
|
deviceAlbum,
|
||||||
excludedAssets: excludedAssets,
|
excludedAssets: excludedAssets,
|
||||||
);
|
);
|
||||||
@ -611,7 +614,7 @@ class SyncService {
|
|||||||
dbAlbum.name == deviceAlbum.name &&
|
dbAlbum.name == deviceAlbum.name &&
|
||||||
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
|
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
|
||||||
// changes only affeted excluded albums
|
// changes only affeted excluded albums
|
||||||
_log.fine(
|
_log.info(
|
||||||
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
|
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
|
||||||
);
|
);
|
||||||
if (assetCountOnDevice !=
|
if (assetCountOnDevice !=
|
||||||
@ -626,11 +629,11 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
_log.fine(
|
_log.info(
|
||||||
"Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
|
"Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
|
||||||
);
|
);
|
||||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||||
_log.fine(
|
_log.info(
|
||||||
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
|
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
|
||||||
);
|
);
|
||||||
deleteCandidates.addAll(toDelete);
|
deleteCandidates.addAll(toDelete);
|
||||||
@ -667,6 +670,9 @@ class SyncService {
|
|||||||
/// returns `true` if successful, else `false`
|
/// returns `true` if successful, else `false`
|
||||||
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
|
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
|
||||||
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
|
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
|
||||||
|
_log.info(
|
||||||
|
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final int totalOnDevice =
|
final int totalOnDevice =
|
||||||
@ -676,15 +682,21 @@ class SyncService {
|
|||||||
?.assetCount ??
|
?.assetCount ??
|
||||||
0;
|
0;
|
||||||
if (totalOnDevice <= lastKnownTotal) {
|
if (totalOnDevice <= lastKnownTotal) {
|
||||||
|
_log.info(
|
||||||
|
"Local album ${deviceAlbum.name} totalOnDevice is less than lastKnownTotal. Skipping sync.",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final List<Asset> newAssets = await _hashService.getHashedAssets(
|
final List<Asset> newAssets = await _getHashedAssets(
|
||||||
deviceAlbum,
|
deviceAlbum,
|
||||||
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
|
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
|
||||||
modifiedUntil: deviceAlbum.modifiedAt,
|
modifiedUntil: deviceAlbum.modifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
if (totalOnDevice != lastKnownTotal + newAssets.length) {
|
||||||
|
_log.info(
|
||||||
|
"Local album ${deviceAlbum.name} totalOnDevice is not equal to lastKnownTotal + newAssets.length. Skipping sync.",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
|
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
|
||||||
@ -719,8 +731,8 @@ class SyncService {
|
|||||||
List<Asset> existing, [
|
List<Asset> existing, [
|
||||||
Set<String>? excludedAssets,
|
Set<String>? excludedAssets,
|
||||||
]) async {
|
]) async {
|
||||||
_log.info("Syncing a new local album to DB: ${album.name}");
|
_log.info("Adding a new local album to DB: ${album.name}");
|
||||||
final assets = await _hashService.getHashedAssets(
|
final assets = await _getHashedAssets(
|
||||||
album,
|
album,
|
||||||
excludedAssets: excludedAssets,
|
excludedAssets: excludedAssets,
|
||||||
);
|
);
|
||||||
@ -824,6 +836,28 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all assets that were successfully hashed
|
||||||
|
Future<List<Asset>> _getHashedAssets(
|
||||||
|
Album album, {
|
||||||
|
int start = 0,
|
||||||
|
int end = 0x7fffffffffffffff,
|
||||||
|
DateTime? modifiedFrom,
|
||||||
|
DateTime? modifiedUntil,
|
||||||
|
Set<String>? excludedAssets,
|
||||||
|
}) async {
|
||||||
|
final entities = await _albumMediaRepository.getAssets(
|
||||||
|
album.localId!,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
modifiedFrom: modifiedFrom,
|
||||||
|
modifiedUntil: modifiedUntil,
|
||||||
|
);
|
||||||
|
final filtered = excludedAssets == null
|
||||||
|
? entities
|
||||||
|
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
|
||||||
|
return _hashService.hashAssets(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
List<Asset> _removeDuplicates(List<Asset> assets) {
|
List<Asset> _removeDuplicates(List<Asset> assets) {
|
||||||
final int before = assets.length;
|
final int before = assets.length;
|
||||||
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
|
assets.sort(Asset.compareByOwnerChecksumCreatedModified);
|
||||||
|
@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||||
@ -39,6 +40,7 @@ abstract final class Bootstrap {
|
|||||||
ETagSchema,
|
ETagSchema,
|
||||||
if (Platform.isAndroid) AndroidDeviceAssetSchema,
|
if (Platform.isAndroid) AndroidDeviceAssetSchema,
|
||||||
if (Platform.isIOS) IOSDeviceAssetSchema,
|
if (Platform.isIOS) IOSDeviceAssetSchema,
|
||||||
|
DeviceAssetEntitySchema,
|
||||||
],
|
],
|
||||||
directory: dir.path,
|
directory: dir.path,
|
||||||
maxSizeMiB: 1024,
|
maxSizeMiB: 1024,
|
||||||
|
@ -75,3 +75,17 @@ bool diffSortedListsSync<T>(
|
|||||||
}
|
}
|
||||||
return diff;
|
return diff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int compareToNullable<T extends Comparable>(T? a, T? b) {
|
||||||
|
if (a == null && b == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a == null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (b == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return a.compareTo(b);
|
||||||
|
}
|
||||||
|
@ -1,40 +1,51 @@
|
|||||||
import 'dart:async';
|
// ignore_for_file: avoid-unsafe-collection-methods
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
const int targetVersion = 9;
|
const int targetVersion = 10;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
final int version = Store.get(StoreKey.version, 1);
|
final int version = Store.get(StoreKey.version, targetVersion);
|
||||||
|
|
||||||
if (version < 9) {
|
if (version < 9) {
|
||||||
await Store.put(StoreKey.version, version);
|
await Store.put(StoreKey.version, targetVersion);
|
||||||
final value = await db.storeValues.get(StoreKey.currentUser.id);
|
final value = await db.storeValues.get(StoreKey.currentUser.id);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
final id = value.intValue;
|
final id = value.intValue;
|
||||||
if (id == null) {
|
if (id != null) {
|
||||||
return;
|
await db.writeTxn(() async {
|
||||||
|
final user = await db.users.get(id);
|
||||||
|
await db.storeValues
|
||||||
|
.put(StoreValue(StoreKey.currentUser.id, strValue: user?.id));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await db.writeTxn(() async {
|
|
||||||
final user = await db.users.get(id);
|
|
||||||
await db.storeValues
|
|
||||||
.put(StoreValue(StoreKey.currentUser.id, strValue: user?.id));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Do not clear other entities
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version < targetVersion) {
|
if (version < 10) {
|
||||||
_migrateTo(db, targetVersion);
|
await Store.put(StoreKey.version, targetVersion);
|
||||||
|
await _migrateDeviceAsset(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldTruncate = version < 8 && version < targetVersion;
|
||||||
|
if (shouldTruncate) {
|
||||||
|
await _migrateTo(db, targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,3 +60,59 @@ Future<void> _migrateTo(Isar db, int version) async {
|
|||||||
});
|
});
|
||||||
await Store.put(StoreKey.version, version);
|
await Store.put(StoreKey.version, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateDeviceAsset(Isar db) async {
|
||||||
|
final ids = Platform.isAndroid
|
||||||
|
? (await db.androidDeviceAssets.where().findAll())
|
||||||
|
.map((a) => _DeviceAsset(assetId: a.id.toString(), hash: a.hash))
|
||||||
|
.toList()
|
||||||
|
: (await db.iOSDeviceAssets.where().findAll())
|
||||||
|
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
|
||||||
|
.toList();
|
||||||
|
final localAssets = (await db.assets
|
||||||
|
.where()
|
||||||
|
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
||||||
|
.findAll())
|
||||||
|
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
||||||
|
.toList();
|
||||||
|
debugPrint("Device Asset Ids length - ${ids.length}");
|
||||||
|
debugPrint("Local Asset Ids length - ${localAssets.length}");
|
||||||
|
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||||
|
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||||
|
final List<DeviceAssetEntity> toAdd = [];
|
||||||
|
await diffSortedLists(
|
||||||
|
ids,
|
||||||
|
localAssets,
|
||||||
|
compare: (a, b) => a.assetId.compareTo(b.assetId),
|
||||||
|
both: (deviceAsset, asset) {
|
||||||
|
toAdd.add(
|
||||||
|
DeviceAssetEntity(
|
||||||
|
assetId: deviceAsset.assetId,
|
||||||
|
hash: deviceAsset.hash!,
|
||||||
|
modifiedTime: asset.dateTime!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onlyFirst: (deviceAsset) {
|
||||||
|
debugPrint(
|
||||||
|
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onlySecond: (asset) {
|
||||||
|
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
debugPrint("Total number of device assets migrated - ${toAdd.length}");
|
||||||
|
await db.writeTxn(() async {
|
||||||
|
await db.deviceAssetEntitys.putAll(toAdd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceAsset {
|
||||||
|
final String assetId;
|
||||||
|
final List<int>? hash;
|
||||||
|
final DateTime? dateTime;
|
||||||
|
|
||||||
|
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
|
||||||
|
}
|
||||||
|
@ -463,7 +463,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: file
|
name: file
|
||||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
@ -102,6 +102,7 @@ dev_dependencies:
|
|||||||
immich_mobile_immich_lint:
|
immich_mobile_immich_lint:
|
||||||
path: './immich_lint'
|
path: './immich_lint'
|
||||||
fake_async: ^1.3.1
|
fake_async: ^1.3.1
|
||||||
|
file: ^7.0.1 # for MemoryFileSystem
|
||||||
# Drift generator
|
# Drift generator
|
||||||
drift_dev: ^2.23.1
|
drift_dev: ^2.23.1
|
||||||
|
|
||||||
|
425
mobile/test/domain/services/hash_service_test.dart
Normal file
425
mobile/test/domain/services/hash_service_test.dart
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:file/memory.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
import '../../fixtures/asset.stub.dart';
|
||||||
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
import '../../service.mocks.dart';
|
||||||
|
|
||||||
|
class MockAsset extends Mock implements Asset {}
|
||||||
|
|
||||||
|
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late HashService sut;
|
||||||
|
late BackgroundService mockBackgroundService;
|
||||||
|
late IDeviceAssetRepository mockDeviceAssetRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockBackgroundService = MockBackgroundService();
|
||||||
|
mockDeviceAssetRepository = MockDeviceAssetRepository();
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
|
||||||
|
});
|
||||||
|
when(() => mockDeviceAssetRepository.updateAll(any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
when(() => mockDeviceAssetRepository.deleteIds(any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: No DeviceAsset entry", () {
|
||||||
|
test("hash successfully", () async {
|
||||||
|
final (mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
// No DB entries for this asset
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
// Verify we stored the new hash in DB
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.updateAll([
|
||||||
|
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
||||||
|
]),
|
||||||
|
).called(1);
|
||||||
|
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[AssetStub.image1.copyWith(checksum: base64.encode(hash))],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Has DeviceAsset entry", () {
|
||||||
|
test("when the asset is not modified", () async {
|
||||||
|
final hash = utf8.encode("image1-hash");
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => [
|
||||||
|
DeviceAsset(
|
||||||
|
assetId: AssetStub.image1.localId!,
|
||||||
|
hash: hash,
|
||||||
|
modifiedTime: AssetStub.image1.fileModifiedAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([AssetStub.image1]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
||||||
|
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashed successful when asset is modified", () async {
|
||||||
|
final (mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => [deviceAsset]);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.updateAll([
|
||||||
|
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
||||||
|
]),
|
||||||
|
).called(1);
|
||||||
|
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
||||||
|
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Cleanup", () {
|
||||||
|
late Asset mockAsset;
|
||||||
|
late Uint8List hash;
|
||||||
|
late DeviceAsset deviceAsset;
|
||||||
|
late File file;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
(mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => [deviceAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanups DeviceAsset when local file cannot be obtained", () async {
|
||||||
|
when(() => mockAsset.local).thenThrow(Exception("File not found"));
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
||||||
|
).called(1);
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanups DeviceAsset when hashing failed", () async {
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
|
||||||
|
// Verify the callback inside the transaction because, doing it outside results
|
||||||
|
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
|
||||||
|
// resulting in an incorrect state
|
||||||
|
//
|
||||||
|
// i.e, consider the following piece of code
|
||||||
|
// await _deviceAssetRepository.transaction(() async {
|
||||||
|
// await _deviceAssetRepository.updateAll(toBeAdded);
|
||||||
|
// await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||||
|
// });
|
||||||
|
// toBeDeleted.clear();
|
||||||
|
// since the transaction method is mocked, the callback is not invoked until it is captured
|
||||||
|
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
|
||||||
|
// immediately once the transaction stub is executed, resulting in the deleteIds method being
|
||||||
|
// called with an empty list.
|
||||||
|
//
|
||||||
|
// To avoid this, we capture the callback and execute it within the transaction stub itself
|
||||||
|
// and verify the results inside the transaction stub
|
||||||
|
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
|
||||||
|
verify(
|
||||||
|
() =>
|
||||||
|
mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
|
||||||
|
// Invalid hash, length != 20
|
||||||
|
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Batch processing", () {
|
||||||
|
test("processes assets in batches when size limit is reached", () async {
|
||||||
|
// Setup multiple assets with large file sizes
|
||||||
|
final (mock1, mock2, mock3) = await (
|
||||||
|
_createAssetMock(AssetStub.image1),
|
||||||
|
_createAssetMock(AssetStub.image2),
|
||||||
|
_createAssetMock(AssetStub.image3),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
// Setup for multiple batch processing calls
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
||||||
|
.thenAnswer((_) async => [hash1, hash2]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash3]);
|
||||||
|
|
||||||
|
final size = await file1.length() + await file2.length();
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
batchSizeLimit: size,
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
||||||
|
|
||||||
|
// Verify multiple batch process calls
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
||||||
|
.called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processes assets in batches when file limit is reached", () async {
|
||||||
|
// Setup multiple assets with large file sizes
|
||||||
|
final (mock1, mock2, mock3) = await (
|
||||||
|
_createAssetMock(AssetStub.image1),
|
||||||
|
_createAssetMock(AssetStub.image2),
|
||||||
|
_createAssetMock(AssetStub.image3),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path]))
|
||||||
|
.thenAnswer((_) async => [hash1]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file2.path]))
|
||||||
|
.thenAnswer((_) async => [hash2]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash3]);
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
batchFileLimit: 1,
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
||||||
|
|
||||||
|
// Verify multiple batch process calls
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HashService: Sort & Process different states", () async {
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) =
|
||||||
|
await _createAssetMock(AssetStub.image1); // Will need rehashing
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) =
|
||||||
|
await _createAssetMock(AssetStub.image2); // Will have matching hash
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) =
|
||||||
|
await _createAssetMock(AssetStub.image3); // No DB entry
|
||||||
|
final asset4 =
|
||||||
|
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash1, hash3]);
|
||||||
|
// DB entries are not sorted and a dummy entry added
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([
|
||||||
|
AssetStub.image1.localId!,
|
||||||
|
AssetStub.image2.localId!,
|
||||||
|
AssetStub.image3.localId!,
|
||||||
|
asset4.localId!,
|
||||||
|
]),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => [
|
||||||
|
// Same timestamp to reuse deviceAsset
|
||||||
|
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
|
||||||
|
deviceAsset1,
|
||||||
|
deviceAsset3.copyWith(assetId: asset4.localId!),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
|
||||||
|
|
||||||
|
// Verify correct processing of all assets
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
||||||
|
.called(1);
|
||||||
|
expect(result.length, 3);
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Edge cases", () {
|
||||||
|
test("handles empty list of assets", () async {
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles all file access failures", () async {
|
||||||
|
// No DB entries
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds(
|
||||||
|
[AssetStub.image1.localId!, AssetStub.image2.localId!],
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([
|
||||||
|
AssetStub.image1,
|
||||||
|
AssetStub.image2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(
|
||||||
|
Asset asset,
|
||||||
|
) async {
|
||||||
|
final random = Random();
|
||||||
|
final hash =
|
||||||
|
Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
|
||||||
|
final mockAsset = MockAsset();
|
||||||
|
final mockAssetEntity = MockAssetEntity();
|
||||||
|
final fs = MemoryFileSystem();
|
||||||
|
final deviceAsset = DeviceAsset(
|
||||||
|
assetId: asset.localId!,
|
||||||
|
hash: Uint8List.fromList(hash),
|
||||||
|
modifiedTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
final tmp = await fs.systemTempDirectory.createTemp();
|
||||||
|
final file = tmp.childFile("${asset.fileName}-path");
|
||||||
|
await file.writeAsString("${asset.fileName}-content");
|
||||||
|
|
||||||
|
when(() => mockAsset.localId).thenReturn(asset.localId);
|
||||||
|
when(() => mockAsset.fileName).thenReturn(asset.fileName);
|
||||||
|
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
|
||||||
|
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
|
||||||
|
when(() => mockAsset.copyWith(checksum: any(named: "checksum")))
|
||||||
|
.thenReturn(asset.copyWith(checksum: base64.encode(hash)));
|
||||||
|
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
|
||||||
|
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
|
||||||
|
|
||||||
|
return (mockAsset, file, deviceAsset, hash);
|
||||||
|
}
|
19
mobile/test/fixtures/asset.stub.dart
vendored
19
mobile/test/fixtures/asset.stub.dart
vendored
@ -8,8 +8,8 @@ final class AssetStub {
|
|||||||
localId: "image1",
|
localId: "image1",
|
||||||
remoteId: 'image1-remote',
|
remoteId: 'image1-remote',
|
||||||
ownerId: 1,
|
ownerId: 1,
|
||||||
fileCreatedAt: DateTime.now(),
|
fileCreatedAt: DateTime(2019),
|
||||||
fileModifiedAt: DateTime.now(),
|
fileModifiedAt: DateTime(2020),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
type: AssetType.image,
|
type: AssetType.image,
|
||||||
@ -34,4 +34,19 @@ final class AssetStub {
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static final image3 = Asset(
|
||||||
|
checksum: "image3-checksum",
|
||||||
|
localId: "image3",
|
||||||
|
ownerId: 1,
|
||||||
|
fileCreatedAt: DateTime(2025),
|
||||||
|
fileModifiedAt: DateTime(2025),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
durationInSeconds: 60,
|
||||||
|
type: AssetType.image,
|
||||||
|
fileName: "image3.jpg",
|
||||||
|
isFavorite: true,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashed: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
||||||
@ -10,5 +11,8 @@ class MockLogRepository extends Mock implements ILogRepository {}
|
|||||||
|
|
||||||
class MockUserRepository extends Mock implements IUserRepository {}
|
class MockUserRepository extends Mock implements IUserRepository {}
|
||||||
|
|
||||||
|
class MockDeviceAssetRepository extends Mock
|
||||||
|
implements IDeviceAssetRepository {}
|
||||||
|
|
||||||
// API Repos
|
// API Repos
|
||||||
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/entity.service.dart';
|
import 'package:immich_mobile/services/entity.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/services/network.service.dart';
|
import 'package:immich_mobile/services/network.service.dart';
|
||||||
@ -17,3 +18,5 @@ class MockEntityService extends Mock implements EntityService {}
|
|||||||
class MockNetworkService extends Mock implements NetworkService {}
|
class MockNetworkService extends Mock implements NetworkService {}
|
||||||
|
|
||||||
class MockSearchApi extends Mock implements SearchApi {}
|
class MockSearchApi extends Mock implements SearchApi {}
|
||||||
|
|
||||||
|
class MockBackgroundService extends Mock implements BackgroundService {}
|
||||||
|
@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||||
@ -52,6 +53,7 @@ abstract final class TestUtils {
|
|||||||
ETagSchema,
|
ETagSchema,
|
||||||
AndroidDeviceAssetSchema,
|
AndroidDeviceAssetSchema,
|
||||||
IOSDeviceAssetSchema,
|
IOSDeviceAssetSchema,
|
||||||
|
DeviceAssetEntitySchema,
|
||||||
],
|
],
|
||||||
directory: "test/",
|
directory: "test/",
|
||||||
maxSizeMiB: 1024,
|
maxSizeMiB: 1024,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user