From 7af9d6d7b7f5b83e11e023fcaf98b078e006b9cb Mon Sep 17 00:00:00 2001 From: Damian Lesiuk Date: Tue, 9 Sep 2025 21:42:32 +0200 Subject: [PATCH] fix: The 'Copy Link' function will prioritize copying any available custom URL, mirroring the behavior found on the web version --- .../models/shared_link/shared_link.model.dart | 9 +- .../shared_link/shared_link_edit.page.dart | 3 +- mobile/lib/utils/url_helper.dart | 10 + .../widgets/shared_link/shared_link_item.dart | 4 +- .../shared_link_edit_page_test.dart | 143 ++++++++++++ .../shared_link/shared_link_item_test.dart | 130 +++++++++++ .../shared_link/shared_link_test_utils.dart | 206 ++++++++++++++++++ 7 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 mobile/test/modules/shared_link/shared_link_edit_page_test.dart create mode 100644 mobile/test/modules/shared_link/shared_link_item_test.dart create mode 100644 mobile/test/modules/shared_link/shared_link_test_utils.dart diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index 57a1f441eb..1d3c81f1a1 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -13,6 +13,7 @@ class SharedLink { final DateTime? expiresAt; final String key; final bool showMetadata; + final String? slug; final SharedLinkSource type; const SharedLink({ @@ -26,6 +27,7 @@ class SharedLink { required this.expiresAt, required this.key, required this.showMetadata, + required this.slug, required this.type, }); @@ -40,6 +42,7 @@ class SharedLink { DateTime? expiresAt, String? key, bool? showMetadata, + String? slug, SharedLinkSource? type, }) { return SharedLink( @@ -53,6 +56,7 @@ class SharedLink { expiresAt: expiresAt ?? this.expiresAt, key: key ?? this.key, showMetadata: showMetadata ?? this.showMetadata, + slug: slug ?? this.slug, type: type ?? this.type, ); } @@ -66,6 +70,7 @@ class SharedLink { expiresAt = dto.expiresAt, key = dto.key, showMetadata = dto.showMetadata, + slug = dto.slug, type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, title = dto.type == SharedLinkType.ALBUM ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE" @@ -78,7 +83,7 @@ class SharedLink { @override String toString() => - 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)'; + 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, slug=$slug, type=$type)'; @override bool operator ==(Object other) => @@ -94,6 +99,7 @@ class SharedLink { other.expiresAt == expiresAt && other.key == key && other.showMetadata == showMetadata && + other.slug == slug && other.type == type; @override @@ -108,5 +114,6 @@ class SharedLink { expiresAt.hashCode ^ key.hashCode ^ showMetadata.hashCode ^ + slug.hashCode ^ type.hashCode; } diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index c78a9d5138..ddaa956b40 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -274,7 +274,8 @@ class SharedLinkEditPage extends HookConsumerWidget { } if (newLink != null && serverUrl != null) { - newShareLink.value = "${serverUrl}share/${newLink.key}"; + final fullPath = getShareUrlPath(newLink); + newShareLink.value = "$serverUrl$fullPath"; copyLinkToClipboard(); } else if (newLink == null) { ImmichToast.show( diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index e3d5b8ed57..c41e3e82fa 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:punycode/punycode.dart'; String sanitizeUrl(String url) { @@ -90,3 +91,12 @@ String? punycodeDecodeUrl(String? serverUrl) { return Uri.decodeFull(serverUri.replace(host: decodedHost).toString()); } + +/// Generates the appropriate share URL path for a given shared link. +/// +/// Returns a path string based on the shared link's slug and key: +/// - If slug is present: 's/${sharedLink.slug}' +/// - Otherwise: 'share/${sharedLink.key}' +String getShareUrlPath(SharedLink sharedLink) { + return sharedLink.slug != null ? 's/${sharedLink.slug}' : 'share/${sharedLink.key}'; +} diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index 0eced33ce3..88b7e53fb7 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -77,7 +77,9 @@ class SharedLinkItem extends ConsumerWidget { return; } - Clipboard.setData(ClipboardData(text: "${serverUrl}share/${sharedLink.key}")).then((_) { + final fullPath = getShareUrlPath(sharedLink); + + Clipboard.setData(ClipboardData(text: "$serverUrl$fullPath")).then((_) { context.scaffoldMessenger.showSnackBar( SnackBar( content: Text( diff --git a/mobile/test/modules/shared_link/shared_link_edit_page_test.dart b/mobile/test/modules/shared_link/shared_link_edit_page_test.dart new file mode 100644 index 0000000000..7a0cbc22d3 --- /dev/null +++ b/mobile/test/modules/shared_link/shared_link_edit_page_test.dart @@ -0,0 +1,143 @@ +@Tags(['widget']) +library; + +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:isar/isar.dart'; + +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/services/shared_link.service.dart'; + +import '../../test_utils.dart'; +import 'shared_link_test_utils.dart'; + +late ClipboardCapturer clipboardCapturer; + +void main() { + late Isar db; + late MockSharedLinkService mockSharedLinkService; + late MockServerInfoNotifier mockServerInfoNotifier; + late ProviderContainer container; + + Future createSharedLink(WidgetTester tester) async { + await tester.enterText(find.byType(TextField).at(0), 'Test Description'); + await tester.pump(); + + final createButton = find.widgetWithText(ElevatedButton, 'create_link'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pumpAndSettle(); + } + + Future pumpSharedLinkEditPage( + WidgetTester tester, + ProviderContainer container, { + SharedLink? existingLink, + List? assetsList, + String? albumId, + }) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: SharedLinkEditPage(existingLink: existingLink, assetsList: assetsList, albumId: albumId), + ), + ), + ); + await tester.pumpAndSettle(); + } + + setUpAll(() async { + TestUtils.init(); + db = await TestUtils.initIsar(); + setupTestViewport(); + }); + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + EasyLocalization.logger.enableBuildModes = []; + + await StoreService.init(storeRepository: IsarStoreRepository(db)); + await Store.put(StoreKey.serverEndpoint, 'https://demo.immich.app'); + + mockSharedLinkService = MockSharedLinkService(); + mockServerInfoNotifier = MockServerInfoNotifier(); + container = ProviderContainer( + overrides: [ + sharedLinkServiceProvider.overrideWith((ref) => mockSharedLinkService), + serverInfoProvider.overrideWith((ref) => mockServerInfoNotifier), + ], + ); + + clipboardCapturer = ClipboardCapturer(); + setupClipboardMock(clipboardCapturer); + + setupDefaultMockResponses(mockSharedLinkService); + }); + + tearDown(() { + container.dispose(); + cleanupClipboardMock(); + clipboardCapturer.clear(); + }); + + group('SharedLinkEditPage Tests', () { + testWidgets('copies URL with slug to clipboard after link creation', (tester) async { + await pumpSharedLinkEditPage(tester, container, albumId: 'album-1'); + await createSharedLink(tester); + + final copyButton = find.byIcon(Icons.copy); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + expect(clipboardCapturer.text, 'https://demo.immich.app/s/new-slug'); + }); + + testWidgets('copies URL without slug to clipboard after link creation', (tester) async { + setupMockResponseForLinkWithoutSlug(mockSharedLinkService); + + await pumpSharedLinkEditPage(tester, container, albumId: 'album-1'); + await createSharedLink(tester); + + final copyButton = find.byIcon(Icons.copy); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://demo.immich.app/share/new-key'); + }); + + testWidgets('handles empty external domain by using server endpoint', (tester) async { + setupEmptyExternalDomain(mockServerInfoNotifier); + + await pumpSharedLinkEditPage(tester, container, albumId: 'album-1'); + await createSharedLink(tester); + + final copyButton = find.byIcon(Icons.copy); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://demo.immich.app/s/new-slug'); + }); + + testWidgets('uses custom domain when external domain is set', (tester) async { + final mockServerInfoWithCustomDomain = createMockServerInfo(externalDomain: 'https://custom-immich.com'); + mockServerInfoNotifier.state = mockServerInfoWithCustomDomain; + + await pumpSharedLinkEditPage(tester, container, albumId: 'album-1'); + await createSharedLink(tester); + + final copyButton = find.byIcon(Icons.copy); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://custom-immich.com/s/new-slug'); + }); + }); +} diff --git a/mobile/test/modules/shared_link/shared_link_item_test.dart b/mobile/test/modules/shared_link/shared_link_item_test.dart new file mode 100644 index 0000000000..686ce82f09 --- /dev/null +++ b/mobile/test/modules/shared_link/shared_link_item_test.dart @@ -0,0 +1,130 @@ +@Tags(['widget']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/shared_link.provider.dart'; +import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart'; + +import '../../fixtures/user.stub.dart'; +import '../../test_utils.dart'; +import '../../widget_tester_extensions.dart'; +import 'shared_link_test_utils.dart'; + +void main() { + late MockServerInfoNotifier mockServerInfoNotifier; + late MockSharedLinksNotifier mockSharedLinksNotifier; + late List overrides; + late ClipboardCapturer clipboardCapturer; + late Isar db; + + final sharedLinkWithSlug = createSharedLinkWithSlug(); + final sharedLinkWithoutSlug = createSharedLinkWithoutSlug(); + + setUpAll(() async { + TestUtils.init(); + db = await TestUtils.initIsar(); + }); + + setUp(() async { + await StoreService.init(storeRepository: IsarStoreRepository(db)); + + await Store.put(StoreKey.currentUser, UserStub.admin); + await Store.put(StoreKey.serverEndpoint, 'http://example.com'); + await Store.put(StoreKey.accessToken, 'test-token'); + await Store.put(StoreKey.serverUrl, 'http://example.com'); + + mockServerInfoNotifier = MockServerInfoNotifier(); + mockSharedLinksNotifier = MockSharedLinksNotifier(); + clipboardCapturer = ClipboardCapturer(); + + setupDefaultServerInfo(mockServerInfoNotifier); + mockSharedLinksNotifier.state = const AsyncValue.data([]); + + overrides = [ + serverInfoProvider.overrideWith((ref) => mockServerInfoNotifier), + sharedLinksStateProvider.overrideWith((ref) => mockSharedLinksNotifier), + ]; + + setupClipboardMock(clipboardCapturer); + + when(() => mockSharedLinksNotifier.deleteLink(any())).thenAnswer((_) async {}); + }); + + tearDown(() { + reset(mockSharedLinksNotifier); + cleanupClipboardMock(); + clipboardCapturer.clear(); + StoreService.I.dispose(); + }); + + group('SharedLinkItem Tests', () { + testWidgets('copies URL with slug to clipboard', (tester) async { + await tester.pumpConsumerWidget(Scaffold(body: SharedLinkItem(sharedLinkWithSlug)), overrides: overrides); + + final copyButton = find.byIcon(Icons.copy_outlined); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://example.com/s/test-slug'); + }); + + testWidgets('copies URL without slug to clipboard', (tester) async { + await tester.pumpConsumerWidget(Scaffold(body: SharedLinkItem(sharedLinkWithoutSlug)), overrides: overrides); + + final copyButton = find.byIcon(Icons.copy_outlined); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://example.com/share/test-key-2'); + }); + + testWidgets('handles empty external domain by using server URL', (tester) async { + setupEmptyExternalDomain(mockServerInfoNotifier); + await Store.put(StoreKey.serverEndpoint, 'http://example.com'); + + await tester.pumpConsumerWidget(Scaffold(body: SharedLinkItem(sharedLinkWithSlug)), overrides: overrides); + + final copyButton = find.byIcon(Icons.copy_outlined); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'http://example.com/s/test-slug'); + }); + + testWidgets('shows snackbar on successful copy', (tester) async { + await tester.pumpConsumerWidget(Scaffold(body: SharedLinkItem(sharedLinkWithSlug)), overrides: overrides); + + final copyButton = find.byIcon(Icons.copy_outlined); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(find.textContaining('copied'), findsOneWidget); + }); + + testWidgets('works with different shared links', (tester) async { + await tester.pumpConsumerWidget(Scaffold(body: SharedLinkItem(sharedLinkWithSlug)), overrides: overrides); + + final copyButton = find.byIcon(Icons.copy_outlined); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://example.com/s/test-slug'); + + await tester.pumpConsumerWidget(Scaffold(body: SharedLinkItem(sharedLinkWithoutSlug)), overrides: overrides); + await tester.tap(copyButton); + await tester.pumpAndSettle(); + + expect(clipboardCapturer.text, 'https://example.com/share/test-key-2'); + }); + }); +} diff --git a/mobile/test/modules/shared_link/shared_link_test_utils.dart b/mobile/test/modules/shared_link/shared_link_test_utils.dart new file mode 100644 index 0000000000..ba184e1be7 --- /dev/null +++ b/mobile/test/modules/shared_link/shared_link_test_utils.dart @@ -0,0 +1,206 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/server_info/server_config.model.dart'; +import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; +import 'package:immich_mobile/models/server_info/server_features.model.dart'; +import 'package:immich_mobile/models/server_info/server_info.model.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/shared_link.provider.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; +import 'package:immich_mobile/services/shared_link.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockSharedLinkService extends Mock implements SharedLinkService {} + +class MockServerInfoService extends Mock implements ServerInfoService {} + +class MockServerInfoNotifier extends ServerInfoNotifier { + MockServerInfoNotifier({String externalDomain = 'https://demo.immich.app'}) : super(MockServerInfoService()) { + state = createMockServerInfo(externalDomain: externalDomain); + } + + @override + Future getServerConfig() async {} +} + +class MockSharedLinksNotifier extends StateNotifier>> + with Mock + implements SharedLinksNotifier { + MockSharedLinksNotifier() : super(const AsyncValue.loading()); +} + +final mockServerInfoNotifierProvider = Provider((ref) => MockServerInfoNotifier()); + +/// Creates a mock ServerInfo with customizable parameters for testing +ServerInfo createMockServerInfo({ + String externalDomain = 'https://demo.immich.app', + String serverEndpoint = 'https://demo.immich.app', +}) { + return ServerInfo( + serverVersion: const ServerVersion(major: 1, minor: 0, patch: 0), + latestVersion: const ServerVersion(major: 1, minor: 0, patch: 0), + serverFeatures: const ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true), + serverConfig: ServerConfig( + trashDays: 30, + oauthButtonText: '', + externalDomain: externalDomain, + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + ), + serverDiskInfo: const ServerDiskInfo(diskAvailable: "0", diskSize: "0", diskUse: "0", diskUsagePercentage: 0), + isVersionMismatch: false, + isNewReleaseAvailable: false, + versionMismatchErrorMessage: "", + ); +} + +/// Creates a mock album shared link with customizable parameters for testing +SharedLink createAlbumSharedLink({ + String id = '1', + String title = 'Test Album', + String description = 'Test Description', + String key = 'test-key', + String? slug = 'test-slug', + bool allowUpload = true, + bool allowDownload = true, + bool showMetadata = true, + String? password, +}) { + return SharedLink( + id: id, + title: title, + description: description, + key: key, + slug: slug, + expiresAt: DateTime.now().add(const Duration(days: 1)), + allowUpload: allowUpload, + allowDownload: allowDownload, + showMetadata: showMetadata, + thumbAssetId: null, + password: password, + type: SharedLinkSource.album, + ); +} + +/// Creates a shared link with slug for testing +SharedLink createSharedLinkWithSlug() { + return SharedLink( + id: '1', + title: 'Test Link', + description: 'Test Description', + key: 'test-key', + slug: 'test-slug', + expiresAt: DateTime.now().add(const Duration(days: 1)), + allowUpload: true, + allowDownload: true, + showMetadata: true, + thumbAssetId: null, + password: null, + type: SharedLinkSource.album, + ); +} + +/// Creates a shared link without slug for testing +SharedLink createSharedLinkWithoutSlug() { + return SharedLink( + id: '2', + title: 'Test Link 2', + description: 'Test Description 2', + key: 'test-key-2', + slug: null, + expiresAt: DateTime.now().add(const Duration(days: 1)), + allowUpload: false, + allowDownload: false, + showMetadata: false, + thumbAssetId: null, + password: null, + type: SharedLinkSource.individual, + ); +} + +/// Sets up default server info with external domain for testing +void setupDefaultServerInfo(MockServerInfoNotifier mockServerInfoNotifier) { + final mockServerInfo = createMockServerInfo(externalDomain: 'https://example.com'); + mockServerInfoNotifier.state = mockServerInfo; +} + +/// Sets up empty external domain for testing +void setupEmptyExternalDomain(ServerInfoNotifier mockServerInfoNotifier) { + final mockServerInfoWithEmptyDomain = createMockServerInfo(externalDomain: ''); + mockServerInfoNotifier.state = mockServerInfoWithEmptyDomain; +} + +/// Sets up default mock responses for shared link service +void setupDefaultMockResponses(MockSharedLinkService mockSharedLinkService) { + when( + () => mockSharedLinkService.createSharedLink( + albumId: any(named: 'albumId'), + assetIds: any(named: 'assetIds'), + showMeta: any(named: 'showMeta'), + allowDownload: any(named: 'allowDownload'), + allowUpload: any(named: 'allowUpload'), + description: any(named: 'description'), + password: any(named: 'password'), + expiresAt: any(named: 'expiresAt'), + ), + ).thenAnswer( + (_) async => createAlbumSharedLink(id: 'new-link-id', title: 'New Link', key: 'new-key', slug: 'new-slug'), + ); +} + +/// Sets up mock response for link without slug +void setupMockResponseForLinkWithoutSlug(MockSharedLinkService mockSharedLinkService) { + when( + () => mockSharedLinkService.createSharedLink( + albumId: any(named: 'albumId'), + assetIds: any(named: 'assetIds'), + showMeta: any(named: 'showMeta'), + allowDownload: any(named: 'allowDownload'), + allowUpload: any(named: 'allowUpload'), + description: any(named: 'description'), + password: any(named: 'password'), + expiresAt: any(named: 'expiresAt'), + ), + ).thenAnswer((_) async => createAlbumSharedLink(id: 'new-link-id', title: 'New Link', key: 'new-key', slug: null)); +} + +/// Test utility to capture clipboard operations for verification +class ClipboardCapturer { + String text = ''; + + void clear() { + text = ''; + } +} + +/// Sets up a mock clipboard handler for tests that captures clipboard operations +void setupClipboardMock(ClipboardCapturer capturer) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, ( + methodCall, + ) async { + if (methodCall.method == 'Clipboard.setData') { + final data = methodCall.arguments as Map; + final text = data['text'] as String?; + capturer.text = text ?? ''; + return null; + } + return null; + }); +} + +/// Cleans up the clipboard mock after tests to prevent interference +void cleanupClipboardMock() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); +} + +/// Sets up larger viewport for test button visibility +void setupTestViewport() { + TestWidgetsFlutterBinding.instance.platformDispatcher.views.first.physicalSize = const Size(1200, 2000); + TestWidgetsFlutterBinding.instance.platformDispatcher.views.first.devicePixelRatio = 1.0; +}