Compare commits

...
3 Commits
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 a167ebd0a3 feat: add build mode, Dart version, timestamp to crash report (#205)
Show app version, build mode, and platform OS in the crash screen UI
so users can report bugs without needing to copy first.  The clipboard
report now also includes Build Mode (debug/release/profile), Dart
runtime version, and a UTC timestamp to aid post-crash diagnosis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:06:32 +02:00
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
6 changed files with 233 additions and 35 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) {
+33 -5
View File
@@ -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(
+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', () {
+32
View File
@@ -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 {
+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(),
),
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------