From 117b546b2c158a2f3ec401815e82eec11c16078d Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 15:11:56 +0200 Subject: [PATCH 001/223] 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. --- lib/ui/screens/account_receive_screen.dart | 39 ++++++++++++++++++--- test/widget/account_export_screen_test.dart | 4 +-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index c1fd035..44ac251 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -32,6 +32,7 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error } class _AccountReceiveScreenState extends ConsumerState { _Step _step = _Step.generatingKey; ShareKeyMaterial? _keyMaterial; + DateTime? _keyExpiresAt; String? _pubKeyQr; String? _errorMessage; bool _scannerActive = false; @@ -64,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState { ); setState(() { _keyMaterial = material; + _keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20)); _pubKeyQr = qr; _step = _Step.showingPubKey; }); @@ -274,7 +276,7 @@ class _AccountReceiveScreenState extends ConsumerState { }, ), const SizedBox(height: 8), - const _ExpiryHint(), + _ExpiryHint(expiresAt: _keyExpiresAt!), const SizedBox(height: 32), if (_errorMessage != null) ...[ Text( @@ -404,8 +406,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 +446,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]), ), ], diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index 35d2220..f8b5bfe 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -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,7 +32,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('20 minutes'), findsOneWidget); + expect(find.textContaining('expires in'), findsOneWidget); }); }); -- 2.52.0 From 43068509d295b1d517bb4e32d17b6eb73b0a787c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 15:15:12 +0200 Subject: [PATCH 002/223] fix: show live countdown with seconds on receive account screen (#203) (#220) --- lib/ui/screens/account_receive_screen.dart | 39 ++++++++++++++++++--- test/widget/account_export_screen_test.dart | 4 +-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index c1fd035..44ac251 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -32,6 +32,7 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error } class _AccountReceiveScreenState extends ConsumerState { _Step _step = _Step.generatingKey; ShareKeyMaterial? _keyMaterial; + DateTime? _keyExpiresAt; String? _pubKeyQr; String? _errorMessage; bool _scannerActive = false; @@ -64,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState { ); setState(() { _keyMaterial = material; + _keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20)); _pubKeyQr = qr; _step = _Step.showingPubKey; }); @@ -274,7 +276,7 @@ class _AccountReceiveScreenState extends ConsumerState { }, ), const SizedBox(height: 8), - const _ExpiryHint(), + _ExpiryHint(expiresAt: _keyExpiresAt!), const SizedBox(height: 32), if (_errorMessage != null) ...[ Text( @@ -404,8 +406,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 +446,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]), ), ], diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index 35d2220..f8b5bfe 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -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,7 +32,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('20 minutes'), findsOneWidget); + expect(find.textContaining('expires in'), findsOneWidget); }); }); -- 2.52.0 From 881a24015556cd7f9b9e93f363cbdeb009eea679 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 15:47:21 +0200 Subject: [PATCH 003/223] fix: probe scanner method channel to detect MissingPluginException (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ui/screens/account_receive_screen.dart | 24 ++--- lib/ui/screens/account_send_screen.dart | 24 ++--- test/widget/account_export_screen_test.dart | 98 +++++++++++++++++++++ test/widget/helpers.dart | 9 +- 4 files changed, 131 insertions(+), 24 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index 44ac251..0be5c89 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -87,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState { } } - // 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 _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('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) { diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart index 59e3548..9049fed 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState { } } - // 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 _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('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) { diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index f8b5bfe..5f2259e 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -34,6 +34,104 @@ void main() { 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', () { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 89da3d4..74ef82f 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository { } class FakeShareKeyRepository implements ShareKeyRepository { + FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material; + ShareKeyMaterial? _material; @override Future createKeyPair() async { - _material = await ShareEncryptionService.generateKeyPair(); + _material ??= await ShareEncryptionService.generateKeyPair(); return _material!; } @@ -511,6 +513,7 @@ List baseOverrides({ List? mailboxes, DiscoveryResult? discovery, Exception? connectionError, + ShareKeyRepository? shareKeyRepository, }) => [ accountRepositoryProvider @@ -525,7 +528,9 @@ List baseOverrides({ connectionTestServiceProvider.overrideWithValue( FakeConnectionTestService(error: connectionError), ), - shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), + shareKeyRepositoryProvider.overrideWithValue( + shareKeyRepository ?? FakeShareKeyRepository(), + ), ]; // --------------------------------------------------------------------------- -- 2.52.0 From d51e67ddcc805482996d2307a24117b2819a1993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 15:55:08 +0200 Subject: [PATCH 004/223] fix: probe scanner method channel to detect MissingPluginException (#204) (#221) --- lib/ui/screens/account_receive_screen.dart | 24 ++--- lib/ui/screens/account_send_screen.dart | 24 ++--- test/widget/account_export_screen_test.dart | 98 +++++++++++++++++++++ test/widget/helpers.dart | 9 +- 4 files changed, 131 insertions(+), 24 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index 44ac251..0be5c89 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -87,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState { } } - // 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 _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('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) { diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart index 59e3548..9049fed 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState { } } - // 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 _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('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) { diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index f8b5bfe..5f2259e 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -34,6 +34,104 @@ void main() { 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', () { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 89da3d4..74ef82f 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository { } class FakeShareKeyRepository implements ShareKeyRepository { + FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material; + ShareKeyMaterial? _material; @override Future createKeyPair() async { - _material = await ShareEncryptionService.generateKeyPair(); + _material ??= await ShareEncryptionService.generateKeyPair(); return _material!; } @@ -511,6 +513,7 @@ List baseOverrides({ List? mailboxes, DiscoveryResult? discovery, Exception? connectionError, + ShareKeyRepository? shareKeyRepository, }) => [ accountRepositoryProvider @@ -525,7 +528,9 @@ List baseOverrides({ connectionTestServiceProvider.overrideWithValue( FakeConnectionTestService(error: connectionError), ), - shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), + shareKeyRepositoryProvider.overrideWithValue( + shareKeyRepository ?? FakeShareKeyRepository(), + ), ]; // --------------------------------------------------------------------------- -- 2.52.0 From a167ebd0a3b260307cd59eeddce3293227facf73 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 16:06:32 +0200 Subject: [PATCH 005/223] 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 --- lib/ui/screens/crash_screen.dart | 38 ++++++++++++++++++++++++++---- test/widget/crash_screen_test.dart | 32 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 02c49f3..0780c25 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -17,20 +18,35 @@ class CrashScreen extends StatelessWidget { final StackTrace? stackTrace; final String gitHash; - Future _buildReport() async { - String version = 'unknown'; + String get _buildMode { + if (kDebugMode) return 'debug'; + if (kProfileMode) return 'profile'; + return 'release'; + } + + Future _fetchVersion() async { try { final info = await PackageInfo.fromPlatform(); - version = '${info.version}+${info.buildNumber}'; - } catch (_) {} + return '${info.version}+${info.buildNumber}'; + } catch (_) { + return 'unknown'; + } + } + + Future _buildReport() async { + final version = await _fetchVersion(); final platform = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; final gitLine = gitHash.isNotEmpty ? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n' : ''; + final timestamp = DateTime.now().toUtc().toIso8601String(); return 'App Version: $version\n' + 'Build Mode: $_buildMode\n' '$gitLine' - 'Platform: $platform\n\n' + 'Platform: $platform\n' + 'Dart: ${Platform.version}\n' + 'Timestamp: $timestamp\n\n' 'Error:\n```\n$exception\n```\n\n' 'Stack Trace:\n```\n$stackTrace\n```'; } @@ -56,6 +72,18 @@ class CrashScreen extends StatelessWidget { style: Theme.of(ctx).textTheme.titleMedium, textAlign: TextAlign.center, ), + const SizedBox(height: 4), + FutureBuilder( + 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) ...[ const SizedBox(height: 8), GestureDetector( diff --git a/test/widget/crash_screen_test.dart b/test/widget/crash_screen_test.dart index 80e5106..3925dbb 100644 --- a/test/widget/crash_screen_test.dart +++ b/test/widget/crash_screen_test.dart @@ -116,7 +116,10 @@ void main() { expect(clipboardText, isNotNull); expect(clipboardText, contains('App Version: 1.0.0+42')); + expect(clipboardText, contains('Build Mode:')); expect(clipboardText, contains('Platform:')); + expect(clipboardText, contains('Dart:')); + expect(clipboardText, contains('Timestamp:')); expect(clipboardText, contains('TestException: clipboard test')); // GIT_HASH is empty in test builds — no Git Commit line expected 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( 'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash', (tester) async { -- 2.52.0 From e7ff9243c9335994a92e81b1fe71286ffdb21878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 16:10:09 +0200 Subject: [PATCH 006/223] feat: add build mode, Dart version, timestamp to crash report (#205) (#222) --- lib/ui/screens/crash_screen.dart | 38 ++++++++++++++++++++++++++---- test/widget/crash_screen_test.dart | 32 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 02c49f3..0780c25 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -17,20 +18,35 @@ class CrashScreen extends StatelessWidget { final StackTrace? stackTrace; final String gitHash; - Future _buildReport() async { - String version = 'unknown'; + String get _buildMode { + if (kDebugMode) return 'debug'; + if (kProfileMode) return 'profile'; + return 'release'; + } + + Future _fetchVersion() async { try { final info = await PackageInfo.fromPlatform(); - version = '${info.version}+${info.buildNumber}'; - } catch (_) {} + return '${info.version}+${info.buildNumber}'; + } catch (_) { + return 'unknown'; + } + } + + Future _buildReport() async { + final version = await _fetchVersion(); final platform = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; final gitLine = gitHash.isNotEmpty ? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n' : ''; + final timestamp = DateTime.now().toUtc().toIso8601String(); return 'App Version: $version\n' + 'Build Mode: $_buildMode\n' '$gitLine' - 'Platform: $platform\n\n' + 'Platform: $platform\n' + 'Dart: ${Platform.version}\n' + 'Timestamp: $timestamp\n\n' 'Error:\n```\n$exception\n```\n\n' 'Stack Trace:\n```\n$stackTrace\n```'; } @@ -56,6 +72,18 @@ class CrashScreen extends StatelessWidget { style: Theme.of(ctx).textTheme.titleMedium, textAlign: TextAlign.center, ), + const SizedBox(height: 4), + FutureBuilder( + 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) ...[ const SizedBox(height: 8), GestureDetector( diff --git a/test/widget/crash_screen_test.dart b/test/widget/crash_screen_test.dart index 80e5106..3925dbb 100644 --- a/test/widget/crash_screen_test.dart +++ b/test/widget/crash_screen_test.dart @@ -116,7 +116,10 @@ void main() { expect(clipboardText, isNotNull); expect(clipboardText, contains('App Version: 1.0.0+42')); + expect(clipboardText, contains('Build Mode:')); expect(clipboardText, contains('Platform:')); + expect(clipboardText, contains('Dart:')); + expect(clipboardText, contains('Timestamp:')); expect(clipboardText, contains('TestException: clipboard test')); // GIT_HASH is empty in test builds — no Git Commit line expected 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( 'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash', (tester) async { -- 2.52.0 From 839a3c63f94cc35d8f51ac75ff24a2d11ca074f7 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 16:32:27 +0200 Subject: [PATCH 007/223] feat: keep secrets in sync via age-encrypted master key (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store all production secrets encrypted in secrets.age (committed to the repo) using age. Only one secret needs to be in CI: SECRETS_AGE_KEY. When a secret changes locally, update secrets.env and re-run scripts/secrets-encrypt.sh to commit a new secrets.age. CI picks up the updated secrets automatically on the next push — no manual CI variable updates required. Changes: - scripts/secrets-encrypt.sh: encrypt secrets.env → secrets.age - scripts/secrets-decrypt.sh: decrypt secrets.age → GITHUB_ENV (CI) or eval-safe export block (local) - scripts/test_secrets.sh: encrypt/decrypt round-trip tests - secrets.env.example: template documenting all production secret keys - ci/main.go: add CheckSecrets function (runs test_secrets.sh via Dagger), wire into Check(), update Graph(); add age to toolchain apt packages - .forgejo/Dockerfile: add age package - .forgejo/workflows/deploy.yml: replace per-secret CI references with a single "Decrypt production secrets" step using SECRETS_AGE_KEY - flake.nix: add age to dev shell - Taskfile.yml: add check-secrets task, include in check-fast - .gitignore: ignore plaintext secrets.env - DAGGER.md: document Option 5 (encrypted secrets file) as active approach Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/Dockerfile | 1 + .forgejo/workflows/deploy.yml | 74 +++++++++------- .gitignore | 1 + DAGGER.md | 66 ++++++++++++++- Taskfile.yml | 7 +- ci/main.go | 22 ++++- flake.nix | 3 + scripts/secrets-decrypt.sh | 85 +++++++++++++++++++ scripts/secrets-encrypt.sh | 42 ++++++++++ scripts/test_secrets.sh | 153 ++++++++++++++++++++++++++++++++++ secrets.env.example | 28 +++++++ 11 files changed, 448 insertions(+), 34 deletions(-) create mode 100755 scripts/secrets-decrypt.sh create mode 100755 scripts/secrets-encrypt.sh create mode 100755 scripts/test_secrets.sh create mode 100644 secrets.env.example diff --git a/.forgejo/Dockerfile b/.forgejo/Dockerfile index 73d5916..fed065b 100644 --- a/.forgejo/Dockerfile +++ b/.forgejo/Dockerfile @@ -10,6 +10,7 @@ FROM ghcr.io/catthehacker/ubuntu:go-24.04 RUN apt-get update && apt-get install -y --no-install-recommends \ stunnel4 \ netcat-openbsd \ + age \ && rm -rf /var/lib/apt/lists/* # Dagger CLI — pinned to match the engine version on the runner host diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a7887b0..418db7a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -65,6 +65,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -75,11 +76,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Run Android Tests on Firebase Test Lab - if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Run Android Tests on Firebase Test Lab + if: env.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' env: - FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }} - FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} DAGGER_NO_NAG: "1" run: task test-android-firebase @@ -103,6 +108,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -113,12 +119,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Publish Android to Play Store - if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Publish Android to Play Store + if: env.PLAY_STORE_CONFIG_JSON != '' env: - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }} DAGGER_NO_NAG: "1" run: task publish-android @@ -142,6 +151,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -152,15 +162,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Build & Deploy APK to server - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Build & Deploy APK to server + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} DAGGER_NO_NAG: "1" run: task deploy-apk @@ -184,6 +194,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -194,13 +205,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Build & Deploy Linux to server - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Build & Deploy Linux to server + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" run: task deploy-linux @@ -226,6 +239,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -236,13 +250,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Generate build history and deploy website - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Generate build history and deploy website + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" run: task publish-website diff --git a/.gitignore b/.gitignore index de47e6c..7608eac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ assets/changelog.txt .env.local .envrc .direnv/ +secrets.env # plaintext secrets — encrypted version (secrets.age) is committed # --- Android --- android/.gradle/ diff --git a/DAGGER.md b/DAGGER.md index 5f7f3de..e17cea1 100644 --- a/DAGGER.md +++ b/DAGGER.md @@ -174,10 +174,70 @@ Run a secret manager co-located with the Dagger host. The CI job authenticates w - Vault itself becomes a security-critical single point of failure. - Operational overhead likely disproportionate for a small single-developer project. +### Option 5: Encrypted secrets file (age) — **implemented** + +Store all production secrets in a file (`secrets.env`) that is encrypted with +[age](https://age-encryption.org/) into `secrets.age`. The encrypted file is +committed to the repository. Only the age private key — a single string — is +stored in Codeberg as `SECRETS_AGE_KEY`. Any CI job or developer with the key +can decrypt the file and obtain all secrets. + +**How it works:** + +1. Generate a key pair once: + ```bash + age-keygen -o ~/.config/age/sharedinbox.key + age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key + ``` +2. Copy `secrets.env.example` to `secrets.env`, fill in all values, then encrypt: + ```bash + scripts/secrets-encrypt.sh # reads public key from .age-public-key + git add secrets.age && git commit -m "chore: update encrypted secrets" + ``` +3. Add the private key content as `SECRETS_AGE_KEY` in Codeberg repository secrets. +4. CI jobs call `scripts/secrets-decrypt.sh` (with `SECRETS_AGE_KEY` set) before + any step that needs production credentials. The script writes each variable + to `$GITHUB_ENV` so subsequent steps see them automatically. + +**Keeping local and CI in sync:** +When you rotate a secret locally, update `secrets.env`, re-run +`scripts/secrets-encrypt.sh`, and commit the new `secrets.age`. CI will pick +up the fresh secrets on the next push — no manual CI variable updates needed. + +Multi-line values (SSH keys, certificates) must be stored as a single line +with `\n` escape sequences inside double quotes. Example: +``` +SSH_PRIVATE_KEY="
\n\n