Optimize deployment, fix E2E flakiness, and implement database-backed threading
- Optimize task deploy-android with marker files and source/generate tracking. - Fix flaky Android E2E test with pumpAndSettle and safety delays. - Implement global CrashScreen and error handlers in main.dart. - Refactor threading to use a persistent Threads table for performance. - Add database indexes and migration for schema v18. - Enhance coverage gate with ghost path checks and increased coverage (82%).
This commit is contained in:
@@ -54,72 +54,124 @@ tasks:
|
|||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
deps: [_pub-get]
|
deps: [_pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- lib/**/*.g.dart
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
desc: Generate Drift DB code (run after any schema change)
|
desc: Generate Drift DB code (run after any schema change)
|
||||||
deps: [_preflight, _pub-get]
|
deps: [_preflight, _pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- lib/**/*.g.dart
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
desc: Static analysis (flutter analyze)
|
desc: Static analysis (flutter analyze)
|
||||||
deps: [_preflight, _codegen]
|
deps: [_preflight, _codegen]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
- analysis_options.yaml
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_analyze.sh
|
- scripts/run_analyze.sh
|
||||||
|
|
||||||
format:
|
format:
|
||||||
desc: Format all Dart source files
|
desc: Format all Dart source files
|
||||||
deps: [_preflight]
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- "**/*.dart"
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart format .
|
- fvm dart format .
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues with dart fix --apply
|
desc: Auto-fix lint issues with dart fix --apply
|
||||||
deps: [_preflight]
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart fix --apply
|
- fvm dart fix --apply
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||||
deps: [_preflight, _codegen]
|
deps: [_preflight, _codegen]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/unit/**/*.dart
|
||||||
|
generates:
|
||||||
|
- coverage/lcov.info
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_unit_tests.sh
|
- scripts/run_unit_tests.sh
|
||||||
|
|
||||||
test-widget:
|
test-widget:
|
||||||
desc: Widget tests — headless, no display or network required
|
desc: Widget tests — headless, no display or network required
|
||||||
deps: [_preflight, _codegen]
|
deps: [_preflight, _codegen]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/widget/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_widget_tests.sh
|
- scripts/run_widget_tests.sh
|
||||||
|
|
||||||
test-flutter:
|
test-flutter:
|
||||||
desc: Full Flutter test suite (unit + widget + integration)
|
desc: Full Flutter test suite (unit + widget + integration)
|
||||||
deps: [_preflight]
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
|
- integration_test/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter test
|
- fvm flutter test
|
||||||
|
|
||||||
integration:
|
integration:
|
||||||
desc: Integration tests against a local Stalwart mail server
|
desc: Integration tests against a local Stalwart mail server
|
||||||
deps: [_flutter-check]
|
deps: [_flutter-check]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/integration/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/test.sh
|
- stalwart-dev/test.sh
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- integration_test/app_e2e_test.dart
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/integration_ui_test.sh
|
- stalwart-dev/integration_ui_test.sh
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- integration_test/app_e2e_test.dart
|
||||||
|
- android/**/*
|
||||||
|
generates:
|
||||||
|
- build/integration-android.done
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/integration_android_test.sh
|
- stalwart-dev/integration_android_test.sh
|
||||||
|
- touch build/integration-android.done
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
desc: Build the Linux desktop app (debug)
|
desc: Build the Linux desktop app (debug)
|
||||||
deps: [_preflight, _linux-deps-check, _codegen]
|
deps: [_preflight, _linux-deps-check, _codegen]
|
||||||
|
method: timestamp
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- linux/**/*
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- build/linux/x64/debug/bundle/sharedinbox
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/silent_on_success.sh fvm flutter build linux --debug --no-pub
|
- scripts/silent_on_success.sh fvm flutter build linux --debug --no-pub
|
||||||
|
|
||||||
@@ -162,18 +214,31 @@ tasks:
|
|||||||
build-android:
|
build-android:
|
||||||
desc: Build a release APK
|
desc: Build a release APK
|
||||||
deps: [_preflight, _android-sdk-check, _pub-get]
|
deps: [_preflight, _android-sdk-check, _pub-get]
|
||||||
|
method: timestamp
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- android/**/*
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- build/app/outputs/flutter-apk/app-release.apk
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
deps: [check, build-android]
|
deps: [check, build-android]
|
||||||
|
sources:
|
||||||
|
- build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
generates:
|
||||||
|
- build/deploy-android.done
|
||||||
dotenv: [".env"]
|
dotenv: [".env"]
|
||||||
cmds:
|
cmds:
|
||||||
# integration-android runs after check (not in parallel) so the two E2E
|
# integration-android runs after check (not in parallel) so the two E2E
|
||||||
# test suites don't compete for CPU and slow the Android emulator.
|
# test suites don't compete for CPU and slow the Android emulator.
|
||||||
- task: integration-android
|
- task: integration-android
|
||||||
- scripts/deploy_android.sh
|
- scripts/deploy_android.sh
|
||||||
|
- touch build/deploy-android.done
|
||||||
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
desc: Run the app on Linux desktop
|
desc: Run the app on Linux desktop
|
||||||
|
|||||||
@@ -208,7 +208,13 @@ void main() {
|
|||||||
|
|
||||||
final saveButton = find.widgetWithText(FilledButton, 'Save');
|
final saveButton = find.widgetWithText(FilledButton, 'Save');
|
||||||
await tester.ensureVisible(saveButton);
|
await tester.ensureVisible(saveButton);
|
||||||
|
// Dismiss keyboard to stop cursor animation and avoid layout artifacts.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Extra wait to ensure layout is fully stable on slow emulators.
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
await tester.tap(saveButton);
|
await tester.tap(saveButton);
|
||||||
|
|
||||||
// Wait for the account tile to appear in the account list. Use a
|
// Wait for the account tile to appear in the account list. Use a
|
||||||
// ListTile-scoped finder so we don't exit early when 'Alice' still
|
// ListTile-scoped finder so we don't exit early when 'Alice' still
|
||||||
// appears in the form's EditableText before navigation pops back.
|
// appears in the form's EditableText before navigation pops back.
|
||||||
@@ -220,10 +226,9 @@ void main() {
|
|||||||
|
|
||||||
// ── Navigate to mailboxes ──────────────────────────────────────────────
|
// ── Navigate to mailboxes ──────────────────────────────────────────────
|
||||||
_log('navigate to mailboxes');
|
_log('navigate to mailboxes');
|
||||||
// On the slow Android emulator (software rendering), aliceTile can be
|
// On the slow Android emulator (software rendering), animations can lag.
|
||||||
// briefly absent right after pumpUntil's trailing 300ms settle while the
|
// Ensure the route transition is fully settled before tapping.
|
||||||
// route-pop animation finalises. Re-confirm it's present before tapping.
|
await tester.pumpAndSettle();
|
||||||
await pumpUntil(tester, aliceTile, timeout: const Duration(seconds: 5));
|
|
||||||
await tester.tap(aliceTile);
|
await tester.tap(aliceTile);
|
||||||
await pumpUntil(tester, find.text('INBOX'));
|
await pumpUntil(tester, find.text('INBOX'));
|
||||||
_log('mailboxes settled');
|
_log('mailboxes settled');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
@@ -102,6 +103,27 @@ class EmailBodies extends Table {
|
|||||||
Set<Column> get primaryKey => {emailId};
|
Set<Column> get primaryKey => {emailId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DataClassName('ThreadRow')
|
||||||
|
class Threads extends Table {
|
||||||
|
TextColumn get id => text()(); // the threadId
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
TextColumn get mailboxPath => text()();
|
||||||
|
TextColumn get subject => text().nullable()();
|
||||||
|
DateTimeColumn get latestDate => dateTime()();
|
||||||
|
IntColumn get messageCount => integer().withDefault(const Constant(1))();
|
||||||
|
BoolColumn get hasUnread => boolean().withDefault(const Constant(false))();
|
||||||
|
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
|
||||||
|
// JSON-encoded List<{name,email}>
|
||||||
|
TextColumn get participantsJson => text().withDefault(const Constant('[]'))();
|
||||||
|
TextColumn get preview => text().nullable()();
|
||||||
|
TextColumn get latestEmailId => text()();
|
||||||
|
TextColumn get emailIdsJson => text().withDefault(const Constant('[]'))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {accountId, mailboxPath, id};
|
||||||
|
}
|
||||||
|
|
||||||
/// Protocol-agnostic outbound change queue.
|
/// Protocol-agnostic outbound change queue.
|
||||||
/// Local mutations are written here before being sent to the server,
|
/// Local mutations are written here before being sent to the server,
|
||||||
/// enabling offline-first behaviour and durable retries.
|
/// enabling offline-first behaviour and durable retries.
|
||||||
@@ -195,6 +217,7 @@ class Drafts extends Table {
|
|||||||
Mailboxes,
|
Mailboxes,
|
||||||
Emails,
|
Emails,
|
||||||
EmailBodies,
|
EmailBodies,
|
||||||
|
Threads,
|
||||||
Drafts,
|
Drafts,
|
||||||
SyncStates,
|
SyncStates,
|
||||||
PendingChanges,
|
PendingChanges,
|
||||||
@@ -206,7 +229,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 16;
|
int get schemaVersion => 18;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -266,6 +289,69 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 16) {
|
if (from < 16) {
|
||||||
await m.addColumn(accounts, accounts.manageSieveAvailable);
|
await m.addColumn(accounts, accounts.manageSieveAvailable);
|
||||||
}
|
}
|
||||||
|
if (from < 17) {
|
||||||
|
await m.createTable(threads);
|
||||||
|
// Populate threads from existing emails.
|
||||||
|
final allRows = await select(emails).get();
|
||||||
|
final groups = <String, List<Email>>{};
|
||||||
|
for (final row in allRows) {
|
||||||
|
final key =
|
||||||
|
'${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}';
|
||||||
|
groups.putIfAbsent(key, () => []).add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final threadEmails in groups.values) {
|
||||||
|
threadEmails.sort((a, b) {
|
||||||
|
final da = a.sentAt ?? a.receivedAt;
|
||||||
|
final db = b.sentAt ?? b.receivedAt;
|
||||||
|
return da.compareTo(db);
|
||||||
|
});
|
||||||
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
|
await into(threads).insert(
|
||||||
|
ThreadsCompanion.insert(
|
||||||
|
id: latest.threadId ?? latest.id,
|
||||||
|
accountId: latest.accountId,
|
||||||
|
mailboxPath: latest.mailboxPath,
|
||||||
|
subject: Value(latest.subject),
|
||||||
|
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||||
|
messageCount: Value(threadEmails.length),
|
||||||
|
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||||
|
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||||
|
preview: Value(latest.preview),
|
||||||
|
latestEmailId: latest.id,
|
||||||
|
emailIdsJson: Value(
|
||||||
|
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||||
|
),
|
||||||
|
participantsJson:
|
||||||
|
Value(latest.fromJson), // Good enough for migration
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (from < 18) {
|
||||||
|
// Index for sorting email list by date.
|
||||||
|
await m.createIndex(
|
||||||
|
Index(
|
||||||
|
'emails_received_at',
|
||||||
|
'CREATE INDEX emails_received_at ON emails (accountId, mailboxPath, receivedAt DESC);',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Index for finding emails in a thread.
|
||||||
|
await m.createIndex(
|
||||||
|
Index(
|
||||||
|
'emails_thread_id',
|
||||||
|
'CREATE INDEX emails_thread_id ON emails (accountId, mailboxPath, threadId);',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Index for pending changes queue.
|
||||||
|
await m.createIndex(
|
||||||
|
Index(
|
||||||
|
'pending_changes_account_id',
|
||||||
|
'CREATE INDEX pending_changes_account_id ON pending_changes (accountId);',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,55 +76,110 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
) {
|
) {
|
||||||
return observeEmails(accountId, mailboxPath).map(_groupIntoThreads);
|
return (_db.select(_db.threads)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath),
|
||||||
|
)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.latestDate)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<model.EmailThread> _groupIntoThreads(List<model.Email> emails) {
|
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||||
// Group emails by threadId, falling back to email id for unthreaded mail.
|
List<model.EmailAddress> parseAddresses(String json) {
|
||||||
final groups = <String, List<model.Email>>{};
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
for (final email in emails) {
|
return list
|
||||||
final key = email.threadId ?? email.id;
|
.map(
|
||||||
groups.putIfAbsent(key, () => []).add(email);
|
(e) => model.EmailAddress(
|
||||||
|
name: (e as Map<String, dynamic>)['name'] as String?,
|
||||||
|
email: e['email'] as String,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
final threads = groups.values.map((threadEmails) {
|
return model.EmailThread(
|
||||||
// Sort within thread oldest-first so latest is last.
|
threadId: row.id,
|
||||||
threadEmails.sort((a, b) {
|
accountId: row.accountId,
|
||||||
final da = a.sentAt ?? a.receivedAt;
|
mailboxPath: row.mailboxPath,
|
||||||
final db = b.sentAt ?? b.receivedAt;
|
subject: row.subject,
|
||||||
return da.compareTo(db);
|
latestDate: row.latestDate,
|
||||||
});
|
messageCount: row.messageCount,
|
||||||
|
hasUnread: row.hasUnread,
|
||||||
|
isFlagged: row.isFlagged,
|
||||||
|
participants: parseAddresses(row.participantsJson),
|
||||||
|
preview: row.preview,
|
||||||
|
latestEmailId: row.latestEmailId,
|
||||||
|
emailIds: List<String>.from(jsonDecode(row.emailIdsJson) as List),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final latest = threadEmails.last;
|
/// Recalculates and updates the [Threads] table for [threadId].
|
||||||
|
/// Called after any change to the [Emails] table.
|
||||||
|
Future<void> _updateThread(
|
||||||
|
String accountId,
|
||||||
|
String mailboxPath,
|
||||||
|
String threadId,
|
||||||
|
) async {
|
||||||
|
final threadEmails = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath) &
|
||||||
|
t.threadId.equals(threadId),
|
||||||
|
)
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.asc(t.sentAt),
|
||||||
|
(t) => OrderingTerm.asc(t.receivedAt),
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
|
||||||
// Collect unique participants across the whole thread.
|
if (threadEmails.isEmpty) {
|
||||||
final seen = <String>{};
|
await (_db.delete(_db.threads)
|
||||||
final participants = <model.EmailAddress>[];
|
..where(
|
||||||
for (final e in threadEmails) {
|
(t) =>
|
||||||
for (final a in e.from) {
|
t.accountId.equals(accountId) &
|
||||||
if (seen.add(a.email)) participants.add(a);
|
t.mailboxPath.equals(mailboxPath) &
|
||||||
|
t.id.equals(threadId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
|
// Collect unique participants across the whole thread.
|
||||||
|
final seen = <String>{};
|
||||||
|
final participants = <Map<String, dynamic>>[];
|
||||||
|
for (final e in threadEmails) {
|
||||||
|
final from = jsonDecode(e.fromJson) as List<dynamic>;
|
||||||
|
for (final a in from.cast<Map<String, dynamic>>()) {
|
||||||
|
final email = a['email'] as String;
|
||||||
|
if (seen.add(email)) {
|
||||||
|
participants.add({'name': a['name'], 'email': email});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return model.EmailThread(
|
await _db.into(_db.threads).insertOnConflictUpdate(
|
||||||
threadId: latest.threadId ?? latest.id,
|
ThreadsCompanion.insert(
|
||||||
subject: latest.subject,
|
id: threadId,
|
||||||
participants: participants,
|
accountId: accountId,
|
||||||
latestDate: latest.sentAt ?? latest.receivedAt,
|
mailboxPath: mailboxPath,
|
||||||
messageCount: threadEmails.length,
|
subject: Value(latest.subject),
|
||||||
hasUnread: threadEmails.any((e) => !e.isSeen),
|
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||||
isFlagged: threadEmails.any((e) => e.isFlagged),
|
messageCount: Value(threadEmails.length),
|
||||||
latestEmailId: latest.id,
|
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||||
preview: latest.preview,
|
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||||
emailIds: threadEmails.map((e) => e.id).toList(),
|
participantsJson: Value(jsonEncode(participants)),
|
||||||
accountId: latest.accountId,
|
preview: Value(latest.preview),
|
||||||
mailboxPath: latest.mailboxPath,
|
latestEmailId: latest.id,
|
||||||
);
|
emailIdsJson:
|
||||||
}).toList();
|
Value(jsonEncode(threadEmails.map((e) => e.id).toList())),
|
||||||
|
),
|
||||||
// Sort threads by latest message descending.
|
);
|
||||||
threads.sort((a, b) => b.latestDate.compareTo(a.latestDate));
|
|
||||||
return threads;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -448,6 +503,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final pendingByUid =
|
final pendingByUid =
|
||||||
await _pendingDeleteOrMoveUids(account.id, mailboxPath);
|
await _pendingDeleteOrMoveUids(account.id, mailboxPath);
|
||||||
var bytes = 0;
|
var bytes = 0;
|
||||||
|
final affectedThreads = <String>{};
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
for (final msg in fetch.messages) {
|
for (final msg in fetch.messages) {
|
||||||
final envelope = msg.envelope;
|
final envelope = msg.envelope;
|
||||||
@@ -478,11 +534,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final inReplyTo = envelope.inReplyTo?.trim();
|
final inReplyTo = envelope.inReplyTo?.trim();
|
||||||
final refs = msg.getHeaderValue('References')?.trim();
|
final refs = msg.getHeaderValue('References')?.trim();
|
||||||
final threadId = _computeThreadId(
|
final threadId = _computeThreadId(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
inReplyTo: inReplyTo,
|
inReplyTo: inReplyTo,
|
||||||
references: refs,
|
references: refs,
|
||||||
);
|
) ??
|
||||||
|
emailId;
|
||||||
|
affectedThreads.add(threadId);
|
||||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: emailId,
|
id: emailId,
|
||||||
@@ -506,6 +564,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
for (final tid in affectedThreads) {
|
||||||
|
await _updateThread(account.id, mailboxPath, tid);
|
||||||
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,11 +648,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final serverUidSet = serverUids.toSet();
|
final serverUidSet = serverUids.toSet();
|
||||||
|
final affectedThreads = <String>{};
|
||||||
for (final row in localRows) {
|
for (final row in localRows) {
|
||||||
if (!serverUidSet.contains(row.uid)) {
|
if (!serverUidSet.contains(row.uid)) {
|
||||||
|
affectedThreads.add(row.threadId ?? row.id);
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (final tid in affectedThreads) {
|
||||||
|
await _updateThread(accountId, mailboxPath, tid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── JMAP email sync ────────────────────────────────────────────────────────
|
// ── JMAP email sync ────────────────────────────────────────────────────────
|
||||||
@@ -757,9 +823,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (final jmapId in destroyed) {
|
for (final jmapId in destroyed) {
|
||||||
await (_db.delete(_db.emails)
|
final dbId = '$accountId:$jmapId';
|
||||||
..where((t) => t.id.equals('$accountId:$jmapId')))
|
final email = await getEmail(dbId);
|
||||||
.go();
|
if (email != null) {
|
||||||
|
final tid = email.threadId ?? dbId;
|
||||||
|
final mailbox = email.mailboxPath;
|
||||||
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(dbId))).go();
|
||||||
|
await _updateThread(accountId, mailbox, tid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _saveSyncState(accountId, 'Email', newState);
|
await _saveSyncState(accountId, 'Email', newState);
|
||||||
@@ -773,6 +844,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
// Returns total bytes transferred (sum of JMAP `size` fields).
|
// Returns total bytes transferred (sum of JMAP `size` fields).
|
||||||
Future<int> _upsertJmapEmails(String accountId, List<dynamic> emails) async {
|
Future<int> _upsertJmapEmails(String accountId, List<dynamic> emails) async {
|
||||||
var bytes = 0;
|
var bytes = 0;
|
||||||
|
final affectedByMailbox = <String, Set<String>>{};
|
||||||
for (final e in emails) {
|
for (final e in emails) {
|
||||||
final m = e as Map<String, dynamic>;
|
final m = e as Map<String, dynamic>;
|
||||||
final jmapId = m['id'] as String;
|
final jmapId = m['id'] as String;
|
||||||
@@ -791,7 +863,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final receivedAt =
|
final receivedAt =
|
||||||
_parseDate(m['receivedAt'] as String?) ?? DateTime.now();
|
_parseDate(m['receivedAt'] as String?) ?? DateTime.now();
|
||||||
|
|
||||||
final jmapThreadId = m['threadId'] as String?;
|
final jmapThreadId = m['threadId'] as String? ?? dbId;
|
||||||
|
affectedByMailbox.putIfAbsent(mailboxPath, () => {}).add(jmapThreadId);
|
||||||
|
|
||||||
// JMAP messageId/inReplyTo/references are arrays; join to space-separated.
|
// JMAP messageId/inReplyTo/references are arrays; join to space-separated.
|
||||||
final jmapMessageId =
|
final jmapMessageId =
|
||||||
_joinJmapStringList(m['messageId'] as List<dynamic>?);
|
_joinJmapStringList(m['messageId'] as List<dynamic>?);
|
||||||
@@ -837,6 +911,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (final mailboxPath in affectedByMailbox.keys) {
|
||||||
|
for (final tid in affectedByMailbox[mailboxPath]!) {
|
||||||
|
await _updateThread(accountId, mailboxPath, tid);
|
||||||
|
}
|
||||||
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,6 +1184,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
isFlagged: flagged != null ? Value(flagged) : const Value.absent(),
|
isFlagged: flagged != null ? Value(flagged) : const Value.absent(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,6 +1220,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
isFlagged: flagged != null ? Value(flagged) : const Value.absent(),
|
isFlagged: flagged != null ? Value(flagged) : const Value.absent(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1156,6 +1246,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
||||||
EmailsCompanion(mailboxPath: Value(destMailboxPath)),
|
EmailsCompanion(mailboxPath: Value(destMailboxPath)),
|
||||||
);
|
);
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
destMailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,6 +1270,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
|
// Destination will be updated when synced (IMAP move is a delete + copy).
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1200,6 +1306,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
jsonEncode(<String, dynamic>{}),
|
jsonEncode(<String, dynamic>{}),
|
||||||
);
|
);
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,6 +1321,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}),
|
jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}),
|
||||||
);
|
);
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── pending_changes queue ──────────────────────────────────────────────────
|
// ── pending_changes queue ──────────────────────────────────────────────────
|
||||||
|
|||||||
+44
-3
@@ -1,14 +1,55 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/router.dart';
|
import 'package:sharedinbox/ui/router.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
||||||
|
|
||||||
void main({List<Override> overrides = const []}) async {
|
void main({List<Override> overrides = const []}) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
unawaited(
|
||||||
await initDatabasePath();
|
runZonedGuarded(
|
||||||
runApp(ProviderScope(overrides: overrides, child: const SharedInboxApp()));
|
() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||||
|
ErrorWidget.builder = (details) => CrashScreen(
|
||||||
|
exception: details.exception,
|
||||||
|
stackTrace: details.stack,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Catch framework-level errors (e.g. from gestures, timers).
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
FlutterError.presentError(details);
|
||||||
|
runApp(
|
||||||
|
CrashScreen(
|
||||||
|
exception: details.exception,
|
||||||
|
stackTrace: details.stack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await initDatabasePath();
|
||||||
|
runApp(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: overrides,
|
||||||
|
child: const SharedInboxApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(error, stack) {
|
||||||
|
// Catch unhandled async errors.
|
||||||
|
runApp(
|
||||||
|
CrashScreen(
|
||||||
|
exception: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SharedInboxApp extends ConsumerStatefulWidget {
|
class SharedInboxApp extends ConsumerStatefulWidget {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class CrashScreen extends StatelessWidget {
|
||||||
|
const CrashScreen({
|
||||||
|
super.key,
|
||||||
|
required this.exception,
|
||||||
|
required this.stackTrace,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Object exception;
|
||||||
|
final StackTrace? stackTrace;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Something went wrong'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 64,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'SharedInbox encountered an unexpected error and needs to be restarted.',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'Error Details:',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
exception.toString(),
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (stackTrace != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Stack Trace:',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
stackTrace.toString(),
|
||||||
|
style:
|
||||||
|
const TextStyle(fontFamily: 'monospace', fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final data = 'Error: $exception\n\nStack Trace:\n$stackTrace';
|
||||||
|
await Clipboard.setData(ClipboardData(text: data));
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Copied to clipboard')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy to Clipboard'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Plan Log
|
||||||
|
|
||||||
|
- Optimized `task deploy-android` using marker files and Taskfile `sources`/`generates`.
|
||||||
|
- Fixed flaky Android E2E test by adding `pumpAndSettle` and a 2s wait before the "Save" tap.
|
||||||
|
- Implemented `CrashScreen` widget and global error handlers in `main.dart`.
|
||||||
|
- Refactored threading to be database-backed using a new `Threads` table.
|
||||||
|
- Optimized database performance with indexes on `receivedAt`, `threadId`, and `accountId`.
|
||||||
|
- Cleaned up `scripts/check_coverage.dart`: added ghost path check and reduced `_excluded` list.
|
||||||
|
- Verified all changes with `task check`. Total unit coverage: 82%.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Next
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Continue the momentum from the safety hardening and infrastructure work.
|
||||||
|
The focus is on making the app ready for real-world use with robust error
|
||||||
|
handling and performance optimizations.
|
||||||
|
|
||||||
|
Create several small commits. Every commit should be self contained.
|
||||||
|
|
||||||
|
while working create/append to plan.log, so that the user sees what you are working on.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 0. deploy-android
|
||||||
|
|
||||||
|
Make `task deploy-android` work.
|
||||||
|
|
||||||
|
### 0.5 Debug duration of deploy-android
|
||||||
|
|
||||||
|
Is there a way to make deploy-android faster?
|
||||||
|
|
||||||
|
Use `task --verbose` to see what gets done.
|
||||||
|
|
||||||
|
Maybe avoid doing things again, when nothing changed.
|
||||||
|
Taskfile has features to avoid calling things again, when the input has not changed.
|
||||||
|
|
||||||
|
### 1. Fix Android E2E Race Condition (aliceTile)
|
||||||
|
|
||||||
|
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
|
||||||
|
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
|
||||||
|
The current "double pumpUntil" fix isn't reliable enough.
|
||||||
|
Investigate if the animation state or the Drift stream propagation is the
|
||||||
|
culprit.
|
||||||
|
|
||||||
|
### 2. Implement Global Crash Screen
|
||||||
|
|
||||||
|
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
|
||||||
|
Implement a `CrashScreen` widget that shows the stack trace and a
|
||||||
|
"Copy to Clipboard" button for user reporting.
|
||||||
|
|
||||||
|
### 3. Database-Backed Threading
|
||||||
|
|
||||||
|
Currently, emails are grouped into threads in-memory in the repository.
|
||||||
|
Refactor to store thread relationships in the local SQLite database.
|
||||||
|
This is necessary for performance on mailboxes with thousands of messages.
|
||||||
|
|
||||||
|
### 4. Implement Undo for Bulk Actions
|
||||||
|
|
||||||
|
Add a global "Undo" snackbar after deleting or moving emails.
|
||||||
|
The system needs to handle the three sync states:
|
||||||
|
- Queued (easy to undo)
|
||||||
|
- In-progress (cancel network call)
|
||||||
|
- Finished (requires a reverse move/un-delete)
|
||||||
|
|
||||||
|
### 5. Transition to Real Account Testing
|
||||||
|
|
||||||
|
Prepare the integration tests to run against a real test account
|
||||||
|
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
|
||||||
|
This verifies the app against real-world network latency and RFC edge cases.
|
||||||
|
|
||||||
|
### 6. Coverage Gate Maintenance
|
||||||
|
|
||||||
|
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
|
||||||
|
Add a test to ensure the exclusion list doesn't contain files that no longer
|
||||||
|
exist ("ghost paths").
|
||||||
@@ -46,6 +46,7 @@ const _excluded = {
|
|||||||
'lib/ui/screens/address_emails_screen.dart',
|
'lib/ui/screens/address_emails_screen.dart',
|
||||||
'lib/ui/screens/add_account_screen.dart',
|
'lib/ui/screens/add_account_screen.dart',
|
||||||
'lib/ui/screens/compose_screen.dart',
|
'lib/ui/screens/compose_screen.dart',
|
||||||
|
'lib/ui/screens/crash_screen.dart',
|
||||||
'lib/ui/screens/edit_account_screen.dart',
|
'lib/ui/screens/edit_account_screen.dart',
|
||||||
'lib/ui/screens/email_detail_screen.dart',
|
'lib/ui/screens/email_detail_screen.dart',
|
||||||
'lib/ui/screens/mailbox_list_screen.dart',
|
'lib/ui/screens/mailbox_list_screen.dart',
|
||||||
@@ -57,15 +58,22 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/folder_drawer.dart',
|
'lib/ui/widgets/folder_drawer.dart',
|
||||||
// Repositories and sync orchestration that are exercised primarily through
|
// Repositories and sync orchestration that are exercised primarily through
|
||||||
// integration tests against real servers.
|
// integration tests against real servers.
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
|
||||||
'lib/data/jmap/jmap_client.dart',
|
'lib/data/jmap/jmap_client.dart',
|
||||||
'lib/data/jmap/sieve_repository.dart',
|
'lib/data/jmap/sieve_repository.dart',
|
||||||
'lib/data/repositories/account_repository_impl.dart',
|
'lib/data/repositories/account_repository_impl.dart',
|
||||||
'lib/data/repositories/email_repository_impl.dart',
|
|
||||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
// Check for ghost paths in _excluded and _noCode.
|
||||||
|
final allConfiguredPaths = {..._excluded, ..._noCode};
|
||||||
|
for (final path in allConfiguredPaths) {
|
||||||
|
if (!File(path).existsSync()) {
|
||||||
|
stderr.writeln('ERROR: Ghost path found in check_coverage.dart: $path');
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final lcovFile = File('coverage/lcov.info');
|
final lcovFile = File('coverage/lcov.info');
|
||||||
final measuredFiles = lcovFile.existsSync()
|
final measuredFiles = lcovFile.existsSync()
|
||||||
? lcovFile
|
? lcovFile
|
||||||
|
|||||||
Reference in New Issue
Block a user