From 8c143d36efb27a5cc5085d64c18e5bc9cb898203 Mon Sep 17 00:00:00 2001
From: Peter Ombodi
Date: Mon, 20 Apr 2026 17:53:48 +0300
Subject: [PATCH] refactor(mobile): split view intent handler by platform and
trigger it from app events
---
mobile/lib/main.dart | 56 ++++-----
.../view_intent_handler.provider.dart | 113 +++---------------
.../view_intent_handler_android.dart | 105 ++++++++++++++++
.../view_intent/view_intent_handler_stub.dart | 18 +++
.../lib/widgets/forms/login/login_form.dart | 4 +-
5 files changed, 167 insertions(+), 129 deletions(-)
create mode 100644 mobile/lib/providers/view_intent/view_intent_handler_android.dart
create mode 100644 mobile/lib/providers/view_intent/view_intent_handler_stub.dart
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 15b5a0587d..ddc974ab6e 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -19,13 +19,13 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
-import 'package:immich_mobile/pages/common/splash_screen.page.dart';
-import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
-import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
-import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
-import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
-import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
+import 'package:immich_mobile/pages/common/splash_screen.page.dart';
+import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
+import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/platform.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';
@@ -120,19 +120,16 @@ class ImmichApp extends ConsumerStatefulWidget {
ImmichAppState createState() => ImmichAppState();
}
-class ImmichAppState extends ConsumerState with WidgetsBindingObserver {
- ProviderSubscription? _authSubscription;
-
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
+class ImmichAppState extends ConsumerState with WidgetsBindingObserver {
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
- dPrint(() => "[APP STATE] resumed");
- ref.read(appStateProvider.notifier).handleAppResume();
- // Check for ACTION_VIEW intent when app resumes
- unawaited(ref.read(viewIntentHandlerProvider).checkForViewIntent());
- unawaited(ref.read(viewIntentHandlerProvider).flushPending());
- break;
+ dPrint(() => "[APP STATE] resumed");
+ ref.read(appStateProvider.notifier).handleAppResume();
+ unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
+ break;
case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive");
ref.read(appStateProvider.notifier).handleAppInactivity();
@@ -215,18 +212,17 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve
StaticTranslations.instance.backup_background_service_default_notification,
);
}
- });
-
- ref.read(viewIntentHandlerProvider).init();
- ref.read(shareIntentUploadProvider.notifier).init();
- }
-
- @override
- void dispose() {
- _authSubscription?.close();
- WidgetsBinding.instance.removeObserver(this);
- super.dispose();
- }
+ });
+
+ ref.read(viewIntentHandlerProvider).init();
+ ref.read(shareIntentUploadProvider.notifier).init();
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
@override
void reassemble() {
diff --git a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart
index 81f970d826..e88a163659 100644
--- a/mobile/lib/providers/view_intent/view_intent_handler.provider.dart
+++ b/mobile/lib/providers/view_intent/view_intent_handler.provider.dart
@@ -1,105 +1,24 @@
-import 'dart:async';
+import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
-import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
-import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
-import 'package:immich_mobile/providers/auth.provider.dart';
-import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
-import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
-import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/services/view_intent.service.dart';
-import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
-import 'package:logging/logging.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
-final viewIntentHandlerProvider = Provider(
- (ref) => ViewIntentHandler(
- ref,
- ref.read(viewIntentServiceProvider),
- ref.read(viewIntentAssetResolverProvider),
- ref.watch(appRouterProvider),
- ),
-);
+abstract class ViewIntentHandler {
+ void init();
-class ViewIntentHandler {
- final Ref _ref;
- final ViewIntentService _viewIntentService;
- final ViewIntentAssetResolver _viewIntentAssetResolver;
- final AppRouter _router;
- static final Logger _logger = Logger('ViewIntentHandler');
+ Future onAppResumed();
- const ViewIntentHandler(this._ref, this._viewIntentService, this._viewIntentAssetResolver, this._router);
+ Future onUserAuthenticated();
- void init() {
- unawaited(checkForViewIntent());
- unawaited(
- Future(() async {
- await flushPending();
- }),
- );
- }
-
- Future checkForViewIntent() async {
- final attachment = await _viewIntentService.consumeViewIntent();
- if (attachment != null) {
- await handle(attachment);
- return;
- }
-
- if (_ref.read(viewIntentPendingProvider) == null) {
- await _viewIntentService.cleanupStaleTempFiles();
- }
- }
-
- Future flushPending() async {
- final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
- _logger.info('flushPending, pendingAttachment:$pendingAttachment}');
- if (pendingAttachment != null) {
- await handle(pendingAttachment);
- }
- }
-
- Future handle(ViewIntentPayload attachment) async {
- _logger.info(
- 'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
- );
-
- if (!_ref.read(authProvider).isAuthenticated) {
- _ref.read(viewIntentPendingProvider.notifier).defer(attachment);
- return;
- }
-
- final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
- _logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
- _openAssetViewer(
- resolvedAsset.asset,
- resolvedAsset.timelineService,
- resolvedAsset.initialIndex,
- viewIntentFilePath: resolvedAsset.viewIntentFilePath,
- );
- }
-
- void _openAssetViewer(
- BaseAsset asset,
- TimelineService timelineService,
- int initialIndex, {
- String? viewIntentFilePath,
- }) {
- _ref.read(assetViewerProvider.notifier).reset();
- _ref.read(assetViewerProvider.notifier).setAsset(asset);
- if (viewIntentFilePath != null) {
- _ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
- unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
- } else {
- _ref.read(viewIntentFilePathProvider.notifier).clear();
- unawaited(_viewIntentService.cleanupManagedTempFile());
- }
-
- if (asset.isVideo) {
- _ref.read(assetViewerProvider.notifier).setControls(true);
- }
-
- _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService));
- }
+ Future handle(ViewIntentPayload attachment);
}
+
+final viewIntentHandlerProvider = Provider((ref) {
+ if (Platform.isAndroid) {
+ return AndroidViewIntentHandler(ref);
+ }
+
+ return const StubViewIntentHandler();
+});
diff --git a/mobile/lib/providers/view_intent/view_intent_handler_android.dart b/mobile/lib/providers/view_intent/view_intent_handler_android.dart
new file mode 100644
index 0000000000..22bb772b20
--- /dev/null
+++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart
@@ -0,0 +1,105 @@
+import 'dart:async';
+
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/domain/services/timeline.service.dart';
+import 'package:immich_mobile/platform/view_intent_api.g.dart';
+import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
+import 'package:immich_mobile/providers/auth.provider.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/services/view_intent.service.dart';
+import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
+import 'package:logging/logging.dart';
+
+class AndroidViewIntentHandler implements ViewIntentHandler {
+ final Ref _ref;
+ final ViewIntentService _viewIntentService;
+ final ViewIntentAssetResolver _viewIntentAssetResolver;
+ final AppRouter _router;
+ static final Logger _logger = Logger('ViewIntentHandler');
+
+ AndroidViewIntentHandler(Ref ref)
+ : _ref = ref,
+ _viewIntentService = ref.read(viewIntentServiceProvider),
+ _viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
+ _router = ref.watch(appRouterProvider);
+
+ @override
+ void init() {
+ // Covers cold start from a view intent before the first lifecycle "resumed".
+ unawaited(onAppResumed());
+ }
+
+ @override
+ Future onAppResumed() => _checkForViewIntent();
+
+ @override
+ Future onUserAuthenticated() => _flushPending();
+
+ Future _checkForViewIntent() async {
+ final attachment = await _viewIntentService.consumeViewIntent();
+ if (attachment != null) {
+ await handle(attachment);
+ return;
+ }
+
+ if (_ref.read(viewIntentPendingProvider) == null) {
+ await _viewIntentService.cleanupStaleTempFiles();
+ }
+ }
+
+ Future _flushPending() async {
+ final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
+ _logger.info('flushPending, pendingAttachment:$pendingAttachment}');
+ if (pendingAttachment != null) {
+ await handle(pendingAttachment);
+ }
+ }
+
+ @override
+ Future handle(ViewIntentPayload attachment) async {
+ _logger.info(
+ 'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
+ );
+
+ if (!_ref.read(authProvider).isAuthenticated) {
+ _ref.read(viewIntentPendingProvider.notifier).defer(attachment);
+ return;
+ }
+
+ final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
+ _logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
+ _openAssetViewer(
+ resolvedAsset.asset,
+ resolvedAsset.timelineService,
+ resolvedAsset.initialIndex,
+ viewIntentFilePath: resolvedAsset.viewIntentFilePath,
+ );
+ }
+
+ void _openAssetViewer(
+ BaseAsset asset,
+ TimelineService timelineService,
+ int initialIndex, {
+ String? viewIntentFilePath,
+ }) {
+ _ref.read(assetViewerProvider.notifier).reset();
+ _ref.read(assetViewerProvider.notifier).setAsset(asset);
+ if (viewIntentFilePath != null) {
+ _ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
+ unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
+ } else {
+ _ref.read(viewIntentFilePathProvider.notifier).clear();
+ unawaited(_viewIntentService.cleanupManagedTempFile());
+ }
+
+ if (asset.isVideo) {
+ _ref.read(assetViewerProvider.notifier).setControls(true);
+ }
+
+ _router.push(AssetViewerRoute(initialIndex: initialIndex, timelineService: timelineService));
+ }
+}
diff --git a/mobile/lib/providers/view_intent/view_intent_handler_stub.dart b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart
new file mode 100644
index 0000000000..3e642e5f44
--- /dev/null
+++ b/mobile/lib/providers/view_intent/view_intent_handler_stub.dart
@@ -0,0 +1,18 @@
+import 'package:immich_mobile/platform/view_intent_api.g.dart';
+import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
+
+class StubViewIntentHandler implements ViewIntentHandler {
+ const StubViewIntentHandler();
+
+ @override
+ void init() {}
+
+ @override
+ Future onAppResumed() async {}
+
+ @override
+ Future onUserAuthenticated() async {}
+
+ @override
+ Future handle(ViewIntentPayload attachment) async {}
+}
diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart
index f464fabbdb..dfbf479b23 100644
--- a/mobile/lib/widgets/forms/login/login_form.dart
+++ b/mobile/lib/widgets/forms/login/login_form.dart
@@ -253,7 +253,7 @@ class LoginForm extends HookConsumerWidget {
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
WidgetsBinding.instance.addPostFrameCallback((_) {
- unawaited(ref.read(viewIntentHandlerProvider).flushPending());
+ unawaited(ref.read(viewIntentHandlerProvider).onUserAuthenticated());
});
return;
}
@@ -343,7 +343,7 @@ class LoginForm extends HookConsumerWidget {
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
WidgetsBinding.instance.addPostFrameCallback((_) {
- unawaited(ref.read(viewIntentHandlerProvider).flushPending());
+ unawaited(ref.read(viewIntentHandlerProvider).onUserAuthenticated());
});
return;
}