diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt index a1e1fea3dd..60041a9311 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntentPlugin.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch private const val TAG = "ViewIntentPlugin" +private const val VIEW_INTENT_UNAVAILABLE = "VIEW_INTENT_UNAVAILABLE" class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi { private var context: Context? = null @@ -92,7 +93,15 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL val localAssetId = extractLocalAssetId(context, uri, mimeType) val tempFilePath = if (localAssetId == null) { copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run { - callback(Result.success(null)) + consumeViewIntent(intent) + callback( + Result.failure( + FlutterError( + VIEW_INTENT_UNAVAILABLE, + "Unable to access the file referenced by the incoming view intent", + ), + ), + ) return@launch } } else { @@ -153,7 +162,8 @@ class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentL } } ?: return null tempFile - } catch (_: Exception) { + } catch (e: Exception) { + Log.w(TAG, "Failed to materialize view intent URI as a temporary file", e) null } } diff --git a/mobile/lib/providers/view_intent/view_intent_handler_android.dart b/mobile/lib/providers/view_intent/view_intent_handler_android.dart index 34b4fb7c67..b5c7d415ce 100644 --- a/mobile/lib/providers/view_intent/view_intent_handler_android.dart +++ b/mobile/lib/providers/view_intent/view_intent_handler_android.dart @@ -1,13 +1,16 @@ import 'dart:async'; +import 'package:flutter/services.dart'; 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/generated/translations.g.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/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_current.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'; @@ -43,7 +46,19 @@ class AndroidViewIntentHandler implements ViewIntentHandler { Future flushDeferredViewIntent() => _flushPending(); Future _checkForViewIntent() async { - final attachment = await _viewIntentService.consumeViewIntent(); + final ViewIntentPayload? attachment; + try { + attachment = await _viewIntentService.consumeViewIntent(); + } on PlatformException catch (error, stackTrace) { + if (error.code != viewIntentUnavailableErrorCode) { + rethrow; + } + + _logger.warning('Incoming view intent is unavailable', error, stackTrace); + _ref.read(toastServiceProvider).error(StaticTranslations.instance.asset_not_found_on_device_android); + await _router.replaceAll([const TabShellRoute()]); + return; + } if (attachment != null) { await handle(attachment); return; diff --git a/mobile/lib/services/view_intent.service.dart b/mobile/lib/services/view_intent.service.dart index 066cb29589..cd725484cb 100644 --- a/mobile/lib/services/view_intent.service.dart +++ b/mobile/lib/services/view_intent.service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:path/path.dart' as p; @@ -7,6 +8,8 @@ import 'package:path_provider/path_provider.dart'; final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi())); +const viewIntentUnavailableErrorCode = 'VIEW_INTENT_UNAVAILABLE'; + class ViewIntentService { final ViewIntentHostApi _viewIntentHostApi; final Future Function() _temporaryDirectory; @@ -19,6 +22,12 @@ class ViewIntentService { Future consumeViewIntent() async { try { return await _viewIntentHostApi.consumeViewIntent(); + } on PlatformException catch (error) { + if (error.code == viewIntentUnavailableErrorCode) { + rethrow; + } + // Ignore errors - view intent might not be present + return null; } catch (_) { // Ignore errors - view intent might not be present return null; diff --git a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart index f27f5b62ce..d33a89df22 100644 --- a/mobile/test/providers/view_intent/view_intent_handler_android_test.dart +++ b/mobile/test/providers/view_intent/view_intent_handler_android_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -8,12 +9,14 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/auth/auth_state.model.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/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_current.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_android.dart'; @@ -22,6 +25,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; @@ -53,6 +57,7 @@ class FakeTimelineService extends Fake implements TimelineService {} class TestViewIntentService extends ViewIntentService { ViewIntentPayload? consumedAttachment; + Object? consumeError; int cleanupStaleTempFilesCalls = 0; int cleanupManagedTempFileCalls = 0; final List managedTempPaths = []; @@ -61,7 +66,12 @@ class TestViewIntentService extends ViewIntentService { TestViewIntentService() : super(MockViewIntentHostApi()); @override - Future consumeViewIntent() async => consumedAttachment; + Future consumeViewIntent() async { + if (consumeError case final Object error) { + throw error; + } + return consumedAttachment; + } @override Future cleanupStaleTempFiles() async { @@ -84,6 +94,15 @@ class TestViewIntentService extends ViewIntentService { } } +class TestToastService extends ToastService { + final List errorMessages = []; + + @override + void error(String message, {ToastOption? toast}) { + errorMessages.add(message); + } +} + class TestAuthNotifier extends AuthNotifier { TestAuthNotifier(Ref ref, AuthState initial) : super( @@ -113,6 +132,7 @@ void main() { late MockTimelineFactory timelineFactory; late MockAppRouter router; late TestAuthNotifier authNotifier; + late TestToastService toastService; late ProviderContainer container; late AndroidViewIntentHandler handler; late ViewIntentPayload payload; @@ -132,6 +152,7 @@ void main() { setUp(() async { viewIntentService = TestViewIntentService(); + toastService = TestToastService(); resolver = MockViewIntentAssetResolver(); assetService = MockAssetService(); timelineFactory = MockTimelineFactory(); @@ -151,6 +172,7 @@ void main() { viewIntentAssetResolverProvider.overrideWithValue(resolver), assetServiceProvider.overrideWithValue(assetService), timelineFactoryProvider.overrideWithValue(timelineFactory), + toastServiceProvider.overrideWithValue(toastService), appRouterProvider.overrideWithValue(router), authProvider.overrideWith((ref) { authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true)); @@ -220,6 +242,16 @@ void main() { verifyNever(() => resolver.resolve(any())); }); + test('onAppResumed returns to the main screen when the incoming view intent is unavailable', () async { + viewIntentService.consumeError = PlatformException(code: 'VIEW_INTENT_UNAVAILABLE'); + + await handler.onAppResumed(); + + expect(toastService.errorMessages, [StaticTranslations.instance.asset_not_found_on_device_android]); + verify(() => router.replaceAll([const TabShellRoute()])).called(1); + verifyNever(() => resolver.resolve(any())); + }); + testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async { viewIntentService.consumedAttachment = payload; when( diff --git a/mobile/test/services/view_intent_service_test.dart b/mobile/test/services/view_intent_service_test.dart index fd8f5f725c..fee659700b 100644 --- a/mobile/test/services/view_intent_service_test.dart +++ b/mobile/test/services/view_intent_service_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; import 'package:immich_mobile/services/view_intent.service.dart'; @@ -58,6 +59,15 @@ void main() { verify(() => hostApi.consumeViewIntent()).called(1); }); + test('consumeViewIntent preserves an unavailable view intent error', () async { + when(() => hostApi.consumeViewIntent()).thenThrow(PlatformException(code: 'VIEW_INTENT_UNAVAILABLE')); + + await expectLater( + service.consumeViewIntent(), + throwsA(isA().having((error) => error.code, 'code', 'VIEW_INTENT_UNAVAILABLE')), + ); + }); + test('setManagedTempFilePath cleans previous managed temp file', () async { final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first'); final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');