diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt index a1e1fea3dd..60041a9311 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch private const val TAG = "ViewIntentPlugin" +private const val VIEW_INTENT_UNAVAILABLE = "VIEW_INTENT_UNAVAILABLE" class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi { private var context: Context? = null @@ -92,7 +93,15 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL val localAssetId = extractLocalAssetId(context, uri, mimeType) val tempFilePath = if (localAssetId == null) { copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run { - callback(Result.success(null)) + consumeViewIntent(intent) + callback( + Result.failure( + FlutterError( + VIEW_INTENT_UNAVAILABLE, + "Unable to access the file referenced by the incoming view intent", + ), + ), + ) return@launch } } else { @@ -153,7 +162,8 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL } } ?: return null tempFile - } catch (_: Exception) { + } catch (e: Exception) { + Log.w(TAG, "Failed to materialize view intent URI as a temporary file", e) null } } diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 59f41f1e51..05a879973f 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:stream_transform/stream_transform.dart'; class AssetService { final RemoteAssetRepository _remoteRepository; @@ -37,8 +38,22 @@ class AssetService { } Stream watchAsset(BaseAsset asset) { - final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; - return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id); + if (asset is RemoteAsset) { + return _remoteRepository.watch(asset.id); + } + + return _localRepository.watch((asset as LocalAsset).id).switchMap((localAsset) { + final remoteId = localAsset?.remoteId; + if (localAsset == null || remoteId == null) { + return Stream.value(localAsset); + } + + return _remoteRepository.watch(remoteId).map((remoteAsset) => remoteAsset ?? localAsset); + }); + } + + Stream watchRemoteAsset(String id) { + return _remoteRepository.watch(id); } Future> getLocalAssetsByChecksum(String checksum) { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index b20ba306ff..13414e05ae 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -37,6 +37,18 @@ enum TimelineOrigin { recentlyAdded, } +extension BaseAssetTimelineX on BaseAsset? { + // Whether this asset should currently be presented as trashed, given the timeline it's + // shown in. The full trash gallery has no single asset to check against; a deep-link + // timeline defers to the asset's own (live) trashed state instead, since — unlike + // origin — that can change while the timeline is still open (e.g. after a restore). + bool isEffectivelyTrashed(TimelineOrigin origin) { + final asset = this; + return origin == TimelineOrigin.trash || + (origin == TimelineOrigin.deepLink && asset is RemoteAsset && asset.isTrashed); + } +} + class TimelineFactory { final DriftTimelineRepository _timelineRepository; final SettingsRepository _settingsRepository; diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 23a2ef2a2f..cdf4ed2673 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; @@ -48,6 +49,11 @@ class DeleteAction extends AssetActionBuilder { @override ActionItem? create(BuildContext context, WidgetRef ref) { + final viewIntentCoordinator = ref.watch(viewIntentAssetActionCoordinatorProvider); + if (!viewIntentCoordinator.canDelete(source)) { + return null; + } + final trash = ref.watch(_stateProvider(source).select((state) => state?.trash)); if (trash == null) { return null; @@ -69,6 +75,7 @@ class DeleteAction extends AssetActionBuilder { final (:localIds, :remoteIds, :trash) = state; final toastService = ref.read(toastServiceProvider); final clearSelection = ref.read(clearSelectionProvider(source)); + final viewIntentCoordinator = ref.read(viewIntentAssetActionCoordinatorProvider); try { final String? message; @@ -86,6 +93,7 @@ class DeleteAction extends AssetActionBuilder { toastService.success(message); clearSelection(); + await viewIntentCoordinator.afterDelete(source: source, remoteAssetIds: remoteIds, movedToTrash: trash); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to delete assets"); } @@ -93,11 +101,11 @@ class DeleteAction extends AssetActionBuilder { Future _removeLocalAssets(BuildContext context, WidgetRef ref, List localIds) async { final count = await _cleanupLocalAssets(context, ref, localIds); - if (count <= 0 || !context.mounted) { + if (count <= 0) { return null; } - return context.t.cleanup_deleted_assets(count: count); + return StaticTranslations.instance.cleanup_deleted_assets(count: count); } Future _moveToTrash( @@ -107,14 +115,11 @@ class DeleteAction extends AssetActionBuilder { List localIds, ) async { final assetService = ref.read(assetServiceProvider); + final message = context.t.trash_action_prompt(count: remoteIds.length); if (localIds.isNotEmpty) { await _cleanupLocalAssets(context, ref, localIds); - if (!context.mounted) { - return null; - } } - final message = context.t.trash_action_prompt(count: remoteIds.length); await assetService.trash(remoteIds); return message; } diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a1b707b39..241333ede1 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart'; import 'package:immich_mobile/utils/error_handler.dart'; final _stateProvider = Provider.family.autoDispose?, ActionSource>((ref, source) { @@ -35,11 +36,13 @@ class RestoreAction extends AssetActionBuilder { final assetService = ref.read(assetServiceProvider); final toastService = ref.read(toastServiceProvider); final clearSelection = ref.read(clearSelectionProvider(source)); + final viewIntentCoordinator = ref.read(viewIntentAssetActionCoordinatorProvider); try { await assetService.restoreTrash(assetIds); toastService.success(message); clearSelection(); + await viewIntentCoordinator.afterRestore(source: source, remoteAssetIds: assetIds); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to restore assets"); } diff --git a/mobile/lib/presentation/actions/upload.action.dart b/mobile/lib/presentation/actions/upload.action.dart index e52659beb7..a67905bab3 100644 --- a/mobile/lib/presentation/actions/upload.action.dart +++ b/mobile/lib/presentation/actions/upload.action.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/asset_upload_coordinator.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; @@ -36,7 +37,7 @@ class UploadAction extends AssetActionBuilder { Future _upload(BuildContext context, WidgetRef ref, List assets) async { try { if (!showProgress) { - await uploadAssets(context, ref, assets); + await uploadAssets(context, ref, assets, source: source); return; } @@ -51,7 +52,7 @@ class UploadAction extends AssetActionBuilder { ).whenComplete(() => isDialogOpen = false), ); - await uploadAssets(context, ref, assets); + await uploadAssets(context, ref, assets, source: source); if (isDialogOpen && context.mounted) { Navigator.of(context, rootNavigator: true).pop(); @@ -63,9 +64,14 @@ class UploadAction extends AssetActionBuilder { } @visibleForTesting -Future uploadAssets(BuildContext context, WidgetRef ref, List assets) async { +Future uploadAssets( + BuildContext context, + WidgetRef ref, + List assets, { + required ActionSource source, +}) async { final progress = ref.read(assetUploadProgressProvider.notifier); - final uploads = ref.read(foregroundUploadServiceProvider); + final uploads = ref.read(assetUploadCoordinatorProvider); final toastService = ref.read(toastServiceProvider); final errorMessage = context.t.scaffold_body_error_occurred; @@ -79,8 +85,9 @@ Future uploadAssets(BuildContext context, WidgetRef ref, List } try { - await uploads.uploadManual( - assets, + await uploads.upload( + source: source, + assets: assets, cancelToken: cancelToken, callbacks: UploadCallbacks( onProgress: (id, _, bytes, total) => progress.setProgress(id, total > 0 ? bytes / total : 0.0), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 9c17d9a035..7d844fbf15 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -412,13 +412,13 @@ class _AssetPageState extends ConsumerState { final timelineOrigin = ref.watch(timelineServiceProvider).origin; final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr)); - final asset = _asset; + final asset = timelineOrigin == TimelineOrigin.deepLink && currentAsset != null ? currentAsset : _asset; if (asset == null) { return const Center(child: ImmichLoadingIndicator()); } BaseAsset displayAsset = asset; - final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash)); + final showAssetStack = !asset.isEffectivelyTrashed(timelineOrigin); final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null; if (stackChildren != null && stackChildren.isNotEmpty) { final safeStackIndex = stackIndex.clamp(0, stackChildren.length - 1); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 77832c2685..9b6336e7be 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -17,7 +17,8 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final hideAssetStack = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash; + final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); + final hideAssetStack = currentAsset.isEffectivelyTrashed(ref.watch(timelineServiceProvider).origin); if (hideAssetStack) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index ad330309ab..7d45c53c73 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -24,6 +24,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart' import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; import 'package:immich_mobile/utils/system_ui.utils.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -283,13 +284,17 @@ class _AssetViewerState extends ConsumerState { if (index != _currentPage) { _pageController.jumpToPage(index); unawaited(_onAssetChanged(index)); - } else if (currentAsset is RemoteAsset && currentAsset.stackId != null && assetIndex == null) { - final timelineAsset = timelineService.getAssetSafe(index); - if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) { + } else if (currentAsset != null && + assetIndex == null && + !_shouldIgnoreMissingAssetOnTimelineReload(currentAsset, timelineService)) { + if (currentAsset is RemoteAsset && currentAsset.stackId != null) { + final timelineAsset = timelineService.getAssetSafe(index); + if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) { + unawaited(_onAssetChanged(index)); + } + } else { unawaited(_onAssetChanged(index)); } - } else if (currentAsset != null && assetIndex == null) { - unawaited(_onAssetChanged(index)); } if (_totalAssets != totalAssets) { @@ -299,6 +304,17 @@ class _AssetViewerState extends ConsumerState { } } + // A view intent can update currentAsset before the previous viewer route is + // disposed. Do not let the old viewer's timeline reload restore its previous asset. + bool _shouldIgnoreMissingAssetOnTimelineReload(BaseAsset currentAsset, TimelineService timelineService) { + if (timelineService.origin == TimelineOrigin.deepLink) { + return true; + } + + final localAssetId = ref.read(activeViewIntentPayloadProvider)?.localAssetId; + return localAssetId != null && currentAsset.localId == localAssetId; + } + Future _setSystemUIMode(bool controls, bool details) { final immersive = !controls || (CurrentPlatform.isIOS && details); return immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge(); 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 15b90e58ea..9a320b763e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -40,7 +40,7 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - final isInTrash = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash; + final isInTrash = asset.isEffectivelyTrashed(ref.watch(timelineServiceProvider).origin); final originalTheme = context.themeData; diff --git a/mobile/lib/providers/asset_upload_coordinator.provider.dart b/mobile/lib/providers/asset_upload_coordinator.provider.dart new file mode 100644 index 0000000000..e43d18f9cf --- /dev/null +++ b/mobile/lib/providers/asset_upload_coordinator.provider.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; +import 'package:logging/logging.dart'; + +final assetUploadCoordinatorProvider = Provider(AssetUploadCoordinator.new); + +class AssetUploadCoordinator { + const AssetUploadCoordinator(this._ref); + + final Ref _ref; + static final Logger _logger = Logger('AssetUploadCoordinator'); + + Future upload({ + required ActionSource source, + required List assets, + required Completer cancelToken, + required UploadCallbacks callbacks, + }) async { + final activeViewIntent = source == ActionSource.viewer ? _ref.read(activeViewIntentPayloadProvider) : null; + final viewIntentFilePath = source == ActionSource.viewer ? _ref.read(viewIntentFilePathProvider) : null; + if (viewIntentFilePath == null) { + final viewerAsset = source == ActionSource.viewer && assets.length == 1 ? assets.single : null; + String? uploadedRemoteAssetId; + final viewerNotifier = _ref.read(assetViewerProvider.notifier); + await _ref + .read(foregroundUploadServiceProvider) + .uploadManual( + assets, + cancelToken: cancelToken, + callbacks: UploadCallbacks( + onProgress: callbacks.onProgress, + onSuccess: (localId, remoteId) { + if (localId == viewerAsset?.localId) { + uploadedRemoteAssetId = remoteId; + } + callbacks.onSuccess?.call(localId, remoteId); + }, + onError: callbacks.onError, + onICloudProgress: callbacks.onICloudProgress, + ), + ); + + final remoteAssetId = uploadedRemoteAssetId; + if (viewerAsset == null || remoteAssetId == null || cancelToken.isCompleted) { + return; + } + + final remoteAsset = await _waitForRemoteAsset(remoteAssetId); + final latestAsset = _ref.read(assetViewerProvider).currentAsset; + final isCurrentViewIntent = + activeViewIntent == null || identical(_ref.read(activeViewIntentPayloadProvider), activeViewIntent); + if (remoteAsset == null || + latestAsset == null || + !latestAsset.refersToSameAsset(viewerAsset) || + !isCurrentViewIntent) { + return; + } + + viewerNotifier.setAsset(remoteAsset.copyWith(localId: viewerAsset.id)); + return; + } + + if (assets.length != 1) { + throw StateError('A file-backed viewer upload requires exactly one asset.'); + } + + _logger.fine('Using file-backed upload for view intent'); + await _uploadViewIntentFile( + asset: assets.single, + path: viewIntentFilePath, + activeViewIntent: activeViewIntent, + cancelToken: cancelToken, + callbacks: callbacks, + ); + } + + Future _uploadViewIntentFile({ + required LocalAsset asset, + required String path, + required ViewIntentPayload? activeViewIntent, + required Completer cancelToken, + required UploadCallbacks callbacks, + }) async { + final viewIntentService = _ref.read(viewIntentServiceProvider); + String? remoteAssetId; + viewIntentService.markUploadActive(path); + + try { + await _ref + .read(foregroundUploadServiceProvider) + .uploadShareIntent( + [File(path)], + cancelToken: cancelToken, + onProgress: (_, bytes, total) => callbacks.onProgress?.call(asset.id, asset.name, bytes, total), + onSuccess: (_, remoteId) { + remoteAssetId = remoteId; + callbacks.onSuccess?.call(asset.id, remoteId); + }, + onError: (_, error) => callbacks.onError?.call(asset.id, error), + ); + + final uploadedRemoteAssetId = remoteAssetId; + if (cancelToken.isCompleted || uploadedRemoteAssetId == null) { + return; + } + + final remoteAsset = await _waitForRemoteAsset(uploadedRemoteAssetId); + if (remoteAsset == null || !_isCurrentUpload(asset, path, activeViewIntent)) { + return; + } + + _ref.read(assetViewerProvider.notifier).setAsset(remoteAsset); + _ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(path); + await viewIntentService.cleanupManagedTempFileIfCurrent(path); + } finally { + await viewIntentService.markUploadInactive(path); + } + } + + Future _waitForRemoteAsset(String remoteAssetId) async { + try { + return await _ref + .read(assetServiceProvider) + .watchRemoteAsset(remoteAssetId) + .where((asset) => asset != null) + .cast() + .first + .timeout(const Duration(seconds: 15)); + } on TimeoutException { + final asset = await _ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId); + _logger.warning( + 'Timed out waiting for uploaded asset $remoteAssetId; direct lookup ${asset == null ? 'failed' : 'succeeded'}', + ); + return asset; + } + } + + bool _isCurrentUpload(LocalAsset asset, String path, ViewIntentPayload? activeViewIntent) { + if (activeViewIntent != null && !identical(_ref.read(activeViewIntentPayloadProvider), activeViewIntent)) { + return false; + } + + if (_ref.read(viewIntentFilePathProvider) != path) { + return false; + } + + final currentAsset = _ref.read(assetViewerProvider).currentAsset; + return currentAsset != null && currentAsset.refersToSameAsset(asset); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 3fb9f8acbc..a8dc642771 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -243,6 +243,7 @@ class ActionNotifier extends Notifier { count: successCount, success: isSuccess, error: isSuccess ? null : 'Failed to upload ${assetsToUpload.length - successCount} assets', + remoteAssetIds: remoteAssetIds, ); } catch (error, stack) { _logger.severe('Failed manually upload assets', error, stack); diff --git a/mobile/lib/providers/view_intent/active_view_intent_payload_provider.dart b/mobile/lib/providers/view_intent/active_view_intent_payload_provider.dart new file mode 100644 index 0000000000..5cedc7a046 --- /dev/null +++ b/mobile/lib/providers/view_intent/active_view_intent_payload_provider.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; + +class ActiveViewIntentNotifier extends Notifier { + @override + ViewIntentPayload? build() => null; + + void setPayload(ViewIntentPayload payload) { + state = payload; + } + + void clear() { + state = null; + } + + void clearIfMatch(ViewIntentPayload payload) { + if (!identical(state, payload)) { + return; + } + state = null; + } +} + +final activeViewIntentPayloadProvider = NotifierProvider( + ActiveViewIntentNotifier.new, +); diff --git a/mobile/lib/providers/view_intent/view_intent_asset_action_coordinator.provider.dart b/mobile/lib/providers/view_intent/view_intent_asset_action_coordinator.provider.dart new file mode 100644 index 0000000000..18db8933c3 --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_asset_action_coordinator.provider.dart @@ -0,0 +1,105 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_trash_scope.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:logging/logging.dart'; + +final viewIntentAssetActionCoordinatorProvider = Provider( + (ref) { + return ViewIntentAssetActionCoordinator( + ref, + viewIntent: ref.watch(activeViewIntentPayloadProvider), + isFileBacked: ref.watch(viewIntentFilePathProvider) != null, + ); + }, + dependencies: [ + assetViewerProvider, + timelineServiceProvider, + activeViewIntentPayloadProvider, + viewIntentFilePathProvider, + viewIntentTrashScopeProvider, + ], +); + +class ViewIntentAssetActionCoordinator { + const ViewIntentAssetActionCoordinator(this._ref, {required this._viewIntent, required this._isFileBacked}); + + final Ref _ref; + final ViewIntentPayload? _viewIntent; + final bool _isFileBacked; + static final Logger _logger = Logger('ViewIntentAssetActionCoordinator'); + + bool canDelete(ActionSource source) => source != ActionSource.viewer || !_isFileBacked; + + Future afterDelete({ + required ActionSource source, + required List remoteAssetIds, + required bool movedToTrash, + }) => _runBestEffort('post-delete view intent transition', () async { + if (!_isActiveViewIntent(source, requireTrash: false)) { + return; + } + + if (remoteAssetIds.isNotEmpty) { + if (movedToTrash) { + await _reopen(remoteAssetIds.single); + } else { + await _ref.read(appRouterProvider).maybePop(); + } + return; + } + + final asset = _ref.read(assetViewerProvider).currentAsset; + if (asset != null && !asset.hasRemote) { + await _ref.read(appRouterProvider).maybePop(); + } + }); + + Future afterRestore({required ActionSource source, required List remoteAssetIds}) => + _runBestEffort('post-restore view intent transition', () async { + if (!_isActiveViewIntent(source, requireTrash: true)) { + return; + } + + await _reopen(remoteAssetIds.single); + }); + + bool _isActiveViewIntent(ActionSource source, {required bool requireTrash}) { + if (source != ActionSource.viewer || _viewIntent == null) { + return false; + } + + if (!identical(_ref.read(activeViewIntentPayloadProvider), _viewIntent)) { + return false; + } + + final origin = _ref.read(timelineServiceProvider).origin; + if (origin != TimelineOrigin.deepLink) { + return false; + } + + return requireTrash ? _ref.read(viewIntentTrashScopeProvider) : true; + } + + Future _reopen(String remoteAssetId) async { + final reopened = await _ref.read(viewIntentHandlerProvider).reopenRemoteAsset(remoteAssetId); + if (!reopened) { + _logger.warning('Unable to reopen remote view intent asset $remoteAssetId'); + } + } + + Future _runBestEffort(String operation, Future Function() transition) async { + try { + await transition(); + } catch (error, stackTrace) { + _logger.warning('Failed to complete $operation', error, stackTrace); + } + } +} diff --git a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart index b266887cab..f47aae0c35 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -11,7 +11,9 @@ abstract class ViewIntentHandler { Future flushDeferredViewIntent(); - Future handle(ViewIntentPayload attachment); + Future handle(ViewIntentPayload payload); + + Future reopenRemoteAsset(String remoteAssetId); } final viewIntentHandlerProvider = Provider((ref) { diff --git a/mobile/lib/providers/view_intent/view_intent_handler_android.dart b/mobile/lib/providers/view_intent/view_intent_handler_android.dart index 51415af7b1..f2434ad073 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -1,15 +1,21 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_trash_scope.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; @@ -41,7 +47,19 @@ class AndroidViewIntentHandler implements ViewIntentHandler { Future flushDeferredViewIntent() => _flushPending(); Future _checkForViewIntent() async { - final attachment = await _viewIntentService.consumeViewIntent(); + final ViewIntentPayload? attachment; + try { + attachment = await _viewIntentService.consumeViewIntent(); + } on PlatformException catch (error, stackTrace) { + if (error.code != viewIntentUnavailableErrorCode) { + rethrow; + } + + _logger.warning('Incoming view intent is unavailable', error, stackTrace); + _ref.read(toastServiceProvider).error(StaticTranslations.instance.asset_not_found_on_device_android); + await _router.replaceAll([const TabShellRoute()]); + return; + } if (attachment != null) { await handle(attachment); return; @@ -61,26 +79,93 @@ class AndroidViewIntentHandler implements ViewIntentHandler { } @override - Future handle(ViewIntentPayload attachment) async { + Future handle(ViewIntentPayload payload) async { _logger.info( - 'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}', + 'handle attachment, mimeType:${payload.mimeType}, localAssetId=${payload.localAssetId}, path=${payload.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}', ); if (!_ref.read(authProvider).isAuthenticated) { - _ref.read(viewIntentPendingProvider.notifier).defer(attachment); + _clearCurrentViewIntent(); + _ref.read(viewIntentPendingProvider.notifier).defer(payload); return; } - final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + _activateViewIntent(payload); + + final ViewIntentResolution resolvedAsset; + try { + resolvedAsset = await _viewIntentAssetResolver.resolve(payload); + } catch (_) { + _ref.read(activeViewIntentPayloadProvider.notifier).clearIfMatch(payload); + rethrow; + } + if (!identical(_ref.read(activeViewIntentPayloadProvider), payload)) { + await resolvedAsset.timelineService.dispose(); + return; + } + + _ref.read(viewIntentTrashScopeProvider.notifier).set(resolvedAsset.isTrashScoped); _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); await _openAssetViewer( - resolvedAsset.asset, - resolvedAsset.timelineService, + asset: resolvedAsset.asset, + timelineService: resolvedAsset.timelineService, + attachment: payload, viewIntentFilePath: resolvedAsset.viewIntentFilePath, ); } - Future _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async { + @override + Future reopenRemoteAsset(String remoteAssetId) async { + final attachment = _ref.read(activeViewIntentPayloadProvider); + if (attachment == null) { + return false; + } + + final asset = await _ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId); + if (asset == null || !identical(_ref.read(activeViewIntentPayloadProvider), attachment)) { + return false; + } + + final reopenedAttachment = ViewIntentPayload( + path: attachment.path, + mimeType: attachment.mimeType, + localAssetId: attachment.localAssetId, + ); + _activateViewIntent(reopenedAttachment); + + _ref.read(viewIntentTrashScopeProvider.notifier).set(asset.isTrashed); + final timelineService = _ref.read(timelineFactoryProvider).fromAssets([asset], TimelineOrigin.deepLink); + unawaited( + _openAssetViewer(asset: asset, timelineService: timelineService, attachment: reopenedAttachment).catchError(( + Object error, + StackTrace stackTrace, + ) { + _logger.severe('Failed to reopen remote view intent asset', error, stackTrace); + }), + ); + return true; + } + + void _activateViewIntent(ViewIntentPayload attachment) { + _ref.read(activeViewIntentPayloadProvider.notifier).setPayload(attachment); + _ref.read(viewIntentFilePathProvider.notifier).clear(); + unawaited(_viewIntentService.cleanupManagedTempFile()); + _router.popUntilRoot(); + } + + void _clearCurrentViewIntent() { + _ref.read(activeViewIntentPayloadProvider.notifier).clear(); + _ref.read(viewIntentFilePathProvider.notifier).clear(); + _ref.read(viewIntentTrashScopeProvider.notifier).clear(); + unawaited(_viewIntentService.cleanupManagedTempFile()); + } + + Future _openAssetViewer({ + required BaseAsset asset, + required TimelineService timelineService, + required ViewIntentPayload attachment, + String? viewIntentFilePath, + }) async { final notifier = _ref.read(assetViewerProvider.notifier); notifier.reset(); if (asset.isVideo) { @@ -96,9 +181,14 @@ class AndroidViewIntentHandler implements ViewIntentHandler { unawaited(_viewIntentService.cleanupManagedTempFile()); } - await _router.replaceAll([ - const TabShellRoute(), - AssetViewerRoute(key: UniqueKey(), initialIndex: 0, timelineService: timelineService), - ]); + try { + await _router.push(AssetViewerRoute(initialIndex: 0, timelineService: timelineService)); + } finally { + _ref.read(activeViewIntentPayloadProvider.notifier).clearIfMatch(attachment); + if (viewIntentFilePath != null) { + _ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(viewIntentFilePath); + await _viewIntentService.cleanupManagedTempFileIfCurrent(viewIntentFilePath); + } + } } } diff --git a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart index ebc6d7425b..468697163a 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart @@ -14,5 +14,8 @@ class StubViewIntentHandler implements ViewIntentHandler { Future flushDeferredViewIntent() async {} @override - Future handle(ViewIntentPayload attachment) async {} + Future handle(ViewIntentPayload payload) async {} + + @override + Future reopenRemoteAsset(String remoteAssetId) async => false; } diff --git a/mobile/lib/providers/view_intent/view_intent_trash_scope.provider.dart b/mobile/lib/providers/view_intent/view_intent_trash_scope.provider.dart new file mode 100644 index 0000000000..0d2d6fb6c0 --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_trash_scope.provider.dart @@ -0,0 +1,14 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ViewIntentTrashScopeNotifier extends Notifier { + @override + bool build() => false; + + void set(bool isTrashScoped) => state = isTrashScoped; + + void clear() => state = false; +} + +final viewIntentTrashScopeProvider = NotifierProvider( + ViewIntentTrashScopeNotifier.new, +); diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart index e822d1ebb6..cd725484cb 100644 --- a/mobile/lib/services/view_intent.service.dart +++ b/mobile/lib/services/view_intent.service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:path/path.dart' as p; @@ -7,6 +8,8 @@ import 'package:path_provider/path_provider.dart'; final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi())); +const viewIntentUnavailableErrorCode = 'VIEW_INTENT_UNAVAILABLE'; + class ViewIntentService { final ViewIntentHostApi _viewIntentHostApi; final Future Function() _temporaryDirectory; @@ -19,6 +22,12 @@ class ViewIntentService { Future consumeViewIntent() async { try { return await _viewIntentHostApi.consumeViewIntent(); + } on PlatformException catch (error) { + if (error.code == viewIntentUnavailableErrorCode) { + rethrow; + } + // Ignore errors - view intent might not be present + return null; } catch (_) { // Ignore errors - view intent might not be present return null; @@ -26,11 +35,12 @@ class ViewIntentService { } Future setManagedTempFilePath(String path) async { + final normalizedPath = p.normalize(path); final previous = _managedTempFilePath; - if (previous == path) { + if (previous == normalizedPath) { return; } - _managedTempFilePath = path; + _managedTempFilePath = normalizedPath; if (previous != null) { await cleanupTempFile(previous); } @@ -45,22 +55,24 @@ class ViewIntentService { } Future cleanupManagedTempFileIfCurrent(String path) async { - if (_managedTempFilePath == path) { + final normalizedPath = p.normalize(path); + if (_managedTempFilePath == normalizedPath) { _managedTempFilePath = null; } - await cleanupTempFile(path); + await cleanupTempFile(normalizedPath); } Future cleanupTempFile(String path) async { - if (!_isManagedTempFile(path)) { + final normalizedPath = p.normalize(path); + if (!_isManagedTempFile(normalizedPath)) { return; } - if (_activeUploadPaths.contains(path)) { + if (_activeUploadPaths.contains(normalizedPath)) { return; } try { - final file = File(path); + final file = File(normalizedPath); // ignore: avoid_slow_async_io if (await file.exists()) { await file.delete(); @@ -78,7 +90,7 @@ class ViewIntentService { continue; } - final path = entity.path; + final path = p.normalize(entity.path); if (!_isManagedTempFile(path) || path == _managedTempFilePath || _activeUploadPaths.contains(path)) { continue; } @@ -91,15 +103,16 @@ class ViewIntentService { } void markUploadActive(String path) { - _activeUploadPaths.add(path); + _activeUploadPaths.add(p.normalize(path)); } Future markUploadInactive(String path) async { - if (!_activeUploadPaths.remove(path)) { + final normalizedPath = p.normalize(path); + if (!_activeUploadPaths.remove(normalizedPath)) { return; } - if (_managedTempFilePath != path) { - await cleanupTempFile(path); + if (_managedTempFilePath != normalizedPath) { + await cleanupTempFile(normalizedPath); } } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 7bda1bdc13..4c6d4c1406 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -1,37 +1,55 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:logging/logging.dart'; -class ViewIntentResolvedAsset { +class ViewIntentResolution { final BaseAsset asset; final TimelineService timelineService; + final bool isTrashScoped; final String? viewIntentFilePath; - const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath}); + const ViewIntentResolution({ + required this.asset, + required this.timelineService, + this.isTrashScoped = false, + this.viewIntentFilePath, + }); } final viewIntentAssetResolverProvider = Provider( (ref) => ViewIntentAssetResolver( localAssetRepository: ref.read(localAssetRepository), + assetService: ref.read(assetServiceProvider), + nativeSyncApi: ref.read(nativeSyncApiProvider), timelineFactory: ref.read(timelineFactoryProvider), ), ); class ViewIntentAssetResolver { final DriftLocalAssetRepository _localAssetRepository; + final AssetService _assetService; + final NativeSyncApi _nativeSyncApi; final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentAssetResolver'); - const ViewIntentAssetResolver({required this._localAssetRepository, required this._timelineFactory}); + const ViewIntentAssetResolver({ + required this._localAssetRepository, + required this._assetService, + required this._nativeSyncApi, + required this._timelineFactory, + }); - Future resolve(ViewIntentPayload attachment) async { + Future resolve(ViewIntentPayload attachment) async { final localAssetId = attachment.localAssetId; final path = attachment.path; _logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}'); @@ -40,21 +58,116 @@ class ViewIntentAssetResolver { throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.'); } - final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null; - final asset = localAsset ?? _toTransientAsset(attachment); + ({LocalAsset? asset, String? checksum}) resolvedLocal = (asset: null, checksum: null); + if (localAssetId != null) { + resolvedLocal = await _resolveLocalAsset(localAssetId); + final remoteAsset = await _resolveRemoteAsset( + localAssetId, + remoteAssetId: resolvedLocal.asset?.remoteId, + checksum: resolvedLocal.checksum, + ); + if (remoteAsset != null) { + return ViewIntentResolution( + asset: remoteAsset, + timelineService: _timelineFor(remoteAsset), + isTrashScoped: remoteAsset.isTrashed, + ); + } + } - return ViewIntentResolvedAsset( + final asset = resolvedLocal.asset ?? _toTransientAsset(attachment, resolvedLocal.checksum); + + return ViewIntentResolution( asset: asset, - timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink), - viewIntentFilePath: localAsset == null ? path : null, + timelineService: _timelineFor(asset), + viewIntentFilePath: resolvedLocal.asset == null ? path : null, ); } - LocalAsset _toTransientAsset(ViewIntentPayload attachment) { + TimelineService _timelineFor(BaseAsset asset) => _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink); + + Future<({LocalAsset? asset, String? checksum})> _resolveLocalAsset(String localAssetId) async { + final localAsset = await _localAssetRepository.get(localAssetId); + final checksum = localAsset?.checksum ?? await _hashLocalAsset(localAssetId); + + if (checksum == null || checksum == localAsset?.checksum) { + return (asset: localAsset, checksum: checksum); + } + + if (localAsset != null) { + await _localAssetRepository.updateHashes({localAssetId: checksum}); + final resolvedAsset = await _localAssetRepository.get(localAssetId); + return (asset: resolvedAsset ?? localAsset.copyWith(checksum: checksum), checksum: checksum); + } + + return (asset: null, checksum: checksum); + } + + Future _hashLocalAsset(String localAssetId) async { + try { + final hashResults = await _nativeSyncApi.hashAssets([localAssetId]); + if (hashResults.isEmpty) { + return null; + } + + final result = hashResults.first; + if (result.error != null) { + _logger.warning('Failed to hash view intent local asset $localAssetId: ${result.error}'); + return null; + } + return result.hash; + } catch (error, stackTrace) { + _logger.warning('Failed to hash view intent local asset $localAssetId', error, stackTrace); + return null; + } + } + + Future _resolveRemoteAsset( + String localAssetId, { + required String? remoteAssetId, + required String? checksum, + }) async { + RemoteAsset? remoteAsset; + if (remoteAssetId != null) { + remoteAsset = await _assetService.getRemoteAsset(remoteAssetId); + if (remoteAsset != null) { + _logger.fine('resolve matched remote asset by id: $remoteAssetId, asset=$remoteAsset'); + } + } + + if (remoteAsset == null && checksum != null) { + final candidates = await _assetService.getAllRemoteAssetDebugByChecksum(checksum); + if (candidates.isNotEmpty) { + remoteAsset = ([...candidates]..sort(_compareRemoteAssetCandidates)).first; + _logger.fine('resolve matched remote asset by checksum: $checksum, asset=$remoteAsset'); + } + } + + if (remoteAsset == null) { + return null; + } + final asset = remoteAsset.copyWith(localId: localAssetId); + return asset; + } + + static int _compareRemoteAssetCandidates(RemoteAsset first, RemoteAsset second) { + if (first.isTrashed != second.isTrashed) { + return first.isTrashed ? 1 : -1; + } + + final firstDate = first.uploadedAt ?? first.createdAt; + final secondDate = second.uploadedAt ?? second.createdAt; + final date = secondDate.compareTo(firstDate); + return date != 0 ? date : first.id.compareTo(second.id); + } + + LocalAsset _toTransientAsset(ViewIntentPayload attachment, String? checksum) { final now = DateTime.now(); + // A FileBackedAsset could model the path more explicitly, but would require broader changes to the asset hierarchy. return LocalAsset( id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}', name: attachment.fileName, + checksum: checksum, type: attachment.isVideo ? AssetType.video : AssetType.image, createdAt: now, updatedAt: now, diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4e0a5efff5..94e84fc54d 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -109,7 +109,7 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.asset.hasRemote && // - context.timelineOrigin == TimelineOrigin.trash, + context.asset.isEffectivelyTrashed(context.timelineOrigin), ActionButtonType.delete => true, ActionButtonType.moveToLockFolder => context.isOwner && // @@ -135,12 +135,13 @@ enum ActionButtonType { context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // - context.timelineOrigin != TimelineOrigin.trash && + !context.asset.isEffectivelyTrashed(context.timelineOrigin) && !context.isInLockedView && // context.isStacked, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && + context.timelineOrigin != TimelineOrigin.deepLink && context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, @@ -154,8 +155,8 @@ enum ActionButtonType { ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && + !context.asset.isEffectivelyTrashed(context.timelineOrigin) && context.timelineOrigin != TimelineOrigin.deepLink && - context.timelineOrigin != TimelineOrigin.trash && context.timelineOrigin != TimelineOrigin.lockedFolder && context.timelineOrigin != TimelineOrigin.archive && context.timelineOrigin != TimelineOrigin.localAlbum && diff --git a/mobile/test/presentation/widgets/asset_viewer/asset_viewer_timeline_reload_test.dart b/mobile/test/presentation/widgets/asset_viewer/asset_viewer_timeline_reload_test.dart new file mode 100644 index 0000000000..fa33657ce3 --- /dev/null +++ b/mobile/test/presentation/widgets/asset_viewer/asset_viewer_timeline_reload_test.dart @@ -0,0 +1,106 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../fixtures/asset.stub.dart'; +import '../../../unit/presentation/presentation_context.dart'; + +final _uploadedAsset = RemoteAsset( + id: 'remote-id', + name: 'uploaded.jpg', + ownerId: 'owner-id', + checksum: 'remote-checksum', + type: AssetType.image, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + isEdited: false, +); + +class _UploadedAssetViewerNotifier extends AssetViewerStateNotifier { + @override + AssetViewerState build() { + super.build(); + return AssetViewerState(currentAsset: _uploadedAsset); + } +} + +TimelineService _viewIntentTimeline() { + return TimelineService(( + assetSource: (_, __) async => [LocalAssetStub.image1], + bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), + origin: TimelineOrigin.deepLink, + )); +} + +void main() { + late PresentationContext presentationContext; + + setUp(() async { + await initializeDateFormatting(); + presentationContext = await PresentationContext.create(); + when(() => presentationContext.service.asset.service.watchAsset(any())).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() async { + await presentationContext.dispose(); + }); + + testWidgets('keeps uploaded remote asset when it is missing from a deep-link timeline reload', (tester) async { + final timeline = _viewIntentTimeline(); + addTearDown(timeline.dispose); + + late ProviderContainer container; + await tester.pumpWidget( + EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + startLocale: locales.values.first, + fallbackLocale: locales.values.first, + saveLocale: false, + useFallbackTranslations: true, + assetLoader: const CodegenLoader(), + child: ProviderScope( + overrides: [ + ...presentationContext.overrides, + timelineServiceProvider.overrideWithValue(timeline), + assetViewerProvider.overrideWith(_UploadedAssetViewerNotifier.new), + ], + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return MaterialApp( + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: const Material(child: AssetViewer(initialIndex: 0)), + ); + }, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + tester.takeException(); + + EventStream.shared.emit(const TimelineReloadEvent()); + await tester.pump(); + await tester.pump(); + tester.takeException(); + + expect(container.read(assetViewerProvider).currentAsset, same(_uploadedAsset)); + }); +} diff --git a/mobile/test/providers/asset_upload_coordinator_provider_test.dart b/mobile/test/providers/asset_upload_coordinator_provider_test.dart new file mode 100644 index 0000000000..2fbb4bfe8b --- /dev/null +++ b/mobile/test/providers/asset_upload_coordinator_provider_test.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/asset_upload_coordinator.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/view_intent.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../service.mocks.dart'; +import '../unit/factories/local_asset_factory.dart'; +import '../unit/factories/remote_asset_factory.dart'; + +class MockViewIntentService extends Mock implements ViewIntentService {} + +void main() { + late ProviderContainer container; + late MockForegroundUploadService uploadService; + late MockAssetService assetService; + late MockViewIntentService viewIntentService; + + setUpAll(() { + registerFallbackValue(LocalAssetFactory.create()); + registerFallbackValue(const UploadCallbacks()); + }); + + setUp(() { + uploadService = MockForegroundUploadService(); + assetService = MockAssetService(); + viewIntentService = MockViewIntentService(); + + when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty()); + + container = ProviderContainer( + overrides: [ + foregroundUploadServiceProvider.overrideWithValue(uploadService), + assetServiceProvider.overrideWithValue(assetService), + viewIntentServiceProvider.overrideWithValue(viewIntentService), + ], + ); + addTearDown(container.dispose); + }); + + test('replaces a device-backed viewer asset after upload when the local database cannot link it', () async { + final localAsset = LocalAssetFactory.create(id: 'local-outside-backup'); + final remoteAsset = RemoteAssetFactory.create(id: 'remote-outside-backup'); + + container.read(assetViewerProvider.notifier).setAsset(localAsset); + when(() => assetService.watchRemoteAsset(remoteAsset.id)).thenAnswer((_) => Stream.value(remoteAsset)); + when( + () => uploadService.uploadManual( + any(), + cancelToken: any(named: 'cancelToken'), + callbacks: any(named: 'callbacks'), + ), + ).thenAnswer((invocation) async { + final callbacks = invocation.namedArguments[#callbacks] as UploadCallbacks; + callbacks.onSuccess?.call(localAsset.id, remoteAsset.id); + }); + + await container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [localAsset], + cancelToken: Completer(), + callbacks: const UploadCallbacks(), + ); + + final currentAsset = container.read(assetViewerProvider).currentAsset; + expect(currentAsset, isA()); + expect(currentAsset?.remoteId, remoteAsset.id); + expect(currentAsset?.localId, localAsset.id); + expect(currentAsset?.isMerged, isTrue); + }); + + test('does not let a device-backed upload from an older view intent replace the viewer', () async { + final oldPayload = ViewIntentPayload(path: '/tmp/old.jpg', mimeType: 'image/jpeg', localAssetId: 'local-old'); + final newPayload = ViewIntentPayload(path: '/tmp/new.jpg', mimeType: 'image/jpeg', localAssetId: 'local-new'); + final localAsset = LocalAssetFactory.create(id: 'local-old'); + final remoteAsset = RemoteAssetFactory.create(id: 'remote-old'); + final remoteController = StreamController.broadcast(); + addTearDown(remoteController.close); + + container.read(activeViewIntentPayloadProvider.notifier).setPayload(oldPayload); + container.read(assetViewerProvider.notifier).setAsset(localAsset); + when(() => assetService.watchRemoteAsset(remoteAsset.id)).thenAnswer((_) => remoteController.stream); + when( + () => uploadService.uploadManual( + any(), + cancelToken: any(named: 'cancelToken'), + callbacks: any(named: 'callbacks'), + ), + ).thenAnswer((invocation) async { + final callbacks = invocation.namedArguments[#callbacks] as UploadCallbacks; + callbacks.onSuccess?.call(localAsset.id, remoteAsset.id); + }); + + final upload = container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [localAsset], + cancelToken: Completer(), + callbacks: const UploadCallbacks(), + ); + await pumpEventQueue(); + + container.read(activeViewIntentPayloadProvider.notifier).setPayload(newPayload); + remoteController.add(remoteAsset); + await upload; + + expect(container.read(assetViewerProvider).currentAsset, same(localAsset)); + }); + + test('uploads a path-only viewer asset as a file and replaces it with the synchronized remote asset', () async { + const path = 'C:/cache/view_intent_1.jpg'; + final localAsset = LocalAssetFactory.create(id: '-1'); + final remoteAsset = RemoteAssetFactory.create(id: 'remote-1'); + final progress = <(String, int, int)>[]; + final succeeded = <(String, String)>[]; + + container.read(viewIntentFilePathProvider.notifier).setPath(path); + container.read(assetViewerProvider.notifier).setAsset(localAsset); + + when(() => viewIntentService.markUploadActive(path)).thenReturn(null); + when(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)).thenAnswer((_) async {}); + when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {}); + when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => Stream.value(remoteAsset)); + when( + () => uploadService.uploadShareIntent( + any(), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + onSuccess: any(named: 'onSuccess'), + onError: any(named: 'onError'), + ), + ).thenAnswer((invocation) async { + final onProgress = invocation.namedArguments[#onProgress] as void Function(String, int, int)?; + final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?; + onProgress?.call('file-id', 5, 10); + onSuccess?.call('file-id', 'remote-1'); + }); + + await container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [localAsset], + cancelToken: Completer(), + callbacks: UploadCallbacks( + onProgress: (id, _, bytes, total) => progress.add((id, bytes, total)), + onSuccess: (localId, remoteId) => succeeded.add((localId, remoteId)), + ), + ); + + final files = + verify( + () => uploadService.uploadShareIntent( + captureAny(), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + onSuccess: any(named: 'onSuccess'), + onError: any(named: 'onError'), + ), + ).captured.single + as List; + expect(files.single.path, path); + expect(progress, [(localAsset.id, 5, 10)]); + expect(succeeded, [(localAsset.id, remoteAsset.id)]); + expect(container.read(assetViewerProvider).currentAsset, remoteAsset); + expect(container.read(viewIntentFilePathProvider), isNull); + verify(() => viewIntentService.markUploadActive(path)).called(1); + verify(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)).called(1); + verify(() => viewIntentService.markUploadInactive(path)).called(1); + verifyNever( + () => uploadService.uploadManual( + any(), + cancelToken: any(named: 'cancelToken'), + callbacks: any(named: 'callbacks'), + ), + ); + }); + + test('keeps the path-only asset current when the upload is cancelled', () async { + const path = 'C:/cache/view_intent_cancelled.jpg'; + final localAsset = LocalAssetFactory.create(id: '-2'); + final cancelToken = Completer(); + + container.read(viewIntentFilePathProvider.notifier).setPath(path); + container.read(assetViewerProvider.notifier).setAsset(localAsset); + when(() => viewIntentService.markUploadActive(path)).thenReturn(null); + when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {}); + when( + () => uploadService.uploadShareIntent( + any(), + cancelToken: cancelToken, + onProgress: any(named: 'onProgress'), + onSuccess: any(named: 'onSuccess'), + onError: any(named: 'onError'), + ), + ).thenAnswer((invocation) async { + cancelToken.complete(); + final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?; + onSuccess?.call('file-id', 'remote-cancelled'); + }); + + await container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [localAsset], + cancelToken: cancelToken, + callbacks: const UploadCallbacks(), + ); + + expect(container.read(assetViewerProvider).currentAsset, localAsset); + expect(container.read(viewIntentFilePathProvider), path); + verifyNever(() => assetService.watchRemoteAsset('remote-cancelled')); + verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)); + verify(() => viewIntentService.markUploadInactive(path)).called(1); + }); + + test('reports a file upload error under the synthetic asset id and keeps the source file', () async { + const path = 'C:/cache/view_intent_failed.jpg'; + final localAsset = LocalAssetFactory.create(id: '-5'); + final errors = <(String, String)>[]; + + container.read(viewIntentFilePathProvider.notifier).setPath(path); + container.read(assetViewerProvider.notifier).setAsset(localAsset); + when(() => viewIntentService.markUploadActive(path)).thenReturn(null); + when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {}); + when( + () => uploadService.uploadShareIntent( + any(), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + onSuccess: any(named: 'onSuccess'), + onError: any(named: 'onError'), + ), + ).thenAnswer((invocation) async { + final onError = invocation.namedArguments[#onError] as void Function(String, String)?; + onError?.call('file-id', 'boom'); + }); + + await container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [localAsset], + cancelToken: Completer(), + callbacks: UploadCallbacks(onError: (id, error) => errors.add((id, error))), + ); + + expect(errors, [(localAsset.id, 'boom')]); + expect(container.read(assetViewerProvider).currentAsset, localAsset); + expect(container.read(viewIntentFilePathProvider), path); + verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)); + verify(() => viewIntentService.markUploadInactive(path)).called(1); + }); + + test('does not let an older upload replace a newer view intent', () async { + const oldPath = 'C:/cache/view_intent_old.jpg'; + const newPath = 'C:/cache/view_intent_new.jpg'; + final oldAsset = LocalAssetFactory.create(id: '-3'); + final newAsset = LocalAssetFactory.create(id: '-4'); + final uploadedRemote = RemoteAssetFactory.create(id: 'remote-old'); + final remoteController = StreamController.broadcast(); + addTearDown(remoteController.close); + + container.read(viewIntentFilePathProvider.notifier).setPath(oldPath); + container.read(assetViewerProvider.notifier).setAsset(oldAsset); + when(() => viewIntentService.markUploadActive(oldPath)).thenReturn(null); + when(() => viewIntentService.markUploadInactive(oldPath)).thenAnswer((_) async {}); + when(() => assetService.watchRemoteAsset(uploadedRemote.id)).thenAnswer((_) => remoteController.stream); + when( + () => uploadService.uploadShareIntent( + any(), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + onSuccess: any(named: 'onSuccess'), + onError: any(named: 'onError'), + ), + ).thenAnswer((invocation) async { + final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?; + onSuccess?.call('file-id', uploadedRemote.id); + }); + + final upload = container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [oldAsset], + cancelToken: Completer(), + callbacks: const UploadCallbacks(), + ); + await pumpEventQueue(); + + container.read(viewIntentFilePathProvider.notifier).setPath(newPath); + container.read(assetViewerProvider.notifier).setAsset(newAsset); + remoteController.add(uploadedRemote); + await upload; + + expect(container.read(assetViewerProvider).currentAsset, newAsset); + expect(container.read(viewIntentFilePathProvider), newPath); + verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(oldPath)); + verify(() => viewIntentService.markUploadInactive(oldPath)).called(1); + }); + + test('does not let an older file upload replace a newer session for the same asset', () async { + const path = 'C:/cache/view_intent_same.jpg'; + final oldPayload = ViewIntentPayload(path: path, mimeType: 'image/jpeg'); + final newPayload = ViewIntentPayload(path: path, mimeType: 'image/jpeg'); + final localAsset = LocalAssetFactory.create(id: '-6'); + final uploadedRemote = RemoteAssetFactory.create(id: 'remote-same'); + final remoteController = StreamController.broadcast(); + addTearDown(remoteController.close); + + container.read(activeViewIntentPayloadProvider.notifier).setPayload(oldPayload); + container.read(viewIntentFilePathProvider.notifier).setPath(path); + container.read(assetViewerProvider.notifier).setAsset(localAsset); + when(() => viewIntentService.markUploadActive(path)).thenReturn(null); + when(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)).thenAnswer((_) async {}); + when(() => viewIntentService.markUploadInactive(path)).thenAnswer((_) async {}); + when(() => assetService.watchRemoteAsset(uploadedRemote.id)).thenAnswer((_) => remoteController.stream); + when( + () => uploadService.uploadShareIntent( + any(), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + onSuccess: any(named: 'onSuccess'), + onError: any(named: 'onError'), + ), + ).thenAnswer((invocation) async { + final onSuccess = invocation.namedArguments[#onSuccess] as void Function(String, String)?; + onSuccess?.call('file-id', uploadedRemote.id); + }); + + final upload = container + .read(assetUploadCoordinatorProvider) + .upload( + source: ActionSource.viewer, + assets: [localAsset], + cancelToken: Completer(), + callbacks: const UploadCallbacks(), + ); + await pumpEventQueue(); + + container.read(activeViewIntentPayloadProvider.notifier).setPayload(newPayload); + remoteController.add(uploadedRemote); + await upload; + + expect(container.read(assetViewerProvider).currentAsset, same(localAsset)); + expect(container.read(viewIntentFilePathProvider), path); + verifyNever(() => viewIntentService.cleanupManagedTempFileIfCurrent(path)); + }); +} diff --git a/mobile/test/providers/view_intent/view_intent_asset_action_coordinator_provider_test.dart b/mobile/test/providers/view_intent/view_intent_asset_action_coordinator_provider_test.dart new file mode 100644 index 0000000000..b046dfc5a0 --- /dev/null +++ b/mobile/test/providers/view_intent/view_intent_asset_action_coordinator_provider_test.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_trash_scope.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../unit/factories/local_asset_factory.dart'; +import '../../unit/factories/remote_asset_factory.dart'; + +class _TestViewIntentHandler implements ViewIntentHandler { + _TestViewIntentHandler({this.reopenResult = true, this.reopenError}); + + final bool reopenResult; + final Object? reopenError; + final List reopenedRemoteAssetIds = []; + + @override + Future flushDeferredViewIntent() async {} + + @override + Future handle(ViewIntentPayload payload) async {} + + @override + void init() {} + + @override + Future onAppResumed() async {} + + @override + Future reopenRemoteAsset(String remoteAssetId) async { + reopenedRemoteAssetIds.add(remoteAssetId); + if (reopenError case final error?) { + throw error; + } + return reopenResult; + } +} + +class _MockAppRouter extends Mock implements AppRouter {} + +class _ViewerNotifier extends AssetViewerStateNotifier { + _ViewerNotifier(this.asset); + + final BaseAsset asset; + + @override + AssetViewerState build() { + super.build(); + return AssetViewerState(currentAsset: asset); + } +} + +typedef _Harness = ({ViewIntentAssetActionCoordinator coordinator, _MockAppRouter router, ProviderContainer scope}); + +void main() { + late ProviderContainer container; + + Future<_Harness> pumpHarness( + WidgetTester tester, { + required BaseAsset asset, + required TimelineOrigin origin, + required _TestViewIntentHandler handler, + bool activeViewIntent = true, + bool isTrashScoped = false, + }) async { + final timeline = TimelineService(( + assetSource: (_, __) async => [asset], + bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), + origin: origin, + )); + addTearDown(timeline.dispose); + + final router = _MockAppRouter(); + when(() => router.maybePop()).thenAnswer((_) async => true); + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(timeline), + assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)), + viewIntentHandlerProvider.overrideWithValue(handler), + appRouterProvider.overrideWithValue(router), + ], + child: const MaterialApp( + home: Scaffold(body: Text('root', key: Key('root'))), + ), + ), + ); + await tester.pumpAndSettle(); + + final scope = ProviderScope.containerOf(tester.element(find.byKey(const Key('root'))), listen: false); + scope.read(viewIntentTrashScopeProvider.notifier).set(isTrashScoped); + if (activeViewIntent) { + scope + .read(activeViewIntentPayloadProvider.notifier) + .setPayload( + ViewIntentPayload(path: '/tmp/view-intent.jpg', mimeType: 'image/jpeg', localAssetId: asset.localId), + ); + } + + return (coordinator: scope.read(viewIntentAssetActionCoordinatorProvider), router: router, scope: scope); + } + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + test('allows timeline deletion for a file-backed view intent', () { + container.read(viewIntentFilePathProvider.notifier).setPath('/tmp/materialized.jpg'); + + final coordinator = container.read(viewIntentAssetActionCoordinatorProvider); + + expect(coordinator.canDelete(ActionSource.timeline), isTrue); + }); + + test('prevents viewer deletion for a file-backed view intent', () { + container.read(viewIntentFilePathProvider.notifier).setPath('/tmp/materialized.jpg'); + + final coordinator = container.read(viewIntentAssetActionCoordinatorProvider); + + expect(coordinator.canDelete(ActionSource.viewer), isFalse); + }); + + test('allows viewer deletion after the file-backed path is cleared', () { + container.read(viewIntentFilePathProvider.notifier) + ..setPath('/tmp/materialized.jpg') + ..clear(); + + final coordinator = container.read(viewIntentAssetActionCoordinatorProvider); + + expect(coordinator.canDelete(ActionSource.viewer), isTrue); + }); + + testWidgets('reopens a remote asset after it is moved to trash', (tester) async { + final asset = RemoteAssetFactory.create(); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true); + + expect(handler.reopenedRemoteAssetIds, [asset.id]); + }); + + testWidgets('reopens a restored remote asset from a trash deep link', (tester) async { + final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4)); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness( + tester, + asset: asset, + origin: TimelineOrigin.deepLink, + handler: handler, + isTrashScoped: true, + ); + + await harness.coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]); + + expect(handler.reopenedRemoteAssetIds, [asset.id]); + }); + + testWidgets('closes the viewer after permanently deleting a remote asset', (tester) async { + final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4)); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: false); + verify(() => harness.router.maybePop()).called(1); + }); + + testWidgets('does not apply a completed delete transition to a newer view intent', (tester) async { + final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4)); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + final newerPayload = ViewIntentPayload( + path: '/tmp/newer-view-intent.jpg', + mimeType: 'image/jpeg', + localAssetId: 'newer-local', + ); + harness.scope.read(activeViewIntentPayloadProvider.notifier).setPayload(newerPayload); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: false); + + verifyNever(() => harness.router.maybePop()); + expect(handler.reopenedRemoteAssetIds, isEmpty); + }); + + testWidgets('closes the viewer after deleting a local-only asset', (tester) async { + final asset = LocalAssetFactory.create(); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: const [], movedToTrash: false); + verify(() => harness.router.maybePop()).called(1); + }); + + testWidgets('keeps the viewer open after deleting only a remote asset device copy', (tester) async { + final asset = RemoteAssetFactory.create(localId: 'local'); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: const [], movedToTrash: false); + verifyNever(() => harness.router.maybePop()); + }); + + testWidgets('does nothing for a timeline deletion', (tester) async { + final asset = RemoteAssetFactory.create(); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + + await harness.coordinator.afterDelete( + source: ActionSource.timeline, + remoteAssetIds: [asset.id], + movedToTrash: true, + ); + + expect(handler.reopenedRemoteAssetIds, isEmpty); + }); + + testWidgets('does nothing when no view intent is active', (tester) async { + final asset = RemoteAssetFactory.create(); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness( + tester, + asset: asset, + origin: TimelineOrigin.deepLink, + handler: handler, + activeViewIntent: false, + ); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true); + + expect(handler.reopenedRemoteAssetIds, isEmpty); + }); + + testWidgets('does nothing in a non-deep-link viewer', (tester) async { + final asset = RemoteAssetFactory.create(); + final handler = _TestViewIntentHandler(); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.main, handler: handler); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true); + + expect(handler.reopenedRemoteAssetIds, isEmpty); + }); + + testWidgets('does not propagate a remote asset that cannot be reopened', (tester) async { + final asset = RemoteAssetFactory.create(); + final handler = _TestViewIntentHandler(reopenResult: false); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); + + await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true); + + expect(handler.reopenedRemoteAssetIds, [asset.id]); + }); + + testWidgets('does not propagate a reopen exception', (tester) async { + final asset = RemoteAssetFactory.create(deletedAt: DateTime(2026, 8, 4)); + final handler = _TestViewIntentHandler(reopenError: StateError('reopen failed')); + final harness = await pumpHarness( + tester, + asset: asset, + origin: TimelineOrigin.deepLink, + handler: handler, + isTrashScoped: true, + ); + + await harness.coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]); + + expect(handler.reopenedRemoteAssetIds, [asset.id]); + }); +} diff --git a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index be7549e202..9c7206d3ad 100644 --- a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart +++ b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -8,16 +9,24 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/auth_state.model.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_trash_scope.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; @@ -27,6 +36,10 @@ class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {} class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {} +class MockAssetService extends Mock implements AssetService {} + +class MockTimelineFactory extends Mock implements TimelineFactory {} + class MockAppRouter extends Mock implements AppRouter {} class MockAuthService extends Mock implements AuthService {} @@ -43,21 +56,23 @@ class FakePageRouteInfo extends Fake implements PageRouteInfo {} class FakeTimelineService extends Fake implements TimelineService {} -class FakeAssetService extends Fake implements AssetService { - @override - Stream watchAsset(BaseAsset asset) => const Stream.empty(); -} - class TestViewIntentService extends ViewIntentService { ViewIntentPayload? consumedAttachment; + Object? consumeError; int cleanupStaleTempFilesCalls = 0; int cleanupManagedTempFileCalls = 0; final List managedTempPaths = []; + final List cleanedManagedTempPaths = []; TestViewIntentService() : super(MockViewIntentHostApi()); @override - Future consumeViewIntent() async => consumedAttachment; + Future consumeViewIntent() async { + if (consumeError case final Object error) { + throw error; + } + return consumedAttachment; + } @override Future cleanupStaleTempFiles() async { @@ -73,6 +88,20 @@ class TestViewIntentService extends ViewIntentService { Future setManagedTempFilePath(String path) async { managedTempPaths.add(path); } + + @override + Future cleanupManagedTempFileIfCurrent(String path) async { + cleanedManagedTempPaths.add(path); + } +} + +class TestToastService extends ToastService { + final List errorMessages = []; + + @override + void error(String message, {ToastOption? toast}) { + errorMessages.add(message); + } } class TestAuthNotifier extends AuthNotifier { @@ -100,8 +129,11 @@ void main() { late TestViewIntentService viewIntentService; late MockViewIntentAssetResolver resolver; + late MockAssetService assetService; + late MockTimelineFactory timelineFactory; late MockAppRouter router; late TestAuthNotifier authNotifier; + late TestToastService toastService; late ProviderContainer container; late AndroidViewIntentHandler handler; late ViewIntentPayload payload; @@ -112,6 +144,8 @@ void main() { registerFallbackValue(FakePageRouteInfo()); registerFallbackValue(>[]); registerFallbackValue(FakeTimelineService()); + registerFallbackValue([]); + registerFallbackValue(_remoteAsset(id: 'fallback-remote', localId: 'fallback-local')); registerFallbackValue( ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'), ); @@ -119,24 +153,32 @@ void main() { setUp(() async { viewIntentService = TestViewIntentService(); + toastService = TestToastService(); resolver = MockViewIntentAssetResolver(); + assetService = MockAssetService(); + timelineFactory = MockTimelineFactory(); router = MockAppRouter(); payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1'); deepLinkAsset = _localAsset(id: 'local-1'); deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink); when(() => router.replaceAll(any())).thenAnswer((_) async {}); + when(() => router.replace(any())).thenAnswer((_) async => null); + when(() => router.push(any())).thenAnswer((_) async => null); + when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty()); container = ProviderContainer( overrides: [ viewIntentServiceProvider.overrideWithValue(viewIntentService), viewIntentAssetResolverProvider.overrideWithValue(resolver), + assetServiceProvider.overrideWithValue(assetService), + timelineFactoryProvider.overrideWithValue(timelineFactory), + toastServiceProvider.overrideWithValue(toastService), appRouterProvider.overrideWithValue(router), authProvider.overrideWith((ref) { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); return authNotifier; }), - assetServiceProvider.overrideWithValue(FakeAssetService()), ], ); @@ -164,7 +206,7 @@ void main() { authNotifier.setAuthenticated(true); when(() => resolver.resolve(payload)).thenAnswer((_) async { - return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService); + return ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService); }); unawaited(handler.flushDeferredViewIntent()); @@ -201,11 +243,21 @@ void main() { verifyNever(() => resolver.resolve(any())); }); + test('onAppResumed returns to the main screen when the incoming view intent is unavailable', () async { + viewIntentService.consumeError = PlatformException(code: 'VIEW_INTENT_UNAVAILABLE'); + + await handler.onAppResumed(); + + expect(toastService.errorMessages, [StaticTranslations.instance.asset_not_found_on_device_android]); + verify(() => router.replaceAll([const TabShellRoute()])).called(1); + verifyNever(() => resolver.resolve(any())); + }); + testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async { viewIntentService.consumedAttachment = payload; when( () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + ).thenAnswer((_) async => ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); unawaited(handler.onAppResumed()); await tester.pump(); @@ -214,14 +266,159 @@ void main() { await tester.idle(); verify(() => resolver.resolve(payload)).called(1); - // Routes the user to [TabShell, AssetViewer] so back-press lands on the - // main timeline — mirrors the home-screen widget navigation pattern. - final captured = verify(() => router.replaceAll(captureAny())).captured; + verify(() => router.popUntilRoot()).called(1); + final captured = verify(() => router.push(captureAny())).captured; expect(captured, hasLength(1)); - final routes = captured.single as List>; - expect(routes, hasLength(2)); - expect(routes[0].routeName, TabShellRoute.name); - expect(routes[1].routeName, AssetViewerRoute.name); + final route = captured.single as PageRouteInfo; + expect(route.routeName, AssetViewerRoute.name); + }); + + test('handle updates current viewer asset when a new view intent arrives', () async { + final secondPayload = ViewIntentPayload( + path: '/tmp/incoming-b.jpg', + mimeType: 'image/jpeg', + localAssetId: 'local-2', + ); + final secondAsset = _localAsset(id: 'local-2'); + final secondTimelineService = await _createReadyTimelineService([secondAsset], TimelineOrigin.deepLink); + addTearDown(() async => secondTimelineService.dispose()); + + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + when( + () => resolver.resolve(secondPayload), + ).thenAnswer((_) async => ViewIntentResolution(asset: secondAsset, timelineService: secondTimelineService)); + + await handler.handle(payload); + expect(container.read(assetViewerProvider).currentAsset, deepLinkAsset); + expect(container.read(activeViewIntentPayloadProvider), isNull); + + await handler.handle(secondPayload); + + expect(container.read(assetViewerProvider).currentAsset, secondAsset); + expect(container.read(activeViewIntentPayloadProvider), isNull); + verify(() => resolver.resolve(payload)).called(1); + verify(() => resolver.resolve(secondPayload)).called(1); + verify(() => router.popUntilRoot()).called(2); + verify(() => router.push(any())).called(2); + verifyNever(() => router.replace(any())); + verifyNever(() => router.replaceAll(any())); + }); + + test('a slower view intent cannot replace a newer one', () async { + final firstResolution = Completer(); + final secondPayload = ViewIntentPayload( + path: '/tmp/incoming-b.jpg', + mimeType: 'image/jpeg', + localAssetId: 'local-2', + ); + final secondAsset = _localAsset(id: 'local-2'); + final secondTimelineService = await _createReadyTimelineService([secondAsset], TimelineOrigin.deepLink); + addTearDown(secondTimelineService.dispose); + + when(() => resolver.resolve(payload)).thenAnswer((_) => firstResolution.future); + when( + () => resolver.resolve(secondPayload), + ).thenAnswer((_) async => ViewIntentResolution(asset: secondAsset, timelineService: secondTimelineService)); + + final firstHandle = handler.handle(payload); + await pumpEventQueue(); + await handler.handle(secondPayload); + + firstResolution.complete(ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + await firstHandle; + + expect(container.read(assetViewerProvider).currentAsset, secondAsset); + expect(container.read(activeViewIntentPayloadProvider), isNull); + verify(() => router.popUntilRoot()).called(2); + verify(() => router.push(any())).called(1); + }); + + test('closing a file-backed view intent clears only its session state', () async { + const path = '/tmp/view_intent_1.jpg'; + final routeClosed = Completer(); + when(() => router.push(any())).thenAnswer((_) => routeClosed.future); + when(() => resolver.resolve(payload)).thenAnswer( + (_) async => ViewIntentResolution( + asset: deepLinkAsset, + timelineService: deepLinkTimelineService, + viewIntentFilePath: path, + ), + ); + + final handling = handler.handle(payload); + await pumpEventQueue(); + + expect(container.read(activeViewIntentPayloadProvider), same(payload)); + expect(container.read(viewIntentFilePathProvider), path); + + routeClosed.complete(null); + await handling; + + expect(container.read(activeViewIntentPayloadProvider), isNull); + expect(container.read(viewIntentFilePathProvider), isNull); + expect(viewIntentService.cleanedManagedTempPaths, [path]); + }); + + test('reopenRemoteAsset opens the restored asset in a regular deep-link timeline', () async { + final restoredAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); + final restoredTimeline = await _createReadyTimelineService([restoredAsset], TimelineOrigin.deepLink); + addTearDown(restoredTimeline.dispose); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload); + + when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) async => restoredAsset); + when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenReturn(restoredTimeline); + + final reopened = await handler.reopenRemoteAsset(restoredAsset.id); + await Future.delayed(Duration.zero); + + expect(reopened, isTrue); + expect(container.read(assetViewerProvider).currentAsset, restoredAsset); + expect(container.read(viewIntentTrashScopeProvider), isFalse); + verify(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).called(1); + verify(() => router.popUntilRoot()).called(1); + final route = verify(() => router.push(captureAny())).captured.single as PageRouteInfo; + expect(route.routeName, AssetViewerRoute.name); + }); + + test('reopenRemoteAsset opens a trashed asset in a trash-scoped deep-link timeline', () async { + final trashedAsset = _remoteAsset(id: 'remote-trashed', localId: 'local-1', deletedAt: DateTime(2026, 8, 4)); + final trashTimeline = await _createReadyTimelineService([trashedAsset], TimelineOrigin.deepLink); + addTearDown(trashTimeline.dispose); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload); + + when(() => assetService.getRemoteAsset(trashedAsset.id)).thenAnswer((_) async => trashedAsset); + when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenReturn(trashTimeline); + + final reopened = await handler.reopenRemoteAsset(trashedAsset.id); + await Future.delayed(Duration.zero); + + expect(reopened, isTrue); + expect(container.read(assetViewerProvider).currentAsset, trashedAsset); + expect(trashTimeline.origin, TimelineOrigin.deepLink); + expect(container.read(viewIntentTrashScopeProvider), isTrue); + verify(() => router.popUntilRoot()).called(1); + final route = verify(() => router.push(captureAny())).captured.single as PageRouteInfo; + expect(route.routeName, AssetViewerRoute.name); + }); + + test('reopenRemoteAsset does not replace a newer view intent', () async { + final restoredAsset = _remoteAsset(id: 'remote-delayed', localId: 'local-1'); + final lookup = Completer(); + final newerPayload = ViewIntentPayload(path: '/tmp/newer.jpg', mimeType: 'image/jpeg', localAssetId: 'local-2'); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload); + when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) => lookup.future); + + final reopening = handler.reopenRemoteAsset(restoredAsset.id); + await pumpEventQueue(); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(newerPayload); + lookup.complete(restoredAsset); + + expect(await reopening, isFalse); + verifyNever(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)); + verifyNever(() => router.popUntilRoot()); + verifyNever(() => router.push(any())); }); } @@ -237,15 +434,31 @@ AuthState _authState({required bool isAuthenticated}) { ); } -LocalAsset _localAsset({required String id}) { +LocalAsset _localAsset({required String id, String? checksum = 'checksum-1', String? remoteId}) { return LocalAsset( id: id, + remoteId: remoteId, + name: '$id.jpg', + checksum: checksum, + type: AssetType.image, + createdAt: DateTime(2026, 4, 20), + updatedAt: DateTime(2026, 4, 20), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, + ); +} + +RemoteAsset _remoteAsset({required String id, required String? localId, DateTime? deletedAt}) { + return RemoteAsset( + id: id, + localId: localId, + ownerId: 'user-1', name: '$id.jpg', checksum: 'checksum-1', type: AssetType.image, createdAt: DateTime(2026, 4, 20), updatedAt: DateTime(2026, 4, 20), - playbackStyle: AssetPlaybackStyle.image, + deletedAt: deletedAt, isEdited: false, ); } diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index 38d2f71f88..245819b125 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -4,9 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -15,27 +18,43 @@ import '../infrastructure/repository.mock.dart'; class MockTimelineFactory extends Mock implements TimelineFactory {} +class MockAssetService extends Mock implements AssetService {} + +class MockNativeSyncApi extends Mock implements NativeSyncApi {} + void main() { late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockAssetService assetService; + late MockNativeSyncApi nativeSyncApi; late MockTimelineFactory timelineFactory; late List createdTimelineServices; late ProviderContainer container; + setUpAll(() { + registerFallbackValue([]); + registerFallbackValue({}); + }); + setUp(() { mockLocalAssetRepository = MockDriftLocalAssetRepository(); + assetService = MockAssetService(); + nativeSyncApi = MockNativeSyncApi(); timelineFactory = MockTimelineFactory(); createdTimelineServices = []; - when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) { - final assets = List.from(invocation.positionalArguments[0] as List); - final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink); - createdTimelineServices.add(timelineService); - return timelineService; - }); + when(() => mockLocalAssetRepository.get(any())).thenAnswer((_) async => null); + when(() => assetService.getRemoteAsset(any())).thenAnswer((_) async => null); + when(() => assetService.getAllRemoteAssetDebugByChecksum(any())).thenAnswer((_) async => const []); + when(() => nativeSyncApi.hashAssets(any())).thenAnswer((_) async => const []); + when(() => mockLocalAssetRepository.updateHashes(any())).thenAnswer((_) async {}); + + _mockTimelineFactoryOrigin(timelineFactory, createdTimelineServices, TimelineOrigin.deepLink); container = ProviderContainer( overrides: [ localAssetRepository.overrideWith((ref) => mockLocalAssetRepository), + assetServiceProvider.overrideWithValue(assetService), + nativeSyncApiProvider.overrideWithValue(nativeSyncApi), timelineFactoryProvider.overrideWith((ref) => timelineFactory), ], ); @@ -50,7 +69,7 @@ void main() { test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async { final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1'); - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => localAsset); final result = await _resolve(container, _payload(localAssetId: 'local-1')); @@ -59,8 +78,63 @@ void main() { expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed'); }); + test('returns remote merged asset when local checksum matches remote asset', () async { + final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1'); + final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => localAsset); + when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset); + + final result = await _resolve(container, _payload(localAssetId: 'local-1')); + + expect(result.asset, isA()); + expect((result.asset as RemoteAsset).localId, 'local-1'); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.viewIntentFilePath, isNull); + verifyNever(() => nativeSyncApi.hashAssets(any())); + }); + + test('returns remote trashed asset in a trash-scoped deep-link timeline', () async { + final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1'); + final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1', deletedAt: DateTime(2026, 4, 21)); + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => localAsset); + when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset); + + final result = await _resolve(container, _payload(localAssetId: 'local-1')); + + expect(result.asset, isA()); + expect((result.asset as RemoteAsset).localId, 'local-1'); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.isTrashScoped, isTrue); + expect(result.viewIntentFilePath, isNull); + }); + + test('hashes local asset without checksum and returns remote merged asset', () async { + final localAsset = _localAsset(id: 'local-1'); + final mergedLocalAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1'); + final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); + var getCallCount = 0; + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async { + getCallCount++; + return getCallCount == 1 ? localAsset : mergedLocalAsset; + }); + when( + () => nativeSyncApi.hashAssets(['local-1']), + ).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]); + when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset); + + final result = await _resolve(container, _payload(localAssetId: 'local-1')); + + expect(result.asset, isA()); + expect((result.asset as RemoteAsset).localId, 'local-1'); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.viewIntentFilePath, isNull); + verify(() => nativeSyncApi.hashAssets(['local-1'])).called(1); + verify(() => mockLocalAssetRepository.updateHashes({'local-1': 'checksum-1'})).called(1); + verify(() => mockLocalAssetRepository.get('local-1')).called(2); + }); + test('returns transient asset with temp file path when localAssetId has no DB row', () async { - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null); + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => null); final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg')); @@ -69,6 +143,22 @@ void main() { expect(result.viewIntentFilePath, '/tmp/incoming.jpg'); }); + test('returns cached remote asset when local Drift row is absent but checksum matches', () async { + final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); + when( + () => nativeSyncApi.hashAssets(['local-1']), + ).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]); + when(() => assetService.getAllRemoteAssetDebugByChecksum('checksum-1')).thenAnswer((_) async => [remoteAsset]); + + final result = await _resolve(container, _payload(localAssetId: 'local-1')); + + expect(result.asset, isA()); + expect((result.asset as RemoteAsset).id, 'remote-1'); + expect((result.asset as RemoteAsset).localId, 'local-1'); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + verify(() => assetService.getAllRemoteAssetDebugByChecksum('checksum-1')).called(1); + }); + test('returns transient asset for path-only attachment', () async { final result = await _resolve( container, @@ -86,14 +176,11 @@ void main() { }); test('throws when neither localAssetId nor path is provided', () async { - await expectLater( - _resolve(container, _payload(localAssetId: null, path: null)), - throwsA(isA()), - ); + await expectLater(_resolve(container, _payload(localAssetId: null, path: null)), throwsA(isA())); }); } -Future _resolve(ProviderContainer container, ViewIntentPayload payload) { +Future _resolve(ProviderContainer container, ViewIntentPayload payload) { return container.read(viewIntentAssetResolverProvider).resolve(payload); } @@ -101,9 +188,10 @@ ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, Stri return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId); } -LocalAsset _localAsset({required String id, String? checksum}) { +LocalAsset _localAsset({required String id, String? checksum, String? remoteId}) { return LocalAsset( id: id, + remoteId: remoteId, name: '$id.jpg', checksum: checksum, type: AssetType.image, @@ -114,6 +202,34 @@ LocalAsset _localAsset({required String id, String? checksum}) { ); } +RemoteAsset _remoteAsset({required String id, String? localId, required String checksum, DateTime? deletedAt}) { + return RemoteAsset( + id: id, + localId: localId, + ownerId: 'user-1', + name: '$id.jpg', + checksum: checksum, + type: AssetType.image, + createdAt: DateTime(2026, 4, 20), + updatedAt: DateTime(2026, 4, 20), + isEdited: false, + deletedAt: deletedAt, + ); +} + +void _mockTimelineFactoryOrigin( + MockTimelineFactory timelineFactory, + List createdTimelineServices, + TimelineOrigin origin, +) { + when(() => timelineFactory.fromAssets(any(), origin)).thenAnswer((invocation) { + final assets = List.from(invocation.positionalArguments[0] as List); + final timelineService = _timelineServiceFromAssets(assets, origin); + createdTimelineServices.add(timelineService); + return timelineService; + }); +} + TimelineService _timelineServiceFromAssets(List assets, TimelineOrigin origin) { return TimelineService(( assetSource: (index, count) async => assets.skip(index).take(count).toList(), diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index fd8f5f725c..fee659700b 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; @@ -58,6 +59,15 @@ void main() { verify(() => hostApi.consumeViewIntent()).called(1); }); + test('consumeViewIntent preserves an unavailable view intent error', () async { + when(() => hostApi.consumeViewIntent()).thenThrow(PlatformException(code: 'VIEW_INTENT_UNAVAILABLE')); + + await expectLater( + service.consumeViewIntent(), + throwsA(isA().having((error) => error.code, 'code', 'VIEW_INTENT_UNAVAILABLE')), + ); + }); + test('setManagedTempFilePath cleans previous managed temp file', () async { final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first'); final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second'); diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 4c1bf9306c..1a82796e6d 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -1,14 +1,23 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; @@ -18,6 +27,20 @@ import '../../factories/local_asset_factory.dart'; import '../../factories/remote_asset_factory.dart'; import '../presentation_context.dart'; +class _MockViewIntentAssetActionCoordinator extends Mock implements ViewIntentAssetActionCoordinator {} + +class _ViewerNotifier extends AssetViewerStateNotifier { + _ViewerNotifier(this.asset); + + final BaseAsset asset; + + @override + AssetViewerState build() { + super.build(); + return AssetViewerState(currentAsset: asset); + } +} + void main() { late PresentationContext context; late MockAssetService assetService; @@ -43,6 +66,27 @@ void main() { localId: localId, ); + TimelineService viewIntentTimeline(BaseAsset asset, TimelineOrigin origin) => TimelineService(( + assetSource: (_, __) async => [asset], + bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), + origin: origin, + )); + + Future pumpViewerDelete(WidgetTester tester, BaseAsset asset, TimelineService timeline, String filePath) async { + await tester.pumpTestWidget( + context, + const ActionIconButton(action: DeleteAction(source: .viewer)), + overrides: [ + timelineServiceProvider.overrideWithValue(timeline), + assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)), + ], + ); + + final scope = ProviderScope.containerOf(tester.element(find.byType(ActionIconButton)), listen: false); + scope.read(viewIntentFilePathProvider.notifier).setPath(filePath); + await tester.pumpAndSettle(); + } + Future pumpDelete(WidgetTester tester, Set selection, {bool trashEnabled = true}) async { if (!trashEnabled) { when( @@ -238,6 +282,101 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing); }); + + group('view intent', () { + testWidgets('delegates a successful delete to the view intent coordinator', (tester) async { + final asset = owned(); + final timeline = viewIntentTimeline(asset, TimelineOrigin.deepLink); + final coordinator = _MockViewIntentAssetActionCoordinator(); + addTearDown(timeline.dispose); + when(() => coordinator.canDelete(ActionSource.viewer)).thenReturn(true); + when( + () => coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true), + ).thenAnswer((_) async {}); + + await tester.pumpTestWidget( + context, + const ActionIconButton(action: DeleteAction(source: .viewer)), + overrides: [ + timelineServiceProvider.overrideWithValue(timeline), + assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)), + viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator), + ], + ); + await tester.tap(find.byType(ImmichIconButton)); + await tester.pumpAndSettle(); + + verify( + () => coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: true), + ).called(1); + }); + + testWidgets('clears timeline selection before the post-delete transition completes', (tester) async { + final asset = owned(); + final coordinator = _MockViewIntentAssetActionCoordinator(); + final transition = Completer(); + when(() => coordinator.canDelete(ActionSource.timeline)).thenReturn(true); + when( + () => coordinator.afterDelete(source: ActionSource.timeline, remoteAssetIds: [asset.id], movedToTrash: true), + ).thenAnswer((_) => transition.future); + + await tester.pumpTestAction( + context, + const DeleteAction(source: .timeline), + overrides: [ + ...context.selected({asset}), + viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator), + ], + ); + await tester.pump(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'successful deletion clears the selection'); + transition.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('is hidden for a synthetic file-backed asset', (tester) async { + final asset = LocalAssetFactory.create(id: '-42'); + final timeline = viewIntentTimeline(asset, TimelineOrigin.deepLink); + addTearDown(timeline.dispose); + + await pumpViewerDelete(tester, asset, timeline, '/tmp/materialized.jpg'); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); + + testWidgets('clears selection when local cleanup finishes after the action context is disposed', (tester) async { + final asset = LocalAssetFactory.create(); + final cleanupResult = Completer(); + when(() => cleanupService.deleteLocalAssets([asset.id])).thenAnswer((_) => cleanupResult.future); + + await tester.pumpTestWidget( + context, + const ActionIconButton(action: DeleteAction(source: .timeline)), + overrides: context.selected({asset}), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold( + body: ActionIconButton(action: DeleteAction(source: .timeline)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ImmichIconButton)); + await tester.pump(); + navigator.pop(); + await tester.pumpAndSettle(); + cleanupResult.complete(1); + await tester.pumpAndSettle(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'successful cleanup clears the selection'); + }); }); group('CleanupLocalAction', () { diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart index 530e1b76e9..f265447a08 100644 --- a/mobile/test/unit/presentation/actions/restore_action_test.dart +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -1,7 +1,15 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_asset_action_coordinator.provider.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; @@ -9,6 +17,20 @@ import '../../../service.mocks.dart'; import '../../factories/remote_asset_factory.dart'; import '../presentation_context.dart'; +class _MockViewIntentAssetActionCoordinator extends Mock implements ViewIntentAssetActionCoordinator {} + +class _ViewerNotifier extends AssetViewerStateNotifier { + _ViewerNotifier(this.asset); + + final BaseAsset asset; + + @override + AssetViewerState build() { + super.build(); + return AssetViewerState(currentAsset: asset); + } +} + void main() { late PresentationContext context; late MockAssetService assetService; @@ -62,6 +84,57 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); }); + testWidgets('clears timeline selection before the post-restore transition completes', (tester) async { + final asset = owned(); + final coordinator = _MockViewIntentAssetActionCoordinator(); + final transition = Completer(); + when( + () => coordinator.afterRestore(source: ActionSource.timeline, remoteAssetIds: [asset.id]), + ).thenAnswer((_) => transition.future); + + await tester.pumpTestAction( + context, + const RestoreAction(source: .timeline), + overrides: [ + ...context.selected({asset}), + viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator), + ], + ); + await tester.pump(); + + expect(find.byType(ImmichIconButton), findsNothing, reason: 'successful restore clears the selection'); + transition.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('delegates a successful viewer restore to the view intent coordinator', (tester) async { + final asset = owned(); + final coordinator = _MockViewIntentAssetActionCoordinator(); + final timeline = TimelineService(( + assetSource: (_, __) async => [asset], + bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), + origin: TimelineOrigin.deepLink, + )); + addTearDown(timeline.dispose); + when( + () => coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]), + ).thenAnswer((_) async {}); + + await tester.pumpTestAction( + context, + const RestoreAction(source: .viewer), + overrides: [ + timelineServiceProvider.overrideWithValue(timeline), + assetViewerProvider.overrideWith(() => _ViewerNotifier(asset)), + viewIntentAssetActionCoordinatorProvider.overrideWithValue(coordinator), + ], + ); + await tester.pump(); + + verify(() => assetService.restoreTrash([asset.id])).called(1); + verify(() => coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id])).called(1); + }); + testWidgets('is hidden when no owned asset is trashed', (tester) async { await tester.pumpTestWidget( context, diff --git a/mobile/test/unit/presentation/actions/upload_action_test.dart b/mobile/test/unit/presentation/actions/upload_action_test.dart index 8aeb7a915f..99c6c0196e 100644 --- a/mobile/test/unit/presentation/actions/upload_action_test.dart +++ b/mobile/test/unit/presentation/actions/upload_action_test.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/upload.action.dart'; +import 'package:immich_mobile/providers/asset_upload_coordinator.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; @@ -32,6 +34,7 @@ void main() { }); List uploadOverrides() => [ + assetUploadCoordinatorProvider.overrideWith(AssetUploadCoordinator.new), foregroundUploadServiceProvider.overrideWithValue(uploadService), toastServiceProvider.overrideWithValue(context.service.toast), ]; @@ -203,7 +206,7 @@ void main() { overrides: uploadOverrides(), ); - await uploadAssets(tester.element(find.byType(SizedBox)), capturedRef, [asset]); + await uploadAssets(tester.element(find.byType(SizedBox)), capturedRef, [asset], source: ActionSource.timeline); await settleUpload(tester); expect(capturedRef.read(assetUploadProgressProvider), isEmpty); diff --git a/mobile/test/unit/services/asset_service_test.dart b/mobile/test/unit/services/asset_service_test.dart index a09465701b..aa11cfe8e4 100644 --- a/mobile/test/unit/services/asset_service_test.dart +++ b/mobile/test/unit/services/asset_service_test.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:mocktail/mocktail.dart'; import '../../infrastructure/repository.mock.dart'; import '../../repository.mocks.dart'; +import '../factories/local_asset_factory.dart'; +import '../factories/remote_asset_factory.dart'; import '../mocks.dart'; void main() { @@ -12,23 +17,55 @@ void main() { late MockAssetApiRepository apiRepository; late MockRemoteAssetRepository remoteRepository; late MockRemoteExifRepository exifRepository; + late MockDriftLocalAssetRepository localRepository; setUp(() { mocks = RepositoryMocks(); apiRepository = mocks.assetApi.api; remoteRepository = mocks.remoteAsset.repo; exifRepository = mocks.remoteExif.repo; + localRepository = MockDriftLocalAssetRepository(); sut = AssetService( remoteRepository: remoteRepository, exifRepository: exifRepository, - localRepository: MockDriftLocalAssetRepository(), + localRepository: localRepository, apiRepository: apiRepository, mediaRepository: mocks.assetMedia.api, trashedLocalRepository: mocks.trashedAsset, ); }); + group('AssetService.watchAsset', () { + test('switches from the local asset to its canonical remote asset after upload', () async { + final localController = StreamController.broadcast(); + final remoteController = StreamController.broadcast(); + addTearDown(localController.close); + addTearDown(remoteController.close); + + final local = LocalAssetFactory.create(id: 'local-1'); + final linkedLocal = local.copyWith(remoteId: 'remote-1'); + final remote = RemoteAssetFactory.create(id: 'remote-1', localId: local.id); + when(() => localRepository.watch(local.id)).thenAnswer((_) => localController.stream); + when(() => remoteRepository.watch(remote.id)).thenAnswer((_) => remoteController.stream); + + final emitted = []; + final subscription = sut.watchAsset(local).listen(emitted.add); + addTearDown(subscription.cancel); + + localController.add(local); + await pumpEventQueue(); + expect(emitted, [local]); + + localController.add(linkedLocal); + await pumpEventQueue(); + remoteController.add(remote); + await pumpEventQueue(); + + expect(emitted.last, remote); + }); + }); + group('AssetService.updateDateTime', () { const ids = ['asset_id_1'];