fix(mobile): handle unavailable view intents

This commit is contained in:
Peter Ombodi 2026-08-06 15:10:57 +03:00
parent a1458f8714
commit f90359655b
5 changed files with 80 additions and 4 deletions

View file

@ -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
}
}

View file

@ -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<void> flushDeferredViewIntent() => _flushPending();
Future<void> _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;

View file

@ -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<Directory> Function() _temporaryDirectory;
@ -19,6 +22,12 @@ class ViewIntentService {
Future<ViewIntentPayload?> 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;

View file

@ -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<String> managedTempPaths = [];
@ -61,7 +66,12 @@ class TestViewIntentService extends ViewIntentService {
TestViewIntentService() : super(MockViewIntentHostApi());
@override
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
Future<ViewIntentPayload?> consumeViewIntent() async {
if (consumeError case final Object error) {
throw error;
}
return consumedAttachment;
}
@override
Future<void> cleanupStaleTempFiles() async {
@ -84,6 +94,15 @@ class TestViewIntentService extends ViewIntentService {
}
}
class TestToastService extends ToastService {
final List<String> 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(

View file

@ -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<PlatformException>().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');