mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feature(mobile): Hardening synchronization mechanism + Pull to refresh (#2085)
* fix(mobile): allow syncing duplicate local IDs * enable to run isar unit tests on CI * serialize sync operations, add pull to refresh on timeline --------- Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
This commit is contained in:
parent
1a94530935
commit
cae37657e9
21 changed files with 653 additions and 249 deletions
|
|
@ -15,11 +15,11 @@ class Asset {
|
|||
Asset.remote(AssetResponseDto remote)
|
||||
: remoteId = remote.id,
|
||||
isLocal = false,
|
||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
|
||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
|
||||
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
|
||||
// use -1 as fallback duration (to not mix it up with non-video assets correctly having duration=0)
|
||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? -1,
|
||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
|
||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
|
||||
updatedAt = DateTime.parse(remote.updatedAt),
|
||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||
type = remote.type.toAssetType(),
|
||||
fileName = p.basename(remote.originalPath),
|
||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
|
|
@ -35,15 +35,16 @@ class Asset {
|
|||
: localId = local.id,
|
||||
isLocal = true,
|
||||
durationInSeconds = local.duration,
|
||||
type = AssetType.values[local.typeInt],
|
||||
height = local.height,
|
||||
width = local.width,
|
||||
fileName = local.title!,
|
||||
deviceId = Store.get(StoreKey.deviceIdHash),
|
||||
ownerId = Store.get(StoreKey.currentUser).isarId,
|
||||
fileModifiedAt = local.modifiedDateTime.toUtc(),
|
||||
updatedAt = local.modifiedDateTime.toUtc(),
|
||||
fileModifiedAt = local.modifiedDateTime,
|
||||
updatedAt = local.modifiedDateTime,
|
||||
isFavorite = local.isFavorite,
|
||||
fileCreatedAt = local.createDateTime.toUtc() {
|
||||
fileCreatedAt = local.createDateTime {
|
||||
if (fileCreatedAt.year == 1970) {
|
||||
fileCreatedAt = fileModifiedAt;
|
||||
}
|
||||
|
|
@ -61,6 +62,7 @@ class Asset {
|
|||
required this.fileModifiedAt,
|
||||
required this.updatedAt,
|
||||
required this.durationInSeconds,
|
||||
required this.type,
|
||||
this.width,
|
||||
this.height,
|
||||
required this.fileName,
|
||||
|
|
@ -77,10 +79,10 @@ class Asset {
|
|||
AssetEntity? get local {
|
||||
if (isLocal && _local == null) {
|
||||
_local = AssetEntity(
|
||||
id: localId.toString(),
|
||||
id: localId,
|
||||
typeInt: isImage ? 1 : 2,
|
||||
width: width!,
|
||||
height: height!,
|
||||
width: width ?? 0,
|
||||
height: height ?? 0,
|
||||
duration: durationInSeconds,
|
||||
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
|
|
@ -96,7 +98,7 @@ class Asset {
|
|||
String? remoteId;
|
||||
|
||||
@Index(
|
||||
unique: true,
|
||||
unique: false,
|
||||
replace: false,
|
||||
type: IndexType.hash,
|
||||
composite: [CompositeIndex('deviceId')],
|
||||
|
|
@ -115,6 +117,9 @@ class Asset {
|
|||
|
||||
int durationInSeconds;
|
||||
|
||||
@Enumerated(EnumType.ordinal)
|
||||
AssetType type;
|
||||
|
||||
short? width;
|
||||
|
||||
short? height;
|
||||
|
|
@ -140,7 +145,7 @@ class Asset {
|
|||
bool get isRemote => remoteId != null;
|
||||
|
||||
@ignore
|
||||
bool get isImage => durationInSeconds == 0;
|
||||
bool get isImage => type == AssetType.image;
|
||||
|
||||
@ignore
|
||||
Duration get duration => Duration(seconds: durationInSeconds);
|
||||
|
|
@ -148,12 +153,43 @@ class Asset {
|
|||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Asset) return false;
|
||||
return id == other.id;
|
||||
return id == other.id &&
|
||||
remoteId == other.remoteId &&
|
||||
localId == other.localId &&
|
||||
deviceId == other.deviceId &&
|
||||
ownerId == other.ownerId &&
|
||||
fileCreatedAt == other.fileCreatedAt &&
|
||||
fileModifiedAt == other.fileModifiedAt &&
|
||||
updatedAt == other.updatedAt &&
|
||||
durationInSeconds == other.durationInSeconds &&
|
||||
type == other.type &&
|
||||
width == other.width &&
|
||||
height == other.height &&
|
||||
fileName == other.fileName &&
|
||||
livePhotoVideoId == other.livePhotoVideoId &&
|
||||
isFavorite == other.isFavorite &&
|
||||
isLocal == other.isLocal;
|
||||
}
|
||||
|
||||
@override
|
||||
@ignore
|
||||
int get hashCode => id.hashCode;
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
localId.hashCode ^
|
||||
deviceId.hashCode ^
|
||||
ownerId.hashCode ^
|
||||
fileCreatedAt.hashCode ^
|
||||
fileModifiedAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
durationInSeconds.hashCode ^
|
||||
type.hashCode ^
|
||||
width.hashCode ^
|
||||
height.hashCode ^
|
||||
fileName.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isLocal.hashCode;
|
||||
|
||||
bool updateFromAssetEntity(AssetEntity ae) {
|
||||
// TODO check more fields;
|
||||
|
|
@ -192,9 +228,24 @@ class Asset {
|
|||
}
|
||||
}
|
||||
|
||||
static int compareByDeviceIdLocalId(Asset a, Asset b) {
|
||||
final int order = a.deviceId.compareTo(b.deviceId);
|
||||
return order == 0 ? a.localId.compareTo(b.localId) : order;
|
||||
/// compares assets by [ownerId], [deviceId], [localId]
|
||||
static int compareByOwnerDeviceLocalId(Asset a, Asset b) {
|
||||
final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
|
||||
if (ownerIdOrder != 0) {
|
||||
return ownerIdOrder;
|
||||
}
|
||||
final int deviceIdOrder = a.deviceId.compareTo(b.deviceId);
|
||||
if (deviceIdOrder != 0) {
|
||||
return deviceIdOrder;
|
||||
}
|
||||
final int localIdOrder = a.localId.compareTo(b.localId);
|
||||
return localIdOrder;
|
||||
}
|
||||
|
||||
/// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt]
|
||||
static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) {
|
||||
final int order = compareByOwnerDeviceLocalId(a, b);
|
||||
return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt);
|
||||
}
|
||||
|
||||
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
||||
|
|
@ -203,6 +254,30 @@ class Asset {
|
|||
a.localId.compareTo(b.localId);
|
||||
}
|
||||
|
||||
enum AssetType {
|
||||
// do not change this order!
|
||||
other,
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
}
|
||||
|
||||
extension AssetTypeEnumHelper on AssetTypeEnum {
|
||||
AssetType toAssetType() {
|
||||
switch (this) {
|
||||
case AssetTypeEnum.IMAGE:
|
||||
return AssetType.image;
|
||||
case AssetTypeEnum.VIDEO:
|
||||
return AssetType.video;
|
||||
case AssetTypeEnum.AUDIO:
|
||||
return AssetType.audio;
|
||||
case AssetTypeEnum.OTHER:
|
||||
return AssetType.other;
|
||||
}
|
||||
throw Exception();
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetsHelper on IsarCollection<Asset> {
|
||||
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
|
||||
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
|
||||
|
|
|
|||
|
|
@ -77,13 +77,19 @@ const AssetSchema = CollectionSchema(
|
|||
name: r'remoteId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'updatedAt': PropertySchema(
|
||||
r'type': PropertySchema(
|
||||
id: 12,
|
||||
name: r'type',
|
||||
type: IsarType.byte,
|
||||
enumMap: _AssettypeEnumValueMap,
|
||||
),
|
||||
r'updatedAt': PropertySchema(
|
||||
id: 13,
|
||||
name: r'updatedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'width': PropertySchema(
|
||||
id: 13,
|
||||
id: 14,
|
||||
name: r'width',
|
||||
type: IsarType.int,
|
||||
)
|
||||
|
|
@ -110,7 +116,7 @@ const AssetSchema = CollectionSchema(
|
|||
r'localId_deviceId': IndexSchema(
|
||||
id: 7649417350086526165,
|
||||
name: r'localId_deviceId',
|
||||
unique: true,
|
||||
unique: false,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
|
|
@ -175,8 +181,9 @@ void _assetSerialize(
|
|||
writer.writeString(offsets[9], object.localId);
|
||||
writer.writeLong(offsets[10], object.ownerId);
|
||||
writer.writeString(offsets[11], object.remoteId);
|
||||
writer.writeDateTime(offsets[12], object.updatedAt);
|
||||
writer.writeInt(offsets[13], object.width);
|
||||
writer.writeByte(offsets[12], object.type.index);
|
||||
writer.writeDateTime(offsets[13], object.updatedAt);
|
||||
writer.writeInt(offsets[14], object.width);
|
||||
}
|
||||
|
||||
Asset _assetDeserialize(
|
||||
|
|
@ -198,8 +205,10 @@ Asset _assetDeserialize(
|
|||
localId: reader.readString(offsets[9]),
|
||||
ownerId: reader.readLong(offsets[10]),
|
||||
remoteId: reader.readStringOrNull(offsets[11]),
|
||||
updatedAt: reader.readDateTime(offsets[12]),
|
||||
width: reader.readIntOrNull(offsets[13]),
|
||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
|
||||
AssetType.other,
|
||||
updatedAt: reader.readDateTime(offsets[13]),
|
||||
width: reader.readIntOrNull(offsets[14]),
|
||||
);
|
||||
object.id = id;
|
||||
return object;
|
||||
|
|
@ -237,14 +246,30 @@ P _assetDeserializeProp<P>(
|
|||
case 11:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 12:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||
AssetType.other) as P;
|
||||
case 13:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 14:
|
||||
return (reader.readIntOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
const _AssettypeEnumValueMap = {
|
||||
'other': 0,
|
||||
'image': 1,
|
||||
'video': 2,
|
||||
'audio': 3,
|
||||
};
|
||||
const _AssettypeValueEnumMap = {
|
||||
0: AssetType.other,
|
||||
1: AssetType.image,
|
||||
2: AssetType.video,
|
||||
3: AssetType.audio,
|
||||
};
|
||||
|
||||
Id _assetGetId(Asset object) {
|
||||
return object.id;
|
||||
}
|
||||
|
|
@ -257,94 +282,6 @@ void _assetAttach(IsarCollection<dynamic> col, Id id, Asset object) {
|
|||
object.id = id;
|
||||
}
|
||||
|
||||
extension AssetByIndex on IsarCollection<Asset> {
|
||||
Future<Asset?> getByLocalIdDeviceId(String localId, int deviceId) {
|
||||
return getByIndex(r'localId_deviceId', [localId, deviceId]);
|
||||
}
|
||||
|
||||
Asset? getByLocalIdDeviceIdSync(String localId, int deviceId) {
|
||||
return getByIndexSync(r'localId_deviceId', [localId, deviceId]);
|
||||
}
|
||||
|
||||
Future<bool> deleteByLocalIdDeviceId(String localId, int deviceId) {
|
||||
return deleteByIndex(r'localId_deviceId', [localId, deviceId]);
|
||||
}
|
||||
|
||||
bool deleteByLocalIdDeviceIdSync(String localId, int deviceId) {
|
||||
return deleteByIndexSync(r'localId_deviceId', [localId, deviceId]);
|
||||
}
|
||||
|
||||
Future<List<Asset?>> getAllByLocalIdDeviceId(
|
||||
List<String> localIdValues, List<int> deviceIdValues) {
|
||||
final len = localIdValues.length;
|
||||
assert(deviceIdValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([localIdValues[i], deviceIdValues[i]]);
|
||||
}
|
||||
|
||||
return getAllByIndex(r'localId_deviceId', values);
|
||||
}
|
||||
|
||||
List<Asset?> getAllByLocalIdDeviceIdSync(
|
||||
List<String> localIdValues, List<int> deviceIdValues) {
|
||||
final len = localIdValues.length;
|
||||
assert(deviceIdValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([localIdValues[i], deviceIdValues[i]]);
|
||||
}
|
||||
|
||||
return getAllByIndexSync(r'localId_deviceId', values);
|
||||
}
|
||||
|
||||
Future<int> deleteAllByLocalIdDeviceId(
|
||||
List<String> localIdValues, List<int> deviceIdValues) {
|
||||
final len = localIdValues.length;
|
||||
assert(deviceIdValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([localIdValues[i], deviceIdValues[i]]);
|
||||
}
|
||||
|
||||
return deleteAllByIndex(r'localId_deviceId', values);
|
||||
}
|
||||
|
||||
int deleteAllByLocalIdDeviceIdSync(
|
||||
List<String> localIdValues, List<int> deviceIdValues) {
|
||||
final len = localIdValues.length;
|
||||
assert(deviceIdValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([localIdValues[i], deviceIdValues[i]]);
|
||||
}
|
||||
|
||||
return deleteAllByIndexSync(r'localId_deviceId', values);
|
||||
}
|
||||
|
||||
Future<Id> putByLocalIdDeviceId(Asset object) {
|
||||
return putByIndex(r'localId_deviceId', object);
|
||||
}
|
||||
|
||||
Id putByLocalIdDeviceIdSync(Asset object, {bool saveLinks = true}) {
|
||||
return putByIndexSync(r'localId_deviceId', object, saveLinks: saveLinks);
|
||||
}
|
||||
|
||||
Future<List<Id>> putAllByLocalIdDeviceId(List<Asset> objects) {
|
||||
return putAllByIndex(r'localId_deviceId', objects);
|
||||
}
|
||||
|
||||
List<Id> putAllByLocalIdDeviceIdSync(List<Asset> objects,
|
||||
{bool saveLinks = true}) {
|
||||
return putAllByIndexSync(r'localId_deviceId', objects,
|
||||
saveLinks: saveLinks);
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetQueryWhereSort on QueryBuilder<Asset, Asset, QWhere> {
|
||||
QueryBuilder<Asset, Asset, QAfterWhere> anyId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
|
|
@ -1582,6 +1519,59 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
|
||||
AssetType value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'type',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeGreaterThan(
|
||||
AssetType value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'type',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeLessThan(
|
||||
AssetType value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'type',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeBetween(
|
||||
AssetType lower,
|
||||
AssetType upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'type',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> updatedAtEqualTo(
|
||||
DateTime value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
|
|
@ -1853,6 +1843,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'type', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByTypeDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'type', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.asc);
|
||||
|
|
@ -2035,6 +2037,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'type', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByTypeDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'type', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.asc);
|
||||
|
|
@ -2138,6 +2152,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'type');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'updatedAt');
|
||||
|
|
@ -2230,6 +2250,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
|
|||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'type');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, DateTime, QQueryOperations> updatedAtProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'updatedAt');
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ class StoreKeyNotFoundException implements Exception {
|
|||
/// Key for each possible value in the `Store`.
|
||||
/// Defines the data type for each value
|
||||
enum StoreKey<T> {
|
||||
userRemoteId<String>(0, type: String),
|
||||
version<int>(0, type: int),
|
||||
assetETag<String>(1, type: String),
|
||||
currentUser<User>(2, type: User, fromDb: _getUser, toDb: _toUser),
|
||||
deviceIdHash<int>(3, type: int),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue