mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 04:05:39 -04: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
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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!]);
|
||||||
|
@ -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(
|
||||||
|
@ -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(),
|
||||||
),
|
),
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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);
|
||||||
|
@ -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();
|
||||||
|
@ -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');
|
||||||
|
@ -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),
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
14
mobile/lib/utils/db.dart
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
41
mobile/test/async_mutex_test.dart
Normal file
41
mobile/test/async_mutex_test.dart
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -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,
|
||||||
);
|
);
|
||||||
|
143
mobile/test/sync_service_test.dart
Normal file
143
mobile/test/sync_service_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user