Compare commits
7
Commits
main
...
issue-237-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774829ece5 | ||
|
|
33949b92c0 | ||
|
|
a1b9e0a8b0 | ||
|
|
3a08daa402 | ||
|
|
2336afa0d7 | ||
|
|
c343ed6bd7 | ||
|
|
1d5eb187bf |
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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: [
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)}';
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
Reference in New Issue
Block a user