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 <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees 2025-06-24 09:20:24 -05:00 committed by GitHub
parent c759233d8c
commit 7d0e8f50f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 251 additions and 5 deletions

View File

@ -90,6 +90,12 @@
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="immich" />
</intent-filter>
</activity>

View File

@ -86,11 +86,23 @@
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>210</string>
@ -120,6 +132,8 @@
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
@ -166,8 +180,6 @@
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

@ -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<ImmichApp>
);
}
Future<DeepLink> _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<ImmichApp>
colorScheme: immichTheme.light,
locale: context.locale,
),
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
),
),

View File

@ -72,7 +72,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
return;
}
if (context.router.current.name != ShareIntentRoute.name) {
if (context.router.current.name == SplashScreenRoute.name) {
context.replaceRoute(const TabControllerRoute());
}

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null);

View File

@ -93,6 +93,10 @@ class AlbumRepository extends DatabaseRepository {
Future<Album?> get(int id) => db.albums.get(id);
Future<Album?> getByRemoteId(String remoteId) {
return db.albums.filter().remoteIdEqualTo(remoteId).findFirst();
}
Future<void> removeUsers(Album album, List<UserDto> users) => txn(
() => album.sharedUsers.update(unlink: users.map(entity.User.fromDto)),
);

View File

@ -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) {

View File

@ -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: '/'),
];
}

View File

@ -486,6 +486,10 @@ class AlbumService {
return _albumRepository.get(id);
}
Future<Album?> getAlbumByRemoteId(String remoteId) {
return _albumRepository.getByRemoteId(remoteId);
}
Stream<Album?> watchAlbum(int id) {
return _albumRepository.watchAlbum(id);
}

View File

@ -549,4 +549,9 @@ class AssetService {
await _assetRepository.updateAll(updatedAssets);
}
Future<Asset?> getAssetByRemoteId(String remoteId) async {
final assets = await _assetRepository.getAllByRemoteId([remoteId]);
return assets.isNotEmpty ? assets.first : null;
}
}

View File

@ -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<DeepLink> 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<dynamic>? 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<DeepLink> 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<dynamic>? 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<MemoryRoute?> _buildMemoryDeepLink(String memoryId) async {
final memory = await _memoryService.getMemoryById(memoryId);
if (memory == null) {
return null;
}
return MemoryRoute(memories: [memory], memoryIndex: 0);
}
Future<GalleryViewerRoute?> _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<AlbumViewerRoute?> _buildAlbumDeepLink(String albumId) async {
final album = await _albumService.getAlbumByRemoteId(albumId);
if (album == null) {
return null;
}
_currentAlbum.set(album);
return AlbumViewerRoute(albumId: album.id);
}
}

View File

@ -59,4 +59,34 @@ class MemoryService {
return null;
}
}
Future<Memory?> 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;
}
}
}