mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
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:
parent
c759233d8c
commit
7d0e8f50f7
@ -90,6 +90,12 @@
|
|||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,11 +86,23 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>Share Extension</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</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>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>210</string>
|
<string>210</string>
|
||||||
@ -120,6 +132,8 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
<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>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
|
||||||
<key>NSLocationUsageDescription</key>
|
<key>NSLocationUsageDescription</key>
|
||||||
@ -166,8 +180,6 @@
|
|||||||
<true />
|
<true />
|
||||||
<key>io.flutter.embedded_views_preview</key>
|
<key>io.flutter.embedded_views_preview</key>
|
||||||
<true />
|
<true />
|
||||||
<key>NSFaceIDUsageDescription</key>
|
|
||||||
<string>We need to use FaceID to allow access to your locked folder</string>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>We need local network permission to connect to the local server using IP address and
|
<string>We need local network permission to connect to the local server using IP address and
|
||||||
allow the casting feature to work</string>
|
allow the casting feature to work</string>
|
||||||
|
@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:my.immich.app</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.networking.wifi-info</key>
|
<key>com.apple.developer.networking.wifi-info</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
@ -4,6 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<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>
|
<key>com.apple.developer.networking.wifi-info</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:easy_localization/easy_localization.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/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/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/locale_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||||
import 'package:immich_mobile/routing/router.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/theme/theme_data.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/cache/widgets_binding.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/download.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/utils/migration.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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@ -220,8 +248,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
|||||||
colorScheme: immichTheme.light,
|
colorScheme: immichTheme.light,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
),
|
),
|
||||||
routeInformationParser: router.defaultRouteParser(),
|
routerConfig: router.config(
|
||||||
routerDelegate: router.delegate(
|
deepLinkBuilder: _deepLinkBuilder,
|
||||||
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -72,7 +72,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.router.current.name != ShareIntentRoute.name) {
|
if (context.router.current.name == SplashScreenRoute.name) {
|
||||||
context.replaceRoute(const TabControllerRoute());
|
context.replaceRoute(const TabControllerRoute());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||||
|
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
|
@ -93,6 +93,10 @@ class AlbumRepository extends DatabaseRepository {
|
|||||||
|
|
||||||
Future<Album?> get(int id) => db.albums.get(id);
|
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(
|
Future<void> removeUsers(Album album, List<UserDto> users) => txn(
|
||||||
() => album.sharedUsers.update(unlink: users.map(entity.User.fromDto)),
|
() => album.sharedUsers.update(unlink: users.map(entity.User.fromDto)),
|
||||||
);
|
);
|
||||||
|
@ -25,6 +25,11 @@ class AppNavigationObserver extends AutoRouterObserver {
|
|||||||
@override
|
@override
|
||||||
void didPush(Route route, Route? previousRoute) {
|
void didPush(Route route, Route? previousRoute) {
|
||||||
_handleLockedViewState(route, previousRoute);
|
_handleLockedViewState(route, previousRoute);
|
||||||
|
|
||||||
|
Future(
|
||||||
|
() => ref.read(currentRouteNameProvider.notifier).state =
|
||||||
|
route.settings.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleLockedViewState(Route route, Route? previousRoute) {
|
_handleLockedViewState(Route route, Route? previousRoute) {
|
||||||
|
@ -340,5 +340,8 @@ class AppRouter extends RootStackRouter {
|
|||||||
page: MainTimelineRoute.page,
|
page: MainTimelineRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
),
|
),
|
||||||
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
|
// auto_route_library#1722
|
||||||
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -486,6 +486,10 @@ class AlbumService {
|
|||||||
return _albumRepository.get(id);
|
return _albumRepository.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Album?> getAlbumByRemoteId(String remoteId) {
|
||||||
|
return _albumRepository.getByRemoteId(remoteId);
|
||||||
|
}
|
||||||
|
|
||||||
Stream<Album?> watchAlbum(int id) {
|
Stream<Album?> watchAlbum(int id) {
|
||||||
return _albumRepository.watchAlbum(id);
|
return _albumRepository.watchAlbum(id);
|
||||||
}
|
}
|
||||||
|
@ -549,4 +549,9 @@ class AssetService {
|
|||||||
|
|
||||||
await _assetRepository.updateAll(updatedAssets);
|
await _assetRepository.updateAll(updatedAssets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Asset?> getAssetByRemoteId(String remoteId) async {
|
||||||
|
final assets = await _assetRepository.getAllByRemoteId([remoteId]);
|
||||||
|
return assets.isNotEmpty ? assets.first : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
140
mobile/lib/services/deep_link.service.dart
Normal file
140
mobile/lib/services/deep_link.service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -59,4 +59,34 @@ class MemoryService {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user