Compare commits

...
7 Commits
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 774829ece5 fix: exclude about_markdown.dart from unit coverage gate
`buildAboutMarkdown` requires BuildContext and MediaQuery so it cannot
be covered by unit tests; add it to _excluded alongside about_screen.dart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:40:54 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 33949b92c0 feat: syncLog add Copy button, stack trace, isPermanent, Android device info (#237)
- Schema v33: add error_stack_trace and is_permanent columns to sync_logs
- SyncLogEntry gains stackTrace and isPermanent fields; SyncLogRepository.log()
  gains matching optional parameters; IMAP and JMAP sync loops forward the
  stack trace string and isPermanent flag when writing error entries
- New lib/ui/utils/about_markdown.dart utility shared by AboutScreen and the
  sync log copy feature; builds the markdown table including Android device
  info (manufacturer, model, OS version) via device_info_plus
- AboutScreen uses the utility and adds Android device info row
- SyncLogScreen: subtitle shows "Error (permanent)" for permanent errors;
  expanded view shows stack trace in red monospace; each tile has a Copy
  button that copies a markdown summary of the entry plus the About section
- Migration test updated for v33; new repo test for stackTrace/isPermanent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:33:45 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a1b9e0a8b0 feat: show app version as link on crash screen and in MD report (#236)
When a git hash is available, the crash screen now displays the app
version number as a tappable link (pointing to the Codeberg commit
page) above the existing git-hash link, and the clipboard markdown
report formats the App Version line as a markdown link in the same way
the About screen already does.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:12:35 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 3a08daa402 style: format edit_account_screen_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:58:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2336afa0d7 fix: show password required error instead of crashing when no stored password (#235)
During _load(), check whether a password exists in secure storage and track the result
in _hasStoredPassword. The password field validator now requires user input when no
password is stored, so _tryConnection() fails fast at form validation instead of
throwing an unhandled StateError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 07:47:51 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c343ed6bd7 feat: monitor agent loop health every 2 hours (#217)
- Track a heartbeat timestamp in ~/.sharedinbox-agent-heartbeat at the
  start of each _run_loop() invocation so we can tell when it last ran.
- Add `agent_loop.py monitor` subcommand that exits 1 with a WARNING
  message if the heartbeat is missing, corrupted, or older than 2 hours.
- Add .forgejo/workflows/monitor.yml scheduled workflow that runs the
  monitor check every 2 hours on the self-hosted runner; a CI failure
  serves as the warning when the loop is stalled.
- Add 7 unit tests covering all monitor / heartbeat scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:27:03 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1d5eb187bf fix: fall back to text input when mobile_scanner plugin is unavailable (#202)
On some Android builds the mobile_scanner native plugin is not registered,
causing a MissingPluginException when the send/receive screens try to open
the QR scanner.  Add a pre-flight _initScanner() method that starts and
immediately stops a temporary MobileScannerController in a try/catch; any
exception (including MissingPluginException) sets _scannerFailed=true and
the UI falls back to the existing copy-paste text-input flow instead of
leaving the user stuck with a blank camera view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:47:15 +02:00
25 changed files with 740 additions and 85 deletions
+18
View File
@@ -0,0 +1,18 @@
name: Monitor Agent Loop
on:
schedule:
- cron: '0 */2 * * *' # every 2 hours
workflow_dispatch:
jobs:
monitor:
name: Check Agent Loop Health
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check agent loop heartbeat
run: python3 scripts/agent_loop.py monitor
@@ -19,6 +19,8 @@ class SyncLogEntry {
required this.id, required this.id,
required this.result, required this.result,
this.errorMessage, this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol, required this.protocol,
required this.emailsFetched, required this.emailsFetched,
required this.emailsSkipped, required this.emailsSkipped,
@@ -34,6 +36,8 @@ class SyncLogEntry {
final int id; final int id;
final String result; // 'ok' or 'error' final String result; // 'ok' or 'error'
final String? errorMessage; final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap' final String protocol; // 'imap' or 'jmap'
final int emailsFetched; final int emailsFetched;
final int emailsSkipped; final int emailsSkipped;
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+4
View File
@@ -260,6 +260,8 @@ class _AccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
@@ -513,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap', protocol: 'jmap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
+8 -1
View File
@@ -192,6 +192,9 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true. // Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()(); TextColumn get protocolLog => text().nullable()();
// Added in schema v33: stack trace and permanent flag for error entries.
TextColumn get errorStackTrace => text().nullable()();
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
} }
/// Per-mailbox breakdown for a single sync cycle. /// Per-mailbox breakdown for a single sync cycle.
@@ -329,7 +332,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 32; int get schemaVersion => 33;
Future<void> _createEmailFts() async { Future<void> _createEmailFts() async {
await customStatement(''' await customStatement('''
@@ -570,6 +573,10 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) { if (from < 32) {
await m.createTable(localSieveApplied); await m.createTable(localSieveApplied);
} }
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
}, },
); );
} }
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId, accountId: accountId,
result: success ? 'ok' : 'error', result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage), errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol), protocol: Value(protocol),
itemsSynced: Value(emailsFetched), itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped), emailsSkipped: Value(emailsSkipped),
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id, id: r.id,
result: r.result, result: r.result,
errorMessage: r.errorMessage, errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol, protocol: r.protocol,
emailsFetched: r.itemsSynced, emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped, emailsSkipped: r.emailsSkipped,
+42 -63
View File
@@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
@@ -19,57 +20,15 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform(); final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
final Future<AndroidDeviceInfo?> _androidInfoFuture = getAndroidDeviceInfo();
late final Stream<List<Account>> _accountsStream; late final Stream<List<Account>> _accountsStream;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts(); _accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
} }
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard( Future<void> _copyToClipboard(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -79,10 +38,17 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
final androidInfo = await _androidInfoFuture;
if (!context.mounted) return; if (!context.mounted) return;
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount), text: buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
androidInfo: androidInfo,
),
), ),
); );
if (context.mounted) { if (context.mounted) {
@@ -104,9 +70,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
final androidInfo = await _androidInfoFuture;
if (!context.mounted) return; if (!context.mounted) return;
final body = Uri.encodeComponent( final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount), buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
androidInfo: androidInfo,
),
); );
final url = Uri.parse( final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
@@ -156,23 +129,29 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Markdown( return FutureBuilder<AndroidDeviceInfo?>(
data: _buildMarkdown( future: _androidInfoFuture,
context, builder: (context, androidSnapshot) {
snapshot.data, return Markdown(
imapCount, data: buildAboutMarkdown(
jmapCount, context: context,
), pkg: snapshot.data,
selectable: true, imapCount: imapCount,
onTapLink: (text, href, title) { jmapCount: jmapCount,
if (href != null) { androidInfo: androidSnapshot.data,
unawaited( ),
launchUrl( selectable: true,
Uri.parse(href), onTapLink: (text, href, title) {
mode: LaunchMode.externalApplication, if (href != null) {
), unawaited(
); launchUrl(
} Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
}
},
);
}, },
); );
}, },
+37 -4
View File
@@ -37,6 +37,9 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
bool _scannerActive = false; bool _scannerActive = false;
MobileScannerController? _scannerController; MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override @override
void initState() { void initState() {
@@ -76,8 +79,35 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
setState(() { setState(() {
_step = _Step.scanning; _step = _Step.scanning;
_scannerActive = true; _scannerActive = true;
_scannerController = MobileScannerController();
}); });
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: start + stop the scanner to verify the plugin is available.
// Falls back to text entry on any exception (including MissingPluginException).
Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false;
try {
ctrl = MobileScannerController();
await ctrl.start();
await ctrl.stop();
available = true;
} catch (_) {
// Plugin not available on this device; text fallback will be shown.
} finally {
try {
await ctrl?.dispose();
} catch (_) {}
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
} }
Future<void> _onScanned(String rawValue) async { Future<void> _onScanned(String rawValue) async {
@@ -266,11 +296,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
} }
Widget _buildScannerView(BuildContext context) { Widget _buildScannerView(BuildContext context) {
// On platforms where the camera scanner is not available (Linux desktop), // Fall back to text input when the platform has no camera support or when
// fall back to a text-input field. // the scanner plugin fails to initialise at runtime (MissingPluginException).
if (!_cameraScanSupported()) { if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context); return _buildTextFallbackView(context);
} }
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack( return Stack(
children: [ children: [
+33 -2
View File
@@ -45,12 +45,40 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
bool _scannerActive = true; bool _scannerActive = true;
MobileScannerController? _scannerController; MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (_cameraScanSupported()) { if (_cameraScanSupported()) {
_scannerController = MobileScannerController(); unawaited(_initScanner());
}
}
// Pre-flight: start + stop the scanner to verify the plugin is available.
// Falls back to text entry on any exception (including MissingPluginException).
Future<void> _initScanner() async {
MobileScannerController? ctrl;
bool available = false;
try {
ctrl = MobileScannerController();
await ctrl.start();
await ctrl.stop();
available = true;
} catch (_) {
// Plugin not available on this device; text fallback will be shown.
} finally {
try {
await ctrl?.dispose();
} catch (_) {}
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
} }
} }
@@ -178,9 +206,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
} }
Widget _buildScanStep(BuildContext context) { Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported()) { if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context); return _buildTextFallbackView(context);
} }
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack( return Stack(
children: [ children: [
+33 -1
View File
@@ -25,10 +25,13 @@ class CrashScreen extends StatelessWidget {
} catch (_) {} } catch (_) {}
final platform = final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final versionDisplay = gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
: version;
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'
: ''; : '';
return 'App Version: $version\n' return 'App Version: $versionDisplay\n'
'$gitLine' '$gitLine'
'Platform: $platform\n\n' 'Platform: $platform\n\n'
'Error:\n```\n$exception\n```\n\n' 'Error:\n```\n$exception\n```\n\n'
@@ -58,6 +61,35 @@ class CrashScreen extends StatelessWidget {
), ),
if (gitHash.isNotEmpty) ...[ if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final version =
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
return GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'App Version: $version',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
},
),
const SizedBox(height: 4),
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
final url = Uri.parse( final url = Uri.parse(
+10 -2
View File
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
var _sieveSsl = true; var _sieveSsl = true;
var _verbose = false; var _verbose = false;
final _jmapUrlCtrl = TextEditingController(); final _jmapUrlCtrl = TextEditingController();
bool _hasStoredPassword = false;
// -- "Try connection" state ------------------------------------------------ // -- "Try connection" state ------------------------------------------------
bool _tryTesting = false; bool _tryTesting = false;
@@ -63,6 +64,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
context.pop(); context.pop();
return; return;
} }
try {
await repo.getPassword(account.id);
_hasStoredPassword = true;
} catch (_) {}
if (!mounted) return;
_account = account; _account = account;
_displayNameCtrl.text = account.displayName; _displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username; _usernameCtrl.text = account.username;
@@ -267,10 +273,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
), ),
_field( _field(
_passwordCtrl, _passwordCtrl,
'New password (leave blank to keep)', _hasStoredPassword
? 'New password (leave blank to keep)'
: 'Password',
key: const Key('editPasswordField'), key: const Key('editPasswordField'),
obscure: true, obscure: true,
required: false, required: !_hasStoredPassword,
), ),
if (account.type == AccountType.jmap) ...[ if (account.type == AccountType.jmap) ...[
const Divider(height: 32), const Divider(height: 32),
+139 -5
View File
@@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss'); final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} }
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
final buf = StringBuffer();
buf.writeln('## Sync Entry');
buf.writeln();
buf.writeln('| Property | Value |');
buf.writeln('|----------|-------|');
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
if (entry.protocol.isNotEmpty) {
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
}
final statusLabel = entry.isOk
? 'OK'
: entry.isPermanent
? 'Error (permanent)'
: 'Error';
buf.writeln('| Status | $statusLabel |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
if (entry.mailboxStats.isNotEmpty) {
buf.writeln();
buf.writeln('### Per mailbox');
buf.writeln();
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
buf.writeln('|---------|---------|------------|----------|');
for (final m in entry.mailboxStats) {
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
}
}
if (entry.errorMessage != null) {
buf.writeln();
buf.writeln('**Error:**');
buf.writeln();
buf.writeln(entry.errorMessage);
}
if (entry.stackTrace != null) {
buf.writeln();
buf.writeln('**Stack trace:**');
buf.writeln();
buf.writeln('```');
buf.write(entry.stackTrace);
buf.writeln('```');
}
return buf.toString();
}
class SyncLogScreen extends ConsumerStatefulWidget { class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId}); const SyncLogScreen({super.key, required this.accountId});
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId); ref.read(syncManagerProvider).syncNow(widget.accountId);
} }
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
PackageInfo? pkg;
try {
pkg = await PackageInfo.fromPlatform();
} catch (_) {}
final androidInfo = await getAndroidDeviceInfo();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
androidInfo: androidInfo,
);
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 3),
content: Text('Copied to clipboard'),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet')) ? const Center(child: Text('No sync entries yet'))
: ListView.builder( : ListView.builder(
itemCount: _entries.length, itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]), itemBuilder: (ctx, i) => _SyncLogTile(
entry: _entries[i],
onCopy: () => _copyEntry(_entries[i], ctx),
),
), ),
); );
} }
} }
class _SyncLogTile extends StatelessWidget { class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry}); const _SyncLogTile({required this.entry, required this.onCopy});
final SyncLogEntry entry; final SyncLogEntry entry;
final VoidCallback onCopy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final errorColor = theme.colorScheme.error; final errorColor = theme.colorScheme.error;
final subtitleText = entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: entry.isPermanent
? 'Error (permanent) · took $durationLabel'
: 'Error · took $durationLabel';
return ExpansionTile( return ExpansionTile(
leading: Icon( leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline, entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor), style: entry.isOk ? null : TextStyle(color: errorColor),
), ),
subtitle: Text( subtitle: Text(
entry.isOk subtitleText,
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor), style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
), ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy as markdown',
onPressed: onCopy,
),
const Icon(Icons.expand_more),
],
),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12), padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12), style: TextStyle(color: errorColor, fontSize: 12),
), ),
), ),
if (entry.stackTrace != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
child: Text(
'Stack trace',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.stackTrace!,
style: TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: Colors.red[300],
),
),
),
],
if (entry.protocolLog != null) ...[ if (entry.protocolLog != null) ...[
const Padding( const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2), padding: EdgeInsets.only(top: 6, bottom: 2),
+68
View File
@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
///
/// Pass [androidInfo] when running on Android; omit on other platforms.
String buildAboutMarkdown({
required BuildContext context,
PackageInfo? pkg,
required int imapCount,
required int jmapCount,
AndroidDeviceInfo? androidInfo,
}) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final androidLines = androidInfo != null
? '| Android Manufacturer | ${androidInfo.manufacturer} |\n'
'| Android Model | ${androidInfo.model} |\n'
'| Android Version | ${androidInfo.version.release} |\n'
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$androidLines'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
/// Fetches Android device info, or null on non-Android platforms.
Future<AndroidDeviceInfo?> getAndroidDeviceInfo() async {
if (!Platform.isAndroid) return null;
try {
return await DeviceInfoPlugin().androidInfo;
} catch (_) {
return null;
}
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
+24
View File
@@ -249,6 +249,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1284,6 +1300,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.3.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
workmanager: workmanager:
dependency: "direct main" dependency: "direct main"
description: description:
+3
View File
@@ -62,6 +62,9 @@ dependencies:
package_info_plus: ^10.1.0 package_info_plus: ^10.1.0
share_plus: ^13.1.0 share_plus: ^13.1.0
# Device hardware info for bug reports
device_info_plus: ^13.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+43
View File
@@ -53,7 +53,9 @@ os.environ["PATH"] = (
REPO = "guettli/sharedinbox" REPO = "guettli/sharedinbox"
REPO_URL = f"https://codeberg.org/{REPO}" REPO_URL = f"https://codeberg.org/{REPO}"
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json" STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / ( CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
"-" + str(Path.home())[1:].replace("/", "-") "-" + str(Path.home())[1:].replace("/", "-")
) )
@@ -273,6 +275,12 @@ def _clear_state() -> None:
STATE_FILE.unlink(missing_ok=True) STATE_FILE.unlink(missing_ok=True)
def _update_heartbeat() -> None:
"""Record that the agent loop ran right now."""
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
HEARTBEAT_FILE.chmod(0o600)
def _find_session_uuid(session_name: str) -> str | None: def _find_session_uuid(session_name: str) -> str | None:
"""Return the Claude session UUID for *session_name*, or None if not found. """Return the Claude session UUID for *session_name*, or None if not found.
@@ -442,12 +450,44 @@ def cmd_list() -> int:
return 0 return 0
# ── monitor subcommand ────────────────────────────────────────────────────────
def cmd_monitor() -> int:
"""Check that the agent loop has run within the last 2 hours.
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
Intended to be called from a scheduled CI job or cron every 2 hours.
"""
if not HEARTBEAT_FILE.exists():
print(
f"WARNING: Agent loop heartbeat file missing — "
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
)
return 1
try:
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
except ValueError:
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
return 1
age = (datetime.now(timezone.utc) - last_run).total_seconds()
if age > MAX_HEARTBEAT_AGE_SECONDS:
print(
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
)
return 1
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
return 0
# ── main flow ───────────────────────────────────────────────────────────────── # ── main flow ─────────────────────────────────────────────────────────────────
def _run_loop() -> int: def _run_loop() -> int:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}") print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
_update_heartbeat()
state = _read_state() state = _read_state()
@@ -795,10 +835,13 @@ def main() -> int:
parser = argparse.ArgumentParser(prog="agent_loop") parser = argparse.ArgumentParser(prog="agent_loop")
sub = parser.add_subparsers(dest="cmd") sub = parser.add_subparsers(dest="cmd")
sub.add_parser("list", help="List recent agent sessions") sub.add_parser("list", help="List recent agent sessions")
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
args = parser.parse_args() args = parser.parse_args()
if args.cmd == "list": if args.cmd == "list":
return cmd_list() return cmd_list()
if args.cmd == "monitor":
return cmd_monitor()
return _run_loop() return _run_loop()
+1
View File
@@ -57,6 +57,7 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart', 'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart', 'lib/core/sync/background_sync.dart',
+66
View File
@@ -6,6 +6,7 @@ import json
import os import os
import tempfile import tempfile
import unittest import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -732,5 +733,70 @@ class TestRunLoopResumeCommand(unittest.TestCase):
self.assertNotIn("Resume:", output) self.assertNotIn("Resume:", output)
class TestHeartbeat(unittest.TestCase):
"""Tests for _update_heartbeat() and cmd_monitor()."""
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
self._tmp.close()
self._orig = agent_loop.HEARTBEAT_FILE
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no heartbeat file.
def tearDown(self):
agent_loop.HEARTBEAT_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_update_heartbeat_writes_timestamp(self):
agent_loop._update_heartbeat()
content = Path(self._tmp.name).read_text().strip()
dt = datetime.fromisoformat(content)
age = (datetime.now(timezone.utc) - dt).total_seconds()
self.assertLess(age, 5)
def test_update_heartbeat_creates_file(self):
self.assertFalse(Path(self._tmp.name).exists())
agent_loop._update_heartbeat()
self.assertTrue(Path(self._tmp.name).exists())
def test_monitor_healthy_when_recent(self):
agent_loop._update_heartbeat()
result = agent_loop.cmd_monitor()
self.assertEqual(result, 0)
def test_monitor_warns_when_heartbeat_missing(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_stale(self):
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
Path(self._tmp.name).write_text(stale)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_corrupted(self):
Path(self._tmp.name).write_text("not-a-timestamp")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_run_loop_updates_heartbeat(self):
self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop()
self.assertTrue(Path(self._tmp.name).exists())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -288,6 +288,8 @@ class _FakeLogs implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+2
View File
@@ -181,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+17 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 32); expect(db.schemaVersion, 33);
await db.close(); await db.close();
}); });
@@ -194,6 +194,11 @@ void main() {
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get(); await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
@@ -381,11 +386,16 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes'); await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test('fresh install creates all tables at schemaVersion 32', () async { test('fresh install creates all tables at schemaVersion 33', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -426,6 +436,11 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes'); await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
await db.close(); await db.close();
}); });
}); });
+2
View File
@@ -170,6 +170,8 @@ class _FakeSyncLog implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -126,4 +126,34 @@ void main() {
expect(rows.first.result, 'error'); expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
}); });
test('stores and retrieves stackTrace and isPermanent on error entries',
() async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 3, 1, 9);
final end = DateTime(2024, 3, 1, 9, 0, 1);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
await repo.log(
accountId: 'acc1',
success: false,
errorMessage: 'MissingPluginException',
stackTrace: fakeTrace,
isPermanent: true,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
mailboxesSynced: 0,
pendingFlushed: 0,
bytesTransferred: 0,
startedAt: start,
finishedAt: end,
);
final entries = await repo.observeSyncLogs('acc1').first;
final entry = entries.firstWhere((e) => e.startedAt == start);
expect(entry.stackTrace, fakeTrace);
expect(entry.isPermanent, true);
expect(entry.errorMessage, 'MissingPluginException');
});
} }
+104
View File
@@ -144,6 +144,7 @@ void main() {
gitHash: testHash, gitHash: testHash,
), ),
); );
await tester.pumpAndSettle();
// Git hash link should be present // Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234'); final gitLinkFinder = find.textContaining('Git Commit: abc1234');
@@ -167,6 +168,109 @@ void main() {
}, },
); );
testWidgets(
'CrashScreen shows app version as clickable link when git hash is set',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
const exception = 'TestException: version link test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
await tester.pumpAndSettle();
// App version link should be present (mocked as 1.0.0+42)
final versionLinkFinder = find.textContaining('App Version: 1.0.0+42');
expect(versionLinkFinder, findsOneWidget);
// It must appear above the git hash link
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(
tester.getTopLeft(versionLinkFinder).dy,
lessThan(tester.getTopLeft(gitLinkFinder).dy),
);
// Tapping it should open the Codeberg commit URL
await tester.tap(versionLinkFinder);
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
);
},
);
testWidgets(
'CrashScreen copy-to-clipboard includes app version as markdown link when git hash is set',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
String? clipboardText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(MethodCall call) async {
if (call.method == 'Clipboard.setData') {
clipboardText =
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
const exception = 'TestException: version link clipboard test';
final stackTrace = StackTrace.current;
const testHash = 'abc1234';
await tester.pumpWidget(
CrashScreen(
exception: exception,
stackTrace: stackTrace,
gitHash: testHash,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Copy to Clipboard'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(clipboardText, isNotNull);
// App Version must be a markdown link pointing to the commit
expect(
clipboardText,
contains(
'App Version: [1.0.0+42](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
),
);
expect(
clipboardText,
contains(
'Git Commit: [abc1234](https://codeberg.org/guettli/sharedinbox/commit/abc1234)',
),
);
},
);
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 {
+27
View File
@@ -105,6 +105,33 @@ void main() {
expect(find.text('Edit account'), findsNothing); expect(find.text('Edit account'), findsNothing);
}); });
testWidgets(
'try connection shows password required when no password stored', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('editTryConnectionButton')));
await tester.pumpAndSettle();
// App must not crash; password field shows a validation error.
expect(find.text('Required'), findsOneWidget);
});
testWidgets('connection error shows error message', (tester) async { testWidgets('connection error shows error message', (tester) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
+13 -5
View File
@@ -44,11 +44,12 @@ import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class FakeAccountRepository implements AccountRepository { class FakeAccountRepository implements AccountRepository {
final List<Account> _accounts;
FakeAccountRepository([List<Account>? accounts]) FakeAccountRepository([List<Account>? accounts])
: _accounts = List.of(accounts ?? []); : _accounts = List.of(accounts ?? []);
final List<Account> _accounts;
bool hasPassword = true;
@override @override
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts)); Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
@@ -75,7 +76,12 @@ class FakeAccountRepository implements AccountRepository {
_accounts.removeWhere((a) => a.id == id); _accounts.removeWhere((a) => a.id == id);
@override @override
Future<String> getPassword(String accountId) async => 'test-password'; Future<String> getPassword(String accountId) async {
if (!hasPassword) {
throw StateError('No password stored for account $accountId');
}
return 'test-password';
}
} }
class FakeShareKeyRepository implements ShareKeyRepository { class FakeShareKeyRepository implements ShareKeyRepository {
@@ -511,10 +517,12 @@ List<Override> baseOverrides({
List<Mailbox>? mailboxes, List<Mailbox>? mailboxes,
DiscoveryResult? discovery, DiscoveryResult? discovery,
Exception? connectionError, Exception? connectionError,
bool hasStoredPassword = true,
}) => }) =>
[ [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository(accounts)), FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
),
mailboxRepositoryProvider mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository(mailboxes)), .overrideWithValue(FakeMailboxRepository(mailboxes)),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),