From ce84da8e4d03b71cc87866f4bd8198a094777658 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 12 Jun 2026 18:47:42 +0600 Subject: [PATCH] fix(mobile): show like and comment options on album photo deep links --- mobile/lib/services/deep_link.service.dart | 10 +- .../test/services/deep_link_service_test.dart | 140 ++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 mobile/test/services/deep_link_service_test.dart diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 26f2fb685b..d5947ceb8f 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -70,7 +70,10 @@ class DeepLinkService { if (assetRegex.hasMatch(path)) { final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; - return _buildAssetDeepLink(assetId, ref); + // /albums//photos/ links carry the album context, + // which drives the like/comment UI in the viewer + final albumId = albumRegex.firstMatch(path)?.group(1); + return _buildAssetDeepLink(assetId, ref, albumId: albumId); } if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; @@ -107,16 +110,19 @@ class DeepLinkService { return DriftMemoryRoute(memories: memories, memoryIndex: 0); } - Future _buildAssetDeepLink(String assetId, WidgetRef ref) async { + Future _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async { final asset = await _betaAssetService.getRemoteAsset(assetId); if (asset == null) { return null; } + final album = albumId != null ? await _betaRemoteAlbumService.get(albumId) : null; + AssetViewer.setAsset(ref, asset); return AssetViewerRoute( initialIndex: 0, timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink), + currentAlbum: album, ); } diff --git a/mobile/test/services/deep_link_service_test.dart b/mobile/test/services/deep_link_service_test.dart new file mode 100644 index 0000000000..ff090367ea --- /dev/null +++ b/mobile/test/services/deep_link_service_test.dart @@ -0,0 +1,140 @@ +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/album/album.model.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/memory.service.dart'; +import 'package:immich_mobile/domain/services/people.service.dart'; +import 'package:immich_mobile/domain/services/remote_album.service.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/deep_link.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTimelineFactory extends Mock implements TimelineFactory {} + +class MockAssetService extends Mock implements AssetService {} + +class MockRemoteAlbumService extends Mock implements RemoteAlbumService {} + +class MockDriftMemoryService extends Mock implements DriftMemoryService {} + +class MockDriftPeopleService extends Mock implements DriftPeopleService {} + +class MockPlatformDeepLink extends Mock implements PlatformDeepLink {} + +class MockWidgetRef extends Mock implements WidgetRef {} + +class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotifier {} + +const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; +const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd'; + +final _asset = RemoteAsset( + id: _assetId, + name: 'photo.jpg', + ownerId: 'user-1', + checksum: 'checksum-1', + type: AssetType.image, + createdAt: DateTime(2026, 6, 12), + updatedAt: DateTime(2026, 6, 12), + isEdited: false, +); + +final _album = RemoteAlbum( + id: _albumId, + name: 'Shared Album', + ownerId: 'user-1', + description: '', + createdAt: DateTime(2026, 6, 12), + updatedAt: DateTime(2026, 6, 12), + isActivityEnabled: true, + isShared: true, + order: AlbumAssetOrder.asc, + assetCount: 1, + ownerName: 'Owner', +); + +void main() { + late MockTimelineFactory timelineFactory; + late MockAssetService assetService; + late MockRemoteAlbumService remoteAlbumService; + late MockWidgetRef ref; + late List createdTimelineServices; + late DeepLinkService sut; + + setUp(() { + timelineFactory = MockTimelineFactory(); + assetService = MockAssetService(); + remoteAlbumService = MockRemoteAlbumService(); + ref = MockWidgetRef(); + createdTimelineServices = []; + + when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) { + final assets = List.from(invocation.positionalArguments[0] as List); + final timelineService = TimelineService(( + assetSource: (index, count) async => assets.skip(index).take(count).toList(), + bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]), + origin: TimelineOrigin.deepLink, + )); + createdTimelineServices.add(timelineService); + return timelineService; + }); + + when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier()); + + sut = DeepLinkService( + timelineFactory, + assetService, + remoteAlbumService, + MockDriftMemoryService(), + MockDriftPeopleService(), + null, + ); + + addTearDown(() async { + for (final timelineService in createdTimelineServices) { + await timelineService.dispose(); + } + }); + }); + + PlatformDeepLink link(String path) { + final deepLink = MockPlatformDeepLink(); + when(() => deepLink.uri).thenReturn(Uri.parse('https://my.immich.app$path')); + return deepLink; + } + + test('album photo link carries the album into the viewer route', () async { + when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset); + when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => _album); + + final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref); + + expect(route, isA()); + expect((route!.args as AssetViewerRouteArgs).currentAlbum, _album); + }); + + test('still opens the viewer when the album cannot be resolved', () async { + when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset); + when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => null); + + final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref); + + expect(route, isA()); + expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull); + }); + + test('plain photo link has no album', () async { + when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset); + + final route = await sut.handleMyImmichApp(link('/photos/$_assetId'), ref); + + expect(route, isA()); + expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull); + verifyNever(() => remoteAlbumService.get(any())); + }); +}