Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
727f10462c | ||
|
|
d994723a2d | ||
|
|
145346c18a | ||
|
|
f3e1ca13de | ||
|
|
d86ce7766c | ||
|
|
f88d14f362 | ||
|
|
3e2da2bdf8 | ||
|
|
6a60c8d73b | ||
|
|
985bac7022 | ||
|
|
aed0d63703 |
@@ -58,6 +58,14 @@ tasks:
|
||||
cmds:
|
||||
- echo "Setup complete."
|
||||
|
||||
generate-icons:
|
||||
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||
deps: [_pub-get]
|
||||
cmds:
|
||||
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||
- fvm flutter pub run flutter_launcher_icons
|
||||
|
||||
generate-changelog:
|
||||
desc: Generate assets/changelog.txt from git history
|
||||
cmds:
|
||||
@@ -521,13 +529,6 @@ 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"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
@@ -542,6 +543,13 @@ tasks:
|
||||
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]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
deploy-android:
|
||||
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]
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,28 @@
|
||||
chmod +x $out/bin/fgj
|
||||
'';
|
||||
};
|
||||
|
||||
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
||||
dagger021 = pkgs.stdenv.mkDerivation {
|
||||
pname = "dagger";
|
||||
version = "0.21.4";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
||||
};
|
||||
sourceRoot = ".";
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp dagger $out/bin/dagger
|
||||
chmod +x $out/bin/dagger
|
||||
'';
|
||||
};
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger.packages.${system}.dagger
|
||||
dagger021
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
@@ -100,12 +117,16 @@
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||
export IN_NIX_SHELL=1
|
||||
|
||||
# Point Dagger client at the running engine socket
|
||||
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
||||
|
||||
# Disable Flutter telemetry inside dev shell
|
||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
@@ -22,6 +23,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/sync_log_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/undo_log_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
@@ -55,6 +57,14 @@ final router = GoRouter(
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':actionId',
|
||||
builder: (ctx, state) => UndoLogDetailScreen(
|
||||
action: state.extra as UndoAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'changelog',
|
||||
|
||||
@@ -50,6 +50,11 @@ 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;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@@ -121,14 +126,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
setState(() => _searchResults = null);
|
||||
return;
|
||||
}
|
||||
final generation = ++_searchGeneration;
|
||||
setState(() => _searchLoading = true);
|
||||
try {
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||
if (mounted) setState(() => _searchResults = results);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchResults = results);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _searchLoading = false);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
class UndoLogDetailScreen extends ConsumerWidget {
|
||||
const UndoLogDetailScreen({super.key, required this.action});
|
||||
|
||||
final UndoAction action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Undo Log Detail'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Undo'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
_SectionHeader(text: 'Transaction', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: const Text('Account'),
|
||||
subtitle: Text(action.accountId),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
: (action.type == UndoType.snooze
|
||||
? Icons.access_time
|
||||
: Icons.move_to_inbox),
|
||||
color: action.type == UndoType.delete
|
||||
? Colors.redAccent
|
||||
: (action.type == UndoType.snooze
|
||||
? Colors.orangeAccent
|
||||
: Colors.blueAccent),
|
||||
),
|
||||
title: const Text('Action'),
|
||||
subtitle: Text(action.type.name.toUpperCase()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: const Text('Timestamp'),
|
||||
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
|
||||
),
|
||||
_SectionHeader(text: 'Folders', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_open),
|
||||
title: const Text('Source'),
|
||||
subtitle: Text(action.sourceMailboxPath),
|
||||
),
|
||||
if (action.type == UndoType.move &&
|
||||
action.destinationMailboxPath != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.drive_file_move),
|
||||
title: const Text('Destination'),
|
||||
subtitle: Text(action.destinationMailboxPath!),
|
||||
),
|
||||
_SectionHeader(
|
||||
text: 'Emails (${action.emailIds.length})',
|
||||
theme: theme,
|
||||
),
|
||||
if (action.originalEmails.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'${action.emailIds.length} email(s) — details not available',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, required this.theme});
|
||||
|
||||
final String text;
|
||||
final ThemeData theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailTile extends StatelessWidget {
|
||||
const _EmailTile({required this.email});
|
||||
|
||||
final Email email;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sender = email.from.isNotEmpty
|
||||
? (email.from.first.name ?? email.from.first.email)
|
||||
: '(Unknown Sender)';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.email_outlined),
|
||||
title: Text(email.subject ?? '(No Subject)'),
|
||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.go(
|
||||
'/accounts/undo-log/${action.id}',
|
||||
extra: action,
|
||||
),
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
|
||||
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
|
||||
DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
|
||||
|
||||
// Show AFTER adding FlView so GTK's first layout pass allocates the full
|
||||
// window content area (1280×800) to FlView, not the default 1×1.
|
||||
gtk_widget_show_all(GTK_WIDGET(window));
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -371,6 +371,14 @@ 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:
|
||||
@@ -562,6 +570,14 @@ 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
|
||||
@@ -659,10 +675,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1088,26 +1104,26 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
version: "1.31.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
version: "0.6.17"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -81,6 +81,15 @@ dev_dependencies:
|
||||
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_icons:
|
||||
android: "ic_launcher"
|
||||
ios: false
|
||||
image_path: "icon.png"
|
||||
linux:
|
||||
generate: true
|
||||
image_path: "icon.png"
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"fileMatch": ["^\\.fvmrc$"],
|
||||
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
|
||||
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||
|
||||
@@ -57,6 +57,7 @@ const _excluded = {
|
||||
'lib/ui/screens/sieve_scripts_screen.dart',
|
||||
'lib/ui/screens/sync_log_screen.dart',
|
||||
'lib/ui/screens/thread_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_screen.dart',
|
||||
'lib/ui/widgets/folder_drawer.dart',
|
||||
'lib/ui/widgets/secure_email_webview.dart',
|
||||
|
||||
@@ -17,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||
|
||||
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
|
||||
# must be masked line-by-line because ::add-mask:: covers one line at a time.
|
||||
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||
done <<< "$DAGGER_SSH_KEY"
|
||||
|
||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||
# can use them without referencing Forgejo secrets directly.
|
||||
export_secret() {
|
||||
local name="$1"
|
||||
local value
|
||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
||||
# Register each non-empty line for log redaction in the Actions runner.
|
||||
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||
done <<< "$value"
|
||||
fi
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
# Use heredoc syntax for multiline-safe export.
|
||||
# Avoid adding a second trailing newline for values that already end with one
|
||||
|
||||
@@ -32,8 +32,13 @@ Future<ImapClient> _imapConnectPlain(
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client = ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
||||
await client.connectToServer(account.imapHost, account.imapPort, isSecure: false);
|
||||
final client =
|
||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
account.imapPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
@@ -47,7 +52,11 @@ Future<SmtpClient> _smtpConnectPlain(
|
||||
final domain =
|
||||
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
|
||||
final client = SmtpClient(domain);
|
||||
await client.connectToServer(account.smtpHost, account.smtpPort, isSecure: false);
|
||||
await client.connectToServer(
|
||||
account.smtpHost,
|
||||
account.smtpPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.ehlo();
|
||||
await client.authenticate(username, password);
|
||||
return client;
|
||||
@@ -122,15 +131,16 @@ void main() {
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('chaos monkey — random operations do not crash the repository', () async {
|
||||
final seedStr = _env('CHAOS_SEED', '');
|
||||
test('chaos monkey — random operations do not crash the repository',
|
||||
() async {
|
||||
final seedStr = _env('CHAOS_SEED');
|
||||
final seed = seedStr.isEmpty
|
||||
? DateTime.now().millisecondsSinceEpoch
|
||||
: int.parse(seedStr);
|
||||
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
|
||||
final rng = Random(seed);
|
||||
|
||||
print('chaos-monkey: seed=$seed rounds=$rounds');
|
||||
stdout.writeln('chaos-monkey: seed=$seed rounds=$rounds');
|
||||
|
||||
// Seed INBOX with a few messages so early rounds have something to act on.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
@@ -149,7 +159,7 @@ void main() {
|
||||
|
||||
for (var round = 0; round < rounds; round++) {
|
||||
final action = rng.nextInt(8);
|
||||
print('chaos-monkey: round=$round action=$action');
|
||||
stdout.writeln('chaos-monkey: round=$round action=$action');
|
||||
|
||||
switch (action) {
|
||||
case 0: // sync INBOX
|
||||
@@ -190,8 +200,9 @@ void main() {
|
||||
await emails.setFlag(e.id, flagged: !e.isFlagged);
|
||||
|
||||
case 6: // flush pending changes to server
|
||||
final flushed = await emails.flushPendingChanges(account.id, userPass);
|
||||
print('chaos-monkey: flushed $flushed pending changes');
|
||||
final flushed =
|
||||
await emails.flushPendingChanges(account.id, userPass);
|
||||
stdout.writeln('chaos-monkey: flushed $flushed pending changes');
|
||||
|
||||
case 7: // delete random email
|
||||
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||
@@ -203,8 +214,8 @@ void main() {
|
||||
|
||||
// Final flush and sync to confirm the server is in a consistent state.
|
||||
final flushed = await emails.flushPendingChanges(account.id, userPass);
|
||||
print('chaos-monkey: final flush flushed=$flushed');
|
||||
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
|
||||
final result = await emails.syncEmails(account.id, 'INBOX');
|
||||
print('chaos-monkey: final sync fetched=${result.fetched}');
|
||||
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -430,6 +432,128 @@ 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('deleting all search results pops back to previous screen', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -216,12 +216,17 @@ 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 ?? [],
|
||||
@@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||
@@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async =>
|
||||
_searchResults;
|
||||
) async {
|
||||
if (onSearch != null) return onSearch!(query);
|
||||
return _searchResults;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
|
||||