From 1a35a01149fda1d33470905203e3524c6db944cd Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 23 Jul 2025 09:20:52 -0500 Subject: [PATCH] feat: drift manual upload (#20101) --- i18n/en.json | 3 ++ .../repositories/timeline.repository.dart | 5 +++ mobile/lib/main.dart | 13 +++++++ .../upload_action_button.widget.dart | 36 ++++++++++++++++++- .../asset_viewer/bottom_bar.widget.dart | 3 ++ .../asset_viewer/bottom_sheet.widget.dart | 2 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../local_album_bottom_sheet.widget.dart | 2 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 18 ++++++++++ .../lib/repositories/upload.repository.dart | 5 +++ mobile/lib/services/drift_backup.service.dart | 28 +++++++++++++-- 14 files changed, 114 insertions(+), 9 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 54c7ca6f1b..e2e0fd3c4f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1941,11 +1941,13 @@ "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", + "upload_action_prompt": "{count} queued for upload", "upload_concurrency": "Upload concurrency", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_title": "Upload Asset", "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", + "upload_finished": "Upload finished", "upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}", "upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_status_duplicates": "Duplicates", @@ -1954,6 +1956,7 @@ "upload_success": "Upload success, refresh the page to see new upload assets.", "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", + "uploading_media": "Uploading media", "url": "URL", "usage": "Usage", "use_biometric": "Use biometric", diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index fe9ae1e60d..2916993a9b 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -141,6 +141,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), useColumns: false, ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), ]) ..addColumns([assetCountExp, dateExp]) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 9f39a89b33..b62fb180e5 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -203,6 +203,19 @@ class ImmichAppState extends ConsumerState ), progressBar: true, ); + + FileDownloader().configureNotificationForGroup( + kManualUploadGroup, + running: TaskNotification( + 'uploading_media'.tr(), + '${'file_name'.tr()}: {displayName}', + ), + complete: TaskNotification( + 'upload_finished'.tr(), + '${'file_name'.tr()}: {displayName}', + ), + progressBar: true, + ); } Future _deepLinkBuilder(PlatformDeepLink deepLink) async { diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 66467e44a3..d67c952b1a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -1,16 +1,50 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class UploadActionButton extends ConsumerWidget { - const UploadActionButton({super.key}); + final ActionSource source; + + const UploadActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).upload(source); + + final successMessage = 'upload_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + + ref.read(multiSelectProvider.notifier).reset(); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.backup_outlined, label: "upload".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index cb558804d2..65bab5acb8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -37,6 +38,8 @@ class ViewerBottomBar extends ConsumerWidget { final actions = [ const ShareActionButton(source: ActionSource.viewer), + if (asset.isLocalOnly) + const UploadActionButton(source: ActionSource.viewer), if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), ]; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 0e426fde6d..9111469629 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -63,7 +63,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { ], if (asset.storage == AssetState.local) ...[ const DeleteLocalActionButton(source: ActionSource.viewer), - const UploadActionButton(), + const UploadActionButton(source: ActionSource.timeline), ], ]; diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index deaaea0d39..76243cf803 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -53,7 +53,7 @@ class ArchiveBottomSheet extends ConsumerWidget { ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), - const UploadActionButton(), + const UploadActionButton(source: ActionSource.timeline), ], ], ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 8199271bfe..3f3f933745 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -53,7 +53,7 @@ class FavoriteBottomSheet extends ConsumerWidget { ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), - const UploadActionButton(), + const UploadActionButton(source: ActionSource.timeline), ], ], ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index d83b8e399d..c148861b43 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -56,7 +56,7 @@ class GeneralBottomSheet extends ConsumerWidget { ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), - const UploadActionButton(), + const UploadActionButton(source: ActionSource.timeline), ], ], ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index 2ad0fe7485..b1e87dfaea 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -18,7 +18,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget { actions: [ ShareActionButton(source: ActionSource.timeline), DeleteLocalActionButton(source: ActionSource.timeline), - UploadActionButton(), + UploadActionButton(source: ActionSource.timeline), ], ); } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 2e6047f0ba..ff77c79906 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -56,7 +56,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), - const UploadActionButton(), + const UploadActionButton(source: ActionSource.timeline), ], RemoveFromAlbumActionButton( source: ActionSource.timeline, diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 419ba0f902..3102511295 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; +import 'package:immich_mobile/services/drift_backup.service.dart'; import 'package:immich_mobile/services/timeline.service.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -32,12 +33,14 @@ class ActionResult { class ActionNotifier extends Notifier { final Logger _logger = Logger('ActionNotifier'); late ActionService _service; + late DriftBackupService _backupService; ActionNotifier() : super(); @override void build() { _service = ref.watch(actionServiceProvider); + _backupService = ref.watch(driftBackupServiceProvider); } List _getRemoteIdsForSource(ActionSource source) { @@ -368,6 +371,21 @@ class ActionNotifier extends Notifier { ); } } + + Future upload(ActionSource source) async { + final assets = _getAssets(source).whereType().toList(); + try { + await _backupService.manualBackup(assets); + return ActionResult(count: assets.length, success: true); + } catch (error, stack) { + _logger.severe('Failed manually upload assets', error, stack); + return ActionResult( + count: assets.length, + success: false, + error: error.toString(), + ); + } + } } extension on Iterable { diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index b98eece656..6d75313a24 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -20,6 +20,11 @@ class UploadRepository { taskStatusCallback: (update) => onUploadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); + FileDownloader().registerCallbacks( + group: kManualUploadGroup, + taskStatusCallback: (_) => {}, + taskProgressCallback: (_) => {}, + ); } void enqueueAll(List tasks) { diff --git a/mobile/lib/services/drift_backup.service.dart b/mobile/lib/services/drift_backup.service.dart index 7373643c95..ab878969a0 100644 --- a/mobile/lib/services/drift_backup.service.dart +++ b/mobile/lib/services/drift_backup.service.dart @@ -26,6 +26,7 @@ final driftBackupServiceProvider = Provider( ), ); +// TODO: Rename to UploadService after removing Isar class DriftBackupService { DriftBackupService( this._backupRepository, @@ -56,6 +57,24 @@ class DriftBackupService { return _backupRepository.getBackupCount(userId); } + Future manualBackup(List localAssets) async { + List tasks = []; + for (final asset in localAssets) { + final task = await _getUploadTask( + asset, + group: kManualUploadGroup, + priority: 1, // High priority after upload motion photo part + ); + if (task != null) { + tasks.add(task); + } + } + + if (tasks.isNotEmpty) { + _uploadService.enqueueTasks(tasks); + } + } + Future backup( String userId, void Function(EnqueueStatus status) onEnqueueTasks, @@ -147,7 +166,11 @@ class DriftBackupService { } } - Future _getUploadTask(LocalAsset asset) async { + Future _getUploadTask( + LocalAsset asset, { + String group = kBackupGroup, + int? priority, + }) async { final entity = await _storageRepository.getAssetEntityForAsset(asset); if (entity == null) { return null; @@ -193,7 +216,8 @@ class DriftBackupService { originalFileName: originalFileName, deviceAssetId: asset.id, metadata: metadata, - group: kBackupGroup, + group: group, + priority: priority, ); }