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