Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 003d2bf658 fix: apply dart format to changelog_screen.dart and migration_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:01:00 +02:00
guettli 3bd3b25dd9 Merge branch 'main' into issue-446-installed-versions-in-changelog 2026-06-06 05:43:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a7aaa8efe2 feat: track installed versions and annotate ChangeLog with install dates
Adds an InstalledVersions table (schema v40) that records the first time
the user runs each app version (identified by GIT_HASH). On every app
start the current GIT_HASH is written to the table if not already present,
preserving the original install timestamp on subsequent launches.

The ChangeLog screen now reads the installed-versions map and injects
"Installed: HH:mm, d Mon YYYY" banners above each changelog entry whose
short git hash matches a recorded version. Closes #446.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 23:00:50 +02:00
25 changed files with 113 additions and 793 deletions
+1 -25
View File
@@ -1,35 +1,11 @@
name: CI
on:
push:
branches:
- main
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
on: [push, pull_request]
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:
-85
View File
@@ -15,23 +15,6 @@ 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
@@ -158,23 +141,6 @@ 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
@@ -209,23 +175,6 @@ 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
@@ -254,23 +203,6 @@ 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
@@ -304,23 +236,6 @@ 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 }}
-34
View File
@@ -14,23 +14,6 @@ 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
@@ -67,23 +50,6 @@ 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
-17
View File
@@ -18,23 +18,6 @@ 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
+3 -17
View File
@@ -529,26 +529,12 @@ tasks:
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
build-android-bundle-local:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
dotenv: [".env"]
method: timestamp
sources:
- lib/**/*.dart
- android/**/*
- pubspec.yaml
generates:
- build/app/outputs/bundle/release/app-release.aab
cmds:
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
desc: Build, sign, and upload AAB to Play Store internal track via Dagger
deps: [generate-changelog]
dotenv: [".env"]
cmds:
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
- sops exec-env secrets.enc.yaml 'HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"'
deploy-android:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.2.1" apply false
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
+1 -1
View File
@@ -58,7 +58,7 @@ abstract class EmailRepository {
);
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
/// if null) by subject, preview, and notes. Fast, works offline.
/// if null) by subject and preview. Fast, works offline.
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
/// Returns all locally cached emails in any mailbox of [accountId] (or all
+10 -27
View File
@@ -7,7 +7,6 @@ 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';
@@ -794,34 +793,18 @@ 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: _setupPragmas);
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;');
},
);
});
}
// 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);
@@ -2934,55 +2934,6 @@ 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);
}
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();
}
@@ -2992,7 +2943,9 @@ class EmailRepositoryImpl implements EmailRepository {
static String _toFtsQuery(String query) {
final words = query
.trim()
.split(RegExp(r'[^\w]+'))
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.where((w) => w.isNotEmpty)
.toList();
if (words.isEmpty) return '';
@@ -3094,41 +3047,68 @@ 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,
String query,
) async {
final ftsQuery = _toFtsQuery(query);
if (ftsQuery.isEmpty) return [];
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';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
Variable<String>(mailboxPath),
];
final queryRows = await _db
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)),
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.selectMailboxByPath(mailboxPath);
final terms =
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
final searchCriteria = terms.map((term) {
final escaped = term.replaceAll('"', '\\"');
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
}).join(' ');
final result = await client.uidSearchMessages(
searchCriteria: searchCriteria,
);
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return [];
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);
final fetch = await client.uidFetchMessages(
imap.MessageSequence.fromIds(uids, isUid: true),
'(UID FLAGS ENVELOPE)',
);
return fetch.messages
.where((msg) => msg.uid != null && msg.envelope != null)
.map((msg) {
final envelope = msg.envelope!;
final uid = msg.uid!;
final emailId = '$accountId:$uid';
return model.Email(
id: emailId,
accountId: accountId,
mailboxPath: mailboxPath,
uid: uid,
subject: envelope.subject,
sentAt: envelope.date,
receivedAt: envelope.date ?? DateTime.now(),
from: _toAddressList(envelope.from),
to: _toAddressList(envelope.to),
cc: _toAddressList(envelope.cc),
isSeen: msg.flags?.contains(r'\Seen') ?? false,
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
hasAttachment: msg.hasAttachments(),
);
}).toList();
} finally {
await client.logout();
}
return merged;
}
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
(addresses ?? const [])
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
.toList();
// ── Helpers ────────────────────────────────────────────────────────────────
/// Computes a stable threadId from RFC 2822 headers.
-2
View File
@@ -109,7 +109,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -117,7 +116,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
routerConfig: router,
);
+1 -13
View File
@@ -31,17 +31,6 @@ class ChangeLogScreen extends ConsumerWidget {
return '$h:$m, ${dt.day} $month ${dt.year}';
}
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
static final _issueRefPattern = RegExp(r'#(\d+)');
static String _linkifyIssueRefs(String text) {
return text.replaceAllMapped(
_issueRefPattern,
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
);
}
// Changelog lines have the form:
// * 2026-06-05 [abc1234](https://...): subject
// This pattern captures the short hash inside the markdown link.
@@ -93,8 +82,7 @@ class ChangeLogScreen extends ConsumerWidget {
child: Text('Error loading changelog: ${snapshot.error}'),
);
}
final raw = snapshot.data ?? 'No changelog entries found.';
final content = _linkifyIssueRefs(raw);
final content = snapshot.data ?? 'No changelog entries found.';
final versions = installedVersions.value ?? {};
final annotated = _injectInstallMarkers(content, versions);
return Markdown(
-1
View File
@@ -57,7 +57,6 @@ 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'),
+7 -34
View File
@@ -50,15 +50,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
// Incremented on every search start; stale completions are ignored when the
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
// The query whose results are currently settled in _searchResults.
// Used to skip redundant re-runs when the user presses Enter on an
// already-settled search (issue #473).
String? _lastSettledQuery;
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -70,7 +61,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() {
_searchResults = null;
_searchLoading = false;
_lastSettledQuery = null;
});
}
});
@@ -127,35 +117,18 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
Future<void> _runSearch(String query) async {
final q = query.trim();
if (q.isEmpty) {
setState(() {
_searchResults = null;
_lastSettledQuery = null;
});
if (query.trim().isEmpty) {
setState(() => _searchResults = null);
return;
}
// Skip if results are already settled for this exact query — prevents the
// Enter key from re-triggering a search that already completed.
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
return;
}
final generation = ++_searchGeneration;
setState(() => _searchLoading = true);
try {
final results = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, q);
if (mounted && generation == _searchGeneration) {
setState(() {
_searchResults = results;
_lastSettledQuery = q;
});
}
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
if (mounted) setState(() => _searchResults = results);
} finally {
if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
if (mounted) setState(() => _searchLoading = false);
}
}
@@ -568,8 +541,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
if (wasSearching && mounted) {
// Filter deleted emails out of the local results immediately.
// Calling searchEmails here would still return deleted rows because the
// delete is only enqueued — not yet applied to the local DB.
// Calling searchEmails here would hit the IMAP server, which still has
// the emails because the delete is only enqueued — not yet applied.
final deletedIds = ids.toSet();
final remaining = (_searchResults ?? [])
.where((e) => !deletedIds.contains(e.id))
-16
View File
@@ -371,14 +371,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -570,14 +562,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
integration_test:
dependency: "direct dev"
description: flutter
+2 -2
View File
@@ -19,7 +19,6 @@ 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
@@ -79,9 +78,10 @@ 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
flutter_launcher_icons: ^0.13.1
flutter_icons:
android: "ic_launcher"
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
trap "rm -f $tmp" EXIT
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
ANDROID_KEYSTORE_PATH="$tmp" \
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
fvm flutter build appbundle --release --no-pub \
--build-number "$(date +%s)" \
--build-name "$(date +%y%m%d-%H%M)" \
--dart-define="GIT_HASH=$(git rev-parse --short HEAD)" \
| grep -Ev "was tree-shaken|Tree-shaking can be disabled"
+3 -4
View File
@@ -76,12 +76,11 @@ 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 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.
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
_t0=$SECONDS
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::SSH tunnel setup took ${_elapsed}s"
+1 -1
View File
@@ -132,7 +132,7 @@ void main() {
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
timeout: Timeout.none, () async {
() async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
@@ -421,7 +421,6 @@ 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));
@@ -433,7 +432,6 @@ void main() {
final r = makeRepo();
await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX');
final results = await r.emails.searchEmails(
'test',
-121
View File
@@ -453,127 +453,6 @@ void main() {
expect(results.first.subject, 'foobar baz');
});
test('searchEmails filters by mailboxPath using local FTS5', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Insert matching email in INBOX.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Meeting agenda'),
receivedAt: DateTime(2024),
),
);
// Insert matching email in a different mailbox — must not appear.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
subject: const Value('Meeting follow-up'),
receivedAt: DateTime(2024),
),
);
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
expect(results, hasLength(1));
expect(results.first.subject, 'Meeting agenda');
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(
'searchAddresses returns results sorted by most recently used',
() async {
-36
View File
@@ -510,40 +510,4 @@ 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();
},
);
});
}
+1 -4
View File
@@ -50,10 +50,7 @@ Widget _buildScreen({List<Account> accounts = const []}) {
FakeAccountRepository(accounts),
),
],
child: MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: const AboutScreen(),
),
child: const MaterialApp(home: AboutScreen()),
);
}
-14
View File
@@ -102,18 +102,4 @@ void main() {
expect(find.textContaining('Installed:'), findsNothing);
expect(find.textContaining('initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
tester,
) async {
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
await tester.pumpWidget(
_buildScreen(assets: {'assets/changelog.txt': changelog}),
);
await tester.pumpAndSettle();
// The link text "#42" must be visible in the rendered output.
expect(find.textContaining('#42'), findsOneWidget);
});
}
+24 -226
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -104,6 +102,30 @@ void main() {
expect(find.byIcon(Icons.star), findsOneWidget);
});
testWidgets('tapping search icon shows search bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search…'), findsOneWidget);
});
testWidgets('submitting a search query shows "No results" when empty', (
tester,
) async {
@@ -408,230 +430,6 @@ void main() {
expect(find.text('Result email'), findsWidgets);
});
testWidgets(
'tapping first of multiple search results opens the first email',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
searchResults: [email1, email2],
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
// Tap the first result.
await tester.tap(find.text('Alpha Match'));
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Alpha Match'),
),
findsOneWidget,
);
// The second email's subject must not appear in the detail view.
expect(
find.descendant(
of: find.byType(EmailDetailScreen),
matching: find.text('Beta Match'),
),
findsNothing,
);
},
);
testWidgets(
'stale search results from a slower concurrent search are discarded',
(tester) async {
// Reproduces: user types quickly, triggering multiple concurrent IMAP
// searches. An older, slower search must not overwrite the results for
// the user's current query (issue #467).
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
// The first search call is held open by a Completer; all subsequent
// calls resolve immediately with freshEmail.
final staleCompleter = Completer<List<Email>>();
var firstCall = true;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) {
if (firstCall) {
firstCall = false;
return staleCompleter.future;
}
return Future.value([freshEmail]);
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Trigger the first (slow) search.
await tester.enterText(find.byType(TextField), 'slow');
await tester.testTextInput.receiveAction(TextInputAction.search);
// Do not pumpAndSettle yet — the slow search is still in flight.
// Trigger the second (fast) search by changing the query.
await tester.enterText(find.byType(TextField), 'fast');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); // fast searches settle immediately
// The fresh results must be shown.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
// Now let the stale search complete.
staleCompleter.complete([staleEmail]);
await tester.pumpAndSettle();
// The stale results must NOT replace the fresh ones.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
},
);
testWidgets(
'pressing Enter on already-settled search does not re-run search (issue #473)',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
var searchCallCount = 0;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
searchCallCount++;
return [email1, email2];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Run the initial search.
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
final countAfterFirstSearch = searchCallCount;
// Re-focus the search bar (simulates user tapping back into the field
// with the keyboard still visible) and press Enter again on the same,
// already-settled query.
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// The search must NOT re-run; call count must not increase.
expect(
searchCallCount,
countAfterFirstSearch,
reason:
'Enter on settled results must not re-run the search (issue #473)',
);
// Results must still be visible — no loading spinner.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Alpha Match'), findsOneWidget);
},
);
testWidgets(
'folder search returns results from local cache without any network call',
(tester) async {
// Verifies that searchEmails is backed by local SQLite (not IMAP).
// The repository throws if a network call is attempted, yet search
// must still return results.
final email = testEmail(subject: 'Cached subject');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
// Local DB: return cached results immediately.
return [email];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Cached');
await tester.pumpAndSettle();
expect(find.text('Cached subject'), findsOneWidget);
},
);
testWidgets('deleting all search results pops back to previous screen', (
tester,
) async {
+3 -20
View File
@@ -216,17 +216,12 @@ class FakeEmailRepository implements EmailRepository {
final List<Email> _searchResults;
/// Optional override: when set, [searchEmails] calls this instead of
/// returning [_searchResults]. Useful for testing race-condition fixes.
final Future<List<Email>> Function(String query)? onSearch;
FakeEmailRepository({
List<Email>? emails,
Email? emailDetail,
EmailBody? emailBody,
List<Email>? searchResults,
String rawRfc822 = '',
this.onSearch,
}) : _emails = emails ?? [],
_emailDetail = emailDetail,
_searchResults = searchResults ?? [],
@@ -279,15 +274,7 @@ class FakeEmailRepository implements EmailRepository {
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
@override
Future<Email?> getEmail(String emailId) async {
for (final e in _searchResults) {
if (e.id == emailId) return e;
}
for (final e in _emails) {
if (e.id == emailId) return e;
}
return _emailDetail;
}
Future<Email?> getEmail(String emailId) async => _emailDetail;
@override
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
@@ -353,10 +340,8 @@ class FakeEmailRepository implements EmailRepository {
String accountId,
String mailboxPath,
String query,
) async {
if (onSearch != null) return onSearch!(query);
return _searchResults;
}
) async =>
_searchResults;
@override
Future<List<Email>> searchEmailsGlobal(
@@ -580,7 +565,6 @@ Widget buildApp({
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -588,7 +572,6 @@ Widget buildApp({
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
),
);