Compare commits
2
Commits
main
...
issue-204-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881a240155 | ||
|
|
117b546b2c |
@@ -32,6 +32,7 @@ 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;
|
||||
@@ -64,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
);
|
||||
setState(() {
|
||||
_keyMaterial = material;
|
||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
_pubKeyQr = qr;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
@@ -85,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> _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<int>('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) {
|
||||
@@ -274,7 +278,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const _ExpiryHint(),
|
||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||
const SizedBox(height: 32),
|
||||
if (_errorMessage != null) ...[
|
||||
Text(
|
||||
@@ -404,8 +408,37 @@ bool _cameraScanSupported() =>
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows;
|
||||
|
||||
class _ExpiryHint extends StatelessWidget {
|
||||
const _ExpiryHint();
|
||||
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) {
|
||||
@@ -415,7 +448,7 @@ class _ExpiryHint extends StatelessWidget {
|
||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'This key expires in 20 minutes',
|
||||
'This key expires in ${_formatRemaining()}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> _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<int>('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) {
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
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(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
@@ -32,8 +32,106 @@ void main() {
|
||||
);
|
||||
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', () {
|
||||
|
||||
@@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository {
|
||||
}
|
||||
|
||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
|
||||
|
||||
ShareKeyMaterial? _material;
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial> createKeyPair() async {
|
||||
_material = await ShareEncryptionService.generateKeyPair();
|
||||
_material ??= await ShareEncryptionService.generateKeyPair();
|
||||
return _material!;
|
||||
}
|
||||
|
||||
@@ -511,6 +513,7 @@ List<Override> baseOverrides({
|
||||
List<Mailbox>? mailboxes,
|
||||
DiscoveryResult? discovery,
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider
|
||||
@@ -525,7 +528,9 @@ List<Override> baseOverrides({
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user