From 7d0e8f50f799782475fd5c1f0c2fd9719f2902aa Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 24 Jun 2025 09:20:24 -0500 Subject: [PATCH] feat(mobile): deep links (#19232) * add deep linking on ios app * add deeplinking to android * code review fixes * lint * cleanly handle malformed URIs when launching app * refactor deep link builder/service, still have bug with navigation stack not containing TabControllerRoute * fix: tab controller insertion conditions * add my.immich.app app linking * chore: remove one-liner if statement --------- Co-authored-by: Alex --- .../android/app/src/main/AndroidManifest.xml | 6 + mobile/ios/Runner/Info.plist | 16 +- mobile/ios/Runner/Runner.entitlements | 4 + mobile/ios/Runner/RunnerProfile.entitlements | 4 + mobile/lib/main.dart | 32 +++- .../lib/pages/common/splash_screen.page.dart | 2 +- mobile/lib/providers/routes.provider.dart | 1 + mobile/lib/repositories/album.repository.dart | 4 + .../lib/routing/app_navigation_observer.dart | 5 + mobile/lib/routing/router.dart | 3 + mobile/lib/services/album.service.dart | 4 + mobile/lib/services/asset.service.dart | 5 + mobile/lib/services/deep_link.service.dart | 140 ++++++++++++++++++ mobile/lib/services/memory.service.dart | 30 ++++ 14 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 mobile/lib/services/deep_link.service.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 2179c9eb3c..d5a7c9ca86 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -90,6 +90,12 @@ + + + + + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index b2566e6790..4237813dfc 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -86,11 +86,23 @@ CFBundleTypeRole Editor + CFBundleURLName + Share Extension CFBundleURLSchemes ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + CFBundleTypeRole + Editor + CFBundleURLName + Deep Link + CFBundleURLSchemes + + immich + + CFBundleVersion 210 @@ -120,6 +132,8 @@ NSCameraUsageDescription We need to access the camera to let you take beautiful video using this app + NSFaceIDUsageDescription + We need to use FaceID to allow access to your locked folder NSLocationAlwaysAndWhenInUseUsageDescription We require this permission to access the local WiFi name for background upload mechanism NSLocationUsageDescription @@ -166,8 +180,6 @@ io.flutter.embedded_views_preview - NSFaceIDUsageDescription - We need to use FaceID to allow access to your locked folder NSLocalNetworkUsageDescription We need local network permission to connect to the local server using IP address and allow the casting feature to work diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index d558e35e0a..e5862cb213 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:my.immich.app + com.apple.developer.networking.wifi-info com.apple.security.application-groups diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index d44633db40..6a5c086baf 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:my.immich.app + com.apple.developer.networking.wifi-info com.apple.security.application-groups diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index bc9edcd46e..1f18ecdf5a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provide import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -26,6 +28,7 @@ import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; +import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -169,6 +172,31 @@ class ImmichAppState extends ConsumerState ); } + Future _deepLinkBuilder(PlatformDeepLink deepLink) async { + final deepLinkHandler = ref.read(deepLinkServiceProvider); + final currentRouteName = ref.read(currentRouteNameProvider.notifier).state; + + if (deepLink.uri.scheme == "immich") { + final proposedRoute = await deepLinkHandler.handleScheme( + deepLink, + currentRouteName == SplashScreenRoute.name, + ); + + return proposedRoute; + } + + if (deepLink.uri.host == "my.immich.app") { + final proposedRoute = await deepLinkHandler.handleMyImmichApp( + deepLink, + currentRouteName == SplashScreenRoute.name, + ); + + return proposedRoute; + } + + return DeepLink.path(deepLink.path); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -220,8 +248,8 @@ class ImmichAppState extends ConsumerState colorScheme: immichTheme.light, locale: context.locale, ), - routeInformationParser: router.defaultRouteParser(), - routerDelegate: router.delegate( + routerConfig: router.config( + deepLinkBuilder: _deepLinkBuilder, navigatorObservers: () => [AppNavigationObserver(ref: ref)], ), ), diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index b640aaa3ed..4b7a10d612 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -72,7 +72,7 @@ class SplashScreenPageState extends ConsumerState { return; } - if (context.router.current.name != ShareIntentRoute.name) { + if (context.router.current.name == SplashScreenRoute.name) { context.replaceRoute(const TabControllerRoute()); } diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index a5b903e312..74d86f4767 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -1,3 +1,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; final inLockedViewProvider = StateProvider((ref) => false); +final currentRouteNameProvider = StateProvider((ref) => null); diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index b555f918b4..aeb9fb3947 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -93,6 +93,10 @@ class AlbumRepository extends DatabaseRepository { Future get(int id) => db.albums.get(id); + Future getByRemoteId(String remoteId) { + return db.albums.filter().remoteIdEqualTo(remoteId).findFirst(); + } + Future removeUsers(Album album, List users) => txn( () => album.sharedUsers.update(unlink: users.map(entity.User.fromDto)), ); diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 44662c0b8b..047e897c8e 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -25,6 +25,11 @@ class AppNavigationObserver extends AutoRouterObserver { @override void didPush(Route route, Route? previousRoute) { _handleLockedViewState(route, previousRoute); + + Future( + () => ref.read(currentRouteNameProvider.notifier).state = + route.settings.name, + ); } _handleLockedViewState(Route route, Route? previousRoute) { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3e1563dd25..79f74bc5ee 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -340,5 +340,8 @@ class AppRouter extends RootStackRouter { page: MainTimelineRoute.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/services/album.service.dart b/mobile/lib/services/album.service.dart index cc1f56f579..6733ec41b2 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -486,6 +486,10 @@ class AlbumService { return _albumRepository.get(id); } + Future getAlbumByRemoteId(String remoteId) { + return _albumRepository.getByRemoteId(remoteId); + } + Stream watchAlbum(int id) { return _albumRepository.watchAlbum(id); } diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index b761ebb39b..cb7f59e3a9 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -549,4 +549,9 @@ class AssetService { await _assetRepository.updateAll(updatedAssets); } + + Future getAssetByRemoteId(String remoteId) async { + final assets = await _assetRepository.getAllByRemoteId([remoteId]); + return assets.isNotEmpty ? assets.first : null; + } } diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart new file mode 100644 index 0000000000..67b6b79793 --- /dev/null +++ b/mobile/lib/services/deep_link.service.dart @@ -0,0 +1,140 @@ +import 'package:auto_route/auto_route.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/routing/router.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; +import 'package:immich_mobile/services/memory.service.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final deepLinkServiceProvider = Provider( + (ref) => DeepLinkService( + ref.watch(memoryServiceProvider), + ref.watch(assetServiceProvider), + ref.watch(albumServiceProvider), + ref.watch(currentAssetProvider.notifier), + ref.watch(currentAlbumProvider.notifier), + ), +); + +class DeepLinkService { + final MemoryService _memoryService; + final AssetService _assetService; + final AlbumService _albumService; + final CurrentAsset _currentAsset; + final CurrentAlbum _currentAlbum; + + DeepLinkService( + this._memoryService, + this._assetService, + this._albumService, + this._currentAsset, + this._currentAlbum, + ); + + Future handleScheme(PlatformDeepLink link, bool isColdStart) async { + // get everything after the scheme, since Uri cannot parse path + final intent = link.uri.host; + final queryParams = link.uri.queryParameters; + + PageRouteInfo? deepLinkRoute; + + switch (intent) { + case "memory": + deepLinkRoute = await _buildMemoryDeepLink(queryParams['id'] ?? ''); + case "asset": + deepLinkRoute = await _buildAssetDeepLink(queryParams['id'] ?? ''); + case "album": + deepLinkRoute = await _buildAlbumDeepLink(queryParams['id'] ?? ''); + } + + // Deep link resolution failed, safely handle it based on the app state + if (deepLinkRoute == null) { + if (isColdStart) { + return DeepLink.defaultPath; + } + + return DeepLink.none; + } + + return DeepLink([ + // we need something to segue back to if the app was cold started + if (isColdStart) const PhotosRoute(), + deepLinkRoute, + ]); + } + + Future handleMyImmichApp( + PlatformDeepLink link, + bool isColdStart, + ) async { + 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}'; + final assetRegex = RegExp('/photos/($uuidRegex)'); + final albumRegex = RegExp('/albums/($uuidRegex)'); + + 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); + } + + // Deep link resolution failed, safely handle it based on the app state + if (deepLinkRoute == null) { + if (isColdStart) return DeepLink.defaultPath; + return DeepLink.none; + } + + return DeepLink([ + // we need something to segue back to if the app was cold started + if (isColdStart) const PhotosRoute(), + deepLinkRoute, + ]); + } + + Future _buildMemoryDeepLink(String memoryId) async { + final memory = await _memoryService.getMemoryById(memoryId); + + if (memory == null) { + return null; + } + + return MemoryRoute(memories: [memory], memoryIndex: 0); + } + + Future _buildAssetDeepLink(String assetId) async { + final asset = await _assetService.getAssetByRemoteId(assetId); + + if (asset == null) { + return null; + } + + _currentAsset.set(asset); + final renderList = await RenderList.fromAssets([asset], GroupAssetsBy.auto); + + return GalleryViewerRoute( + renderList: renderList, + initialIndex: 0, + heroOffset: 0, + showStack: true, + ); + } + + Future _buildAlbumDeepLink(String albumId) async { + final album = await _albumService.getAlbumByRemoteId(albumId); + + if (album == null) { + return null; + } + + _currentAlbum.set(album); + + return AlbumViewerRoute(albumId: album.id); + } +} diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index 6e9d017c7a..a44a5ad1bd 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -59,4 +59,34 @@ class MemoryService { return null; } } + + Future getMemoryById(String id) async { + try { + final memoryResponse = await _apiService.memoriesApi.getMemory(id); + + if (memoryResponse == null) { + return null; + } + final dbAssets = await _assetRepository + .getAllByRemoteId(memoryResponse.assets.map((e) => e.id)); + if (dbAssets.isEmpty) { + log.warning("No assets found for memory with ID: $id"); + return null; + } + final yearsAgo = DateTime.now().year - memoryResponse.data.year; + final String title = 'years_ago'.t( + args: { + 'years': yearsAgo.toString(), + }, + ); + + return Memory( + title: title, + assets: dbAssets, + ); + } catch (error, stack) { + log.severe("Cannot get memory with ID: $id", error, stack); + return null; + } + } }