mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
fix(mobile): handle unavailable view intents
This commit is contained in:
parent
a1458f8714
commit
f90359655b
5 changed files with 80 additions and 4 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue