From e662dfcd287e9e2c2dfeaccaf70af6521b7b08f4 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 11 Jun 2026 13:19:20 +0300 Subject: [PATCH] fix(mobile): distinguish trashed view intent assets handle restoring trashed view intent assets --- .../lib/domain/services/timeline.service.dart | 7 +++ .../open_in_browser_action_button.widget.dart | 1 + .../restore_action_button.widget.dart | 55 ++++++++++++++++++- .../asset_viewer/asset_page.widget.dart | 6 +- .../asset_viewer/asset_stack.widget.dart | 2 +- .../asset_viewer/asset_viewer.page.dart | 2 +- .../asset_viewer/bottom_bar.widget.dart | 2 +- .../view_intent_asset_resolver.service.dart | 3 +- mobile/lib/utils/action_button.utils.dart | 14 ++--- .../view_intent_asset_resolver_test.dart | 38 ++++++++++--- 10 files changed, 108 insertions(+), 22 deletions(-) diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 4cc58b0fe7..4ea6226c58 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -32,11 +32,18 @@ enum TimelineOrigin { map, search, deepLink, + deepLinkTrash, albumActivities, folder, recentlyAdded, } +extension TimelineOriginX on TimelineOrigin { + bool get isDeepLink => this == TimelineOrigin.deepLink || this == TimelineOrigin.deepLinkTrash; + + bool get isTrash => this == TimelineOrigin.trash || this == TimelineOrigin.deepLinkTrash; +} + class TimelineFactory { final DriftTimelineRepository _timelineRepository; final SettingsRepository _settingsRepository; diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart index 541a9f8093..5ee29374c7 100644 --- a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -30,6 +30,7 @@ class OpenInBrowserActionButton extends ConsumerWidget { originPath = '/favorites'; break; case TimelineOrigin.trash: + case TimelineOrigin.deepLinkTrash: originPath = '/trash'; break; case TimelineOrigin.archive: diff --git a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart index 1713718967..936368e98f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart @@ -1,13 +1,21 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.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/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class RestoreActionButton extends ConsumerWidget { @@ -22,11 +30,18 @@ class RestoreActionButton extends ConsumerWidget { return; } + final currentAsset = ref.read(assetViewerProvider).currentAsset; + final shouldReopenAsViewIntent = _isViewIntentTrashViewer(ref); final result = await ref.read(actionProvider.notifier).restoreTrash(source); ref.read(multiSelectProvider.notifier).reset(); if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); + final handled = result.success && shouldReopenAsViewIntent && currentAsset is RemoteAsset + ? await _reopenRestoredViewIntentAsset(context, ref, currentAsset.id) + : false; + if (!handled) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } } final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()}); @@ -52,4 +67,42 @@ class RestoreActionButton extends ConsumerWidget { maxWidth: 100.0, ); } + + bool _isViewIntentTrashViewer(WidgetRef ref) { + final timelineService = ref.read(timelineServiceProvider); + + return timelineService.origin == TimelineOrigin.deepLinkTrash; + } + + Future _reopenRestoredViewIntentAsset(BuildContext context, WidgetRef ref, String remoteAssetId) async { + final restoredAsset = await ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId); + if (restoredAsset == null) { + return false; + } + + final timelineService = ref.read(timelineFactoryProvider).fromAssets([restoredAsset], TimelineOrigin.deepLink); + final notifier = ref.read(assetViewerProvider.notifier); + notifier.reset(); + if (restoredAsset.isVideo) { + notifier.setControls(false); + } + notifier.setAsset(restoredAsset); + + if (!context.mounted) { + return true; + } + + final router = ref.read(appRouterProvider); + router.popUntilRoot(); + unawaited( + router.push( + AssetViewerRoute( + key: ValueKey('restored-view-intent-$remoteAssetId'), + initialIndex: 0, + timelineService: timelineService, + ), + ), + ); + return true; + } } 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 e78ecb966f..0a49cae27c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -403,13 +403,13 @@ class _AssetPageState extends ConsumerState { final timelineOrigin = ref.read(timelineServiceProvider).origin; final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr)); - final asset = timelineOrigin == TimelineOrigin.deepLink && currentViewerAsset != null ? currentViewerAsset : _asset; + final asset = timelineOrigin.isDeepLink && currentViewerAsset != null ? currentViewerAsset : _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 = ref.watch(timelineServiceProvider.select((s) => !s.origin.isTrash)); final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null; if (stackChildren != null && stackChildren.isNotEmpty) { displayAsset = stackChildren.elementAt(stackIndex); @@ -430,7 +430,7 @@ class _AssetPageState extends ConsumerState { _scrollController.snapPosition.snapOffset = _snapOffset; } - final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null; + final viewIntentFilePath = timelineOrigin.isDeepLink ? ref.watch(viewIntentFilePathProvider) : null; return Stack( children: [ 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 f5d75a6a86..7a2f6e16ec 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,7 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final hideAssetStack = ref.read(timelineServiceProvider).origin.isTrash; 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 5cc851970e..64f8a3f332 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -266,7 +266,7 @@ 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) { + if (timelineService.origin.isDeepLink) { return true; } 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 01a48e7e97..90d055b6a7 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -38,7 +38,7 @@ class ViewerBottomBar extends ConsumerWidget { final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); final serverInfo = ref.watch(serverInfoProvider); - final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final isInTrash = ref.read(timelineServiceProvider).origin.isTrash; final originalTheme = context.themeData; diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 830372ef4e..98f4dfe2c3 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -71,7 +71,8 @@ class ViewIntentAssetResolver { } TimelineService timelineFor(BaseAsset asset) { - return _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink); + final origin = asset is RemoteAsset && asset.isTrashed ? TimelineOrigin.deepLinkTrash : TimelineOrigin.deepLink; + return _timelineFactory.fromAssets([asset], origin); } Future<({LocalAsset? asset, String? checksum})> _resolveLocalAsset(String localAssetId) async { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 0388464910..687cbead02 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -117,16 +117,16 @@ enum ActionButtonType { !context.isInLockedView && // context.asset.hasRemote && // context.isTrashEnabled && // - context.timelineOrigin != TimelineOrigin.trash, + !context.timelineOrigin.isTrash, ActionButtonType.restoreTrash => context.isOwner && // !context.isInLockedView && // context.asset.hasRemote && // - context.timelineOrigin == TimelineOrigin.trash, + context.timelineOrigin.isTrash, ActionButtonType.deletePermanent => context.isOwner && // context.asset.hasRemote && // - (!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView), + (!context.isTrashEnabled || context.timelineOrigin.isTrash || context.isInLockedView), ActionButtonType.delete => context.isOwner && // !context.isInLockedView && // @@ -155,13 +155,13 @@ enum ActionButtonType { context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // - context.timelineOrigin != TimelineOrigin.trash && + !context.timelineOrigin.isTrash && !context.isInLockedView && // context.isStacked, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && - context.timelineOrigin != TimelineOrigin.deepLink && + !context.timelineOrigin.isDeepLink && context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, @@ -175,8 +175,8 @@ enum ActionButtonType { ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && - context.timelineOrigin != TimelineOrigin.deepLink && - context.timelineOrigin != TimelineOrigin.trash && + !context.timelineOrigin.isDeepLink && + !context.timelineOrigin.isTrash && context.timelineOrigin != TimelineOrigin.lockedFolder && context.timelineOrigin != TimelineOrigin.archive && context.timelineOrigin != TimelineOrigin.localAlbum && diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index ba4801585f..7eb3abf956 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -46,12 +46,8 @@ void main() { when(() => nativeSyncApi.hashAssets(any())).thenAnswer((_) async => const []); when(() => mockLocalAssetRepository.updateHashes(any())).thenAnswer((_) async {}); - 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; - }); + _mockTimelineFactoryOrigin(timelineFactory, createdTimelineServices, TimelineOrigin.deepLink); + _mockTimelineFactoryOrigin(timelineFactory, createdTimelineServices, TimelineOrigin.deepLinkTrash); container = ProviderContainer( overrides: [ @@ -96,6 +92,20 @@ void main() { verifyNever(() => nativeSyncApi.hashAssets(any())); }); + test('returns remote trashed asset in a 1-element deep-link trash timeline', () async { + final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1'); + final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1', deletedAt: DateTime(2026, 4, 21)); + when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); + when(() => assetService.getRemoteAssetByChecksum('checksum-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.deepLinkTrash); + expect(result.viewIntentFilePath, isNull); + }); + test('hashes local asset without checksum and returns remote merged asset', () async { final localAsset = _localAsset(id: 'local-1'); final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); @@ -166,7 +176,7 @@ LocalAsset _localAsset({required String id, String? checksum}) { ); } -RemoteAsset _remoteAsset({required String id, String? localId, required String checksum}) { +RemoteAsset _remoteAsset({required String id, String? localId, required String checksum, DateTime? deletedAt}) { return RemoteAsset( id: id, localId: localId, @@ -177,9 +187,23 @@ RemoteAsset _remoteAsset({required String id, String? localId, required String c 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(),