From d51e67ddcc805482996d2307a24117b2819a1993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 15:55:08 +0200 Subject: [PATCH] fix: probe scanner method channel to detect MissingPluginException (#204) (#221) --- lib/ui/screens/account_receive_screen.dart | 24 ++--- lib/ui/screens/account_send_screen.dart | 24 ++--- test/widget/account_export_screen_test.dart | 98 +++++++++++++++++++++ test/widget/helpers.dart | 9 +- 4 files changed, 131 insertions(+), 24 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index 44ac251..0be5c89 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -87,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState { } } - // Pre-flight: start + stop the scanner to verify the plugin is available. - // Falls back to text entry on any exception (including MissingPluginException). + // Pre-flight: probe the scanner's permission-state method to verify the + // plugin is registered. MissingPluginException is thrown on Android builds + // where the plugin is not linked (issue #204). All other exceptions mean + // the plugin exists but something else failed — the MobileScanner widget + // will surface those via its own error builder. Future _initScanner() async { - MobileScannerController? ctrl; bool available = false; try { - ctrl = MobileScannerController(); - await ctrl.start(); - await ctrl.stop(); + await const MethodChannel( + 'dev.steenbakker.mobile_scanner/scanner/method', + ).invokeMethod('state'); available = true; + } on MissingPluginException { + // Plugin not registered on this device; text fallback will be shown. } catch (_) { - // Plugin not available on this device; text fallback will be shown. - } finally { - try { - await ctrl?.dispose(); - } catch (_) {} + // Plugin registered but state check failed; let the scanner widget + // handle it via its errorBuilder. + available = true; } if (!mounted) return; if (available) { diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart index 59e3548..9049fed 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState { } } - // Pre-flight: start + stop the scanner to verify the plugin is available. - // Falls back to text entry on any exception (including MissingPluginException). + // Pre-flight: probe the scanner's permission-state method to verify the + // plugin is registered. MissingPluginException is thrown on Android builds + // where the plugin is not linked (issue #204). All other exceptions mean + // the plugin exists but something else failed — the MobileScanner widget + // will surface those via its own error builder. Future _initScanner() async { - MobileScannerController? ctrl; bool available = false; try { - ctrl = MobileScannerController(); - await ctrl.start(); - await ctrl.stop(); + await const MethodChannel( + 'dev.steenbakker.mobile_scanner/scanner/method', + ).invokeMethod('state'); available = true; + } on MissingPluginException { + // Plugin not registered on this device; text fallback will be shown. } catch (_) { - // Plugin not available on this device; text fallback will be shown. - } finally { - try { - await ctrl?.dispose(); - } catch (_) {} + // Plugin registered but state check failed; let the scanner widget + // handle it via its errorBuilder. + available = true; } if (!mounted) return; if (available) { diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index f8b5bfe..5f2259e 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -34,6 +34,104 @@ void main() { expect(find.textContaining('expires in'), findsOneWidget); }); + + testWidgets( + 'step 2 button shows text-input fallback on platforms without camera', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/receive', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('scanEncryptedButton'))); + await tester.pumpAndSettle(); + + // On Linux (desktop, no camera) the text fallback field must appear. + expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget); + }, + ); + + testWidgets( + 'step 2 — valid encrypted QR imports account via text fallback', + (tester) async { + // Pre-generate a key pair so we can encrypt a QR code with the same + // material the screen will use for decryption. + final material = await ShareEncryptionService.generateKeyPair(); + final repo = FakeShareKeyRepository(material: material); + + const account = Account( + id: 'src-1', + displayName: 'Alice', + email: 'alice@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + + final encryptedQr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: material.keyId, + recipientPublicKeyBytes: material.publicKeyBytes, + accounts: [ + AccountPayload( + accountJson: account.toJson(), + password: 'secret', + ), + ], + ); + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/receive', + overrides: baseOverrides(shareKeyRepository: repo), + ), + ); + await tester.pumpAndSettle(); // key generation completes + + await tester.tap(find.byKey(const Key('scanEncryptedButton'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('encryptedCodeField')), + encryptedQr, + ); + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + + expect( + find.text('Imported 1 account successfully.'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'step 2 — invalid encrypted QR shows error and returns to pub-key step', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/receive', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('scanEncryptedButton'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('encryptedCodeField')), + 'not-a-valid-qr-code', + ); + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + + // Screen returns to the pub-key step with an error message visible. + expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget); + expect(find.textContaining('Import failed:'), findsWidgets); + }, + ); }); group('AccountSendScreen', () { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 89da3d4..74ef82f 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository { } class FakeShareKeyRepository implements ShareKeyRepository { + FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material; + ShareKeyMaterial? _material; @override Future createKeyPair() async { - _material = await ShareEncryptionService.generateKeyPair(); + _material ??= await ShareEncryptionService.generateKeyPair(); return _material!; } @@ -511,6 +513,7 @@ List baseOverrides({ List? mailboxes, DiscoveryResult? discovery, Exception? connectionError, + ShareKeyRepository? shareKeyRepository, }) => [ accountRepositoryProvider @@ -525,7 +528,9 @@ List baseOverrides({ connectionTestServiceProvider.overrideWithValue( FakeConnectionTestService(error: connectionError), ), - shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), + shareKeyRepositoryProvider.overrideWithValue( + shareKeyRepository ?? FakeShareKeyRepository(), + ), ]; // ---------------------------------------------------------------------------