Compare commits
18
Commits
@@ -4,12 +4,32 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Dagger Remote Engine
|
||||
env:
|
||||
|
||||
@@ -15,6 +15,23 @@ jobs:
|
||||
linux: ${{ steps.diff.outputs.linux }}
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -141,6 +158,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -175,6 +209,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -203,6 +254,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.linux == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -236,6 +304,23 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -14,6 +14,23 @@ jobs:
|
||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -50,6 +67,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -18,6 +18,23 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
wait_seconds=$((runner_start - queued_epoch))
|
||||
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
+2
-2
@@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
@@ -570,7 +570,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ abstract class EmailRepository {
|
||||
);
|
||||
|
||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
||||
/// if null) by subject and preview. Fast, works offline.
|
||||
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||
|
||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
|
||||
/// A lightweight email representation used by [SieveInterpreter].
|
||||
/// Header names are lower-cased.
|
||||
@@ -102,18 +103,11 @@ class SieveInterpreter {
|
||||
return switch (matchType) {
|
||||
':contains' => k.isEmpty || v.contains(k),
|
||||
':is' => v == k,
|
||||
':matches' => _globMatch(v, k),
|
||||
':matches' => globMatch(v, k),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _globMatch(String value, String pattern) {
|
||||
final regexStr = RegExp.escape(
|
||||
pattern,
|
||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$').hasMatch(value);
|
||||
}
|
||||
|
||||
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||
for (final action in actions) {
|
||||
switch (action) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/// Returns true if [value] matches the glob [pattern].
|
||||
///
|
||||
/// Supports `*` (any number of characters) and `?` (exactly one character).
|
||||
/// The comparison is case-insensitive, which is appropriate for email addresses.
|
||||
bool globMatch(String value, String pattern) {
|
||||
final regexStr =
|
||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value);
|
||||
}
|
||||
+27
-10
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@@ -793,18 +794,34 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||
void resetDatabasePathForTesting() => _dbPath = null;
|
||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||
|
||||
/// Configures PRAGMAs on a newly opened SQLite connection.
|
||||
///
|
||||
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
|
||||
/// instead of immediately failing.
|
||||
///
|
||||
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
|
||||
/// WorkManager background task may already have the DB open when the app
|
||||
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
|
||||
/// returned in that situation; it only occurs when the DB is already in WAL
|
||||
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
|
||||
void _setupPragmas(Database db) {
|
||||
db.execute('PRAGMA busy_timeout = 5000;');
|
||||
try {
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
} on SqliteException catch (e) {
|
||||
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
|
||||
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
|
||||
if (e.resultCode != 5) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final file = File(await _resolveDatabasePath());
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
// WAL lets readers and writers proceed concurrently (different account
|
||||
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
||||
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
db.execute('PRAGMA busy_timeout = 5000;');
|
||||
},
|
||||
);
|
||||
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||
});
|
||||
}
|
||||
|
||||
// Exposed so tests can run the exact production setup logic on a raw
|
||||
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
|
||||
void setupPragmasForTesting(Database db) => _setupPragmas(db);
|
||||
|
||||
@@ -2922,9 +2922,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final sql = accountId != null
|
||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
|
||||
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
||||
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
|
||||
final variables = accountId != null
|
||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||
: [Variable<String>(ftsQuery)];
|
||||
@@ -2934,6 +2934,56 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
);
|
||||
|
||||
final noteRows = await _searchEmailsByNotes(accountId, null, query);
|
||||
|
||||
final seen = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
}
|
||||
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// Returns emails whose associated notes contain all words from [query].
|
||||
/// Optionally filtered by [accountId] and [mailboxPath].
|
||||
Future<List<model.Email>> _searchEmailsByNotes(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String query,
|
||||
) async {
|
||||
final words =
|
||||
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||
if (words.isEmpty) return [];
|
||||
|
||||
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
|
||||
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
|
||||
|
||||
final extraConditions = StringBuffer();
|
||||
final extraVars = <Variable<String>>[];
|
||||
if (accountId != null) {
|
||||
extraConditions.write(' AND e.account_id = ?');
|
||||
extraVars.add(Variable<String>(accountId));
|
||||
}
|
||||
if (mailboxPath != null) {
|
||||
extraConditions.write(' AND e.mailbox_path = ?');
|
||||
extraVars.add(Variable<String>(mailboxPath));
|
||||
}
|
||||
|
||||
final sql = 'SELECT DISTINCT e.* FROM emails e'
|
||||
' JOIN email_notes n ON n.message_id = e.message_id'
|
||||
' AND n.account_id = e.account_id'
|
||||
' WHERE $noteConditions$extraConditions'
|
||||
' ORDER BY e.received_at DESC LIMIT 50';
|
||||
|
||||
final rows = await _db.customSelect(
|
||||
sql,
|
||||
variables: [...likeVars, ...extraVars],
|
||||
readsFrom: {_db.emails, _db.emailNotes},
|
||||
).get();
|
||||
final emailRows =
|
||||
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
|
||||
return emailRows.map(_toModel).toList();
|
||||
}
|
||||
|
||||
@@ -2943,9 +2993,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
static String _toFtsQuery(String query) {
|
||||
final words = query
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
||||
.split(RegExp(r'[^\w]+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
if (words.isEmpty) return '';
|
||||
@@ -3047,6 +3095,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
// Results are limited to emails already synced into the local SQLite FTS5
|
||||
// index; call syncEmails first to ensure the index is up-to-date.
|
||||
Future<List<model.Email>> searchEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
@@ -3057,7 +3107,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||
' ORDER BY rank LIMIT 50';
|
||||
' ORDER BY e.received_at DESC LIMIT 50';
|
||||
final variables = [
|
||||
Variable<String>(ftsQuery),
|
||||
Variable<String>(accountId),
|
||||
@@ -3069,7 +3119,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
);
|
||||
return emailRows.map(_toModel).toList();
|
||||
|
||||
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||
|
||||
final seen = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
}
|
||||
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -116,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Something went wrong'),
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/note.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
@@ -208,8 +209,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
final senderEmail = header?.from.isNotEmpty == true
|
||||
? header!.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
final isTrusted = senderEmail != null &&
|
||||
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return ListView(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
@@ -118,8 +119,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
final senderEmail = widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
final isTrusted = senderEmail != null &&
|
||||
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -16,6 +16,11 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
tooltip: 'Add address',
|
||||
onPressed: () => _showAddDialog(context, ref),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: trustedSendersAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
@@ -26,7 +31,8 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'No addresses added yet. '
|
||||
'Tap "Load remote images" in an email to add the sender.',
|
||||
'Tap + to add an address or pattern (e.g. *@example.com), '
|
||||
'or tap "Load remote images" in an email to add the sender automatically.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -60,4 +66,61 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add allowed address'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email address or pattern',
|
||||
hintText: '*@example.com',
|
||||
helperText: '* matches any characters, e.g. *@example.com',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
onSubmitted: (value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
_addSender(ref, value);
|
||||
Navigator.of(ctx).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
_addSender(ref, controller.text);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addSender(WidgetRef ref, String value) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(value.trim()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,6 +19,7 @@ dependencies:
|
||||
|
||||
# Local persistence (offline-first)
|
||||
drift: ^2.20.3
|
||||
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
@@ -78,7 +79,6 @@ dev_dependencies:
|
||||
mockito: ^5.4.4
|
||||
fake_async: ^1.3.1
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
|
||||
@@ -76,11 +76,12 @@ if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
||||
fi
|
||||
|
||||
# Create a background SSH tunnel to the Dagger engine.
|
||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||
# Create a background SSH tunnel to the Dagger engine Unix socket.
|
||||
# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host,
|
||||
# eliminating the need for a socat bridge on the server side.
|
||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||
_t0=$SECONDS
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
|
||||
_elapsed=$(( SECONDS - _t0 ))
|
||||
if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
|
||||
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
|
||||
|
||||
@Tags(['nightly'])
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
@@ -132,7 +135,7 @@ void main() {
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('chaos monkey — random operations do not crash the repository',
|
||||
() async {
|
||||
timeout: Timeout.none, () async {
|
||||
final seedStr = _env('CHAOS_SEED');
|
||||
final seed = seedStr.isEmpty
|
||||
? DateTime.now().millisecondsSinceEpoch
|
||||
|
||||
@@ -421,6 +421,7 @@ void main() {
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
||||
expect(results, hasLength(1));
|
||||
@@ -432,6 +433,7 @@ void main() {
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final results = await r.emails.searchEmails(
|
||||
'test',
|
||||
|
||||
@@ -486,6 +486,158 @@ void main() {
|
||||
expect(results.first.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal includes emails matched by note text', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Email whose subject does NOT match — but its note does.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
messageId: const Value('<msg1@example.com>'),
|
||||
subject: const Value('Weekly report'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Add a note referencing the email's messageId.
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg1@example.com>',
|
||||
noteText: 'Urgent follow-up needed',
|
||||
serverId: '42',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Weekly report');
|
||||
});
|
||||
|
||||
test('searchEmails includes emails matched by note text in mailbox',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
messageId: const Value('<msg1@example.com>'),
|
||||
subject: const Value('Project update'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Email in a different mailbox — its note must not appear in INBOX search.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
messageId: const Value('<msg2@example.com>'),
|
||||
subject: const Value('Other email'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg1@example.com>',
|
||||
noteText: 'remember to call client',
|
||||
serverId: '42',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-2',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg2@example.com>',
|
||||
noteText: 'remember to call client',
|
||||
serverId: '43',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Project update');
|
||||
expect(results.first.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal returns results sorted by receivedAt descending',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('Older report'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 2,
|
||||
subject: const Value('Newer report'),
|
||||
receivedAt: DateTime(2024, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'report');
|
||||
expect(results, hasLength(2));
|
||||
expect(results[0].subject, 'Newer report');
|
||||
expect(results[1].subject, 'Older report');
|
||||
});
|
||||
|
||||
test('searchEmails returns results sorted by receivedAt descending',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('Older meeting'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 2,
|
||||
subject: const Value('Newer meeting'),
|
||||
receivedAt: DateTime(2024, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||
expect(results, hasLength(2));
|
||||
expect(results[0].subject, 'Newer meeting');
|
||||
expect(results[1].subject, 'Older meeting');
|
||||
});
|
||||
|
||||
test(
|
||||
'searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
|
||||
void main() {
|
||||
group('globMatch', () {
|
||||
test('exact match (no wildcards)', () {
|
||||
expect(globMatch('alice@example.com', 'alice@example.com'), isTrue);
|
||||
expect(globMatch('alice@example.com', 'bob@example.com'), isFalse);
|
||||
});
|
||||
|
||||
test('* matches any domain wildcard', () {
|
||||
expect(globMatch('alice@example.com', '*@example.com'), isTrue);
|
||||
expect(globMatch('bob@example.com', '*@example.com'), isTrue);
|
||||
expect(globMatch('alice@other.com', '*@example.com'), isFalse);
|
||||
});
|
||||
|
||||
test('* matches zero or more characters', () {
|
||||
expect(
|
||||
globMatch('newsletter@news.example.com', '*@*.example.com'),
|
||||
isTrue,
|
||||
);
|
||||
expect(globMatch('alice@example.com', 'alice*'), isTrue);
|
||||
expect(globMatch('alice@example.com', '*example*'), isTrue);
|
||||
});
|
||||
|
||||
test('? matches exactly one character', () {
|
||||
expect(globMatch('alice@example.com', 'alice@exampl?.com'), isTrue);
|
||||
expect(globMatch('alice@example.com', 'alice@exampl??.com'), isFalse);
|
||||
});
|
||||
|
||||
test('case-insensitive comparison', () {
|
||||
expect(globMatch('Alice@Example.COM', '*@example.com'), isTrue);
|
||||
expect(globMatch('alice@example.com', '*@EXAMPLE.COM'), isTrue);
|
||||
});
|
||||
|
||||
test('no wildcards — mismatch is false', () {
|
||||
expect(globMatch('alice@example.com', 'alice@other.com'), isFalse);
|
||||
});
|
||||
|
||||
test('bare * matches everything', () {
|
||||
expect(globMatch('alice@example.com', '*'), isTrue);
|
||||
expect(globMatch('', '*'), isTrue);
|
||||
});
|
||||
|
||||
test('empty pattern only matches empty string', () {
|
||||
expect(globMatch('', ''), isTrue);
|
||||
expect(globMatch('alice@example.com', ''), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -510,4 +510,40 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/508:
|
||||
// _openConnection's setup callback must not crash when PRAGMA journal_mode =
|
||||
// WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5)
|
||||
// because a WorkManager background task already has the DB open in WAL mode.
|
||||
group('WAL setup (#508)', () {
|
||||
test(
|
||||
'setupPragmasForTesting does not throw when WAL is already active and '
|
||||
'another connection holds an open read transaction',
|
||||
() {
|
||||
final dbFile = File('test_wal_busy_508.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
addTearDown(() {
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
// conn1: enable WAL and keep a read transaction open — simulates a
|
||||
// WorkManager background task that opened the DB before the foreground
|
||||
// app starts.
|
||||
final conn1 = sqlite.sqlite3.open(dbFile.path);
|
||||
conn1.execute('PRAGMA journal_mode = WAL;');
|
||||
conn1.execute('BEGIN;');
|
||||
conn1.select('SELECT 1;');
|
||||
|
||||
// conn2: run the exact production setup through setupPragmasForTesting.
|
||||
// This must not throw even though conn1 holds an open transaction and
|
||||
// the DB is already in WAL mode.
|
||||
final conn2 = sqlite.sqlite3.open(dbFile.path);
|
||||
expect(() => setupPragmasForTesting(conn2), returnsNormally);
|
||||
|
||||
conn1.execute('ROLLBACK;');
|
||||
conn1.close();
|
||||
conn2.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
|
||||
FakeAccountRepository(accounts),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: AboutScreen()),
|
||||
child: MaterialApp(
|
||||
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||
home: const AboutScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/search_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -476,6 +477,12 @@ Widget buildApp({
|
||||
path: 'preferences',
|
||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'trusted-senders',
|
||||
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||
highlightedSender: state.extra as String?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
@@ -688,6 +695,9 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
AfterMailViewAction afterMailViewAction;
|
||||
final List<String> _trustedImageSenders;
|
||||
|
||||
List<String> get trustedImageSendersForTest =>
|
||||
List.unmodifiable(_trustedImageSenders);
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
UserPreferences(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('TrustedImageSendersScreen', () {
|
||||
testWidgets('shows empty state with glob hint when no senders', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('*@example.com'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists existing senders', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository(
|
||||
trustedImageSenders: ['alice@example.com', '*@work.com'],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('alice@example.com'), findsOneWidget);
|
||||
expect(find.text('*@work.com'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('add dialog shows glob hint text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add allowed address'), findsOneWidget);
|
||||
expect(find.textContaining('*@example.com'), findsWidgets);
|
||||
expect(find.textContaining('* matches any characters'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Add button is disabled when input is empty', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final addButton = find.widgetWithText(TextButton, 'Add');
|
||||
final button = tester.widget<TextButton>(addButton);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('typing in dialog enables Add button and adds sender', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = FakeUserPreferencesRepository();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), '*@example.com');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final addButton = find.widgetWithText(TextButton, 'Add');
|
||||
final button = tester.widget<TextButton>(addButton);
|
||||
expect(button.onPressed, isNotNull);
|
||||
|
||||
await tester.tap(addButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.trustedImageSendersForTest, contains('*@example.com'));
|
||||
});
|
||||
|
||||
testWidgets('cancel closes dialog without adding', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'someone@test.com');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(repo.trustedImageSendersForTest, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('delete button removes a sender', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository(
|
||||
trustedImageSenders: ['alice@example.com'],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.trustedImageSendersForTest, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('lists existing glob patterns', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository(
|
||||
trustedImageSenders: ['*@example.com', 'alice@other.com'],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('*@example.com'), findsOneWidget);
|
||||
expect(find.text('alice@other.com'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user