From c8c6f8651828b78dd3a4f7a2593a7c59e128367a Mon Sep 17 00:00:00 2001 From: bwees Date: Thu, 31 Jul 2025 13:38:14 -0500 Subject: [PATCH] feat: view shared links inside of mobile app --- .../models/album/shared_album.model.dart | 21 +++ .../services/remote_shared_album.service.dart | 14 ++ .../lib/domain/services/search.service.dart | 41 +--- .../lib/domain/services/timeline.service.dart | 1 + .../models/shared_link/shared_link.model.dart | 10 +- .../pages/shared_remote_link.page.dart | 176 ++++++++++++++++++ mobile/lib/providers/api.provider.dart | 2 +- mobile/lib/providers/api.provider.g.dart | 6 +- mobile/lib/providers/auth.provider.dart | 2 +- mobile/lib/providers/cast.provider.dart | 2 + .../infrastructure/album.provider.dart | 3 +- .../repositories/asset_api.repository.dart | 45 ++++- .../drift_album_api_repository.dart | 24 +++ mobile/lib/routing/auth_guard.dart | 6 + mobile/lib/routing/router.dart | 8 +- mobile/lib/routing/router.gr.dart | 52 ++++++ mobile/lib/services/api.service.dart | 11 +- mobile/lib/services/deep_link.service.dart | 15 +- mobile/lib/services/shared_link.service.dart | 9 + mobile/lib/utils/image_url_builder.dart | 47 ++++- 20 files changed, 434 insertions(+), 61 deletions(-) create mode 100644 mobile/lib/domain/models/album/shared_album.model.dart create mode 100644 mobile/lib/domain/services/remote_shared_album.service.dart create mode 100644 mobile/lib/presentation/pages/shared_remote_link.page.dart diff --git a/mobile/lib/domain/models/album/shared_album.model.dart b/mobile/lib/domain/models/album/shared_album.model.dart new file mode 100644 index 0000000000..84a7420bb4 --- /dev/null +++ b/mobile/lib/domain/models/album/shared_album.model.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +class SharedRemoteAlbum extends RemoteAlbum { + final List assets; + + const SharedRemoteAlbum({ + required super.id, + required this.assets, + required super.name, + required super.ownerId, + required super.description, + required super.createdAt, + required super.updatedAt, + super.thumbnailAssetId, + required super.isActivityEnabled, + required super.order, + required super.assetCount, + required super.ownerName, + }); +} diff --git a/mobile/lib/domain/services/remote_shared_album.service.dart b/mobile/lib/domain/services/remote_shared_album.service.dart new file mode 100644 index 0000000000..b72bdeb26c --- /dev/null +++ b/mobile/lib/domain/services/remote_shared_album.service.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/album/shared_album.model.dart'; +import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; + +class RemoteSharedAlbumService { + final DriftAlbumApiRepository _albumApiRepository; + + const RemoteSharedAlbumService(this._albumApiRepository); + + Future getSharedAlbum(String albumId) { + return _albumApiRepository.getShared(albumId); + } +} diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index 6ccc5a97bf..e56c2f71c6 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -1,10 +1,9 @@ -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/search_result.model.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' as api show AssetVisibility; import 'package:openapi/api.dart' hide AssetVisibility; class SearchService { @@ -52,41 +51,3 @@ class SearchService { return null; } } - -extension on AssetResponseDto { - RemoteAsset toDto() { - return RemoteAsset( - id: id, - name: originalFileName, - checksum: checksum, - createdAt: fileCreatedAt, - updatedAt: updatedAt, - ownerId: ownerId, - visibility: switch (visibility) { - api.AssetVisibility.timeline => AssetVisibility.timeline, - api.AssetVisibility.hidden => AssetVisibility.hidden, - api.AssetVisibility.archive => AssetVisibility.archive, - api.AssetVisibility.locked => AssetVisibility.locked, - _ => AssetVisibility.timeline, - }, - durationInSeconds: duration.toDuration()?.inSeconds ?? 0, - height: exifInfo?.exifImageHeight?.toInt(), - width: exifInfo?.exifImageWidth?.toInt(), - isFavorite: isFavorite, - livePhotoVideoId: livePhotoVideoId, - thumbHash: thumbhash, - localId: null, - type: type.toAssetType(), - ); - } -} - -extension on AssetTypeEnum { - AssetType toAssetType() => switch (this) { - AssetTypeEnum.IMAGE => AssetType.image, - AssetTypeEnum.VIDEO => AssetType.video, - AssetTypeEnum.AUDIO => AssetType.audio, - AssetTypeEnum.OTHER => AssetType.other, - _ => throw Exception('Unknown AssetType value: $this'), - }; -} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 9fa4106d17..4665022a69 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -158,6 +158,7 @@ class TimelineService { BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); BaseAsset getAsset(int index) { + print("buffer len: " + _buffer.length.toString()); if (!hasRange(index, 1)) { throw RangeError( 'TimelineService::getAsset Index $index not in buffer range [$_bufferOffset, ${_bufferOffset + _buffer.length})', diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index 57a1f441eb..45ece3ba12 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:openapi/api.dart'; enum SharedLinkSource { album, individual } @@ -14,6 +16,8 @@ class SharedLink { final String key; final bool showMetadata; final SharedLinkSource type; + final List assets; + final String? albumId; const SharedLink({ required this.id, @@ -27,6 +31,8 @@ class SharedLink { required this.key, required this.showMetadata, required this.type, + this.assets = const [], + this.albumId, }); SharedLink copyWith({ @@ -74,7 +80,9 @@ class SharedLink { ? dto.album?.albumThumbnailAssetId : dto.assets.isNotEmpty ? dto.assets[0].id - : null; + : null, + assets = dto.assets.map((asset) => asset.toDto()).toList(), + albumId = dto.album?.id; @override String toString() => diff --git a/mobile/lib/presentation/pages/shared_remote_link.page.dart b/mobile/lib/presentation/pages/shared_remote_link.page.dart new file mode 100644 index 0000000000..67fd44b377 --- /dev/null +++ b/mobile/lib/presentation/pages/shared_remote_link.page.dart @@ -0,0 +1,176 @@ +import 'package:auto_route/auto_route.dart'; +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/remote_shared_album.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/shared_link.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +// ignore: import_rule_openapi +import 'package:openapi/api.dart'; + +@RoutePage() +class RemoteSharedLinkPage extends ConsumerStatefulWidget { + final String shareKey; + final String endpoint; + + const RemoteSharedLinkPage({super.key, required this.shareKey, required this.endpoint}); + + @override + ConsumerState createState() => _RemoteSharedLinkPageState(); +} + +class _RemoteSharedLinkPageState extends ConsumerState { + late final ApiService _apiService; + + SharedLink? sharedLink; + List? assets; + + @override + void initState() { + super.initState(); + + String endpoint = widget.endpoint; + if (!endpoint.endsWith('/api')) { + endpoint += '/api'; + } + ImageUrlBuilder.setHost(endpoint); + ImageUrlBuilder.setParameter('key', widget.shareKey); + _apiService = ApiService.shared(endpoint, widget.shareKey); + + retrieveSharedLink(); + } + + @override + void dispose() { + ImageUrlBuilder.clear(); + super.dispose(); + } + + Future retrieveSharedLink() async { + try { + sharedLink = await SharedLinkService(_apiService).getMySharedLink(); + } on ApiException catch (error, _) { + if (error.code == 401) { + // We need a password from user. + // TODO: make password input and try to auth again + } + } + + if (sharedLink == null) { + ImmichToast.show( + context: context, + msg: "shared_link_not_found".t(context: context), + toastType: ToastType.error, + ); + context.pop(); + return; + } + + // Retrieve assets from the shared link + switch (sharedLink!.type) { + case SharedLinkSource.album: + assets = await retrieveSharedAlbumAssets(); + break; + case SharedLinkSource.individual: + assets = sharedLink!.assets; + break; + } + + // if (assets!.isEmpty) { + // context.pop(); + // } + + setState(() {}); + } + + Future> retrieveSharedAlbumAssets() async { + try { + final driftApiRepository = DriftAlbumApiRepository(_apiService.albumsApi); + final albumService = RemoteSharedAlbumService(driftApiRepository); + final sharedAlbum = await albumService.getSharedAlbum(sharedLink!.albumId!); + + return sharedAlbum?.assets ?? []; + } catch (e) { + ImmichToast.show( + context: context, + msg: "failed_to_retrieve_assets".t(context: context), + toastType: ToastType.error, + ); + return []; + } + } + + Future addAssets(BuildContext context) async { + // TODO add upload assets to shared album + } + + void showOptionSheet(BuildContext context) { + final user = ref.watch(currentUserProvider); + + // showModalBottomSheet( + // context: context, + // backgroundColor: context.colorScheme.surface, + // isScrollControlled: false, + // builder: (context) { + // return DriftRemoteAlbumOption( + // onDeleteAlbum: isOwner + // ? () async { + // await deleteAlbum(context); + // if (context.mounted) { + // context.pop(); + // } + // } + // : null, + // onAddUsers: isOwner + // ? () async { + // await addUsers(context); + // context.pop(); + // } + // : null, + // onAddPhotos: () async { + // await addAssets(context); + // context.pop(); + // }, + // onToggleAlbumOrder: () async { + // await toggleAlbumOrder(); + // context.pop(); + // }, + // onEditAlbum: () async { + // context.pop(); + // await showEditTitleAndDescription(context); + // }, + // ); + // }, + // ); + } + + @override + Widget build(BuildContext context) { + if (assets == null || assets!.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return ProviderScope( + overrides: [ + apiServiceProvider.overrideWith((ref) => _apiService), + timelineServiceProvider.overrideWith((ref) { + print(assets!.length); + final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets!); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: Timeline(appBar: SliverToBoxAdapter(child: AppBar())), + ); + } +} diff --git a/mobile/lib/providers/api.provider.dart b/mobile/lib/providers/api.provider.dart index a54496d94c..748aefb556 100644 --- a/mobile/lib/providers/api.provider.dart +++ b/mobile/lib/providers/api.provider.dart @@ -4,5 +4,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'api.provider.g.dart'; -@Riverpod(keepAlive: true) +@Riverpod(keepAlive: true, dependencies: []) ApiService apiService(Ref _) => ApiService(); diff --git a/mobile/lib/providers/api.provider.g.dart b/mobile/lib/providers/api.provider.g.dart index ee1781c24c..83c51ccba6 100644 --- a/mobile/lib/providers/api.provider.g.dart +++ b/mobile/lib/providers/api.provider.g.dart @@ -6,7 +6,7 @@ part of 'api.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$apiServiceHash() => r'187a7de59b064fab1104c23717f18ce0ae3e426c'; +String _$apiServiceHash() => r'46d8a043f41b85f36f56d81e5261c842cb5c0c06'; /// See also [apiService]. @ProviderFor(apiService) @@ -16,8 +16,8 @@ final apiServiceProvider = Provider.internal( debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null : _$apiServiceHash, - dependencies: null, - allTransitiveDependencies: null, + dependencies: const [], + allTransitiveDependencies: const {}, ); @Deprecated('Will be removed in 3.0. Use Ref instead') diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 02f7920d6f..70552fb243 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -28,7 +28,7 @@ final authProvider = StateNotifierProvider((ref) { ref.watch(secureStorageServiceProvider), ref.watch(widgetServiceProvider), ); -}); +}, dependencies: [apiServiceProvider]); class AuthNotifier extends StateNotifier { final AuthService _authService; diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 75a2a35fb6..c040e00a6e 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -2,10 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/services/gcast.service.dart'; final castProvider = StateNotifierProvider( (ref) => CastNotifier(ref.watch(gCastServiceProvider)), + dependencies: [apiServiceProvider], ); class CastNotifier extends StateNotifier { diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index da0f9bc9ce..71210c7d15 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; @@ -31,7 +32,7 @@ final remoteAlbumRepository = Provider( final remoteAlbumServiceProvider = Provider( (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)), - dependencies: [remoteAlbumRepository], + dependencies: [remoteAlbumRepository, apiServiceProvider], ); final remoteAlbumProvider = NotifierProvider( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 07639fbb3a..eb2e906a20 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.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/stack.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType; +import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -108,3 +111,41 @@ extension on StackResponseDto { return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList()); } } + +extension RemoteAssetDtoExt on AssetResponseDto { + RemoteAsset toDto() { + return RemoteAsset( + id: id, + name: originalFileName, + checksum: checksum, + createdAt: fileCreatedAt, + updatedAt: updatedAt, + ownerId: ownerId, + visibility: switch (visibility) { + api.AssetVisibility.timeline => AssetVisibility.timeline, + api.AssetVisibility.hidden => AssetVisibility.hidden, + api.AssetVisibility.archive => AssetVisibility.archive, + api.AssetVisibility.locked => AssetVisibility.locked, + _ => AssetVisibility.timeline, + }, + durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + height: exifInfo?.exifImageHeight?.toInt(), + width: exifInfo?.exifImageWidth?.toInt(), + isFavorite: isFavorite, + livePhotoVideoId: livePhotoVideoId, + thumbHash: thumbhash, + localId: null, + type: type.toType(), + ); + } +} + +extension on AssetTypeEnum { + AssetType toType() => switch (this) { + AssetTypeEnum.IMAGE => AssetType.image, + AssetTypeEnum.VIDEO => AssetType.video, + AssetTypeEnum.AUDIO => AssetType.audio, + AssetTypeEnum.OTHER => AssetType.other, + _ => throw Exception('Unknown AssetType value: $this'), + }; +} diff --git a/mobile/lib/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart index 6de025fb47..80facaace1 100644 --- a/mobile/lib/repositories/drift_album_api_repository.dart +++ b/mobile/lib/repositories/drift_album_api_repository.dart @@ -1,7 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/album/shared_album.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; // ignore: import_rule_openapi import 'package:openapi/api.dart'; @@ -87,6 +89,11 @@ class DriftAlbumApiRepository extends ApiRepository { final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers))); return response.toRemoteAlbum(); } + + Future getShared(String albumId) async { + final responseDto = await checkNull(_api.getAlbumInfo(albumId)); + return responseDto.toSharedRemoteAlbum(); + } } extension on AlbumResponseDto { @@ -105,4 +112,21 @@ extension on AlbumResponseDto { ownerName: owner.name, ); } + + SharedRemoteAlbum toSharedRemoteAlbum() { + return SharedRemoteAlbum( + id: id, + name: albumName, + ownerId: owner.id, + description: description, + createdAt: createdAt, + updatedAt: updatedAt, + thumbnailAssetId: albumThumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc, + assetCount: assetCount, + ownerName: owner.name, + assets: assets.map((e) => e.toDto()).toList(), + ); + } } diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 33eb8e81ad..fc80fe56f0 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -17,6 +18,11 @@ class AuthGuard extends AutoRouteGuard { void onNavigation(NavigationResolver resolver, StackRouter router) async { resolver.next(true); + if (ImageUrlBuilder.isSharedLink()) { + // If the URL is a shared link, we don't need to validate the access token + return; + } + try { // Look in the store for an access token Store.get(StoreKey.accessToken); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 4fe1673893..949b82c938 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -23,11 +23,11 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart' import 'package:immich_mobile/pages/album/album_viewer.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; import 'package:immich_mobile/pages/backup/album_preview.page.dart'; -import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart'; -import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart'; import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; @@ -95,12 +95,13 @@ import 'package:immich_mobile/presentation/pages/drift_person.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; +import 'package:immich_mobile/presentation/pages/shared_remote_link.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -329,6 +330,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: RemoteSharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e8f0dd8b1f..08746ec754 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -2167,6 +2167,58 @@ class RemoteMediaSummaryRoute extends PageRouteInfo { ); } +/// generated route for +/// [RemoteSharedLinkPage] +class RemoteSharedLinkRoute extends PageRouteInfo { + RemoteSharedLinkRoute({ + Key? key, + required String shareKey, + required String endpoint, + List? children, + }) : super( + RemoteSharedLinkRoute.name, + args: RemoteSharedLinkRouteArgs( + key: key, + shareKey: shareKey, + endpoint: endpoint, + ), + initialChildren: children, + ); + + static const String name = 'RemoteSharedLinkRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return RemoteSharedLinkPage( + key: args.key, + shareKey: args.shareKey, + endpoint: args.endpoint, + ); + }, + ); +} + +class RemoteSharedLinkRouteArgs { + const RemoteSharedLinkRouteArgs({ + this.key, + required this.shareKey, + required this.endpoint, + }); + + final Key? key; + + final String shareKey; + + final String endpoint; + + @override + String toString() { + return 'RemoteSharedLinkRouteArgs{key: $key, shareKey: $shareKey, endpoint: $endpoint}'; + } +} + /// generated route for /// [SearchPage] class SearchRoute extends PageRouteInfo { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index fca9080c86..983304f853 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -8,9 +8,9 @@ import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; +import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; class ApiService implements Authentication { late ApiClient _apiClient; @@ -45,7 +45,14 @@ class ApiService implements Authentication { setEndpoint(endpoint); } } + + ApiService.shared(String endpoint, String sharedKey) { + setEndpoint(endpoint); + _queryParams = {'key': sharedKey}; + } + String? _accessToken; + Map? _queryParams; final _log = Logger("ApiService"); setEndpoint(String endpoint) { @@ -208,6 +215,8 @@ class ApiService implements Authentication { return Future(() { var headers = ApiService.getRequestHeaders(); headerParams.addAll(headers); + + queryParams.addAll(_queryParams?.entries.map((e) => QueryParam(e.key, e.value)) ?? []); }); } diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 1b717a6eeb..cacc39a6de 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -80,6 +80,7 @@ class DeepLinkService { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), + "sharedlink" => await _buildSharedLinkDeepLink(queryParams['key'] ?? '', queryParams['instanceUrl'] ?? ''), _ => null, }; @@ -99,18 +100,24 @@ class DeepLinkService { final path = link.uri.path; const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; + const b64Regex = r'^[A-Za-z0-9_-]+$'; final assetRegex = RegExp('/photos/($uuidRegex)'); final albumRegex = RegExp('/albums/($uuidRegex)'); + final sharedLinkRegex = RegExp('/share/($b64Regex)'); PageRouteInfo? deepLinkRoute; + if (assetRegex.hasMatch(path)) { final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; deepLinkRoute = await _buildAssetDeepLink(assetId); } else if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; deepLinkRoute = await _buildAlbumDeepLink(albumId); + } else if (sharedLinkRegex.hasMatch(path)) { + final shareKey = sharedLinkRegex.firstMatch(path)?.group(1) ?? ''; + final instanceUrl = link.uri.queryParameters['instanceUrl'] ?? ''; + deepLinkRoute = await _buildSharedLinkDeepLink(shareKey, instanceUrl); } - // Deep link resolution failed, safely handle it based on the app state if (deepLinkRoute == null) { if (isColdStart) return DeepLink.defaultPath; @@ -185,4 +192,8 @@ class DeepLinkService { return AlbumViewerRoute(albumId: album.id); } } + + Future _buildSharedLinkDeepLink(String shareKey, String instanceUrl) async { + return RemoteSharedLinkRoute(shareKey: shareKey, endpoint: instanceUrl); + } } diff --git a/mobile/lib/services/shared_link.service.dart b/mobile/lib/services/shared_link.service.dart index 25151c234f..f77ca42833 100644 --- a/mobile/lib/services/shared_link.service.dart +++ b/mobile/lib/services/shared_link.service.dart @@ -111,4 +111,13 @@ class SharedLinkService { } return null; } + + Future getMySharedLink() async { + final responseDto = await _apiService.sharedLinksApi.getMySharedLink(); + if (responseDto != null) { + return SharedLink.fromDto(responseDto); + } + + return null; + } } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 21722cb901..d994c6e613 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -36,7 +36,7 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset } String getOriginalUrlForRemoteId(final String id) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original'; + return ImageUrlBuilder.build('/assets/$id/original'); } String getImageCacheKey(final Asset asset) { @@ -46,16 +46,51 @@ String getImageCacheKey(final Asset asset) { } String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}'; + return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${type.value}'); } -String getPreviewUrlForRemoteId(final String id) => - '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}'; +String getPreviewUrlForRemoteId(final String id) { + return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${AssetMediaSize.preview}'); +} String getPlaybackUrlForRemoteId(final String id) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; + return ImageUrlBuilder.build('/assets/$id/video/playback?'); } String getFaceThumbnailUrl(final String personId) { - return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail'; + return ImageUrlBuilder.build('/people/$personId/thumbnail'); +} + +class ImageUrlBuilder { + static String? host; + static Map? queryParams; + + static void setHost(String? host) { + ImageUrlBuilder.host = host; + } + + static bool isSharedLink() { + return ImageUrlBuilder.host != null && ImageUrlBuilder.queryParams!.containsKey('key'); + } + + static void setParameter(String key, String value) { + ImageUrlBuilder.queryParams ??= {}; + ImageUrlBuilder.queryParams![key] = value; + } + + static String build(String path) { + final endpoint = host ?? Store.get(StoreKey.serverEndpoint); + + final uri = Uri.parse('$endpoint$path'); + if (queryParams == null || queryParams!.isEmpty) { + return uri.toString(); + } + final updatedUri = uri.replace(queryParameters: {...uri.queryParameters, ...queryParams!}); + return updatedUri.toString(); + } + + static void clear() { + ImageUrlBuilder.host = null; + ImageUrlBuilder.queryParams = null; + } }