Compare commits

...
2 Commits
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 881a240155 fix: probe scanner method channel to detect MissingPluginException (#204)
The previous pre-flight check called ctrl.start() without ctrl.attach(),
causing MobileScannerController to time out after 500 ms with
controllerNotAttached on every device — the scanner never worked even
when the plugin was present.

Replace with a direct invokeMethod('state') probe on the scanner channel.
MissingPluginException (thrown when the plugin is not registered) is the
exact error from the crash report; all other errors are surfaced by the
MobileScanner widget's own error builder.

Also add widget tests for the AccountReceiveScreen step-2 import flow
(text fallback, successful import, invalid-QR error) which were
previously untested.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:47:21 +02:00
Thomas SharedInbox 117b546b2c feat: show live countdown with seconds on receive account screen (#203)
Replace the static "expires in 20 minutes" label with a StatefulWidget
that ticks every second and displays the remaining time as MM:SS.
2026-05-24 15:11:56 +02:00
4 changed files with 168 additions and 30 deletions
+48 -15
View File
@@ -32,6 +32,7 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> { class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey; _Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial; ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr; String? _pubKeyQr;
String? _errorMessage; String? _errorMessage;
bool _scannerActive = false; bool _scannerActive = false;
@@ -64,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
); );
setState(() { setState(() {
_keyMaterial = material; _keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr; _pubKeyQr = qr;
_step = _Step.showingPubKey; _step = _Step.showingPubKey;
}); });
@@ -85,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
} }
} }
// Pre-flight: start + stop the scanner to verify the plugin is available. // Pre-flight: probe the scanner's permission-state method to verify the
// Falls back to text entry on any exception (including MissingPluginException). // 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<void> _initScanner() async { Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false; bool available = false;
try { try {
ctrl = MobileScannerController(); await const MethodChannel(
await ctrl.start(); 'dev.steenbakker.mobile_scanner/scanner/method',
await ctrl.stop(); ).invokeMethod<int>('state');
available = true; available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) { } catch (_) {
// Plugin not available on this device; text fallback will be shown. // Plugin registered but state check failed; let the scanner widget
} finally { // handle it via its errorBuilder.
try { available = true;
await ctrl?.dispose();
} catch (_) {}
} }
if (!mounted) return; if (!mounted) return;
if (available) { if (available) {
@@ -274,7 +278,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const _ExpiryHint(), _ExpiryHint(expiresAt: _keyExpiresAt!),
const SizedBox(height: 32), const SizedBox(height: 32),
if (_errorMessage != null) ...[ if (_errorMessage != null) ...[
Text( Text(
@@ -404,8 +408,37 @@ bool _cameraScanSupported() =>
Platform.isMacOS || Platform.isMacOS ||
Platform.isWindows; Platform.isWindows;
class _ExpiryHint extends StatelessWidget { class _ExpiryHint extends StatefulWidget {
const _ExpiryHint(); const _ExpiryHint({required this.expiresAt});
final DateTime expiresAt;
@override
State<_ExpiryHint> createState() => _ExpiryHintState();
}
class _ExpiryHintState extends State<_ExpiryHint> {
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String _formatRemaining() {
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -415,7 +448,7 @@ class _ExpiryHint extends StatelessWidget {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]), Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'This key expires in 20 minutes', 'This key expires in ${_formatRemaining()}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
], ],
+13 -11
View File
@@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
} }
} }
// Pre-flight: start + stop the scanner to verify the plugin is available. // Pre-flight: probe the scanner's permission-state method to verify the
// Falls back to text entry on any exception (including MissingPluginException). // 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<void> _initScanner() async { Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false; bool available = false;
try { try {
ctrl = MobileScannerController(); await const MethodChannel(
await ctrl.start(); 'dev.steenbakker.mobile_scanner/scanner/method',
await ctrl.stop(); ).invokeMethod<int>('state');
available = true; available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) { } catch (_) {
// Plugin not available on this device; text fallback will be shown. // Plugin registered but state check failed; let the scanner widget
} finally { // handle it via its errorBuilder.
try { available = true;
await ctrl?.dispose();
} catch (_) {}
} }
if (!mounted) return; if (!mounted) return;
if (available) { if (available) {
+100 -2
View File
@@ -23,7 +23,7 @@ void main() {
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget); expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
}); });
testWidgets('shows 20-minute expiry hint', (tester) async { testWidgets('shows expiry countdown hint', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/receive', initialLocation: '/accounts/receive',
@@ -32,8 +32,106 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('20 minutes'), findsOneWidget); 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', () { group('AccountSendScreen', () {
+7 -2
View File
@@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository {
} }
class FakeShareKeyRepository implements ShareKeyRepository { class FakeShareKeyRepository implements ShareKeyRepository {
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
ShareKeyMaterial? _material; ShareKeyMaterial? _material;
@override @override
Future<ShareKeyMaterial> createKeyPair() async { Future<ShareKeyMaterial> createKeyPair() async {
_material = await ShareEncryptionService.generateKeyPair(); _material ??= await ShareEncryptionService.generateKeyPair();
return _material!; return _material!;
} }
@@ -511,6 +513,7 @@ List<Override> baseOverrides({
List<Mailbox>? mailboxes, List<Mailbox>? mailboxes,
DiscoveryResult? discovery, DiscoveryResult? discovery,
Exception? connectionError, Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
}) => }) =>
[ [
accountRepositoryProvider accountRepositoryProvider
@@ -525,7 +528,9 @@ List<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue( connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError), FakeConnectionTestService(error: connectionError),
), ),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), shareKeyRepositoryProvider.overrideWithValue(
shareKeyRepository ?? FakeShareKeyRepository(),
),
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------