refactor(mobile): split view intent handler by platform and trigger it from app events

This commit is contained in:
Peter Ombodi
2026-04-20 17:53:48 +03:00
parent 45411f38e8
commit 8c143d36ef
5 changed files with 167 additions and 129 deletions
+26 -30
View File
@@ -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<ImmichApp> with WidgetsBindingObserver {
ProviderSubscription<bool>? _authSubscription;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
class ImmichAppState extends ConsumerState<ImmichApp> 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<ImmichApp> 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() {
@@ -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<ViewIntentHandler>(
(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<void> onAppResumed();
const ViewIntentHandler(this._ref, this._viewIntentService, this._viewIntentAssetResolver, this._router);
Future<void> onUserAuthenticated();
void init() {
unawaited(checkForViewIntent());
unawaited(
Future(() async {
await flushPending();
}),
);
}
Future<void> checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
if (attachment != null) {
await handle(attachment);
return;
}
if (_ref.read(viewIntentPendingProvider) == null) {
await _viewIntentService.cleanupStaleTempFiles();
}
}
Future<void> flushPending() async {
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
_logger.info('flushPending, pendingAttachment:$pendingAttachment}');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
}
Future<void> 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<void> handle(ViewIntentPayload attachment);
}
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
if (Platform.isAndroid) {
return AndroidViewIntentHandler(ref);
}
return const StubViewIntentHandler();
});
@@ -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<void> onAppResumed() => _checkForViewIntent();
@override
Future<void> onUserAuthenticated() => _flushPending();
Future<void> _checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
if (attachment != null) {
await handle(attachment);
return;
}
if (_ref.read(viewIntentPendingProvider) == null) {
await _viewIntentService.cleanupStaleTempFiles();
}
}
Future<void> _flushPending() async {
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
_logger.info('flushPending, pendingAttachment:$pendingAttachment}');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
}
@override
Future<void> 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));
}
}
@@ -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<void> onAppResumed() async {}
@override
Future<void> onUserAuthenticated() async {}
@override
Future<void> handle(ViewIntentPayload attachment) async {}
}
@@ -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;
}