From 45fdd20c41b2da512849d9c8cdb32396abb56382 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 4 Jun 2026 15:49:35 +0300 Subject: [PATCH 01/25] fix(mobile): update view intent asset refresh --- mobile/lib/domain/services/asset.service.dart | 4 + .../upload_action_button.widget.dart | 11 ++ .../asset_viewer/asset_page.widget.dart | 5 +- .../asset_viewer/asset_viewer.page.dart | 2 +- .../infrastructure/action.provider.dart | 1 + .../view_intent_current.provider.dart | 19 +++ .../view_intent_handler.provider.dart | 5 + .../view_intent_handler_android.dart | 61 +++++++- .../view_intent/view_intent_handler_stub.dart | 6 + .../view_intent_asset_resolver.service.dart | 86 ++++++++++- .../view_intent_handler_android_test.dart | 137 +++++++++++++++++- .../view_intent_asset_resolver_test.dart | 74 +++++++++- 12 files changed, 388 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/providers/view_intent/view_intent_current.provider.dart diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index b055ad38b1..35acf43faf 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -20,6 +20,10 @@ class AssetService { return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id); } + Stream watchRemoteAsset(String id) { + return _remoteAssetRepository.watch(id); + } + Future> getLocalAssetsByChecksum(String checksum) { return _localAssetRepository.getByChecksum(checksum); } diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 599e11d467..7ec7cc933b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -11,7 +11,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_current.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/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -34,6 +36,7 @@ class UploadActionButton extends ConsumerWidget { List? assets; var isUploadDialogOpen = false; var wasUploadCancelled = false; + String? remoteAssetId; Future? uploadDialogFuture; if (source == ActionSource.timeline) { @@ -69,6 +72,9 @@ class UploadActionButton extends ConsumerWidget { .read(foregroundUploadServiceProvider) .uploadShareIntent( [File(viewerIntentFilePath)], + onSuccess: (_, uploadedRemoteAssetId) { + remoteAssetId = uploadedRemoteAssetId; + }, onError: (_, _) { hasError = true; }, @@ -80,12 +86,17 @@ class UploadActionButton extends ConsumerWidget { } else { final result = await ref.read(actionProvider.notifier).upload(source, assets: assets); success = result.success; + remoteAssetId = result.remoteAssetIds.isNotEmpty ? result.remoteAssetIds.first : null; } if (!isTimeline && context.mounted && isUploadDialogOpen) { Navigator.of(context, rootNavigator: true).pop(); } + if (!isTimeline && success && ref.read(viewIntentCurrentProvider) != null && remoteAssetId != null) { + await ref.read(viewIntentHandlerProvider).refreshCurrentAfterUpload(remoteAssetId: remoteAssetId!); + } + if (context.mounted && !success && !wasUploadCancelled) { ImmichToast.show( context: context, 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 fdedd15a0f..a60c115e28 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -389,13 +389,14 @@ class _AssetPageState extends ConsumerState { @override Widget build(BuildContext context) { - final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + final currentViewerAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); + final currentHeroTag = currentViewerAsset?.heroTag; _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); final timelineOrigin = ref.read(timelineServiceProvider).origin; - final asset = _asset; + final asset = timelineOrigin == TimelineOrigin.deepLink && currentViewerAsset != null ? currentViewerAsset : _asset; if (asset == null) { return const Center(child: ImmichLoadingIndicator()); } 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 c8d8f63fa9..2bdccfeee5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -239,7 +239,7 @@ class _AssetViewerState extends ConsumerState { if (index != _currentPage) { _pageController.jumpToPage(index); _onAssetChanged(index); - } else if (currentAsset != null && assetIndex == null) { + } else if (currentAsset != null && assetIndex == null && timelineService.origin != TimelineOrigin.deepLink) { _onAssetChanged(index); } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 6fdd9fc5c9..46652aaa11 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -609,6 +609,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/view_intent_current.provider.dart b/mobile/lib/providers/view_intent/view_intent_current.provider.dart new file mode 100644 index 0000000000..9a7af72873 --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_current.provider.dart @@ -0,0 +1,19 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/platform/view_intent_api.g.dart'; + +class ViewIntentCurrentNotifier extends Notifier { + @override + ViewIntentPayload? build() => null; + + void setPayload(ViewIntentPayload payload) { + state = payload; + } + + void clear() { + state = null; + } +} + +final viewIntentCurrentProvider = NotifierProvider( + ViewIntentCurrentNotifier.new, +); 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..3cc2cd12d8 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -12,6 +12,11 @@ abstract class ViewIntentHandler { Future flushDeferredViewIntent(); Future handle(ViewIntentPayload attachment); + + Future refreshCurrentAfterUpload({ + required String remoteAssetId, + Duration timeout = const Duration(seconds: 15), + }); } 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 c00ff38648..a376b5dd06 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -2,10 +2,13 @@ import 'dart:async'; 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/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/view_intent/view_intent_current.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'; @@ -18,6 +21,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { final Ref _ref; final ViewIntentService _viewIntentService; final ViewIntentAssetResolver _viewIntentAssetResolver; + final AssetService _assetService; final AppRouter _router; static final Logger _logger = Logger('ViewIntentHandler'); @@ -25,6 +29,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { : _ref = ref, _viewIntentService = ref.read(viewIntentServiceProvider), _viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider), + _assetService = ref.read(assetServiceProvider), _router = ref.watch(appRouterProvider); @override @@ -73,19 +78,69 @@ class AndroidViewIntentHandler implements ViewIntentHandler { final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); await _openAssetViewer( - resolvedAsset.asset, - resolvedAsset.timelineService, + asset: resolvedAsset.asset, + timelineService: resolvedAsset.timelineService, + attachment: attachment, viewIntentFilePath: resolvedAsset.viewIntentFilePath, ); } - Future _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async { + @override + Future refreshCurrentAfterUpload({ + required String remoteAssetId, + Duration timeout = const Duration(seconds: 15), + }) async { + final attachment = _ref.read(viewIntentCurrentProvider); + if (attachment == null) { + _logger.fine('refreshCurrentAfterUpload skipped: no current view intent'); + return; + } + + final uploadedAsset = await _waitForUploadedAsset(remoteAssetId: remoteAssetId, timeout: timeout); + if (uploadedAsset == null) { + _logger.warning('refreshCurrentAfterUpload timed out, remoteAssetId=$remoteAssetId'); + return; + } + + final viewerAsset = uploadedAsset.copyWith(localId: uploadedAsset.localId ?? attachment.localAssetId); + await _openAssetViewer( + asset: viewerAsset, + timelineService: _viewIntentAssetResolver.timelineFor(viewerAsset), + attachment: attachment, + ); + } + + Future _waitForUploadedAsset({required String remoteAssetId, required Duration timeout}) async { + RemoteAsset uploadedAsset; + try { + uploadedAsset = await _assetService + .watchRemoteAsset(remoteAssetId) + .where((asset) => asset != null) + .cast() + .first + .timeout(timeout); + } on TimeoutException { + final asset = await _assetService.getRemoteAsset(remoteAssetId); + _logger.warning('watchRemoteAsset timed out for $remoteAssetId; direct get result: $asset'); + return null; + } + + return uploadedAsset; + } + + 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) { notifier.setControls(false); } notifier.setAsset(asset); + _ref.read(viewIntentCurrentProvider.notifier).setPayload(attachment); if (viewIntentFilePath != null) { _ref.read(viewIntentFilePathProvider.notifier).setPath(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..57591fd945 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart @@ -15,4 +15,10 @@ class StubViewIntentHandler implements ViewIntentHandler { @override Future handle(ViewIntentPayload attachment) async {} + + @override + Future refreshCurrentAfterUpload({ + required String remoteAssetId, + Duration timeout = const Duration(seconds: 15), + }) async {} } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 7bda1bdc13..830372ef4e 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -1,10 +1,13 @@ 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'; @@ -20,16 +23,25 @@ class ViewIntentResolvedAsset { 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 { final localAssetId = attachment.localAssetId; @@ -40,21 +52,83 @@ 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, resolvedLocal.checksum); + if (remoteAsset != null) { + return ViewIntentResolvedAsset(asset: remoteAsset, timelineService: timelineFor(remoteAsset)); + } + } + + final asset = resolvedLocal.asset ?? _toTransientAsset(attachment, resolvedLocal.checksum); return ViewIntentResolvedAsset( 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) { + return _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink); + } + + Future<({LocalAsset? asset, String? checksum})> _resolveLocalAsset(String localAssetId) async { + final localAsset = await _localAssetRepository.getById(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}); + } + + return (asset: localAsset?.copyWith(checksum: checksum), 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, String? checksum) async { + if (checksum == null) { + return null; + } + + final remoteAsset = await _assetService.getRemoteAssetByChecksum(checksum); + if (remoteAsset == null) { + return null; + } + + final asset = remoteAsset.copyWith(localId: localAssetId); + _logger.fine('resolve matched remote asset by checksum: $checksum, asset=$asset'); + return asset; + } + + LocalAsset _toTransientAsset(ViewIntentPayload attachment, String? checksum) { final now = DateTime.now(); 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/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index f9c2c9d323..23245d9dff 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 @@ -4,12 +4,16 @@ import 'package:auto_route/auto_route.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'; +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/user.service.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/view_intent/view_intent_current.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/routing/router.dart'; @@ -25,6 +29,8 @@ class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {} class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {} +class MockAssetService extends Mock implements AssetService {} + class MockAppRouter extends Mock implements AppRouter {} class MockAuthService extends Mock implements AuthService {} @@ -93,6 +99,7 @@ void main() { late TestViewIntentService viewIntentService; late MockViewIntentAssetResolver resolver; + late MockAssetService assetService; late MockAppRouter router; late TestAuthNotifier authNotifier; late ProviderContainer container; @@ -105,6 +112,7 @@ void main() { registerFallbackValue(FakePageRouteInfo()); registerFallbackValue(>[]); registerFallbackValue(FakeTimelineService()); + registerFallbackValue(_remoteAsset(id: 'fallback-remote', localId: 'fallback-local')); registerFallbackValue( ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'), ); @@ -113,17 +121,20 @@ void main() { setUp(() async { viewIntentService = TestViewIntentService(); resolver = MockViewIntentAssetResolver(); + assetService = MockAssetService(); 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); container = ProviderContainer( overrides: [ viewIntentServiceProvider.overrideWithValue(viewIntentService), viewIntentAssetResolverProvider.overrideWithValue(resolver), + assetServiceProvider.overrideWithValue(assetService), appRouterProvider.overrideWithValue(router), authProvider.overrideWith((ref) { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); @@ -195,9 +206,9 @@ void main() { testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async { viewIntentService.consumedAttachment = payload; - when(() => resolver.resolve(payload)).thenAnswer( - (_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService), - ); + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); unawaited(handler.onAppResumed()); await tester.pump(); @@ -206,8 +217,6 @@ 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; expect(captured, hasLength(1)); final routes = captured.single as List>; @@ -215,6 +224,105 @@ void main() { expect(routes[0].routeName, TabShellRoute.name); expect(routes[1].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 => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + when( + () => resolver.resolve(secondPayload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); + + await handler.handle(payload); + expect(container.read(assetViewerProvider).currentAsset, deepLinkAsset); + expect(container.read(viewIntentCurrentProvider), payload); + + await handler.handle(secondPayload); + + expect(container.read(assetViewerProvider).currentAsset, secondAsset); + expect(container.read(viewIntentCurrentProvider), secondPayload); + verify(() => resolver.resolve(payload)).called(1); + verify(() => resolver.resolve(secondPayload)).called(1); + verify(() => router.replaceAll(any())).called(2); + verifyNever(() => router.replace(any())); + }); + + test('refreshCurrentAfterUpload waits until the current asset becomes remote-backed', () async { + final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); + final remoteTimelineService = await _createReadyTimelineService([remoteAsset], TimelineOrigin.deepLink); + addTearDown(() async => remoteTimelineService.dispose()); + + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => Stream.value(remoteAsset)); + when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); + + await handler.handle(payload); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1'); + + expect(container.read(assetViewerProvider).currentAsset, remoteAsset); + verify(() => resolver.resolve(payload)).called(1); + verify(() => assetService.watchRemoteAsset('remote-1')).called(1); + verify(() => router.replaceAll(any())).called(2); + verifyNever(() => router.replace(any())); + }); + + test('refreshCurrentAfterUpload uses attachment localAssetId when watched remote asset is remote-only', () async { + final remoteAsset = _remoteAsset(id: 'remote-1', localId: null); + final viewerAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); + final remoteTimelineService = _timelineServiceFromAssets([viewerAsset], TimelineOrigin.deepLink); + addTearDown(() async => remoteTimelineService.dispose()); + + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + final remoteAssetController = StreamController(); + addTearDown(remoteAssetController.close); + + when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => remoteAssetController.stream); + when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); + + await handler.handle(payload); + final refresh = handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1'); + remoteAssetController.add(null); + await Future.delayed(Duration.zero); + remoteAssetController.add(remoteAsset); + await refresh; + + expect(container.read(assetViewerProvider).currentAsset, viewerAsset); + verify(() => assetService.watchRemoteAsset('remote-1')).called(1); + verify(() => router.replaceAll(any())).called(2); + verifyNever(() => router.replace(any())); + }); + + test('refreshCurrentAfterUpload watches only the uploaded remote asset stream', () async { + final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); + final remoteTimelineService = _timelineServiceFromAssets([remoteAsset], TimelineOrigin.deepLink); + addTearDown(() async => remoteTimelineService.dispose()); + + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => Stream.value(remoteAsset)); + when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); + + await handler.handle(payload); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1'); + + expect(container.read(assetViewerProvider).currentAsset, remoteAsset); + verify(() => assetService.watchRemoteAsset('remote-1')).called(1); + verifyNever(() => assetService.watchRemoteAsset('remote-2')); + }); } AuthState _authState({required bool isAuthenticated}) { @@ -229,15 +337,30 @@ 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}) { + 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, 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..ba4801585f 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,17 +18,34 @@ 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(() => assetService.getRemoteAssetByChecksum(any())).thenAnswer((_) async => null); + 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); @@ -36,6 +56,8 @@ void main() { container = ProviderContainer( overrides: [ localAssetRepository.overrideWith((ref) => mockLocalAssetRepository), + assetServiceProvider.overrideWithValue(assetService), + nativeSyncApiProvider.overrideWithValue(nativeSyncApi), timelineFactoryProvider.overrideWith((ref) => timelineFactory), ], ); @@ -59,6 +81,39 @@ 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'); + final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); + 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.deepLink); + expect(result.viewIntentFilePath, isNull); + verifyNever(() => nativeSyncApi.hashAssets(any())); + }); + + 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'); + when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); + when( + () => nativeSyncApi.hashAssets(['local-1']), + ).thenAnswer((_) async => [HashResult(assetId: 'local-1', hash: 'checksum-1')]); + 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.deepLink); + expect(result.viewIntentFilePath, isNull); + verify(() => nativeSyncApi.hashAssets(['local-1'])).called(1); + }); + test('returns transient asset with temp file path when localAssetId has no DB row', () async { when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null); @@ -86,10 +141,7 @@ 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())); }); } @@ -114,6 +166,20 @@ LocalAsset _localAsset({required String id, String? checksum}) { ); } +RemoteAsset _remoteAsset({required String id, String? localId, required String checksum}) { + 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, + ); +} + TimelineService _timelineServiceFromAssets(List assets, TimelineOrigin origin) { return TimelineService(( assetSource: (index, count) async => assets.skip(index).take(count).toList(), From 6cc8cdfa3e9b24123d444f1aaccf61395b7727d3 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 4 Jun 2026 18:45:23 +0300 Subject: [PATCH 02/25] fix(mobile): prevent stale asset viewer state for view intents --- .../asset_viewer/asset_viewer.page.dart | 26 ++++++++++++++++++- .../view_intent_handler_android.dart | 8 +++--- mobile/lib/utils/action_button.utils.dart | 1 + 3 files changed, 29 insertions(+), 6 deletions(-) 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 2bdccfeee5..5cc851970e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -23,6 +23,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/view_intent_current.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @RoutePage() @@ -89,6 +90,7 @@ class _AssetViewerState extends ConsumerState { StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; + bool _isDisposed = false; void _onTapNavigate(int direction) { final page = _pageController.page?.toInt(); @@ -123,6 +125,7 @@ class _AssetViewerState extends ConsumerState { @override void dispose() { + _isDisposed = true; _pageController.dispose(); _preloader.dispose(); _reloadSubscription?.cancel(); @@ -203,6 +206,10 @@ class _AssetViewerState extends ConsumerState { } void _onEvent(Event event) { + if (_isDisposed || !mounted) { + return; + } + switch (event) { case TimelineReloadEvent(): _onTimelineReloadEvent(); @@ -224,6 +231,10 @@ class _AssetViewerState extends ConsumerState { } void _onTimelineReloadEvent() { + if (_isDisposed || !mounted) { + return; + } + final timelineService = ref.read(timelineServiceProvider); final totalAssets = timelineService.totalAssets; @@ -239,7 +250,9 @@ class _AssetViewerState extends ConsumerState { if (index != _currentPage) { _pageController.jumpToPage(index); _onAssetChanged(index); - } else if (currentAsset != null && assetIndex == null && timelineService.origin != TimelineOrigin.deepLink) { + } else if (currentAsset != null && + assetIndex == null && + !_shouldIgnoreMissingAssetOnTimelineReload(currentAsset, timelineService)) { _onAssetChanged(index); } @@ -250,6 +263,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(viewIntentCurrentProvider)?.localAssetId; + return localAssetId != null && currentAsset.localId == localAssetId; + } + void _setSystemUIMode(bool controls, bool details) { final mode = !controls || (CurrentPlatform.isIOS && details) ? SystemUiMode.immersiveSticky 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 a376b5dd06..de60607489 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -139,8 +139,8 @@ class AndroidViewIntentHandler implements ViewIntentHandler { if (asset.isVideo) { notifier.setControls(false); } - notifier.setAsset(asset); _ref.read(viewIntentCurrentProvider.notifier).setPayload(attachment); + notifier.setAsset(asset); if (viewIntentFilePath != null) { _ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath); @@ -150,9 +150,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { unawaited(_viewIntentService.cleanupManagedTempFile()); } - await _router.replaceAll([ - const TabShellRoute(), - AssetViewerRoute(initialIndex: 0, timelineService: timelineService), - ]); + _router.popUntilRoot(); + await _router.push(AssetViewerRoute(initialIndex: 0, timelineService: timelineService)); } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index b9cff613fd..0388464910 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -161,6 +161,7 @@ enum ActionButtonType { ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && + context.timelineOrigin != TimelineOrigin.deepLink && context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, From 166be0c89e674dd40b81357069f53e2b6dcb8951 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 5 Jun 2026 11:01:05 +0300 Subject: [PATCH 03/25] fix(mobile): fix tests --- mobile/lib/services/view_intent.service.dart | 28 +++++++++++-------- .../view_intent_handler_android_test.dart | 22 +++++++++------ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart index 22a3407e5a..93a82d49db 100644 --- a/mobile/lib/services/view_intent.service.dart +++ b/mobile/lib/services/view_intent.service.dart @@ -26,11 +26,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 +46,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); if (await file.exists()) { await file.delete(); } @@ -77,7 +80,7 @@ class ViewIntentService { continue; } - final path = entity.path; + final path = p.normalize(entity.path); if (!_isManagedTempFile(path) || path == _managedTempFilePath || _activeUploadPaths.contains(path)) { continue; } @@ -90,15 +93,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/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index 23245d9dff..a54f45ddd6 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 @@ -129,6 +129,7 @@ void main() { when(() => router.replaceAll(any())).thenAnswer((_) async {}); when(() => router.replace(any())).thenAnswer((_) async => null); + when(() => router.push(any())).thenAnswer((_) async => null); container = ProviderContainer( overrides: [ @@ -217,12 +218,11 @@ void main() { await tester.idle(); verify(() => resolver.resolve(payload)).called(1); - 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 { @@ -252,8 +252,10 @@ void main() { expect(container.read(viewIntentCurrentProvider), secondPayload); verify(() => resolver.resolve(payload)).called(1); verify(() => resolver.resolve(secondPayload)).called(1); - verify(() => router.replaceAll(any())).called(2); + verify(() => router.popUntilRoot()).called(2); + verify(() => router.push(any())).called(2); verifyNever(() => router.replace(any())); + verifyNever(() => router.replaceAll(any())); }); test('refreshCurrentAfterUpload waits until the current asset becomes remote-backed', () async { @@ -273,8 +275,10 @@ void main() { expect(container.read(assetViewerProvider).currentAsset, remoteAsset); verify(() => resolver.resolve(payload)).called(1); verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - verify(() => router.replaceAll(any())).called(2); + verify(() => router.popUntilRoot()).called(2); + verify(() => router.push(any())).called(2); verifyNever(() => router.replace(any())); + verifyNever(() => router.replaceAll(any())); }); test('refreshCurrentAfterUpload uses attachment localAssetId when watched remote asset is remote-only', () async { @@ -301,8 +305,10 @@ void main() { expect(container.read(assetViewerProvider).currentAsset, viewerAsset); verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - verify(() => router.replaceAll(any())).called(2); + verify(() => router.popUntilRoot()).called(2); + verify(() => router.push(any())).called(2); verifyNever(() => router.replace(any())); + verifyNever(() => router.replaceAll(any())); }); test('refreshCurrentAfterUpload watches only the uploaded remote asset stream', () async { From c5ec832422c0df75535c76f7eae4385d35fb5aec Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 5 Jun 2026 12:18:43 +0300 Subject: [PATCH 04/25] fix(mobile): fix return value --- .../view_intent_handler_android.dart | 2 +- .../view_intent_handler_android_test.dart | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 de60607489..137c50af23 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -122,7 +122,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { } on TimeoutException { final asset = await _assetService.getRemoteAsset(remoteAssetId); _logger.warning('watchRemoteAsset timed out for $remoteAssetId; direct get result: $asset'); - return null; + return asset; } return uploadedAsset; 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 a54f45ddd6..647ea18355 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 @@ -311,6 +311,33 @@ void main() { verifyNever(() => router.replaceAll(any())); }); + test('refreshCurrentAfterUpload falls back to direct get when watch times out', () async { + final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); + final remoteTimelineService = _timelineServiceFromAssets([remoteAsset], TimelineOrigin.deepLink); + addTearDown(() async => remoteTimelineService.dispose()); + + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + final remoteAssetController = StreamController(); + addTearDown(remoteAssetController.close); + + when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => remoteAssetController.stream); + when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset); + when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); + + await handler.handle(payload); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', timeout: Duration.zero); + + expect(container.read(assetViewerProvider).currentAsset, remoteAsset); + verify(() => assetService.watchRemoteAsset('remote-1')).called(1); + verify(() => assetService.getRemoteAsset('remote-1')).called(1); + verify(() => router.popUntilRoot()).called(2); + verify(() => router.push(any())).called(2); + verifyNever(() => router.replace(any())); + verifyNever(() => router.replaceAll(any())); + }); + test('refreshCurrentAfterUpload watches only the uploaded remote asset stream', () async { final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); final remoteTimelineService = _timelineServiceFromAssets([remoteAsset], TimelineOrigin.deepLink); From 0e20ad6ac7c4734bf831f456441b551560ff9f1d Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 5 Jun 2026 14:46:07 +0300 Subject: [PATCH 05/25] fix(mobile): notify asset watchers after websocket upload sync --- mobile/lib/providers/websocket.provider.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 8d9bd5bfe3..de62e5ab9b 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -7,6 +8,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -197,6 +199,7 @@ class WebsocketNotifier extends StateNotifier { try { unawaited( _ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) { + _notifyAssetUploadReadyProcessed(); if (isSyncAlbumEnabled) { _ref.read(backgroundSyncProvider).syncLinkedAlbum(); } @@ -218,6 +221,7 @@ class WebsocketNotifier extends StateNotifier { try { unawaited( _ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) { + _notifyAssetUploadReadyProcessed(); if (isSyncAlbumEnabled) { _ref.read(backgroundSyncProvider).syncLinkedAlbum(); } @@ -229,6 +233,11 @@ class WebsocketNotifier extends StateNotifier { _batchedAssetUploadReady.clear(); } + + void _notifyAssetUploadReadyProcessed() { + final db = _ref.read(driftProvider); + db.notifyUpdates({TableUpdate.onTable(db.remoteAssetEntity), TableUpdate.onTable(db.remoteExifEntity)}); + } } final websocketProvider = StateNotifierProvider((ref) { From ed99906d9efa710b466be2d97fc37d02dc580296 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 5 Jun 2026 16:44:28 +0300 Subject: [PATCH 06/25] fix(mobile): guard view intent upload handoff --- .../upload_action_button.widget.dart | 14 +++- .../view_intent_handler.provider.dart | 1 + .../view_intent_handler_android.dart | 12 +++- .../view_intent/view_intent_handler_stub.dart | 1 + .../view_intent_handler_android_test.dart | 69 +++++++++++++++++-- 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index 7ec7cc933b..0dc019c08b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -6,10 +6,12 @@ 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/services/timeline.service.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/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_current.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart'; @@ -32,6 +34,9 @@ class UploadActionButton extends ConsumerWidget { } final isTimeline = source == ActionSource.timeline; + final isViewIntentUpload = + source == ActionSource.viewer && ref.read(timelineServiceProvider).origin == TimelineOrigin.deepLink; + final viewIntentPayload = isViewIntentUpload ? ref.read(viewIntentCurrentProvider) : null; final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null; List? assets; var isUploadDialogOpen = false; @@ -93,8 +98,13 @@ class UploadActionButton extends ConsumerWidget { Navigator.of(context, rootNavigator: true).pop(); } - if (!isTimeline && success && ref.read(viewIntentCurrentProvider) != null && remoteAssetId != null) { - await ref.read(viewIntentHandlerProvider).refreshCurrentAfterUpload(remoteAssetId: remoteAssetId!); + if (success && + viewIntentPayload != null && + ref.read(viewIntentCurrentProvider) == viewIntentPayload && + remoteAssetId != null) { + await ref + .read(viewIntentHandlerProvider) + .refreshCurrentAfterUpload(remoteAssetId: remoteAssetId!, attachment: viewIntentPayload); } if (context.mounted && !success && !wasUploadCancelled) { 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 3cc2cd12d8..663b8e03a8 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -15,6 +15,7 @@ abstract class ViewIntentHandler { Future refreshCurrentAfterUpload({ required String remoteAssetId, + required ViewIntentPayload attachment, Duration timeout = const Duration(seconds: 15), }); } 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 137c50af23..efa1975737 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -88,11 +88,13 @@ class AndroidViewIntentHandler implements ViewIntentHandler { @override Future refreshCurrentAfterUpload({ required String remoteAssetId, + required ViewIntentPayload attachment, Duration timeout = const Duration(seconds: 15), }) async { - final attachment = _ref.read(viewIntentCurrentProvider); - if (attachment == null) { - _logger.fine('refreshCurrentAfterUpload skipped: no current view intent'); + bool isCurrentAttachment() => _ref.read(viewIntentCurrentProvider) == attachment; + + if (!isCurrentAttachment()) { + _logger.fine('refreshCurrentAfterUpload skipped: view intent changed'); return; } @@ -101,6 +103,10 @@ class AndroidViewIntentHandler implements ViewIntentHandler { _logger.warning('refreshCurrentAfterUpload timed out, remoteAssetId=$remoteAssetId'); return; } + if (!isCurrentAttachment()) { + _logger.fine('refreshCurrentAfterUpload skipped: view intent changed'); + return; + } final viewerAsset = uploadedAsset.copyWith(localId: uploadedAsset.localId ?? attachment.localAssetId); await _openAssetViewer( 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 57591fd945..ff58edf1a5 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart @@ -19,6 +19,7 @@ class StubViewIntentHandler implements ViewIntentHandler { @override Future refreshCurrentAfterUpload({ required String remoteAssetId, + required ViewIntentPayload attachment, Duration timeout = const Duration(seconds: 15), }) async {} } 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 647ea18355..9bf3fec400 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 @@ -270,7 +270,7 @@ void main() { when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); await handler.handle(payload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1'); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); expect(container.read(assetViewerProvider).currentAsset, remoteAsset); verify(() => resolver.resolve(payload)).called(1); @@ -297,7 +297,7 @@ void main() { when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); await handler.handle(payload); - final refresh = handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1'); + final refresh = handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); remoteAssetController.add(null); await Future.delayed(Duration.zero); remoteAssetController.add(remoteAsset); @@ -327,7 +327,7 @@ void main() { when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); await handler.handle(payload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', timeout: Duration.zero); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload, timeout: Duration.zero); expect(container.read(assetViewerProvider).currentAsset, remoteAsset); verify(() => assetService.watchRemoteAsset('remote-1')).called(1); @@ -350,12 +350,73 @@ void main() { when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); await handler.handle(payload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1'); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); expect(container.read(assetViewerProvider).currentAsset, remoteAsset); verify(() => assetService.watchRemoteAsset('remote-1')).called(1); verifyNever(() => assetService.watchRemoteAsset('remote-2')); }); + + test('refreshCurrentAfterUpload skips when another view intent became current', () 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 => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + when( + () => resolver.resolve(secondPayload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); + when( + () => assetService.watchRemoteAsset('remote-1'), + ).thenAnswer((_) => Stream.value(_remoteAsset(id: 'remote-1', localId: 'local-1'))); + + await handler.handle(payload); + await handler.handle(secondPayload); + await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); + + expect(container.read(assetViewerProvider).currentAsset, secondAsset); + verifyNever(() => assetService.watchRemoteAsset('remote-1')); + }); + + test('refreshCurrentAfterUpload skips when view intent changes while waiting for upload', () 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()); + final remoteAssetController = StreamController(); + addTearDown(remoteAssetController.close); + + when( + () => resolver.resolve(payload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + when( + () => resolver.resolve(secondPayload), + ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); + when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => remoteAssetController.stream); + + await handler.handle(payload); + final refresh = handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); + remoteAssetController.add(null); + await Future.delayed(Duration.zero); + + await handler.handle(secondPayload); + remoteAssetController.add(_remoteAsset(id: 'remote-1', localId: 'local-1')); + await refresh; + + expect(container.read(assetViewerProvider).currentAsset, secondAsset); + verify(() => assetService.watchRemoteAsset('remote-1')).called(1); + }); } AuthState _authState({required bool isAuthenticated}) { From 9943b3d2ecb9cf0f83529516dd838c4715a1bbf9 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 11 Jun 2026 11:30:03 +0300 Subject: [PATCH 07/25] fix(mobile): remove redundant websocket watcher notification after upstream fix --- mobile/lib/providers/websocket.provider.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index de62e5ab9b..8d9bd5bfe3 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -8,7 +7,6 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -199,7 +197,6 @@ class WebsocketNotifier extends StateNotifier { try { unawaited( _ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) { - _notifyAssetUploadReadyProcessed(); if (isSyncAlbumEnabled) { _ref.read(backgroundSyncProvider).syncLinkedAlbum(); } @@ -221,7 +218,6 @@ class WebsocketNotifier extends StateNotifier { try { unawaited( _ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) { - _notifyAssetUploadReadyProcessed(); if (isSyncAlbumEnabled) { _ref.read(backgroundSyncProvider).syncLinkedAlbum(); } @@ -233,11 +229,6 @@ class WebsocketNotifier extends StateNotifier { _batchedAssetUploadReady.clear(); } - - void _notifyAssetUploadReadyProcessed() { - final db = _ref.read(driftProvider); - db.notifyUpdates({TableUpdate.onTable(db.remoteAssetEntity), TableUpdate.onTable(db.remoteExifEntity)}); - } } final websocketProvider = StateNotifierProvider((ref) { From e662dfcd287e9e2c2dfeaccaf70af6521b7b08f4 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 11 Jun 2026 13:19:20 +0300 Subject: [PATCH 08/25] 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(), From de60619eca0ead5629b9d12c989072156019e593 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 11 Jun 2026 13:44:19 +0300 Subject: [PATCH 09/25] fix(mobile): handle trashed view intent delete --- .../delete_permanent_action_button.widget.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 267a9f55e6..1ba44e013b 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -1,12 +1,15 @@ +import 'package:auto_route/auto_route.dart'; 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/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/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -44,7 +47,11 @@ class DeletePermanentActionButton extends ConsumerWidget { return; } - if (source == ActionSource.viewer) { + final isViewer = source == ActionSource.viewer; + final isViewIntentTrashDelete = + isViewer && ref.read(timelineServiceProvider).origin == TimelineOrigin.deepLinkTrash; + + if (isViewer && !isViewIntentTrashDelete) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } @@ -63,6 +70,10 @@ class DeletePermanentActionButton extends ConsumerWidget { gravity: ToastGravity.BOTTOM, toastType: result.success ? ToastType.success : ToastType.error, ); + + if (result.success && isViewIntentTrashDelete) { + await context.maybePop(); + } } } From 1903b49fdcc9b90325b07844f73b6bd24ccd25d0 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 25 Jun 2026 12:34:16 +0300 Subject: [PATCH 10/25] fix(mobile): resolve merge conflicts --- .../lib/providers/view_intent/view_intent_handler_android.dart | 1 - .../providers/view_intent/view_intent_handler_android_test.dart | 2 -- 2 files changed, 3 deletions(-) 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 5abee2e5e2..efa1975737 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/material.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/asset.service.dart'; 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 1c965db68a..55cea9c07a 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 @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.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'; -import 'package:immich_mobile/domain/services/asset.service.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'; @@ -15,7 +14,6 @@ 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/view_intent/view_intent_current.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.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/routing/router.dart'; From 48079a0f6fc7562181179dbff8d3a90ee532ae37 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Wed, 1 Jul 2026 11:53:23 +0300 Subject: [PATCH 11/25] fix(mobile): fix test setup --- .../view_intent/view_intent_handler_android_test.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 55cea9c07a..42f98cc56d 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 @@ -47,11 +47,6 @@ 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; int cleanupStaleTempFilesCalls = 0; @@ -135,6 +130,7 @@ void main() { 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: [ @@ -146,7 +142,6 @@ void main() { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); return authNotifier; }), - assetServiceProvider.overrideWithValue(FakeAssetService()), ], ); From bc6dc313453c528dec26a59a72750325b3cfa095 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 2 Jul 2026 10:43:31 +0300 Subject: [PATCH 12/25] fix(mobile): fix repo name --- mobile/lib/domain/services/asset.service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 304fcf7fc3..d5b14eb6e7 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -23,7 +23,7 @@ class AssetService { } Stream watchRemoteAsset(String id) { - return _remoteAssetRepository.watch(id); + return _remoteRepository.watch(id); } Future> getLocalAssetsByChecksum(String checksum) { From 933d2bfe4cb0d0caf9568416eff3e9cfa9d67318 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Mon, 3 Aug 2026 16:50:57 +0300 Subject: [PATCH 13/25] fix(mobile): resolve merge conflicts resolve view intent assets by remote ID --- .../lib/domain/services/timeline.service.dart | 4 +++ .../asset_viewer/asset_page.widget.dart | 2 +- .../asset_viewer/asset_viewer.page.dart | 2 +- .../asset_viewer/bottom_bar.widget.dart | 4 ++- .../view_intent_asset_resolver.service.dart | 16 +++++---- mobile/lib/utils/action_button.utils.dart | 10 +++--- .../view_intent_asset_resolver_test.dart | 33 ++++++++++++------- 7 files changed, 44 insertions(+), 27 deletions(-) diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 70d9e6d3c5..daaa029ebf 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -42,6 +42,10 @@ extension TimelineOriginX on TimelineOrigin { bool get isDeepLink => this == TimelineOrigin.deepLink || this == TimelineOrigin.deepLinkTrash; bool get isTrash => this == TimelineOrigin.trash || this == TimelineOrigin.deepLinkTrash; + + bool get isNotDeepLink => !isDeepLink; + + bool get isNotTrash => !isTrash; } class TimelineFactory { 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 97d34c6fb6..ce7b75a6ff 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -410,7 +410,7 @@ class _AssetPageState extends ConsumerState { } BaseAsset displayAsset = asset; - final showAssetStack = ref.watch(timelineServiceProvider.select((s) => !s.origin.isTrash)); + final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin.isNotTrash)); 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_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index b3f6d0c86e..b354ba8fae 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -312,7 +312,7 @@ class _AssetViewerState extends ConsumerState { return localAssetId != null && currentAsset.localId == localAssetId; } - void _setSystemUIMode(bool controls, bool details) { + 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 3ed2b3a576..c7761fbf00 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -30,6 +30,8 @@ class ViewerBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + + debugPrint('ViewerBottomBar,ViewerBottomBar ViewerBottomBar'); final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); @@ -38,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.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 98f4dfe2c3..252bbd950a 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -55,7 +55,7 @@ class ViewIntentAssetResolver { ({LocalAsset? asset, String? checksum}) resolvedLocal = (asset: null, checksum: null); if (localAssetId != null) { resolvedLocal = await _resolveLocalAsset(localAssetId); - final remoteAsset = await _resolveRemoteAsset(localAssetId, resolvedLocal.checksum); + final remoteAsset = await _resolveRemoteAsset(localAssetId, resolvedLocal.asset?.remoteId); if (remoteAsset != null) { return ViewIntentResolvedAsset(asset: remoteAsset, timelineService: timelineFor(remoteAsset)); } @@ -76,7 +76,7 @@ class ViewIntentAssetResolver { } Future<({LocalAsset? asset, String? checksum})> _resolveLocalAsset(String localAssetId) async { - final localAsset = await _localAssetRepository.getById(localAssetId); + final localAsset = await _localAssetRepository.get(localAssetId); final checksum = localAsset?.checksum ?? await _hashLocalAsset(localAssetId); if (checksum == null || checksum == localAsset?.checksum) { @@ -85,9 +85,11 @@ class ViewIntentAssetResolver { 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: localAsset?.copyWith(checksum: checksum), checksum: checksum); + return (asset: null, checksum: checksum); } Future _hashLocalAsset(String localAssetId) async { @@ -109,18 +111,18 @@ class ViewIntentAssetResolver { } } - Future _resolveRemoteAsset(String localAssetId, String? checksum) async { - if (checksum == null) { + Future _resolveRemoteAsset(String localAssetId, String? remoteAssetId) async { + if (remoteAssetId == null) { return null; } - final remoteAsset = await _assetService.getRemoteAssetByChecksum(checksum); + final remoteAsset = await _assetService.getRemoteAsset(remoteAssetId); if (remoteAsset == null) { return null; } final asset = remoteAsset.copyWith(localId: localAssetId); - _logger.fine('resolve matched remote asset by checksum: $checksum, asset=$asset'); + _logger.fine('resolve matched remote asset by id: $remoteAssetId, asset=$asset'); return asset; } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index ad37555ded..05c25ec875 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.timelineOrigin.isTrash, ActionButtonType.delete => true, ActionButtonType.moveToLockFolder => context.isOwner && // @@ -135,13 +135,13 @@ enum ActionButtonType { context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // - context.timelineOrigin != TimelineOrigin.trash && + context.timelineOrigin.isNotTrash && !context.isInLockedView && // context.isStacked, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && - !context.timelineOrigin.isDeepLink && + context.timelineOrigin.isNotDeepLink && context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, @@ -155,8 +155,8 @@ enum ActionButtonType { ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && - context.timelineOrigin != TimelineOrigin.deepLink && - context.timelineOrigin != TimelineOrigin.trash && + context.timelineOrigin.isNotDeepLink && + context.timelineOrigin.isNotTrash && 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 7eb3abf956..544692fb13 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -42,7 +42,8 @@ void main() { timelineFactory = MockTimelineFactory(); createdTimelineServices = []; - when(() => assetService.getRemoteAssetByChecksum(any())).thenAnswer((_) async => null); + when(() => mockLocalAssetRepository.get(any())).thenAnswer((_) async => null); + when(() => assetService.getRemoteAsset(any())).thenAnswer((_) async => null); when(() => nativeSyncApi.hashAssets(any())).thenAnswer((_) async => const []); when(() => mockLocalAssetRepository.updateHashes(any())).thenAnswer((_) async {}); @@ -68,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')); @@ -78,10 +79,10 @@ void main() { }); test('returns remote merged asset when local checksum matches remote asset', () async { - final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1'); + final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1', remoteId: 'remote-1'); final remoteAsset = _remoteAsset(id: 'remote-1', checksum: 'checksum-1'); - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); - when(() => assetService.getRemoteAssetByChecksum('checksum-1')).thenAnswer((_) async => remoteAsset); + 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')); @@ -93,10 +94,10 @@ void main() { }); test('returns remote trashed asset in a 1-element deep-link trash timeline', () async { - final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1'); + 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.getById('local-1')).thenAnswer((_) async => localAsset); - when(() => assetService.getRemoteAssetByChecksum('checksum-1')).thenAnswer((_) async => remoteAsset); + 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')); @@ -108,12 +109,17 @@ void main() { 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'); - when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset); + 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.getRemoteAssetByChecksum('checksum-1')).thenAnswer((_) async => remoteAsset); + when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset); final result = await _resolve(container, _payload(localAssetId: 'local-1')); @@ -122,10 +128,12 @@ void main() { 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')); @@ -163,9 +171,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, From 49d74e498ef8e1d4ddfd9ceb84059a546fcd7ad3 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Mon, 3 Aug 2026 17:13:22 +0300 Subject: [PATCH 14/25] fix(mobile): cleanup code --- .../presentation/widgets/asset_viewer/bottom_bar.widget.dart | 2 -- 1 file changed, 2 deletions(-) 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 c7761fbf00..3da35c18ea 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -30,8 +30,6 @@ class ViewerBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - - debugPrint('ViewerBottomBar,ViewerBottomBar ViewerBottomBar'); final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); From 849e4472ac9da6924fc81461d454e9343e5ecec7 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Mon, 3 Aug 2026 19:48:32 +0300 Subject: [PATCH 15/25] fix(mobile): switch asset viewer to remote asset after upload --- mobile/lib/domain/services/asset.service.dart | 17 +- .../asset_viewer/asset_viewer.page.dart | 3 +- .../view_intent_handler.provider.dart | 6 - .../view_intent_handler_android.dart | 53 ------ .../view_intent/view_intent_handler_stub.dart | 7 - .../view_intent_asset_resolver.service.dart | 6 +- .../view_intent_handler_android_test.dart | 160 ------------------ .../unit/services/asset_service_test.dart | 39 ++++- 8 files changed, 55 insertions(+), 236 deletions(-) diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index b16bfa2e07..1f7f77df18 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,12 +38,18 @@ 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); + } - Stream watchRemoteAsset(String id) { - return _remoteRepository.watch(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); + }); } Future> getLocalAssetsByChecksum(String checksum) { 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 b354ba8fae..d7b48cf0dd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -283,7 +283,8 @@ class _AssetViewerState extends ConsumerState { if (index != _currentPage) { _pageController.jumpToPage(index); unawaited(_onAssetChanged(index)); - } else if (currentAsset is RemoteAsset && currentAsset.stackId != null && + } else if (currentAsset is RemoteAsset && + currentAsset.stackId != null && assetIndex == null && !_shouldIgnoreMissingAssetOnTimelineReload(currentAsset, timelineService)) { final timelineAsset = timelineService.getAssetSafe(index); 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 663b8e03a8..b266887cab 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -12,12 +12,6 @@ abstract class ViewIntentHandler { Future flushDeferredViewIntent(); Future handle(ViewIntentPayload attachment); - - Future refreshCurrentAfterUpload({ - required String remoteAssetId, - required ViewIntentPayload attachment, - Duration timeout = const Duration(seconds: 15), - }); } 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 efa1975737..515097999d 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -2,12 +2,10 @@ import 'dart:async'; 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/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/view_intent/view_intent_current.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'; @@ -21,7 +19,6 @@ class AndroidViewIntentHandler implements ViewIntentHandler { final Ref _ref; final ViewIntentService _viewIntentService; final ViewIntentAssetResolver _viewIntentAssetResolver; - final AssetService _assetService; final AppRouter _router; static final Logger _logger = Logger('ViewIntentHandler'); @@ -29,7 +26,6 @@ class AndroidViewIntentHandler implements ViewIntentHandler { : _ref = ref, _viewIntentService = ref.read(viewIntentServiceProvider), _viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider), - _assetService = ref.read(assetServiceProvider), _router = ref.watch(appRouterProvider); @override @@ -85,55 +81,6 @@ class AndroidViewIntentHandler implements ViewIntentHandler { ); } - @override - Future refreshCurrentAfterUpload({ - required String remoteAssetId, - required ViewIntentPayload attachment, - Duration timeout = const Duration(seconds: 15), - }) async { - bool isCurrentAttachment() => _ref.read(viewIntentCurrentProvider) == attachment; - - if (!isCurrentAttachment()) { - _logger.fine('refreshCurrentAfterUpload skipped: view intent changed'); - return; - } - - final uploadedAsset = await _waitForUploadedAsset(remoteAssetId: remoteAssetId, timeout: timeout); - if (uploadedAsset == null) { - _logger.warning('refreshCurrentAfterUpload timed out, remoteAssetId=$remoteAssetId'); - return; - } - if (!isCurrentAttachment()) { - _logger.fine('refreshCurrentAfterUpload skipped: view intent changed'); - return; - } - - final viewerAsset = uploadedAsset.copyWith(localId: uploadedAsset.localId ?? attachment.localAssetId); - await _openAssetViewer( - asset: viewerAsset, - timelineService: _viewIntentAssetResolver.timelineFor(viewerAsset), - attachment: attachment, - ); - } - - Future _waitForUploadedAsset({required String remoteAssetId, required Duration timeout}) async { - RemoteAsset uploadedAsset; - try { - uploadedAsset = await _assetService - .watchRemoteAsset(remoteAssetId) - .where((asset) => asset != null) - .cast() - .first - .timeout(timeout); - } on TimeoutException { - final asset = await _assetService.getRemoteAsset(remoteAssetId); - _logger.warning('watchRemoteAsset timed out for $remoteAssetId; direct get result: $asset'); - return asset; - } - - return uploadedAsset; - } - Future _openAssetViewer({ required BaseAsset asset, required TimelineService timelineService, 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 ff58edf1a5..ebc6d7425b 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart @@ -15,11 +15,4 @@ class StubViewIntentHandler implements ViewIntentHandler { @override Future handle(ViewIntentPayload attachment) async {} - - @override - Future refreshCurrentAfterUpload({ - required String remoteAssetId, - required ViewIntentPayload attachment, - Duration timeout = const Duration(seconds: 15), - }) async {} } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 252bbd950a..fe5cb78f6f 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -57,7 +57,7 @@ class ViewIntentAssetResolver { resolvedLocal = await _resolveLocalAsset(localAssetId); final remoteAsset = await _resolveRemoteAsset(localAssetId, resolvedLocal.asset?.remoteId); if (remoteAsset != null) { - return ViewIntentResolvedAsset(asset: remoteAsset, timelineService: timelineFor(remoteAsset)); + return ViewIntentResolvedAsset(asset: remoteAsset, timelineService: _timelineFor(remoteAsset)); } } @@ -65,12 +65,12 @@ class ViewIntentAssetResolver { return ViewIntentResolvedAsset( asset: asset, - timelineService: timelineFor(asset), + timelineService: _timelineFor(asset), viewIntentFilePath: resolvedLocal.asset == null ? path : null, ); } - TimelineService timelineFor(BaseAsset asset) { + TimelineService _timelineFor(BaseAsset asset) { final origin = asset is RemoteAsset && asset.isTrashed ? TimelineOrigin.deepLinkTrash : TimelineOrigin.deepLink; return _timelineFactory.fromAssets([asset], origin); } 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 42f98cc56d..a1672ff232 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 @@ -258,166 +258,6 @@ void main() { verifyNever(() => router.replace(any())); verifyNever(() => router.replaceAll(any())); }); - - test('refreshCurrentAfterUpload waits until the current asset becomes remote-backed', () async { - final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); - final remoteTimelineService = await _createReadyTimelineService([remoteAsset], TimelineOrigin.deepLink); - addTearDown(() async => remoteTimelineService.dispose()); - - when( - () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); - when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => Stream.value(remoteAsset)); - when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); - - await handler.handle(payload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); - - expect(container.read(assetViewerProvider).currentAsset, remoteAsset); - verify(() => resolver.resolve(payload)).called(1); - verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - verify(() => router.popUntilRoot()).called(2); - verify(() => router.push(any())).called(2); - verifyNever(() => router.replace(any())); - verifyNever(() => router.replaceAll(any())); - }); - - test('refreshCurrentAfterUpload uses attachment localAssetId when watched remote asset is remote-only', () async { - final remoteAsset = _remoteAsset(id: 'remote-1', localId: null); - final viewerAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); - final remoteTimelineService = _timelineServiceFromAssets([viewerAsset], TimelineOrigin.deepLink); - addTearDown(() async => remoteTimelineService.dispose()); - - when( - () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); - final remoteAssetController = StreamController(); - addTearDown(remoteAssetController.close); - - when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => remoteAssetController.stream); - when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); - - await handler.handle(payload); - final refresh = handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); - remoteAssetController.add(null); - await Future.delayed(Duration.zero); - remoteAssetController.add(remoteAsset); - await refresh; - - expect(container.read(assetViewerProvider).currentAsset, viewerAsset); - verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - verify(() => router.popUntilRoot()).called(2); - verify(() => router.push(any())).called(2); - verifyNever(() => router.replace(any())); - verifyNever(() => router.replaceAll(any())); - }); - - test('refreshCurrentAfterUpload falls back to direct get when watch times out', () async { - final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); - final remoteTimelineService = _timelineServiceFromAssets([remoteAsset], TimelineOrigin.deepLink); - addTearDown(() async => remoteTimelineService.dispose()); - - when( - () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); - final remoteAssetController = StreamController(); - addTearDown(remoteAssetController.close); - - when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => remoteAssetController.stream); - when(() => assetService.getRemoteAsset('remote-1')).thenAnswer((_) async => remoteAsset); - when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); - - await handler.handle(payload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload, timeout: Duration.zero); - - expect(container.read(assetViewerProvider).currentAsset, remoteAsset); - verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - verify(() => assetService.getRemoteAsset('remote-1')).called(1); - verify(() => router.popUntilRoot()).called(2); - verify(() => router.push(any())).called(2); - verifyNever(() => router.replace(any())); - verifyNever(() => router.replaceAll(any())); - }); - - test('refreshCurrentAfterUpload watches only the uploaded remote asset stream', () async { - final remoteAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); - final remoteTimelineService = _timelineServiceFromAssets([remoteAsset], TimelineOrigin.deepLink); - addTearDown(() async => remoteTimelineService.dispose()); - - when( - () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); - when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => Stream.value(remoteAsset)); - when(() => resolver.timelineFor(any())).thenReturn(remoteTimelineService); - - await handler.handle(payload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); - - expect(container.read(assetViewerProvider).currentAsset, remoteAsset); - verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - verifyNever(() => assetService.watchRemoteAsset('remote-2')); - }); - - test('refreshCurrentAfterUpload skips when another view intent became current', () 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 => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); - when( - () => resolver.resolve(secondPayload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); - when( - () => assetService.watchRemoteAsset('remote-1'), - ).thenAnswer((_) => Stream.value(_remoteAsset(id: 'remote-1', localId: 'local-1'))); - - await handler.handle(payload); - await handler.handle(secondPayload); - await handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); - - expect(container.read(assetViewerProvider).currentAsset, secondAsset); - verifyNever(() => assetService.watchRemoteAsset('remote-1')); - }); - - test('refreshCurrentAfterUpload skips when view intent changes while waiting for upload', () 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()); - final remoteAssetController = StreamController(); - addTearDown(remoteAssetController.close); - - when( - () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); - when( - () => resolver.resolve(secondPayload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); - when(() => assetService.watchRemoteAsset('remote-1')).thenAnswer((_) => remoteAssetController.stream); - - await handler.handle(payload); - final refresh = handler.refreshCurrentAfterUpload(remoteAssetId: 'remote-1', attachment: payload); - remoteAssetController.add(null); - await Future.delayed(Duration.zero); - - await handler.handle(secondPayload); - remoteAssetController.add(_remoteAsset(id: 'remote-1', localId: 'local-1')); - await refresh; - - expect(container.read(assetViewerProvider).currentAsset, secondAsset); - verify(() => assetService.watchRemoteAsset('remote-1')).called(1); - }); } AuthState _authState({required bool isAuthenticated}) { 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']; From 6894b2ea2279b5f73259e95e2111142a61849a69 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 4 Aug 2026 12:00:03 +0300 Subject: [PATCH 16/25] fix(mobile): handle file-backed view intent uploads -Route materialized view intents through share-intent upload, replace the synthetic viewer asset after sync, and manage temporary file cleanup. --- mobile/lib/domain/services/asset.service.dart | 4 + .../presentation/actions/upload.action.dart | 19 +- .../asset_viewer/asset_viewer.page.dart | 13 +- .../asset_upload_coordinator.provider.dart | 117 +++++++++ .../view_intent_asset_resolver.service.dart | 1 + .../asset_viewer_timeline_reload_test.dart | 106 ++++++++ ...sset_upload_coordinator_provider_test.dart | 243 ++++++++++++++++++ .../actions/upload_action_test.dart | 3 +- 8 files changed, 493 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/providers/asset_upload_coordinator.provider.dart create mode 100644 mobile/test/presentation/widgets/asset_viewer/asset_viewer_timeline_reload_test.dart create mode 100644 mobile/test/providers/asset_upload_coordinator_provider_test.dart diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 1f7f77df18..05a879973f 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -52,6 +52,10 @@ class AssetService { }); } + Stream watchRemoteAsset(String id) { + return _remoteRepository.watch(id); + } + Future> getLocalAssetsByChecksum(String checksum) { return _localRepository.getByChecksum(checksum); } diff --git a/mobile/lib/presentation/actions/upload.action.dart b/mobile/lib/presentation/actions/upload.action.dart index ceb35c8786..5b9427866c 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_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index d7b48cf0dd..b24e96ce98 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -283,16 +283,17 @@ class _AssetViewerState extends ConsumerState { if (index != _currentPage) { _pageController.jumpToPage(index); unawaited(_onAssetChanged(index)); - } else if (currentAsset is RemoteAsset && - currentAsset.stackId != null && + } else if (currentAsset != null && assetIndex == null && !_shouldIgnoreMissingAssetOnTimelineReload(currentAsset, timelineService)) { - final timelineAsset = timelineService.getAssetSafe(index); - if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) { + 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) { 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..827c77eeb0 --- /dev/null +++ b/mobile/lib/providers/asset_upload_coordinator.provider.dart @@ -0,0 +1,117 @@ +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/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.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 { + 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 viewIntentFilePath = source == ActionSource.viewer ? _ref.read(viewIntentFilePathProvider) : null; + if (viewIntentFilePath == null) { + await _ref + .read(foregroundUploadServiceProvider) + .uploadManual(assets, cancelToken: cancelToken, callbacks: callbacks); + 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, + cancelToken: cancelToken, + callbacks: callbacks, + ); + } + + Future _uploadViewIntentFile({ + required LocalAsset asset, + required String path, + 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)) { + 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) { + if (_ref.read(viewIntentFilePathProvider) != path) { + return false; + } + + final currentAsset = _ref.read(assetViewerProvider).currentAsset; + return currentAsset != null && currentAsset.refersToSameAsset(asset); + } +} diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index fe5cb78f6f..e44839c2d2 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -128,6 +128,7 @@ class ViewIntentAssetResolver { 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, 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..494c1a65b7 --- /dev/null +++ b/mobile/test/providers/asset_upload_coordinator_provider_test.dart @@ -0,0 +1,243 @@ +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/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/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('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); + }); +} diff --git a/mobile/test/unit/presentation/actions/upload_action_test.dart b/mobile/test/unit/presentation/actions/upload_action_test.dart index 8aeb7a915f..253a7fb56d 100644 --- a/mobile/test/unit/presentation/actions/upload_action_test.dart +++ b/mobile/test/unit/presentation/actions/upload_action_test.dart @@ -3,6 +3,7 @@ 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'; @@ -203,7 +204,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); From d0a1f22a389d363ec5bddcc04b7fe03b54b090b7 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 4 Aug 2026 18:45:35 +0300 Subject: [PATCH 17/25] fix(mobile): handle view intent asset lifecycle Refresh viewer assets after upload, trash, and restore, and close the viewer after permanent deletion. Support file-backed intent uploads and keep post-action transitions independent of the widget context. --- .../presentation/actions/delete.action.dart | 17 +- .../presentation/actions/restore.action.dart | 3 + .../asset_upload_coordinator.provider.dart | 32 ++- ...ent_asset_action_coordinator.provider.dart | 91 ++++++ .../view_intent_handler.provider.dart | 2 + .../view_intent_handler_android.dart | 27 ++ .../view_intent/view_intent_handler_stub.dart | 3 + ...sset_upload_coordinator_provider_test.dart | 33 +++ ...sset_action_coordinator_provider_test.dart | 269 ++++++++++++++++++ .../view_intent_handler_android_test.dart | 69 ++++- .../actions/delete_action_test.dart | 139 +++++++++ .../actions/restore_action_test.dart | 73 +++++ .../actions/upload_action_test.dart | 2 + 13 files changed, 752 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/providers/view_intent/view_intent_asset_action_coordinator.provider.dart create mode 100644 mobile/test/providers/view_intent/view_intent_asset_action_coordinator_provider_test.dart diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 31d01b32ef..89207b84ec 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'; @@ -47,6 +48,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; @@ -68,6 +74,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; @@ -85,6 +92,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"); } @@ -92,11 +100,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( @@ -106,14 +114,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/providers/asset_upload_coordinator.provider.dart b/mobile/lib/providers/asset_upload_coordinator.provider.dart index 827c77eeb0..00a912b95e 100644 --- a/mobile/lib/providers/asset_upload_coordinator.provider.dart +++ b/mobile/lib/providers/asset_upload_coordinator.provider.dart @@ -27,9 +27,39 @@ class AssetUploadCoordinator { }) async { 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: callbacks); + .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; + if (remoteAsset == null || latestAsset == null || !latestAsset.refersToSameAsset(viewerAsset)) { + return; + } + + viewerNotifier.setAsset(remoteAsset.copyWith(localId: viewerAsset.id)); return; } 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..8300216a1f --- /dev/null +++ b/mobile/lib/providers/view_intent/view_intent_asset_action_coordinator.provider.dart @@ -0,0 +1,91 @@ +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/view_intent_current.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/routing/router.dart'; +import 'package:logging/logging.dart'; + +final viewIntentAssetActionCoordinatorProvider = Provider((ref) { + return ViewIntentAssetActionCoordinator( + ref, + viewIntent: ref.watch(viewIntentCurrentProvider), + isFileBacked: ref.watch(viewIntentFilePathProvider) != null, + ); +}, dependencies: [assetViewerProvider, timelineServiceProvider, viewIntentCurrentProvider, viewIntentFilePathProvider]); + +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(viewIntentCurrentProvider), _viewIntent)) { + return false; + } + + final origin = _ref.read(timelineServiceProvider).origin; + return requireTrash ? origin == TimelineOrigin.deepLinkTrash : origin.isDeepLink; + } + + 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..0602da63c5 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart @@ -12,6 +12,8 @@ abstract class ViewIntentHandler { Future flushDeferredViewIntent(); Future handle(ViewIntentPayload attachment); + + 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 515097999d..311241aa1c 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -6,6 +6,8 @@ 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/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/view_intent/view_intent_current.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'; @@ -81,6 +83,31 @@ class AndroidViewIntentHandler implements ViewIntentHandler { ); } + @override + Future reopenRemoteAsset(String remoteAssetId) async { + final attachment = _ref.read(viewIntentCurrentProvider); + if (attachment == null) { + return false; + } + + final asset = await _ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId); + if (asset == null || !identical(_ref.read(viewIntentCurrentProvider), attachment)) { + return false; + } + + final origin = asset.isTrashed ? TimelineOrigin.deepLinkTrash : TimelineOrigin.deepLink; + final timelineService = _ref.read(timelineFactoryProvider).fromAssets([asset], origin); + unawaited( + _openAssetViewer(asset: asset, timelineService: timelineService, attachment: attachment).catchError(( + Object error, + StackTrace stackTrace, + ) { + _logger.severe('Failed to reopen remote view intent asset', error, stackTrace); + }), + ); + return true; + } + Future _openAssetViewer({ required BaseAsset asset, required TimelineService timelineService, 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..ab795aff1e 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart @@ -15,4 +15,7 @@ class StubViewIntentHandler implements ViewIntentHandler { @override Future handle(ViewIntentPayload attachment) async {} + + @override + Future reopenRemoteAsset(String remoteAssetId) async => false; } diff --git a/mobile/test/providers/asset_upload_coordinator_provider_test.dart b/mobile/test/providers/asset_upload_coordinator_provider_test.dart index 494c1a65b7..ba5b04d3b7 100644 --- a/mobile/test/providers/asset_upload_coordinator_provider_test.dart +++ b/mobile/test/providers/asset_upload_coordinator_provider_test.dart @@ -47,6 +47,39 @@ void main() { 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('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'); 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..62fdf10f7e --- /dev/null +++ b/mobile/test/providers/view_intent/view_intent_asset_action_coordinator_provider_test.dart @@ -0,0 +1,269 @@ +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/view_intent_asset_action_coordinator.provider.dart'; +import 'package:immich_mobile/providers/view_intent/view_intent_current.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/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 attachment) 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, + }) 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); + if (activeViewIntent) { + scope + .read(viewIntentCurrentProvider.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.deepLinkTrash, handler: handler); + + 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.deepLinkTrash, 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.deepLinkTrash, handler: handler); + final newerPayload = ViewIntentPayload( + path: '/tmp/newer-view-intent.jpg', + mimeType: 'image/jpeg', + localAssetId: 'newer-local', + ); + harness.scope.read(viewIntentCurrentProvider.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.deepLinkTrash, handler: handler); + + 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 a1672ff232..5d6d24698c 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 @@ -13,6 +13,7 @@ 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/view_intent/view_intent_current.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'; @@ -31,6 +32,8 @@ class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolve class MockAssetService extends Mock implements AssetService {} +class MockTimelineFactory extends Mock implements TimelineFactory {} + class MockAppRouter extends Mock implements AppRouter {} class MockAuthService extends Mock implements AuthService {} @@ -100,6 +103,7 @@ void main() { late TestViewIntentService viewIntentService; late MockViewIntentAssetResolver resolver; late MockAssetService assetService; + late MockTimelineFactory timelineFactory; late MockAppRouter router; late TestAuthNotifier authNotifier; late ProviderContainer container; @@ -112,6 +116,7 @@ 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'), @@ -122,6 +127,7 @@ void main() { viewIntentService = TestViewIntentService(); 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'); @@ -137,6 +143,7 @@ void main() { viewIntentServiceProvider.overrideWithValue(viewIntentService), viewIntentAssetResolverProvider.overrideWithValue(resolver), assetServiceProvider.overrideWithValue(assetService), + timelineFactoryProvider.overrideWithValue(timelineFactory), appRouterProvider.overrideWithValue(router), authProvider.overrideWith((ref) { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); @@ -258,6 +265,65 @@ void main() { verifyNever(() => router.replace(any())); verifyNever(() => router.replaceAll(any())); }); + + 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(viewIntentCurrentProvider.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); + 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 deep-link timeline', () async { + final trashedAsset = _remoteAsset(id: 'remote-trashed', localId: 'local-1', deletedAt: DateTime(2026, 8, 4)); + final trashTimeline = await _createReadyTimelineService([trashedAsset], TimelineOrigin.deepLinkTrash); + addTearDown(trashTimeline.dispose); + container.read(viewIntentCurrentProvider.notifier).setPayload(payload); + + when(() => assetService.getRemoteAsset(trashedAsset.id)).thenAnswer((_) async => trashedAsset); + when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLinkTrash)).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.deepLinkTrash); + 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(viewIntentCurrentProvider.notifier).setPayload(payload); + when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) => lookup.future); + + final reopening = handler.reopenRemoteAsset(restoredAsset.id); + await pumpEventQueue(); + container.read(viewIntentCurrentProvider.notifier).setPayload(newerPayload); + lookup.complete(restoredAsset); + + expect(await reopening, isFalse); + verifyNever(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)); + verifyNever(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLinkTrash)); + verifyNever(() => router.popUntilRoot()); + verifyNever(() => router.push(any())); + }); } AuthState _authState({required bool isAuthenticated}) { @@ -286,7 +352,7 @@ LocalAsset _localAsset({required String id, String? checksum = 'checksum-1', Str ); } -RemoteAsset _remoteAsset({required String id, required String? localId}) { +RemoteAsset _remoteAsset({required String id, required String? localId, DateTime? deletedAt}) { return RemoteAsset( id: id, localId: localId, @@ -296,6 +362,7 @@ RemoteAsset _remoteAsset({required String id, required String? localId}) { type: AssetType.image, createdAt: DateTime(2026, 4, 20), updatedAt: DateTime(2026, 4, 20), + deletedAt: deletedAt, isEdited: false, ); } diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 29d81a5c2c..5b9bf086e2 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( @@ -227,6 +271,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..9e47eb8653 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.deepLinkTrash, + )); + 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 253a7fb56d..99c6c0196e 100644 --- a/mobile/test/unit/presentation/actions/upload_action_test.dart +++ b/mobile/test/unit/presentation/actions/upload_action_test.dart @@ -8,6 +8,7 @@ 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'; @@ -33,6 +34,7 @@ void main() { }); List uploadOverrides() => [ + assetUploadCoordinatorProvider.overrideWith(AssetUploadCoordinator.new), foregroundUploadServiceProvider.overrideWithValue(uploadService), toastServiceProvider.overrideWithValue(context.service.toast), ]; From a1458f8714487b27363c554d987412cf6c39d4b0 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 6 Aug 2026 14:07:02 +0300 Subject: [PATCH 18/25] fix(mobile): invalidate stale view intent sessions Discard stale view-intent resolution and upload results, and clean up session state when the viewer closes. --- .../asset_upload_coordinator.provider.dart | 20 ++++- .../view_intent_current.provider.dart | 7 ++ .../view_intent_handler_android.dart | 50 +++++++++-- ...sset_upload_coordinator_provider_test.dart | 89 +++++++++++++++++++ .../view_intent_handler_android_test.dart | 66 +++++++++++++- 5 files changed, 222 insertions(+), 10 deletions(-) diff --git a/mobile/lib/providers/asset_upload_coordinator.provider.dart b/mobile/lib/providers/asset_upload_coordinator.provider.dart index 00a912b95e..caab17ad5e 100644 --- a/mobile/lib/providers/asset_upload_coordinator.provider.dart +++ b/mobile/lib/providers/asset_upload_coordinator.provider.dart @@ -4,8 +4,10 @@ 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/view_intent_current.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'; @@ -25,6 +27,7 @@ class AssetUploadCoordinator { required Completer cancelToken, required UploadCallbacks callbacks, }) async { + final activeViewIntent = source == ActionSource.viewer ? _ref.read(viewIntentCurrentProvider) : null; final viewIntentFilePath = source == ActionSource.viewer ? _ref.read(viewIntentFilePathProvider) : null; if (viewIntentFilePath == null) { final viewerAsset = source == ActionSource.viewer && assets.length == 1 ? assets.single : null; @@ -55,7 +58,12 @@ class AssetUploadCoordinator { final remoteAsset = await _waitForRemoteAsset(remoteAssetId); final latestAsset = _ref.read(assetViewerProvider).currentAsset; - if (remoteAsset == null || latestAsset == null || !latestAsset.refersToSameAsset(viewerAsset)) { + final isCurrentViewIntent = + activeViewIntent == null || identical(_ref.read(viewIntentCurrentProvider), activeViewIntent); + if (remoteAsset == null || + latestAsset == null || + !latestAsset.refersToSameAsset(viewerAsset) || + !isCurrentViewIntent) { return; } @@ -71,6 +79,7 @@ class AssetUploadCoordinator { await _uploadViewIntentFile( asset: assets.single, path: viewIntentFilePath, + activeViewIntent: activeViewIntent, cancelToken: cancelToken, callbacks: callbacks, ); @@ -79,6 +88,7 @@ class AssetUploadCoordinator { Future _uploadViewIntentFile({ required LocalAsset asset, required String path, + required ViewIntentPayload? activeViewIntent, required Completer cancelToken, required UploadCallbacks callbacks, }) async { @@ -106,7 +116,7 @@ class AssetUploadCoordinator { } final remoteAsset = await _waitForRemoteAsset(uploadedRemoteAssetId); - if (remoteAsset == null || !_isCurrentUpload(asset, path)) { + if (remoteAsset == null || !_isCurrentUpload(asset, path, activeViewIntent)) { return; } @@ -136,7 +146,11 @@ class AssetUploadCoordinator { } } - bool _isCurrentUpload(LocalAsset asset, String path) { + bool _isCurrentUpload(LocalAsset asset, String path, ViewIntentPayload? activeViewIntent) { + if (activeViewIntent != null && !identical(_ref.read(viewIntentCurrentProvider), activeViewIntent)) { + return false; + } + if (_ref.read(viewIntentFilePathProvider) != path) { return false; } diff --git a/mobile/lib/providers/view_intent/view_intent_current.provider.dart b/mobile/lib/providers/view_intent/view_intent_current.provider.dart index 9a7af72873..4a64380c9d 100644 --- a/mobile/lib/providers/view_intent/view_intent_current.provider.dart +++ b/mobile/lib/providers/view_intent/view_intent_current.provider.dart @@ -12,6 +12,13 @@ class ViewIntentCurrentNotifier extends Notifier { void clear() { state = null; } + + void clearIfMatch(ViewIntentPayload payload) { + if (!identical(state, payload)) { + return; + } + state = null; + } } final viewIntentCurrentProvider = NotifierProvider( 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 311241aa1c..34b4fb7c67 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -69,11 +69,25 @@ class AndroidViewIntentHandler implements ViewIntentHandler { ); if (!_ref.read(authProvider).isAuthenticated) { + _clearCurrentViewIntent(); _ref.read(viewIntentPendingProvider.notifier).defer(attachment); return; } - final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + _activateViewIntent(attachment); + + final ViewIntentResolvedAsset resolvedAsset; + try { + resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + } catch (_) { + _ref.read(viewIntentCurrentProvider.notifier).clearIfMatch(attachment); + rethrow; + } + if (!identical(_ref.read(viewIntentCurrentProvider), attachment)) { + await resolvedAsset.timelineService.dispose(); + return; + } + _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); await _openAssetViewer( asset: resolvedAsset.asset, @@ -95,10 +109,17 @@ class AndroidViewIntentHandler implements ViewIntentHandler { return false; } + final reopenedAttachment = ViewIntentPayload( + path: attachment.path, + mimeType: attachment.mimeType, + localAssetId: attachment.localAssetId, + ); + _activateViewIntent(reopenedAttachment); + final origin = asset.isTrashed ? TimelineOrigin.deepLinkTrash : TimelineOrigin.deepLink; final timelineService = _ref.read(timelineFactoryProvider).fromAssets([asset], origin); unawaited( - _openAssetViewer(asset: asset, timelineService: timelineService, attachment: attachment).catchError(( + _openAssetViewer(asset: asset, timelineService: timelineService, attachment: reopenedAttachment).catchError(( Object error, StackTrace stackTrace, ) { @@ -108,6 +129,19 @@ class AndroidViewIntentHandler implements ViewIntentHandler { return true; } + void _activateViewIntent(ViewIntentPayload attachment) { + _ref.read(viewIntentCurrentProvider.notifier).setPayload(attachment); + _ref.read(viewIntentFilePathProvider.notifier).clear(); + unawaited(_viewIntentService.cleanupManagedTempFile()); + _router.popUntilRoot(); + } + + void _clearCurrentViewIntent() { + _ref.read(viewIntentCurrentProvider.notifier).clear(); + _ref.read(viewIntentFilePathProvider.notifier).clear(); + unawaited(_viewIntentService.cleanupManagedTempFile()); + } + Future _openAssetViewer({ required BaseAsset asset, required TimelineService timelineService, @@ -119,7 +153,6 @@ class AndroidViewIntentHandler implements ViewIntentHandler { if (asset.isVideo) { notifier.setControls(false); } - _ref.read(viewIntentCurrentProvider.notifier).setPayload(attachment); notifier.setAsset(asset); if (viewIntentFilePath != null) { @@ -130,7 +163,14 @@ class AndroidViewIntentHandler implements ViewIntentHandler { unawaited(_viewIntentService.cleanupManagedTempFile()); } - _router.popUntilRoot(); - await _router.push(AssetViewerRoute(initialIndex: 0, timelineService: timelineService)); + try { + await _router.push(AssetViewerRoute(initialIndex: 0, timelineService: timelineService)); + } finally { + _ref.read(viewIntentCurrentProvider.notifier).clearIfMatch(attachment); + if (viewIntentFilePath != null) { + _ref.read(viewIntentFilePathProvider.notifier).clearIfMatch(viewIntentFilePath); + await _viewIntentService.cleanupManagedTempFileIfCurrent(viewIntentFilePath); + } + } } } diff --git a/mobile/test/providers/asset_upload_coordinator_provider_test.dart b/mobile/test/providers/asset_upload_coordinator_provider_test.dart index ba5b04d3b7..882b54c8f4 100644 --- a/mobile/test/providers/asset_upload_coordinator_provider_test.dart +++ b/mobile/test/providers/asset_upload_coordinator_provider_test.dart @@ -5,9 +5,11 @@ 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/view_intent_current.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'; @@ -80,6 +82,45 @@ void main() { 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(viewIntentCurrentProvider.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(viewIntentCurrentProvider.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'); @@ -273,4 +314,52 @@ void main() { 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(viewIntentCurrentProvider.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(viewIntentCurrentProvider.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_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index 5d6d24698c..f27f5b62ce 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 @@ -15,6 +15,7 @@ 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/view_intent/view_intent_current.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/routing/router.dart'; @@ -55,6 +56,7 @@ class TestViewIntentService extends ViewIntentService { int cleanupStaleTempFilesCalls = 0; int cleanupManagedTempFileCalls = 0; final List managedTempPaths = []; + final List cleanedManagedTempPaths = []; TestViewIntentService() : super(MockViewIntentHostApi()); @@ -75,6 +77,11 @@ class TestViewIntentService extends ViewIntentService { Future setManagedTempFilePath(String path) async { managedTempPaths.add(path); } + + @override + Future cleanupManagedTempFileIfCurrent(String path) async { + cleanedManagedTempPaths.add(path); + } } class TestAuthNotifier extends AuthNotifier { @@ -252,12 +259,12 @@ void main() { await handler.handle(payload); expect(container.read(assetViewerProvider).currentAsset, deepLinkAsset); - expect(container.read(viewIntentCurrentProvider), payload); + expect(container.read(viewIntentCurrentProvider), isNull); await handler.handle(secondPayload); expect(container.read(assetViewerProvider).currentAsset, secondAsset); - expect(container.read(viewIntentCurrentProvider), secondPayload); + expect(container.read(viewIntentCurrentProvider), isNull); verify(() => resolver.resolve(payload)).called(1); verify(() => resolver.resolve(secondPayload)).called(1); verify(() => router.popUntilRoot()).called(2); @@ -266,6 +273,61 @@ void main() { 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 => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); + + final firstHandle = handler.handle(payload); + await pumpEventQueue(); + await handler.handle(secondPayload); + + firstResolution.complete(ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + await firstHandle; + + expect(container.read(assetViewerProvider).currentAsset, secondAsset); + expect(container.read(viewIntentCurrentProvider), 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 => ViewIntentResolvedAsset( + asset: deepLinkAsset, + timelineService: deepLinkTimelineService, + viewIntentFilePath: path, + ), + ); + + final handling = handler.handle(payload); + await pumpEventQueue(); + + expect(container.read(viewIntentCurrentProvider), same(payload)); + expect(container.read(viewIntentFilePathProvider), path); + + routeClosed.complete(null); + await handling; + + expect(container.read(viewIntentCurrentProvider), 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); From f90359655b02be37e43f37863ad2a4bca235e6b7 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 6 Aug 2026 15:10:57 +0300 Subject: [PATCH 19/25] fix(mobile): handle unavailable view intents --- .../immich/viewintent/ViewIntentPlugin.kt | 14 ++++++-- .../view_intent_handler_android.dart | 17 +++++++++- mobile/lib/services/view_intent.service.dart | 9 +++++ .../view_intent_handler_android_test.dart | 34 ++++++++++++++++++- .../services/view_intent_service_test.dart | 10 ++++++ 5 files changed, 80 insertions(+), 4 deletions(-) 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/providers/view_intent/view_intent_handler_android.dart b/mobile/lib/providers/view_intent/view_intent_handler_android.dart index 34b4fb7c67..b5c7d415ce 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -1,13 +1,16 @@ import 'dart:async'; +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/view_intent_current.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'; @@ -43,7 +46,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; diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart index 066cb29589..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; 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 f27f5b62ce..d33a89df22 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,12 +9,14 @@ 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/view_intent_current.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'; @@ -22,6 +25,7 @@ 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'; @@ -53,6 +57,7 @@ class FakeTimelineService extends Fake implements TimelineService {} class TestViewIntentService extends ViewIntentService { ViewIntentPayload? consumedAttachment; + Object? consumeError; int cleanupStaleTempFilesCalls = 0; int cleanupManagedTempFileCalls = 0; final List managedTempPaths = []; @@ -61,7 +66,12 @@ class TestViewIntentService extends ViewIntentService { 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 { @@ -84,6 +94,15 @@ class TestViewIntentService extends ViewIntentService { } } +class TestToastService extends ToastService { + final List errorMessages = []; + + @override + void error(String message, {ToastOption? toast}) { + errorMessages.add(message); + } +} + class TestAuthNotifier extends AuthNotifier { TestAuthNotifier(Ref ref, AuthState initial) : super( @@ -113,6 +132,7 @@ void main() { late MockTimelineFactory timelineFactory; late MockAppRouter router; late TestAuthNotifier authNotifier; + late TestToastService toastService; late ProviderContainer container; late AndroidViewIntentHandler handler; late ViewIntentPayload payload; @@ -132,6 +152,7 @@ void main() { setUp(() async { viewIntentService = TestViewIntentService(); + toastService = TestToastService(); resolver = MockViewIntentAssetResolver(); assetService = MockAssetService(); timelineFactory = MockTimelineFactory(); @@ -151,6 +172,7 @@ void main() { viewIntentAssetResolverProvider.overrideWithValue(resolver), assetServiceProvider.overrideWithValue(assetService), timelineFactoryProvider.overrideWithValue(timelineFactory), + toastServiceProvider.overrideWithValue(toastService), appRouterProvider.overrideWithValue(router), authProvider.overrideWith((ref) { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); @@ -220,6 +242,16 @@ 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( 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'); From 595a3a7f28cd2912b044195723490cceeb5815ab Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 6 Aug 2026 16:49:32 +0300 Subject: [PATCH 20/25] fix(mobile): resolve view intents from remote checksum matches Use cached remote assets when the local Drift record is unavailable, preferring active and most recently uploaded matches. --- .../view_intent_asset_resolver.service.dart | 42 +++++++++++++++---- .../view_intent_asset_resolver_test.dart | 17 ++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index e44839c2d2..f9d5ed2183 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -55,7 +55,11 @@ class ViewIntentAssetResolver { ({LocalAsset? asset, String? checksum}) resolvedLocal = (asset: null, checksum: null); if (localAssetId != null) { resolvedLocal = await _resolveLocalAsset(localAssetId); - final remoteAsset = await _resolveRemoteAsset(localAssetId, resolvedLocal.asset?.remoteId); + final remoteAsset = await _resolveRemoteAsset( + localAssetId, + remoteAssetId: resolvedLocal.asset?.remoteId, + checksum: resolvedLocal.checksum, + ); if (remoteAsset != null) { return ViewIntentResolvedAsset(asset: remoteAsset, timelineService: _timelineFor(remoteAsset)); } @@ -111,21 +115,45 @@ class ViewIntentAssetResolver { } } - Future _resolveRemoteAsset(String localAssetId, String? remoteAssetId) async { - if (remoteAssetId == null) { - 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'); + } } - final remoteAsset = await _assetService.getRemoteAsset(remoteAssetId); if (remoteAsset == null) { return null; } - final asset = remoteAsset.copyWith(localId: localAssetId); - _logger.fine('resolve matched remote asset by id: $remoteAssetId, asset=$asset'); 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. diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index 544692fb13..8c8369776a 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -44,6 +44,7 @@ void main() { 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 {}); @@ -142,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, From b3013244b148217450a6d70f012bb4c76f851b2c Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Thu, 6 Aug 2026 17:15:51 +0300 Subject: [PATCH 21/25] fix(mobile): address DCM warnings --- .../presentation/widgets/asset_viewer/bottom_bar.widget.dart | 2 +- mobile/lib/providers/asset_upload_coordinator.provider.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 fd3b18a70f..bb75dc67fd 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.read(timelineServiceProvider).origin.isTrash; + final isInTrash = ref.watch(timelineServiceProvider).origin.isTrash; final originalTheme = context.themeData; diff --git a/mobile/lib/providers/asset_upload_coordinator.provider.dart b/mobile/lib/providers/asset_upload_coordinator.provider.dart index caab17ad5e..ee36d8be4c 100644 --- a/mobile/lib/providers/asset_upload_coordinator.provider.dart +++ b/mobile/lib/providers/asset_upload_coordinator.provider.dart @@ -16,7 +16,7 @@ import 'package:logging/logging.dart'; final assetUploadCoordinatorProvider = Provider(AssetUploadCoordinator.new); class AssetUploadCoordinator { - AssetUploadCoordinator(this._ref); + const AssetUploadCoordinator(this._ref); final Ref _ref; static final Logger _logger = Logger('AssetUploadCoordinator'); From eb4817486da5fd73eb2a1e5e44afb374b63cfa3a Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 7 Aug 2026 14:44:06 +0300 Subject: [PATCH 22/25] refactor(mobile): improve naming for clarity, refactor code --- .../lib/domain/services/timeline.service.dart | 4 --- .../asset_viewer/asset_page.widget.dart | 2 +- .../asset_viewer/asset_viewer.page.dart | 4 +-- .../asset_upload_coordinator.provider.dart | 8 ++--- ... active_view_intent_payload_provider.dart} | 6 ++-- ...ent_asset_action_coordinator.provider.dart | 8 ++--- .../view_intent_handler.provider.dart | 2 +- .../view_intent_handler_android.dart | 30 ++++++++-------- .../view_intent/view_intent_handler_stub.dart | 2 +- .../view_intent_asset_resolver.service.dart | 10 +++--- mobile/lib/utils/action_button.utils.dart | 8 ++--- ...sset_upload_coordinator_provider_test.dart | 10 +++--- ...sset_action_coordinator_provider_test.dart | 8 ++--- .../view_intent_handler_android_test.dart | 36 +++++++++---------- .../view_intent_asset_resolver_test.dart | 2 +- 15 files changed, 68 insertions(+), 72 deletions(-) rename mobile/lib/providers/view_intent/{view_intent_current.provider.dart => active_view_intent_payload_provider.dart} (66%) diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index daaa029ebf..70d9e6d3c5 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -42,10 +42,6 @@ extension TimelineOriginX on TimelineOrigin { bool get isDeepLink => this == TimelineOrigin.deepLink || this == TimelineOrigin.deepLinkTrash; bool get isTrash => this == TimelineOrigin.trash || this == TimelineOrigin.deepLinkTrash; - - bool get isNotDeepLink => !isDeepLink; - - bool get isNotTrash => !isTrash; } class TimelineFactory { 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 271c2d47dc..709de3c356 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -410,7 +410,7 @@ class _AssetPageState extends ConsumerState { } BaseAsset displayAsset = asset; - final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin.isNotTrash)); + final showAssetStack = ref.watch(timelineServiceProvider.select((s) => !s.origin.isTrash)); 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_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index b24e96ce98..24fa483b07 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -24,7 +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/view_intent_current.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'; @@ -310,7 +310,7 @@ class _AssetViewerState extends ConsumerState { return true; } - final localAssetId = ref.read(viewIntentCurrentProvider)?.localAssetId; + final localAssetId = ref.read(activeViewIntentPayloadProvider)?.localAssetId; return localAssetId != null && currentAsset.localId == localAssetId; } diff --git a/mobile/lib/providers/asset_upload_coordinator.provider.dart b/mobile/lib/providers/asset_upload_coordinator.provider.dart index ee36d8be4c..e43d18f9cf 100644 --- a/mobile/lib/providers/asset_upload_coordinator.provider.dart +++ b/mobile/lib/providers/asset_upload_coordinator.provider.dart @@ -7,7 +7,7 @@ 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/view_intent_current.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'; @@ -27,7 +27,7 @@ class AssetUploadCoordinator { required Completer cancelToken, required UploadCallbacks callbacks, }) async { - final activeViewIntent = source == ActionSource.viewer ? _ref.read(viewIntentCurrentProvider) : null; + 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; @@ -59,7 +59,7 @@ class AssetUploadCoordinator { final remoteAsset = await _waitForRemoteAsset(remoteAssetId); final latestAsset = _ref.read(assetViewerProvider).currentAsset; final isCurrentViewIntent = - activeViewIntent == null || identical(_ref.read(viewIntentCurrentProvider), activeViewIntent); + activeViewIntent == null || identical(_ref.read(activeViewIntentPayloadProvider), activeViewIntent); if (remoteAsset == null || latestAsset == null || !latestAsset.refersToSameAsset(viewerAsset) || @@ -147,7 +147,7 @@ class AssetUploadCoordinator { } bool _isCurrentUpload(LocalAsset asset, String path, ViewIntentPayload? activeViewIntent) { - if (activeViewIntent != null && !identical(_ref.read(viewIntentCurrentProvider), activeViewIntent)) { + if (activeViewIntent != null && !identical(_ref.read(activeViewIntentPayloadProvider), activeViewIntent)) { return false; } diff --git a/mobile/lib/providers/view_intent/view_intent_current.provider.dart b/mobile/lib/providers/view_intent/active_view_intent_payload_provider.dart similarity index 66% rename from mobile/lib/providers/view_intent/view_intent_current.provider.dart rename to mobile/lib/providers/view_intent/active_view_intent_payload_provider.dart index 4a64380c9d..5cedc7a046 100644 --- a/mobile/lib/providers/view_intent/view_intent_current.provider.dart +++ b/mobile/lib/providers/view_intent/active_view_intent_payload_provider.dart @@ -1,7 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -class ViewIntentCurrentNotifier extends Notifier { +class ActiveViewIntentNotifier extends Notifier { @override ViewIntentPayload? build() => null; @@ -21,6 +21,6 @@ class ViewIntentCurrentNotifier extends Notifier { } } -final viewIntentCurrentProvider = NotifierProvider( - ViewIntentCurrentNotifier.new, +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 index 8300216a1f..fd26e47b9c 100644 --- 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 @@ -4,7 +4,7 @@ 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/view_intent_current.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/routing/router.dart'; @@ -13,10 +13,10 @@ import 'package:logging/logging.dart'; final viewIntentAssetActionCoordinatorProvider = Provider((ref) { return ViewIntentAssetActionCoordinator( ref, - viewIntent: ref.watch(viewIntentCurrentProvider), + viewIntent: ref.watch(activeViewIntentPayloadProvider), isFileBacked: ref.watch(viewIntentFilePathProvider) != null, ); -}, dependencies: [assetViewerProvider, timelineServiceProvider, viewIntentCurrentProvider, viewIntentFilePathProvider]); +}, dependencies: [assetViewerProvider, timelineServiceProvider, activeViewIntentPayloadProvider, viewIntentFilePathProvider]); class ViewIntentAssetActionCoordinator { const ViewIntentAssetActionCoordinator(this._ref, {required this._viewIntent, required this._isFileBacked}); @@ -66,7 +66,7 @@ class ViewIntentAssetActionCoordinator { return false; } - if (!identical(_ref.read(viewIntentCurrentProvider), _viewIntent)) { + if (!identical(_ref.read(activeViewIntentPayloadProvider), _viewIntent)) { return false; } 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 0602da63c5..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,7 @@ abstract class ViewIntentHandler { Future flushDeferredViewIntent(); - Future handle(ViewIntentPayload attachment); + Future handle(ViewIntentPayload payload); Future reopenRemoteAsset(String remoteAssetId); } 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 b5c7d415ce..12e449ae1c 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -11,7 +11,7 @@ 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/view_intent_current.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'; @@ -78,27 +78,27 @@ 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) { _clearCurrentViewIntent(); - _ref.read(viewIntentPendingProvider.notifier).defer(attachment); + _ref.read(viewIntentPendingProvider.notifier).defer(payload); return; } - _activateViewIntent(attachment); + _activateViewIntent(payload); - final ViewIntentResolvedAsset resolvedAsset; + final ViewIntentResolution resolvedAsset; try { - resolvedAsset = await _viewIntentAssetResolver.resolve(attachment); + resolvedAsset = await _viewIntentAssetResolver.resolve(payload); } catch (_) { - _ref.read(viewIntentCurrentProvider.notifier).clearIfMatch(attachment); + _ref.read(activeViewIntentPayloadProvider.notifier).clearIfMatch(payload); rethrow; } - if (!identical(_ref.read(viewIntentCurrentProvider), attachment)) { + if (!identical(_ref.read(activeViewIntentPayloadProvider), payload)) { await resolvedAsset.timelineService.dispose(); return; } @@ -107,20 +107,20 @@ class AndroidViewIntentHandler implements ViewIntentHandler { await _openAssetViewer( asset: resolvedAsset.asset, timelineService: resolvedAsset.timelineService, - attachment: attachment, + attachment: payload, viewIntentFilePath: resolvedAsset.viewIntentFilePath, ); } @override Future reopenRemoteAsset(String remoteAssetId) async { - final attachment = _ref.read(viewIntentCurrentProvider); + final attachment = _ref.read(activeViewIntentPayloadProvider); if (attachment == null) { return false; } final asset = await _ref.read(assetServiceProvider).getRemoteAsset(remoteAssetId); - if (asset == null || !identical(_ref.read(viewIntentCurrentProvider), attachment)) { + if (asset == null || !identical(_ref.read(activeViewIntentPayloadProvider), attachment)) { return false; } @@ -145,14 +145,14 @@ class AndroidViewIntentHandler implements ViewIntentHandler { } void _activateViewIntent(ViewIntentPayload attachment) { - _ref.read(viewIntentCurrentProvider.notifier).setPayload(attachment); + _ref.read(activeViewIntentPayloadProvider.notifier).setPayload(attachment); _ref.read(viewIntentFilePathProvider.notifier).clear(); unawaited(_viewIntentService.cleanupManagedTempFile()); _router.popUntilRoot(); } void _clearCurrentViewIntent() { - _ref.read(viewIntentCurrentProvider.notifier).clear(); + _ref.read(activeViewIntentPayloadProvider.notifier).clear(); _ref.read(viewIntentFilePathProvider.notifier).clear(); unawaited(_viewIntentService.cleanupManagedTempFile()); } @@ -181,7 +181,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { try { await _router.push(AssetViewerRoute(initialIndex: 0, timelineService: timelineService)); } finally { - _ref.read(viewIntentCurrentProvider.notifier).clearIfMatch(attachment); + _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 ab795aff1e..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,7 +14,7 @@ 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/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index f9d5ed2183..7e9b6ca770 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -11,13 +11,13 @@ 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 String? viewIntentFilePath; - const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath}); + const ViewIntentResolution({required this.asset, required this.timelineService, this.viewIntentFilePath}); } final viewIntentAssetResolverProvider = Provider( @@ -43,7 +43,7 @@ class ViewIntentAssetResolver { 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}'); @@ -61,13 +61,13 @@ class ViewIntentAssetResolver { checksum: resolvedLocal.checksum, ); if (remoteAsset != null) { - return ViewIntentResolvedAsset(asset: remoteAsset, timelineService: _timelineFor(remoteAsset)); + return ViewIntentResolution(asset: remoteAsset, timelineService: _timelineFor(remoteAsset)); } } final asset = resolvedLocal.asset ?? _toTransientAsset(attachment, resolvedLocal.checksum); - return ViewIntentResolvedAsset( + return ViewIntentResolution( asset: asset, timelineService: _timelineFor(asset), viewIntentFilePath: resolvedLocal.asset == null ? path : null, diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 05c25ec875..719a5ce434 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -135,13 +135,13 @@ enum ActionButtonType { context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // - context.timelineOrigin.isNotTrash && + !context.timelineOrigin.isTrash && !context.isInLockedView && // context.isStacked, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && - context.timelineOrigin.isNotDeepLink && + !context.timelineOrigin.isDeepLink && context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, @@ -155,8 +155,8 @@ enum ActionButtonType { ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && - context.timelineOrigin.isNotDeepLink && - context.timelineOrigin.isNotTrash && + !context.timelineOrigin.isDeepLink && + !context.timelineOrigin.isTrash && context.timelineOrigin != TimelineOrigin.lockedFolder && context.timelineOrigin != TimelineOrigin.archive && context.timelineOrigin != TimelineOrigin.localAlbum && diff --git a/mobile/test/providers/asset_upload_coordinator_provider_test.dart b/mobile/test/providers/asset_upload_coordinator_provider_test.dart index 882b54c8f4..2fbb4bfe8b 100644 --- a/mobile/test/providers/asset_upload_coordinator_provider_test.dart +++ b/mobile/test/providers/asset_upload_coordinator_provider_test.dart @@ -9,7 +9,7 @@ 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/view_intent_current.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'; @@ -90,7 +90,7 @@ void main() { final remoteController = StreamController.broadcast(); addTearDown(remoteController.close); - container.read(viewIntentCurrentProvider.notifier).setPayload(oldPayload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(oldPayload); container.read(assetViewerProvider.notifier).setAsset(localAsset); when(() => assetService.watchRemoteAsset(remoteAsset.id)).thenAnswer((_) => remoteController.stream); when( @@ -114,7 +114,7 @@ void main() { ); await pumpEventQueue(); - container.read(viewIntentCurrentProvider.notifier).setPayload(newPayload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(newPayload); remoteController.add(remoteAsset); await upload; @@ -324,7 +324,7 @@ void main() { final remoteController = StreamController.broadcast(); addTearDown(remoteController.close); - container.read(viewIntentCurrentProvider.notifier).setPayload(oldPayload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(oldPayload); container.read(viewIntentFilePathProvider.notifier).setPath(path); container.read(assetViewerProvider.notifier).setAsset(localAsset); when(() => viewIntentService.markUploadActive(path)).thenReturn(null); @@ -354,7 +354,7 @@ void main() { ); await pumpEventQueue(); - container.read(viewIntentCurrentProvider.notifier).setPayload(newPayload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(newPayload); remoteController.add(uploadedRemote); await upload; 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 index 62fdf10f7e..f822594d3a 100644 --- 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 @@ -8,8 +8,8 @@ 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_current.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/routing/router.dart'; @@ -29,7 +29,7 @@ class _TestViewIntentHandler implements ViewIntentHandler { Future flushDeferredViewIntent() async {} @override - Future handle(ViewIntentPayload attachment) async {} + Future handle(ViewIntentPayload payload) async {} @override void init() {} @@ -100,7 +100,7 @@ void main() { final scope = ProviderScope.containerOf(tester.element(find.byKey(const Key('root'))), listen: false); if (activeViewIntent) { scope - .read(viewIntentCurrentProvider.notifier) + .read(activeViewIntentPayloadProvider.notifier) .setPayload( ViewIntentPayload(path: '/tmp/view-intent.jpg', mimeType: 'image/jpeg', localAssetId: asset.localId), ); @@ -181,7 +181,7 @@ void main() { mimeType: 'image/jpeg', localAssetId: 'newer-local', ); - harness.scope.read(viewIntentCurrentProvider.notifier).setPayload(newerPayload); + harness.scope.read(activeViewIntentPayloadProvider.notifier).setPayload(newerPayload); await harness.coordinator.afterDelete(source: ActionSource.viewer, remoteAssetIds: [asset.id], movedToTrash: false); 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 d33a89df22..295eb59f7c 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 @@ -17,7 +17,7 @@ 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/view_intent_current.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'; @@ -205,7 +205,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()); @@ -256,7 +256,7 @@ void main() { 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(); @@ -284,19 +284,19 @@ void main() { when( () => resolver.resolve(payload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + ).thenAnswer((_) async => ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); when( () => resolver.resolve(secondPayload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); + ).thenAnswer((_) async => ViewIntentResolution(asset: secondAsset, timelineService: secondTimelineService)); await handler.handle(payload); expect(container.read(assetViewerProvider).currentAsset, deepLinkAsset); - expect(container.read(viewIntentCurrentProvider), isNull); + expect(container.read(activeViewIntentPayloadProvider), isNull); await handler.handle(secondPayload); expect(container.read(assetViewerProvider).currentAsset, secondAsset); - expect(container.read(viewIntentCurrentProvider), isNull); + expect(container.read(activeViewIntentPayloadProvider), isNull); verify(() => resolver.resolve(payload)).called(1); verify(() => resolver.resolve(secondPayload)).called(1); verify(() => router.popUntilRoot()).called(2); @@ -306,7 +306,7 @@ void main() { }); test('a slower view intent cannot replace a newer one', () async { - final firstResolution = Completer(); + final firstResolution = Completer(); final secondPayload = ViewIntentPayload( path: '/tmp/incoming-b.jpg', mimeType: 'image/jpeg', @@ -319,17 +319,17 @@ void main() { when(() => resolver.resolve(payload)).thenAnswer((_) => firstResolution.future); when( () => resolver.resolve(secondPayload), - ).thenAnswer((_) async => ViewIntentResolvedAsset(asset: secondAsset, timelineService: secondTimelineService)); + ).thenAnswer((_) async => ViewIntentResolution(asset: secondAsset, timelineService: secondTimelineService)); final firstHandle = handler.handle(payload); await pumpEventQueue(); await handler.handle(secondPayload); - firstResolution.complete(ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); + firstResolution.complete(ViewIntentResolution(asset: deepLinkAsset, timelineService: deepLinkTimelineService)); await firstHandle; expect(container.read(assetViewerProvider).currentAsset, secondAsset); - expect(container.read(viewIntentCurrentProvider), isNull); + expect(container.read(activeViewIntentPayloadProvider), isNull); verify(() => router.popUntilRoot()).called(2); verify(() => router.push(any())).called(1); }); @@ -339,7 +339,7 @@ void main() { final routeClosed = Completer(); when(() => router.push(any())).thenAnswer((_) => routeClosed.future); when(() => resolver.resolve(payload)).thenAnswer( - (_) async => ViewIntentResolvedAsset( + (_) async => ViewIntentResolution( asset: deepLinkAsset, timelineService: deepLinkTimelineService, viewIntentFilePath: path, @@ -349,13 +349,13 @@ void main() { final handling = handler.handle(payload); await pumpEventQueue(); - expect(container.read(viewIntentCurrentProvider), same(payload)); + expect(container.read(activeViewIntentPayloadProvider), same(payload)); expect(container.read(viewIntentFilePathProvider), path); routeClosed.complete(null); await handling; - expect(container.read(viewIntentCurrentProvider), isNull); + expect(container.read(activeViewIntentPayloadProvider), isNull); expect(container.read(viewIntentFilePathProvider), isNull); expect(viewIntentService.cleanedManagedTempPaths, [path]); }); @@ -364,7 +364,7 @@ void main() { final restoredAsset = _remoteAsset(id: 'remote-1', localId: 'local-1'); final restoredTimeline = await _createReadyTimelineService([restoredAsset], TimelineOrigin.deepLink); addTearDown(restoredTimeline.dispose); - container.read(viewIntentCurrentProvider.notifier).setPayload(payload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload); when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) async => restoredAsset); when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenReturn(restoredTimeline); @@ -384,7 +384,7 @@ void main() { final trashedAsset = _remoteAsset(id: 'remote-trashed', localId: 'local-1', deletedAt: DateTime(2026, 8, 4)); final trashTimeline = await _createReadyTimelineService([trashedAsset], TimelineOrigin.deepLinkTrash); addTearDown(trashTimeline.dispose); - container.read(viewIntentCurrentProvider.notifier).setPayload(payload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload); when(() => assetService.getRemoteAsset(trashedAsset.id)).thenAnswer((_) async => trashedAsset); when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLinkTrash)).thenReturn(trashTimeline); @@ -404,12 +404,12 @@ void main() { 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(viewIntentCurrentProvider.notifier).setPayload(payload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(payload); when(() => assetService.getRemoteAsset(restoredAsset.id)).thenAnswer((_) => lookup.future); final reopening = handler.reopenRemoteAsset(restoredAsset.id); await pumpEventQueue(); - container.read(viewIntentCurrentProvider.notifier).setPayload(newerPayload); + container.read(activeViewIntentPayloadProvider.notifier).setPayload(newerPayload); lookup.complete(restoredAsset); expect(await reopening, isFalse); diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index 8c8369776a..617947bc48 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -180,7 +180,7 @@ void main() { }); } -Future _resolve(ProviderContainer container, ViewIntentPayload payload) { +Future _resolve(ProviderContainer container, ViewIntentPayload payload) { return container.read(viewIntentAssetResolverProvider).resolve(payload); } From 07bf600b8576c4485ae56da7f83e8d86bc7f45ff Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 7 Aug 2026 17:43:30 +0300 Subject: [PATCH 23/25] refactor(mobile): split trash state out of TimelineOrigin --- .../lib/domain/services/timeline.service.dart | 14 +++++---- .../asset_viewer/asset_page.widget.dart | 6 ++-- .../asset_viewer/asset_stack.widget.dart | 3 +- .../asset_viewer/asset_viewer.page.dart | 2 +- .../asset_viewer/bottom_bar.widget.dart | 2 +- ...ent_asset_action_coordinator.provider.dart | 30 ++++++++++++++----- .../view_intent_handler_android.dart | 7 +++-- .../view_intent_asset_resolver.service.dart | 19 ++++++++---- mobile/lib/utils/action_button.utils.dart | 10 +++---- ...sset_action_coordinator_provider_test.dart | 23 +++++++++++--- .../view_intent_handler_android_test.dart | 12 ++++---- .../view_intent_asset_resolver_test.dart | 6 ++-- .../actions/restore_action_test.dart | 2 +- 13 files changed, 91 insertions(+), 45 deletions(-) diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 70d9e6d3c5..32f14f82f7 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -32,16 +32,20 @@ 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; +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 { 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 709de3c356..10a7b099e7 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -404,13 +404,13 @@ class _AssetPageState extends ConsumerState { final timelineOrigin = ref.watch(timelineServiceProvider).origin; final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr)); - final asset = timelineOrigin.isDeepLink && currentAsset != null ? currentAsset : _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.isTrash)); + 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); @@ -432,7 +432,7 @@ class _AssetPageState extends ConsumerState { _scrollController.snapPosition.snapOffset = _snapOffset; } - final viewIntentFilePath = timelineOrigin.isDeepLink ? ref.watch(viewIntentFilePathProvider) : null; + final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? 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 4925da219f..6118a4af9a 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.isTrash; + 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 24fa483b07..a1b73880a7 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -306,7 +306,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.isDeepLink) { + if (timelineService.origin == TimelineOrigin.deepLink) { 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 bb75dc67fd..0207b59781 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.isTrash; + final isInTrash = asset.isEffectivelyTrashed(ref.watch(timelineServiceProvider).origin); final originalTheme = context.themeData; 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 index fd26e47b9c..18db8933c3 100644 --- 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 @@ -7,16 +7,26 @@ 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]); +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}); @@ -71,7 +81,11 @@ class ViewIntentAssetActionCoordinator { } final origin = _ref.read(timelineServiceProvider).origin; - return requireTrash ? origin == TimelineOrigin.deepLinkTrash : origin.isDeepLink; + if (origin != TimelineOrigin.deepLink) { + return false; + } + + return requireTrash ? _ref.read(viewIntentTrashScopeProvider) : true; } Future _reopen(String remoteAssetId) async { 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 12e449ae1c..f2434ad073 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_p 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'; @@ -103,6 +104,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { return; } + _ref.read(viewIntentTrashScopeProvider.notifier).set(resolvedAsset.isTrashScoped); _logger.fine('resolved view intent asset: ${resolvedAsset.asset}'); await _openAssetViewer( asset: resolvedAsset.asset, @@ -131,8 +133,8 @@ class AndroidViewIntentHandler implements ViewIntentHandler { ); _activateViewIntent(reopenedAttachment); - final origin = asset.isTrashed ? TimelineOrigin.deepLinkTrash : TimelineOrigin.deepLink; - final timelineService = _ref.read(timelineFactoryProvider).fromAssets([asset], origin); + _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, @@ -154,6 +156,7 @@ class AndroidViewIntentHandler implements ViewIntentHandler { void _clearCurrentViewIntent() { _ref.read(activeViewIntentPayloadProvider.notifier).clear(); _ref.read(viewIntentFilePathProvider.notifier).clear(); + _ref.read(viewIntentTrashScopeProvider.notifier).clear(); unawaited(_viewIntentService.cleanupManagedTempFile()); } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 7e9b6ca770..4c6d4c1406 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -14,10 +14,16 @@ import 'package:logging/logging.dart'; class ViewIntentResolution { final BaseAsset asset; final TimelineService timelineService; + final bool isTrashScoped; final String? viewIntentFilePath; - const ViewIntentResolution({required this.asset, required this.timelineService, this.viewIntentFilePath}); + const ViewIntentResolution({ + required this.asset, + required this.timelineService, + this.isTrashScoped = false, + this.viewIntentFilePath, + }); } final viewIntentAssetResolverProvider = Provider( @@ -61,7 +67,11 @@ class ViewIntentAssetResolver { checksum: resolvedLocal.checksum, ); if (remoteAsset != null) { - return ViewIntentResolution(asset: remoteAsset, timelineService: _timelineFor(remoteAsset)); + return ViewIntentResolution( + asset: remoteAsset, + timelineService: _timelineFor(remoteAsset), + isTrashScoped: remoteAsset.isTrashed, + ); } } @@ -74,10 +84,7 @@ class ViewIntentAssetResolver { ); } - TimelineService _timelineFor(BaseAsset asset) { - final origin = asset is RemoteAsset && asset.isTrashed ? TimelineOrigin.deepLinkTrash : TimelineOrigin.deepLink; - return _timelineFactory.fromAssets([asset], origin); - } + TimelineService _timelineFor(BaseAsset asset) => _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink); Future<({LocalAsset? asset, String? checksum})> _resolveLocalAsset(String localAssetId) async { final localAsset = await _localAssetRepository.get(localAssetId); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 719a5ce434..d2bb45fa3b 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.isTrash, + context.asset.isEffectivelyTrashed(context.timelineOrigin), ActionButtonType.delete => true, ActionButtonType.moveToLockFolder => context.isOwner && // @@ -135,13 +135,13 @@ enum ActionButtonType { context.selectedCount == 1, ActionButtonType.unstack => context.isOwner && // - !context.timelineOrigin.isTrash && + !context.asset.isEffectivelyTrashed(context.timelineOrigin) && !context.isInLockedView && // context.isStacked, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && - !context.timelineOrigin.isDeepLink && + context.timelineOrigin != TimelineOrigin.deepLink && context.currentAlbum != null && context.currentAlbum!.isActivityEnabled && context.currentAlbum!.isShared, @@ -155,8 +155,8 @@ enum ActionButtonType { ActionButtonType.openInfo => true, ActionButtonType.viewInTimeline => context.timelineOrigin != TimelineOrigin.main && - !context.timelineOrigin.isDeepLink && - !context.timelineOrigin.isTrash && + !context.asset.isEffectivelyTrashed(context.timelineOrigin) && + context.timelineOrigin != TimelineOrigin.deepLink && context.timelineOrigin != TimelineOrigin.lockedFolder && context.timelineOrigin != TimelineOrigin.archive && context.timelineOrigin != TimelineOrigin.localAlbum && 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 index f822594d3a..b046dfc5a0 100644 --- 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 @@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_p 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'; @@ -72,6 +73,7 @@ void main() { required TimelineOrigin origin, required _TestViewIntentHandler handler, bool activeViewIntent = true, + bool isTrashScoped = false, }) async { final timeline = TimelineService(( assetSource: (_, __) async => [asset], @@ -98,6 +100,7 @@ void main() { 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) @@ -156,7 +159,13 @@ void main() { 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.deepLinkTrash, handler: handler); + final harness = await pumpHarness( + tester, + asset: asset, + origin: TimelineOrigin.deepLink, + handler: handler, + isTrashScoped: true, + ); await harness.coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [asset.id]); @@ -166,7 +175,7 @@ void main() { 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.deepLinkTrash, handler: handler); + 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); @@ -175,7 +184,7 @@ void main() { 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.deepLinkTrash, handler: handler); + final harness = await pumpHarness(tester, asset: asset, origin: TimelineOrigin.deepLink, handler: handler); final newerPayload = ViewIntentPayload( path: '/tmp/newer-view-intent.jpg', mimeType: 'image/jpeg', @@ -260,7 +269,13 @@ void main() { 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.deepLinkTrash, handler: handler); + final harness = await pumpHarness( + tester, + asset: asset, + origin: TimelineOrigin.deepLink, + handler: handler, + isTrashScoped: true, + ); await harness.coordinator.afterRestore(source: ActionSource.viewer, remoteAssetIds: [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 295eb59f7c..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 @@ -21,6 +21,7 @@ import 'package:immich_mobile/providers/view_intent/active_view_intent_payload_p 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'; @@ -374,27 +375,29 @@ void main() { 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 deep-link timeline', () async { + 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.deepLinkTrash); + 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.deepLinkTrash)).thenReturn(trashTimeline); + 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.deepLinkTrash); + 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); @@ -414,7 +417,6 @@ void main() { expect(await reopening, isFalse); verifyNever(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)); - verifyNever(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLinkTrash)); verifyNever(() => router.popUntilRoot()); verifyNever(() => router.push(any())); }); diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index 617947bc48..245819b125 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -49,7 +49,6 @@ void main() { when(() => mockLocalAssetRepository.updateHashes(any())).thenAnswer((_) async {}); _mockTimelineFactoryOrigin(timelineFactory, createdTimelineServices, TimelineOrigin.deepLink); - _mockTimelineFactoryOrigin(timelineFactory, createdTimelineServices, TimelineOrigin.deepLinkTrash); container = ProviderContainer( overrides: [ @@ -94,7 +93,7 @@ void main() { verifyNever(() => nativeSyncApi.hashAssets(any())); }); - test('returns remote trashed asset in a 1-element deep-link trash timeline', () async { + 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); @@ -104,7 +103,8 @@ void main() { expect(result.asset, isA()); expect((result.asset as RemoteAsset).localId, 'local-1'); - expect(result.timelineService.origin, TimelineOrigin.deepLinkTrash); + expect(result.timelineService.origin, TimelineOrigin.deepLink); + expect(result.isTrashScoped, isTrue); expect(result.viewIntentFilePath, isNull); }); diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart index 9e47eb8653..f265447a08 100644 --- a/mobile/test/unit/presentation/actions/restore_action_test.dart +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -113,7 +113,7 @@ void main() { final timeline = TimelineService(( assetSource: (_, __) async => [asset], bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), - origin: TimelineOrigin.deepLinkTrash, + origin: TimelineOrigin.deepLink, )); addTearDown(timeline.dispose); when( From 3db323f158ea5fc8c59b663aeba7f0a691150e64 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 7 Aug 2026 18:06:57 +0300 Subject: [PATCH 24/25] fix(mobile): add missed file --- .../view_intent_trash_scope.provider.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 mobile/lib/providers/view_intent/view_intent_trash_scope.provider.dart 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, +); From 76098c36d76f09c5905b7cf3b46cf865e2421626 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 7 Aug 2026 18:12:42 +0300 Subject: [PATCH 25/25] fix(mobile): format code --- mobile/lib/domain/services/timeline.service.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 32f14f82f7..13414e05ae 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -44,7 +44,8 @@ extension BaseAssetTimelineX on BaseAsset? { // 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); + return origin == TimelineOrigin.trash || + (origin == TimelineOrigin.deepLink && asset is RemoteAsset && asset.isTrashed); } }