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:
Fynn Petersen-Frey 2023-03-27 04:35:52 +02:00 committed by GitHub
parent 1a94530935
commit cae37657e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 653 additions and 249 deletions

View File

@ -50,6 +50,7 @@ void main() async {
await initApp(); await initApp();
await migrateHiveToStoreIfNecessary(); await migrateHiveToStoreIfNecessary();
await migrateJsonCacheIfNecessary(); await migrateJsonCacheIfNecessary();
await migrateDatabaseIfNeeded(db);
runApp(getMainWidget(db)); runApp(getMainWidget(db));
} }

View File

@ -53,7 +53,7 @@ class AlbumThumbnailCard extends StatelessWidget {
// Add the owner name to the subtitle // Add the owner name to the subtitle
String? owner; String? owner;
if (showOwner) { if (showOwner) {
if (album.ownerId == Store.get(StoreKey.userRemoteId)) { if (album.ownerId == Store.get(StoreKey.currentUser).id) {
owner = 'album_thumbnail_owned'.tr(); owner = 'album_thumbnail_owned'.tr();
} else if (album.ownerName != null) { } else if (album.ownerName != null) {
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]); owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);

View File

@ -17,7 +17,7 @@ class SharingPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider); final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
final userId = store.Store.get(store.StoreKey.userRemoteId); final userId = store.Store.get(store.StoreKey.currentUser).id;
var isDarkMode = Theme.of(context).brightness == Brightness.dark; var isDarkMode = Theme.of(context).brightness == Brightness.dark;
useEffect( useEffect(

View File

@ -17,10 +17,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool selectionActive; final bool selectionActive;
final List<Asset> assets; final List<Asset> assets;
final RenderList? renderList; final RenderList? renderList;
final Future<void> Function()? onRefresh;
const ImmichAssetGrid({ const ImmichAssetGrid({
super.key, super.key,
required this.assets, required this.assets,
this.onRefresh,
this.renderList, this.renderList,
this.assetsPerRow, this.assetsPerRow,
this.showStorageIndicator, this.showStorageIndicator,
@ -62,11 +64,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
enabled: enableHeroAnimations.value, enabled: enableHeroAnimations.value,
child: ImmichAssetGridView( child: ImmichAssetGridView(
allAssets: assets, allAssets: assets,
assetsPerRow: assetsPerRow onRefresh: onRefresh,
?? settings.getSetting(AppSettingsEnum.tilesPerRow), assetsPerRow: assetsPerRow ??
settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener, listener: listener,
showStorageIndicator: showStorageIndicator showStorageIndicator: showStorageIndicator ??
?? settings.getSetting(AppSettingsEnum.storageIndicator), settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList!, renderList: renderList!,
margin: margin, margin: margin,
selectionActive: selectionActive, selectionActive: selectionActive,
@ -76,26 +79,25 @@ class ImmichAssetGrid extends HookConsumerWidget {
} }
return renderListFuture.when( return renderListFuture.when(
data: (renderList) => data: (renderList) => WillPopScope(
WillPopScope( onWillPop: onWillPop,
onWillPop: onWillPop, child: HeroMode(
child: HeroMode( enabled: enableHeroAnimations.value,
enabled: enableHeroAnimations.value, child: ImmichAssetGridView(
child: ImmichAssetGridView( allAssets: assets,
allAssets: assets, onRefresh: onRefresh,
assetsPerRow: assetsPerRow assetsPerRow: assetsPerRow ??
?? settings.getSetting(AppSettingsEnum.tilesPerRow), settings.getSetting(AppSettingsEnum.tilesPerRow),
listener: listener, listener: listener,
showStorageIndicator: showStorageIndicator showStorageIndicator: showStorageIndicator ??
?? settings.getSetting(AppSettingsEnum.storageIndicator), settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList, renderList: renderList,
margin: margin, margin: margin,
selectionActive: selectionActive, selectionActive: selectionActive,
),
), ),
), ),
error: (err, stack) => ),
Center(child: Text("$err")), error: (err, stack) => Center(child: Text("$err")),
loading: () => const Center( loading: () => const Center(
child: ImmichLoadingIndicator(), child: ImmichLoadingIndicator(),
), ),

View File

@ -199,21 +199,23 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
addRepaintBoundaries: true, addRepaintBoundaries: true,
); );
if (!useDragScrolling) { final child = useDragScrolling
return listWidget; ? DraggableScrollbar.semicircle(
} scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
)
: listWidget;
return DraggableScrollbar.semicircle( return widget.onRefresh == null
scrollStateListener: dragScrolling, ? child
itemPositionsListener: _itemPositionsListener, : RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
controller: _itemScrollController,
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
child: listWidget,
);
} }
@override @override
@ -248,7 +250,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
} }
void _scrollToTop() { void _scrollToTop() {
// for some reason, this is necessary as well in order // for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar // to correctly reposition the drag thumb scroll bar
_itemScrollController.jumpTo( _itemScrollController.jumpTo(
index: 0, index: 0,
@ -281,6 +283,7 @@ class ImmichAssetGridView extends StatefulWidget {
final ImmichAssetGridSelectionListener? listener; final ImmichAssetGridSelectionListener? listener;
final bool selectionActive; final bool selectionActive;
final List<Asset> allAssets; final List<Asset> allAssets;
final Future<void> Function()? onRefresh;
const ImmichAssetGridView({ const ImmichAssetGridView({
super.key, super.key,
@ -291,6 +294,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.listener, this.listener,
this.margin = 5.0, this.margin = 5.0,
this.selectionActive = false, this.selectionActive = false,
this.onRefresh,
}); });
@override @override

View File

@ -43,6 +43,7 @@ class HomePage extends HookConsumerWidget {
final albumService = ref.watch(albumServiceProvider); final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0); final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
useEffect( useEffect(
() { () {
@ -182,6 +183,22 @@ class HomePage extends HookConsumerWidget {
} }
} }
Future<void> refreshAssets() async {
debugPrint("refreshCount.value ${refreshCount.value}");
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (fullRefresh) {
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 2), () {
refreshCount.value = 0;
});
}
}
buildLoadingIndicator() { buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () { Timer(const Duration(seconds: 2), () {
tipOneOpacity.value = 1; tipOneOpacity.value = 1;
@ -241,6 +258,7 @@ class HomePage extends HookConsumerWidget {
.getSetting(AppSettingsEnum.storageIndicator), .getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener, listener: selectionListener,
selectionActive: selectionEnabledHook.value, selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
), ),
if (selectionEnabledHook.value) if (selectionEnabledHook.value)
SafeArea( SafeArea(

View File

@ -78,7 +78,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
await Future.wait([ await Future.wait([
_apiService.authenticationApi.logout(), _apiService.authenticationApi.logout(),
Store.delete(StoreKey.assetETag), Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.userRemoteId),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
]); ]);
@ -133,7 +132,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]); Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"])); Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
Store.put(StoreKey.userRemoteId, userResponseDto.id);
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto)); Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl); Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken); Store.put(StoreKey.accessToken, accessToken);

View File

@ -15,11 +15,11 @@ class Asset {
Asset.remote(AssetResponseDto remote) Asset.remote(AssetResponseDto remote)
: remoteId = remote.id, : remoteId = remote.id,
isLocal = false, isLocal = false,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(), fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(), fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
updatedAt = DateTime.parse(remote.updatedAt).toUtc(), updatedAt = DateTime.parse(remote.updatedAt),
// use -1 as fallback duration (to not mix it up with non-video assets correctly having duration=0) durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? -1, type = remote.type.toAssetType(),
fileName = p.basename(remote.originalPath), fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(), height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(), width = remote.exifInfo?.exifImageWidth?.toInt(),
@ -35,15 +35,16 @@ class Asset {
: localId = local.id, : localId = local.id,
isLocal = true, isLocal = true,
durationInSeconds = local.duration, durationInSeconds = local.duration,
type = AssetType.values[local.typeInt],
height = local.height, height = local.height,
width = local.width, width = local.width,
fileName = local.title!, fileName = local.title!,
deviceId = Store.get(StoreKey.deviceIdHash), deviceId = Store.get(StoreKey.deviceIdHash),
ownerId = Store.get(StoreKey.currentUser).isarId, ownerId = Store.get(StoreKey.currentUser).isarId,
fileModifiedAt = local.modifiedDateTime.toUtc(), fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime.toUtc(), updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite, isFavorite = local.isFavorite,
fileCreatedAt = local.createDateTime.toUtc() { fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) { if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt; fileCreatedAt = fileModifiedAt;
} }
@ -61,6 +62,7 @@ class Asset {
required this.fileModifiedAt, required this.fileModifiedAt,
required this.updatedAt, required this.updatedAt,
required this.durationInSeconds, required this.durationInSeconds,
required this.type,
this.width, this.width,
this.height, this.height,
required this.fileName, required this.fileName,
@ -77,10 +79,10 @@ class Asset {
AssetEntity? get local { AssetEntity? get local {
if (isLocal && _local == null) { if (isLocal && _local == null) {
_local = AssetEntity( _local = AssetEntity(
id: localId.toString(), id: localId,
typeInt: isImage ? 1 : 2, typeInt: isImage ? 1 : 2,
width: width!, width: width ?? 0,
height: height!, height: height ?? 0,
duration: durationInSeconds, duration: durationInSeconds,
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000, createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000, modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
@ -96,7 +98,7 @@ class Asset {
String? remoteId; String? remoteId;
@Index( @Index(
unique: true, unique: false,
replace: false, replace: false,
type: IndexType.hash, type: IndexType.hash,
composite: [CompositeIndex('deviceId')], composite: [CompositeIndex('deviceId')],
@ -115,6 +117,9 @@ class Asset {
int durationInSeconds; int durationInSeconds;
@Enumerated(EnumType.ordinal)
AssetType type;
short? width; short? width;
short? height; short? height;
@ -140,7 +145,7 @@ class Asset {
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ignore @ignore
bool get isImage => durationInSeconds == 0; bool get isImage => type == AssetType.image;
@ignore @ignore
Duration get duration => Duration(seconds: durationInSeconds); Duration get duration => Duration(seconds: durationInSeconds);
@ -148,12 +153,43 @@ class Asset {
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; 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 @override
@ignore @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) { bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields; // TODO check more fields;
@ -192,9 +228,24 @@ class Asset {
} }
} }
static int compareByDeviceIdLocalId(Asset a, Asset b) { /// compares assets by [ownerId], [deviceId], [localId]
final int order = a.deviceId.compareTo(b.deviceId); static int compareByOwnerDeviceLocalId(Asset a, Asset b) {
return order == 0 ? a.localId.compareTo(b.localId) : order; final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
if (ownerIdOrder != 0) {
return ownerIdOrder;
}
final int deviceIdOrder = a.deviceId.compareTo(b.deviceId);
if (deviceIdOrder != 0) {
return deviceIdOrder;
}
final int localIdOrder = a.localId.compareTo(b.localId);
return localIdOrder;
}
/// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt]
static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) {
final int order = compareByOwnerDeviceLocalId(a, b);
return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt);
} }
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id); static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
@ -203,6 +254,30 @@ class Asset {
a.localId.compareTo(b.localId); 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> { extension AssetsHelper on IsarCollection<Asset> {
Future<int> deleteAllByRemoteId(Iterable<String> ids) => Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll(); ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();

View File

@ -77,13 +77,19 @@ const AssetSchema = CollectionSchema(
name: r'remoteId', name: r'remoteId',
type: IsarType.string, type: IsarType.string,
), ),
r'updatedAt': PropertySchema( r'type': PropertySchema(
id: 12, id: 12,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 13,
name: r'updatedAt', name: r'updatedAt',
type: IsarType.dateTime, type: IsarType.dateTime,
), ),
r'width': PropertySchema( r'width': PropertySchema(
id: 13, id: 14,
name: r'width', name: r'width',
type: IsarType.int, type: IsarType.int,
) )
@ -110,7 +116,7 @@ const AssetSchema = CollectionSchema(
r'localId_deviceId': IndexSchema( r'localId_deviceId': IndexSchema(
id: 7649417350086526165, id: 7649417350086526165,
name: r'localId_deviceId', name: r'localId_deviceId',
unique: true, unique: false,
replace: false, replace: false,
properties: [ properties: [
IndexPropertySchema( IndexPropertySchema(
@ -175,8 +181,9 @@ void _assetSerialize(
writer.writeString(offsets[9], object.localId); writer.writeString(offsets[9], object.localId);
writer.writeLong(offsets[10], object.ownerId); writer.writeLong(offsets[10], object.ownerId);
writer.writeString(offsets[11], object.remoteId); writer.writeString(offsets[11], object.remoteId);
writer.writeDateTime(offsets[12], object.updatedAt); writer.writeByte(offsets[12], object.type.index);
writer.writeInt(offsets[13], object.width); writer.writeDateTime(offsets[13], object.updatedAt);
writer.writeInt(offsets[14], object.width);
} }
Asset _assetDeserialize( Asset _assetDeserialize(
@ -198,8 +205,10 @@ Asset _assetDeserialize(
localId: reader.readString(offsets[9]), localId: reader.readString(offsets[9]),
ownerId: reader.readLong(offsets[10]), ownerId: reader.readLong(offsets[10]),
remoteId: reader.readStringOrNull(offsets[11]), remoteId: reader.readStringOrNull(offsets[11]),
updatedAt: reader.readDateTime(offsets[12]), type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
width: reader.readIntOrNull(offsets[13]), AssetType.other,
updatedAt: reader.readDateTime(offsets[13]),
width: reader.readIntOrNull(offsets[14]),
); );
object.id = id; object.id = id;
return object; return object;
@ -237,14 +246,30 @@ P _assetDeserializeProp<P>(
case 11: case 11:
return (reader.readStringOrNull(offset)) as P; return (reader.readStringOrNull(offset)) as P;
case 12: case 12:
return (reader.readDateTime(offset)) as P; return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 13: case 13:
return (reader.readDateTime(offset)) as P;
case 14:
return (reader.readIntOrNull(offset)) as P; return (reader.readIntOrNull(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); 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) { Id _assetGetId(Asset object) {
return object.id; return object.id;
} }
@ -257,94 +282,6 @@ void _assetAttach(IsarCollection<dynamic> col, Id id, Asset object) {
object.id = id; 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> { extension AssetQueryWhereSort on QueryBuilder<Asset, Asset, QWhere> {
QueryBuilder<Asset, Asset, QAfterWhere> anyId() { QueryBuilder<Asset, Asset, QAfterWhere> anyId() {
return QueryBuilder.apply(this, (query) { 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( QueryBuilder<Asset, Asset, QAfterFilterCondition> updatedAtEqualTo(
DateTime value) { DateTime value) {
return QueryBuilder.apply(this, (query) { 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() { QueryBuilder<Asset, Asset, QAfterSortBy> sortByUpdatedAt() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'updatedAt', Sort.asc); 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() { QueryBuilder<Asset, Asset, QAfterSortBy> thenByUpdatedAt() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'updatedAt', Sort.asc); 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() { QueryBuilder<Asset, Asset, QDistinct> distinctByUpdatedAt() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'updatedAt'); 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() { QueryBuilder<Asset, DateTime, QQueryOperations> updatedAtProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'updatedAt'); return query.addPropertyName(r'updatedAt');

View File

@ -138,7 +138,7 @@ class StoreKeyNotFoundException implements Exception {
/// Key for each possible value in the `Store`. /// Key for each possible value in the `Store`.
/// Defines the data type for each value /// Defines the data type for each value
enum StoreKey<T> { enum StoreKey<T> {
userRemoteId<String>(0, type: String), version<int>(0, type: int),
assetETag<String>(1, type: String), assetETag<String>(1, type: String),
currentUser<User>(2, type: User, fromDb: _getUser, toDb: _toUser), currentUser<User>(2, type: User, fromDb: _getUser, toDb: _toUser),
deviceIdHash<int>(3, type: int), deviceIdHash<int>(3, type: int),

View File

@ -1,7 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
@ -12,6 +10,9 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -53,15 +54,18 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService; final AssetService _assetService;
final AppSettingsService _settingsService; final AppSettingsService _settingsService;
final AlbumService _albumService; final AlbumService _albumService;
final SyncService _syncService;
final Isar _db; final Isar _db;
final log = Logger('AssetNotifier'); final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false; bool _getAllAssetInProgress = false;
bool _deleteInProgress = false; bool _deleteInProgress = false;
final AsyncMutex _stateUpdateLock = AsyncMutex();
AssetNotifier( AssetNotifier(
this._assetService, this._assetService,
this._settingsService, this._settingsService,
this._albumService, this._albumService,
this._syncService,
this._db, this._db,
) : super(AssetsState.fromAssetList([])); ) : super(AssetsState.fromAssetList([]));
@ -81,24 +85,30 @@ class AssetNotifier extends StateNotifier<AssetsState> {
await _updateAssetsState(state.allAssets); await _updateAssetsState(state.allAssets);
} }
getAllAsset() async { Future<void> getAllAsset({bool clear = false}) async {
if (_getAllAssetInProgress || _deleteInProgress) { if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working // guard against multiple calls to this method while it's still working
return; return;
} }
final stopwatch = Stopwatch(); final stopwatch = Stopwatch()..start();
try { try {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
final User me = Store.get(StoreKey.currentUser); final User me = Store.get(StoreKey.currentUser);
final int cachedCount = if (clear) {
await _db.assets.filter().ownerIdEqualTo(me.isarId).count(); await clearAssetsAndAlbums(_db);
stopwatch.start(); log.info("Manual refresh requested, cleared assets and albums from db");
if (cachedCount > 0 && cachedCount != state.allAssets.length) { } else if (_stateUpdateLock.enqueued <= 1) {
await _updateAssetsState(await _getUserAssets(me.isarId)); final int cachedCount =
log.info( await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms", if (cachedCount > 0 && cachedCount != state.allAssets.length) {
); await _stateUpdateLock.run(
stopwatch.reset(); () async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
} }
final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums(); final bool newLocal = await _albumService.refreshDeviceAlbums();
@ -112,10 +122,14 @@ class AssetNotifier extends StateNotifier<AssetsState> {
return; return;
} }
stopwatch.reset(); stopwatch.reset();
final assets = await _getUserAssets(me.isarId); if (_stateUpdateLock.enqueued <= 1) {
if (!const ListEquality().equals(assets, state.allAssets)) { _stateUpdateLock.run(() async {
log.info("setting new asset state"); final assets = await _getUserAssets(me.isarId);
await _updateAssetsState(assets); if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
});
} }
} finally { } finally {
_getAllAssetInProgress = false; _getAllAssetInProgress = false;
@ -130,47 +144,18 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<void> clearAllAsset() { Future<void> clearAllAsset() {
state = AssetsState.empty(); state = AssetsState.empty();
return _db.writeTxn(() async { return clearAssetsAndAlbums(_db);
await _db.assets.clear();
await _db.exifInfos.clear();
await _db.albums.clear();
});
} }
Future<void> onNewAssetUploaded(Asset newAsset) async { Future<void> onNewAssetUploaded(Asset newAsset) async {
final int i = state.allAssets.indexWhere( final bool ok = await _syncService.syncNewAssetToDb(newAsset);
(a) => if (ok && _stateUpdateLock.enqueued <= 1) {
a.isRemote || // run this sequentially if there is at most 1 other task waiting
(a.localId == newAsset.localId && a.deviceId == newAsset.deviceId), await _stateUpdateLock.run(() async {
); final userId = Store.get(StoreKey.currentUser).isarId;
final assets = await _getUserAssets(userId);
if (i == -1 || await _updateAssetsState(assets);
state.allAssets[i].localId != newAsset.localId || });
state.allAssets[i].deviceId != newAsset.deviceId) {
await _updateAssetsState([...state.allAssets, newAsset]);
} else {
// unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset
final Asset? inDb = await _db.assets
.where()
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
.findFirst();
if (inDb != null) {
newAsset.id = inDb.id;
newAsset.isLocal = inDb.isLocal;
}
// order is important to keep all local-only assets at the beginning!
await _updateAssetsState([
...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1),
newAsset,
]);
}
try {
await _db.writeTxn(() => newAsset.put(_db));
} on IsarError catch (e) {
debugPrint(e.toString());
} }
} }
@ -253,6 +238,7 @@ final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
ref.watch(assetServiceProvider), ref.watch(assetServiceProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
); );
}); });

View File

@ -45,14 +45,11 @@ class AssetService {
.filter() .filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.count(); .count();
final List<AssetResponseDto>? dtos = final bool changes = await _syncService.syncRemoteAssetsToDb(
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0); () async => (await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0))
if (dtos == null) { ?.map(Asset.remote)
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms"); .toList(),
return false; );
}
final bool changes = await _syncService
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms"); debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes; return changes;
} }

View File

@ -20,7 +20,7 @@ class ImmichLogger {
static final ImmichLogger _instance = ImmichLogger._internal(); static final ImmichLogger _instance = ImmichLogger._internal();
final maxLogEntries = 200; final maxLogEntries = 200;
final Isar _db = Isar.getInstance()!; final Isar _db = Isar.getInstance()!;
final List<LoggerMessage> _msgBuffer = []; List<LoggerMessage> _msgBuffer = [];
Timer? _timer; Timer? _timer;
factory ImmichLogger() => _instance; factory ImmichLogger() => _instance;
@ -41,7 +41,12 @@ class ImmichLogger {
final msgCount = _db.loggerMessages.countSync(); final msgCount = _db.loggerMessages.countSync();
if (msgCount > maxLogEntries) { if (msgCount > maxLogEntries) {
final numberOfEntryToBeDeleted = msgCount - maxLogEntries; final numberOfEntryToBeDeleted = msgCount - maxLogEntries;
_db.loggerMessages.where().limit(numberOfEntryToBeDeleted).deleteAll(); _db.writeTxn(
() => _db.loggerMessages
.where()
.limit(numberOfEntryToBeDeleted)
.deleteAll(),
);
} }
} }
@ -63,8 +68,9 @@ class ImmichLogger {
void _flushBufferToDatabase() { void _flushBufferToDatabase() {
_timer = null; _timer = null;
_db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); final buffer = _msgBuffer;
_msgBuffer.clear(); _msgBuffer = [];
_db.writeTxn(() => _db.loggerMessages.putAll(buffer));
} }
void clearLogs() { void clearLogs() {
@ -111,7 +117,7 @@ class ImmichLogger {
void flush() { void flush() {
if (_timer != null) { if (_timer != null) {
_timer!.cancel(); _timer!.cancel();
_flushBufferToDatabase(); _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer));
} }
} }
} }

View File

@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -61,8 +60,10 @@ class SyncService {
/// Syncs remote assets owned by the logged-in user to the DB /// Syncs remote assets owned by the logged-in user to the DB
/// Returns `true` if there were any changes /// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) => Future<bool> syncRemoteAssetsToDb(
_lock.run(() => _syncRemoteAssetsToDb(remote)); FutureOr<List<Asset>?> Function() loadAssets,
) =>
_lock.run(() => _syncRemoteAssetsToDb(loadAssets));
/// Syncs remote albums to the database /// Syncs remote albums to the database
/// returns `true` if there were any changes /// returns `true` if there were any changes
@ -97,19 +98,72 @@ class SyncService {
.toList(); .toList();
} }
/// Syncs a new asset to the db. Returns `true` if successful
Future<bool> syncNewAssetToDb(Asset newAsset) =>
_lock.run(() => _syncNewAssetToDb(newAsset));
// private methods: // private methods:
/// Syncs a new asset to the db. Returns `true` if successful
Future<bool> _syncNewAssetToDb(Asset newAsset) async {
final List<Asset> inDb = await _db.assets
.where()
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
.findAll();
Asset? match;
if (inDb.length == 1) {
// exactly one match: trivial case
match = inDb.first;
} else if (inDb.length > 1) {
// TODO instead of this heuristics: match by checksum once available
for (Asset a in inDb) {
if (a.ownerId == newAsset.ownerId &&
a.fileModifiedAt == newAsset.fileModifiedAt) {
assert(match == null);
match = a;
}
}
if (match == null) {
for (Asset a in inDb) {
if (a.ownerId == newAsset.ownerId) {
assert(match == null);
match = a;
}
}
}
}
if (match != null) {
// unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset
newAsset.updateFromDb(match);
}
try {
await _db.writeTxn(() => newAsset.put(_db));
} on IsarError catch (e) {
_log.severe("Failed to put new asset into db: $e");
return false;
}
return true;
}
/// Syncs remote assets to the databas /// Syncs remote assets to the databas
/// returns `true` if there were any changes /// returns `true` if there were any changes
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async { Future<bool> _syncRemoteAssetsToDb(
FutureOr<List<Asset>?> Function() loadAssets,
) async {
final List<Asset>? remote = await loadAssets();
if (remote == null) {
return false;
}
final User user = Store.get(StoreKey.currentUser); final User user = Store.get(StoreKey.currentUser);
final List<Asset> inDb = await _db.assets final List<Asset> inDb = await _db.assets
.filter() .filter()
.ownerIdEqualTo(user.isarId) .ownerIdEqualTo(user.isarId)
.sortByDeviceId() .sortByDeviceId()
.thenByLocalId() .thenByLocalId()
.thenByFileModifiedAt()
.findAll(); .findAll();
remote.sort(Asset.compareByDeviceIdLocalId); remote.sort(Asset.compareByOwnerDeviceLocalIdModified);
final diff = _diffAssets(remote, inDb, remote: true); final diff = _diffAssets(remote, inDb, remote: true);
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) { if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
return false; return false;
@ -119,7 +173,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await _upsertAssetsWithExif(diff.first + diff.second); await _upsertAssetsWithExif(diff.first + diff.second);
} on IsarError catch (e) { } on IsarError catch (e) {
debugPrint(e.toString()); _log.severe("Failed to sync remote assets to db: $e");
} }
return true; return true;
} }
@ -188,10 +242,15 @@ class SyncService {
if (dto.assetCount != dto.assets.length) { if (dto.assetCount != dto.assets.length) {
return false; return false;
} }
final assetsInDb = final assetsInDb = await album.assets
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll(); .filter()
.sortByOwnerId()
.thenByDeviceId()
.thenByLocalId()
.thenByFileModifiedAt()
.findAll();
final List<Asset> assetsOnRemote = dto.getAssets(); final List<Asset> assetsOnRemote = dto.getAssets();
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId); assetsOnRemote.sort(Asset.compareByOwnerDeviceLocalIdModified);
final d = _diffAssets(assetsOnRemote, assetsInDb); final d = _diffAssets(assetsOnRemote, assetsInDb);
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third; final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
@ -237,7 +296,7 @@ class SyncService {
await _db.albums.put(album); await _db.albums.put(album);
}); });
} on IsarError catch (e) { } on IsarError catch (e) {
debugPrint(e.toString()); _log.severe("Failed to sync remote album to database $e");
} }
if (album.shared || dto.shared) { if (album.shared || dto.shared) {
@ -300,7 +359,7 @@ class SyncService {
assert(ok); assert(ok);
_log.info("Removed local album $album from DB"); _log.info("Removed local album $album from DB");
} catch (e) { } catch (e) {
_log.warning("Failed to remove local album $album from DB"); _log.severe("Failed to remove local album $album from DB");
} }
} }
@ -331,7 +390,7 @@ class SyncService {
_addAlbumFromDevice(ape, existing, excludedAssets), _addAlbumFromDevice(ape, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates), onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
); );
final pair = _handleAssetRemoval(deleteCandidates, existing); final pair = _handleAssetRemoval(deleteCandidates, existing, remote: false);
if (pair.first.isNotEmpty || pair.second.isNotEmpty) { if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
await _db.writeTxn(() async { await _db.writeTxn(() async {
await _db.assets.deleteAll(pair.first); await _db.assets.deleteAll(pair.first);
@ -366,7 +425,12 @@ class SyncService {
} }
// 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
final inDb = await album.assets.filter().sortByLocalId().findAll(); final inDb = await album.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.deviceIdEqualTo(Store.get(StoreKey.deviceIdHash))
.sortByLocalId()
.findAll();
final List<Asset> onDevice = final List<Asset> onDevice =
await ape.getAssets(excludedAssets: excludedAssets); await ape.getAssets(excludedAssets: excludedAssets);
onDevice.sort(Asset.compareByLocalId); onDevice.sort(Asset.compareByLocalId);
@ -401,7 +465,7 @@ class SyncService {
}); });
_log.info("Synced changes of local album $ape to DB"); _log.info("Synced changes of local album $ape to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.warning("Failed to update synced album $ape in DB: $e"); _log.severe("Failed to update synced album $ape in DB: $e");
} }
return true; return true;
@ -438,7 +502,7 @@ class SyncService {
}); });
_log.info("Fast synced local album $ape to DB"); _log.info("Fast synced local album $ape to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.warning("Failed to fast sync local album $ape to DB: $e"); _log.severe("Failed to fast sync local album $ape to DB: $e");
return false; return false;
} }
@ -470,7 +534,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: $ape"); _log.info("Added a new local album to DB: $ape");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.warning("Failed to add new local album $ape to DB: $e"); _log.severe("Failed to add new local album $ape to DB: $e");
} }
} }
@ -487,15 +551,19 @@ class SyncService {
assets, assets,
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId), (q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
) )
.sortByDeviceId() .sortByOwnerId()
.thenByDeviceId()
.thenByLocalId() .thenByLocalId()
.thenByFileModifiedAt()
.findAll(); .findAll();
assets.sort(Asset.compareByDeviceIdLocalId); assets.sort(Asset.compareByOwnerDeviceLocalIdModified);
final List<Asset> existing = [], toUpsert = []; final List<Asset> existing = [], toUpsert = [];
diffSortedListsSync( diffSortedListsSync(
inDb, inDb,
assets, assets,
compare: Asset.compareByDeviceIdLocalId, // do not compare by modified date because for some assets dates differ on
// client and server, thus never reaching "both" case below
compare: Asset.compareByOwnerDeviceLocalId,
both: (Asset a, Asset b) { both: (Asset a, Asset b) {
if ((a.isLocal || !b.isLocal) && if ((a.isLocal || !b.isLocal) &&
(a.isRemote || !b.isRemote) && (a.isRemote || !b.isRemote) &&
@ -541,7 +609,7 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
List<Asset> assets, List<Asset> assets,
List<Asset> inDb, { List<Asset> inDb, {
bool? remote, bool? remote,
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId, int Function(Asset, Asset) compare = Asset.compareByOwnerDeviceLocalId,
}) { }) {
final List<Asset> toAdd = []; final List<Asset> toAdd = [];
final List<Asset> toUpdate = []; final List<Asset> toUpdate = [];
@ -582,15 +650,20 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
/// returns a tuple (toDelete toUpdate) when assets are to be deleted /// returns a tuple (toDelete toUpdate) when assets are to be deleted
Pair<List<int>, List<Asset>> _handleAssetRemoval( Pair<List<int>, List<Asset>> _handleAssetRemoval(
List<Asset> deleteCandidates, List<Asset> deleteCandidates,
List<Asset> existing, List<Asset> existing, {
) { bool? remote,
}) {
if (deleteCandidates.isEmpty) { if (deleteCandidates.isEmpty) {
return const Pair([], []); return const Pair([], []);
} }
deleteCandidates.sort(Asset.compareById); deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById); existing.sort(Asset.compareById);
final triple = final triple = _diffAssets(
_diffAssets(existing, deleteCandidates, compare: Asset.compareById); existing,
deleteCandidates,
compare: Asset.compareById,
remote: remote,
);
return Pair(triple.third.map((e) => e.id).toList(), triple.second); return Pair(triple.third.map((e) => e.id).toList(), triple.second);
} }

View File

@ -3,12 +3,17 @@ import 'dart:async';
/// Async mutex to guarantee actions are performed sequentially and do not interleave /// Async mutex to guarantee actions are performed sequentially and do not interleave
class AsyncMutex { class AsyncMutex {
Future _running = Future.value(null); Future _running = Future.value(null);
int _enqueued = 0;
get enqueued => _enqueued;
/// Execute [operation] exclusively, after any currently running operations. /// Execute [operation] exclusively, after any currently running operations.
/// Returns a [Future] with the result of the [operation]. /// Returns a [Future] with the result of the [operation].
Future<T> run<T>(Future<T> Function() operation) { Future<T> run<T>(Future<T> Function() operation) {
final completer = Completer<T>(); final completer = Completer<T>();
_enqueued++;
_running.whenComplete(() { _running.whenComplete(() {
_enqueued--;
completer.complete(Future<T>.sync(operation)); completer.complete(Future<T>.sync(operation));
}); });
return _running = completer.future; return _running = completer.future;

14
mobile/lib/utils/db.dart Normal file
View File

@ -0,0 +1,14 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:isar/isar.dart';
Future<void> clearAssetsAndAlbums(Isar db) async {
await Store.delete(StoreKey.assetETag);
await db.writeTxn(() async {
await db.assets.clear();
await db.exifInfos.clear();
await db.albums.clear();
});
}

View File

@ -15,6 +15,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart'; import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
Future<void> migrateHiveToStoreIfNecessary() async { Future<void> migrateHiveToStoreIfNecessary() async {
@ -53,7 +54,6 @@ Future<void> _migrateLoginInfoBox(Box<HiveSavedLoginInfo> box) async {
} }
Future<void> _migrateHiveUserInfoBox(Box box) async { Future<void> _migrateHiveUserInfoBox(Box box) async {
await _migrateKey(box, userIdKey, StoreKey.userRemoteId);
await _migrateKey(box, assetEtagKey, StoreKey.assetETag); await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
if (Store.tryGet(StoreKey.deviceId) == null) { if (Store.tryGet(StoreKey.deviceId) == null) {
await _migrateKey(box, deviceIdKey, StoreKey.deviceId); await _migrateKey(box, deviceIdKey, StoreKey.deviceId);
@ -143,3 +143,16 @@ Future<void> migrateJsonCacheIfNecessary() async {
await SharedAlbumCacheService().invalidate(); await SharedAlbumCacheService().invalidate();
await AssetCacheService().invalidate(); await AssetCacheService().invalidate();
} }
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);
switch (version) {
case 1:
await _migrateV1ToV2(db);
}
}
Future<void> _migrateV1ToV2(Isar db) async {
await clearAssetsAndAlbums(db);
await Store.put(StoreKey.version, 2);
}

View File

@ -20,6 +20,7 @@ void main() {
fileModifiedAt: date, fileModifiedAt: date,
updatedAt: date, updatedAt: date,
durationInSeconds: 0, durationInSeconds: 0,
type: AssetType.image,
fileName: '', fileName: '',
isFavorite: false, isFavorite: false,
isLocal: false, isLocal: false,

View File

@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
void main() {
group('Test AsyncMutex grouped', () {
test('test ordered execution', () async {
AsyncMutex lock = AsyncMutex();
List<int> events = [];
expect(0, lock.enqueued);
lock.run(
() => Future.delayed(
const Duration(milliseconds: 10),
() => events.add(1),
),
);
expect(1, lock.enqueued);
lock.run(
() => Future.delayed(
const Duration(milliseconds: 3),
() => events.add(2),
),
);
expect(2, lock.enqueued);
lock.run(
() => Future.delayed(
const Duration(milliseconds: 1),
() => events.add(3),
),
);
expect(3, lock.enqueued);
await lock.run(
() => Future.delayed(
const Duration(milliseconds: 10),
() => events.add(4),
),
);
expect(0, lock.enqueued);
expect(events, [1, 2, 3, 4]);
});
});
}

View File

@ -23,6 +23,7 @@ Asset _getTestAsset(int id, bool favorite) {
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isLocal: false, isLocal: false,
durationInSeconds: 0, durationInSeconds: 0,
type: AssetType.image,
fileName: '', fileName: '',
isFavorite: favorite, isFavorite: favorite,
); );

View File

@ -0,0 +1,143 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:isar/isar.dart';
void main() {
Asset makeAsset({
required String localId,
String? remoteId,
int deviceId = 1,
int ownerId = 590700560494856554, // hash of "1"
bool isLocal = false,
}) {
final DateTime date = DateTime(2000);
return Asset(
localId: localId,
remoteId: remoteId,
deviceId: deviceId,
ownerId: ownerId,
fileCreatedAt: date,
fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0,
type: AssetType.image,
fileName: localId,
isFavorite: false,
isLocal: isLocal,
);
}
Isar loadDb() {
return Isar.openSync(
[
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
StoreValueSchema,
LoggerMessageSchema
],
maxSizeMiB: 256,
);
}
group('Test SyncService grouped', () {
late final Isar db;
setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized();
await Isar.initializeIsarCore(download: true);
db = loadDb();
ImmichLogger();
db.writeTxnSync(() => db.clearSync());
Store.init(db);
await Store.put(
StoreKey.currentUser,
User(
id: "1",
updatedAt: DateTime.now(),
email: "a@b.c",
firstName: "first",
lastName: "last",
isAdmin: false,
),
);
});
final List<Asset> initialAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "1-1", isLocal: true),
makeAsset(localId: "2", isLocal: true),
makeAsset(localId: "3", isLocal: true),
];
setUp(() {
db.writeTxnSync(() {
db.assets.clearSync();
db.assets.putAllSync(initialAssets);
});
});
test('test inserting existing assets', () async {
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "1-1"),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c1, false);
expect(db.assets.countSync(), 5);
});
test('test inserting new assets', () async {
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "1-1"),
makeAsset(localId: "2", remoteId: "1-2"),
makeAsset(localId: "4", remoteId: "1-4"),
makeAsset(localId: "1", remoteId: "3-1", deviceId: 3),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 7);
});
test('test syncing duplicate assets', () async {
SyncService s = SyncService(db);
final List<Asset> remoteAssets = [
makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
makeAsset(localId: "1", remoteId: "1-1"),
makeAsset(localId: "1", remoteId: "2-1", deviceId: 2),
makeAsset(localId: "1", remoteId: "2-1b", deviceId: 2),
makeAsset(localId: "1", remoteId: "2-1c", deviceId: 2),
makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2),
];
expect(db.assets.countSync(), 5);
final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c1, true);
expect(db.assets.countSync(), 8);
final bool c2 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c2, false);
expect(db.assets.countSync(), 8);
remoteAssets.removeAt(4);
final bool c3 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c3, true);
expect(db.assets.countSync(), 7);
remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2));
remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2));
final bool c4 = await s.syncRemoteAssetsToDb(() => remoteAssets);
expect(c4, true);
expect(db.assets.countSync(), 9);
});
});
}