fix: The 'Copy Link' function will prioritize copying any available custom URL, mirroring the behavior found on the web version

This commit is contained in:
Damian Lesiuk 2025-09-09 21:42:32 +02:00
parent 27bc8eba7b
commit 7af9d6d7b7
7 changed files with 502 additions and 3 deletions

View file

@ -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;
}

View file

@ -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(

View file

@ -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}';
}

View file

@ -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(

View file

@ -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<void> 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<void> pumpSharedLinkEditPage(
WidgetTester tester,
ProviderContainer container, {
SharedLink? existingLink,
List<String>? 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');
});
});
}

View file

@ -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<Override> 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');
});
});
}

View file

@ -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<void> getServerConfig() async {}
}
class MockSharedLinksNotifier extends StateNotifier<AsyncValue<List<SharedLink>>>
with Mock
implements SharedLinksNotifier {
MockSharedLinksNotifier() : super(const AsyncValue.loading());
}
final mockServerInfoNotifierProvider = Provider<ServerInfoNotifier>((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<String, dynamic>;
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;
}