Files
sharedinbox/lib/ui/screens/account_receive_screen.dart
T
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

458 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/di.dart';
/// Receiving side of the secure account-sharing flow.
///
/// Step 1 generates an X25519 key pair with a 20-minute lifetime and shows
/// the public key as a QR code to be scanned by the sender.
///
/// Step 2 scans the encrypted-accounts QR code shown by the sender, decrypts
/// it using the private key, and imports the accounts.
class AccountReceiveScreen extends ConsumerStatefulWidget {
const AccountReceiveScreen({super.key});
@override
ConsumerState<AccountReceiveScreen> createState() =>
_AccountReceiveScreenState();
}
enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr;
String? _errorMessage;
bool _scannerActive = false;
MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override
void initState() {
super.initState();
unawaited(_generateKey());
}
@override
void dispose() {
final ctrl = _scannerController;
if (ctrl != null) unawaited(ctrl.dispose());
super.dispose();
}
Future<void> _generateKey() async {
try {
final repo = ref.read(shareKeyRepositoryProvider);
final material = await repo.createKeyPair();
final qr = ShareEncryptionService.encodePublicKeyQr(
material.keyId,
material.publicKeyBytes,
);
setState(() {
_keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr;
_step = _Step.showingPubKey;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_step = _Step.error;
});
}
}
void _startScanning() {
setState(() {
_step = _Step.scanning;
_scannerActive = true;
});
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// 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<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
}
Future<void> _onScanned(String rawValue) async {
if (!_scannerActive) return;
_scannerActive = false;
await _scannerController?.stop();
setState(() => _step = _Step.importing);
try {
final material = _keyMaterial!;
final accounts = await ShareEncryptionService.decryptAccounts(
qrString: rawValue,
privateKeyBytes: material.privateKeyBytes,
publicKeyBytes: material.publicKeyBytes,
keyId: material.keyId,
);
final repo = ref.read(accountRepositoryProvider);
for (final ap in accounts) {
final account = Account.fromJson(ap.accountJson);
final newAccount = Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: account.displayName,
email: account.email,
username: account.username,
type: account.type,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSsl: account.imapSsl,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpSsl: account.smtpSsl,
manageSieveHost: account.manageSieveHost,
manageSievePort: account.manageSievePort,
manageSieveSsl: account.manageSieveSsl,
jmapUrl: account.jmapUrl,
);
await repo.addAccount(newAccount, ap.password);
}
if (mounted) {
setState(() => _step = _Step.done);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Imported ${accounts.length} account${accounts.length == 1 ? '' : 's'} successfully.',
),
),
);
context.pop();
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = _friendlyError(e);
_scannerActive = false;
// Let user retry from the pubkey step.
_step = _Step.showingPubKey;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_friendlyError(e)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
String _friendlyError(Object e) {
final s = e.toString();
if (s.contains('expired') || s.contains('older than')) {
return 'The QR code has expired. Ask the sender to generate a new one.';
}
if (s.contains('Key ID mismatch') || s.contains('Unknown')) {
return 'QR code does not match this session. Regenerate the public key and try again.';
}
if (s.contains('authentication') ||
s.contains('mac') ||
s.contains('SecretBox')) {
return 'Authentication failed — the QR code may have been tampered with.';
}
return 'Import failed: $s';
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Receive accounts')),
body: switch (_step) {
_Step.generatingKey => const Center(child: CircularProgressIndicator()),
_Step.showingPubKey => _buildPubKeyView(context),
_Step.scanning => _buildScannerView(context),
_Step.importing => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Importing accounts…'),
],
),
),
_Step.done => const Center(
child: Icon(
Icons.check_circle,
size: 64,
color: Colors.green,
),
),
_Step.error => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Error: $_errorMessage'),
),
),
},
);
}
Widget _buildPubKeyView(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Step 1 of 2 — Show this QR code to the sender',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The sender scans this code, selects the account(s) to transfer, '
'and shows an encrypted QR code. Then come back here for step 2.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Center(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(8),
child: QrImageView(
key: const Key('pubKeyQrCode'),
data: _pubKeyQr!,
size: 260,
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy public key'),
onPressed: () {
unawaited(Clipboard.setData(ClipboardData(text: _pubKeyQr!)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public key copied to clipboard')),
);
},
),
const SizedBox(height: 8),
_ExpiryHint(expiresAt: _keyExpiresAt!),
const SizedBox(height: 32),
if (_errorMessage != null) ...[
Text(
_errorMessage!,
style: TextStyle(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
FilledButton.icon(
key: const Key('scanEncryptedButton'),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Step 2 — Scan encrypted QR code'),
onPressed: _startScanning,
),
],
),
);
}
Widget _buildScannerView(BuildContext context) {
// Fall back to text input when the platform has no camera support or when
// the scanner plugin fails to initialise at runtime (MissingPluginException).
if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context);
}
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
MobileScanner(
controller: _scannerController!,
onDetect: (capture) {
final raw = capture.barcodes.firstOrNull?.rawValue;
if (raw != null) unawaited(_onScanned(raw));
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: const Text(
'Point the camera at the encrypted QR code from the sender\'s device',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
Positioned(
bottom: 32,
left: 16,
right: 16,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
),
onPressed: () {
final ctrl = _scannerController;
if (ctrl != null) unawaited(ctrl.dispose());
_scannerController = null;
setState(() {
_scannerActive = false;
_step = _Step.showingPubKey;
});
},
child: const Text('Cancel'),
),
),
],
);
}
Widget _buildTextFallbackView(BuildContext context) {
final ctrl = TextEditingController();
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Paste the encrypted code from the sender\'s device',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 16),
TextField(
key: const Key('encryptedCodeField'),
controller: ctrl,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Encrypted code',
border: OutlineInputBorder(),
hintText: 'sharedinbox.de:encrypted-accounts:v1:…',
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final text = ctrl.text.trim();
if (text.isNotEmpty) unawaited(_onScanned(text));
},
child: const Text('Import'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => setState(() {
_scannerActive = false;
_step = _Step.showingPubKey;
}),
child: const Text('Cancel'),
),
],
),
);
}
}
bool _cameraScanSupported() =>
Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows;
class _ExpiryHint extends StatefulWidget {
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
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'This key expires in ${_formatRemaining()}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
);
}
}