Compare commits
3
Commits
main
...
issue-205-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a167ebd0a3 | ||
|
|
881a240155 | ||
|
|
117b546b2c |
@@ -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]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -17,20 +18,35 @@ class CrashScreen extends StatelessWidget {
|
|||||||
final StackTrace? stackTrace;
|
final StackTrace? stackTrace;
|
||||||
final String gitHash;
|
final String gitHash;
|
||||||
|
|
||||||
Future<String> _buildReport() async {
|
String get _buildMode {
|
||||||
String version = 'unknown';
|
if (kDebugMode) return 'debug';
|
||||||
|
if (kProfileMode) return 'profile';
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _fetchVersion() async {
|
||||||
try {
|
try {
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
version = '${info.version}+${info.buildNumber}';
|
return '${info.version}+${info.buildNumber}';
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _buildReport() async {
|
||||||
|
final version = await _fetchVersion();
|
||||||
final platform =
|
final platform =
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||||
final gitLine = gitHash.isNotEmpty
|
final gitLine = gitHash.isNotEmpty
|
||||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||||
: '';
|
: '';
|
||||||
|
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||||
return 'App Version: $version\n'
|
return 'App Version: $version\n'
|
||||||
|
'Build Mode: $_buildMode\n'
|
||||||
'$gitLine'
|
'$gitLine'
|
||||||
'Platform: $platform\n\n'
|
'Platform: $platform\n'
|
||||||
|
'Dart: ${Platform.version}\n'
|
||||||
|
'Timestamp: $timestamp\n\n'
|
||||||
'Error:\n```\n$exception\n```\n\n'
|
'Error:\n```\n$exception\n```\n\n'
|
||||||
'Stack Trace:\n```\n$stackTrace\n```';
|
'Stack Trace:\n```\n$stackTrace\n```';
|
||||||
}
|
}
|
||||||
@@ -56,6 +72,18 @@ class CrashScreen extends StatelessWidget {
|
|||||||
style: Theme.of(ctx).textTheme.titleMedium,
|
style: Theme.of(ctx).textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: _fetchVersion(),
|
||||||
|
builder: (context, snapshot) => Text(
|
||||||
|
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||||
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (gitHash.isNotEmpty) ...[
|
if (gitHash.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ void main() {
|
|||||||
|
|
||||||
expect(clipboardText, isNotNull);
|
expect(clipboardText, isNotNull);
|
||||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||||
|
expect(clipboardText, contains('Build Mode:'));
|
||||||
expect(clipboardText, contains('Platform:'));
|
expect(clipboardText, contains('Platform:'));
|
||||||
|
expect(clipboardText, contains('Dart:'));
|
||||||
|
expect(clipboardText, contains('Timestamp:'));
|
||||||
expect(clipboardText, contains('TestException: clipboard test'));
|
expect(clipboardText, contains('TestException: clipboard test'));
|
||||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||||
@@ -167,6 +170,35 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'CrashScreen shows version, build mode, and platform in the UI',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
const exception = 'TestException: info row test';
|
||||||
|
final stackTrace = StackTrace.current;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Info row shows app version (from mock), build mode, and platform OS.
|
||||||
|
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||||
|
// In test builds kDebugMode is true.
|
||||||
|
expect(find.textContaining('debug'), findsOneWidget);
|
||||||
|
// Platform OS is always present (linux in CI, android/ios on device).
|
||||||
|
expect(
|
||||||
|
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user