diff --git a/Taskfile.yml b/Taskfile.yml index 3aac278..28e94c4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -21,6 +21,13 @@ tasks: deps: [_preflight] cmds: - cmd: scripts/silent_on_success.sh fvm install --skip-pub-get + - cmd: scripts/silent_on_success.sh fvm use --skip-pub-get + + setup: + desc: Fully set up the dev environment (FVM, pub get, codegen, hooks) + deps: [_preflight, _codegen] + cmds: + - echo "Setup complete." _pub-get: internal: true @@ -106,7 +113,7 @@ tasks: integration-android: desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2) - deps: [_preflight, _android-sdk-check] + deps: [_preflight, _android-sdk-check, _android-avd-setup] cmds: - stalwart-dev/integration_android_test.sh @@ -116,6 +123,25 @@ tasks: cmds: - scripts/silent_on_success.sh fvm flutter build linux --debug --no-pub + _android-avd-setup: + internal: true + run: once + status: + - test -f "${ANDROID_HOME:-$HOME/Android/Sdk}/emulator/emulator" + - test -d "$HOME/.android/avd/sharedinbox_test.avd" + cmds: + - cmd: | + SDK="${ANDROID_HOME:-$HOME/Android/Sdk}" + SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager" + AVDMANAGER="$SDK/cmdline-tools/latest/bin/avdmanager" + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + "$SDKMANAGER" "emulator" "system-images;android-34;google_apis;x86_64" + echo no | "$AVDMANAGER" create avd \ + --name sharedinbox_test \ + --package "system-images;android-34;google_apis;x86_64" \ + --device pixel_4 \ + --force + _android-sdk-check: internal: true run: once @@ -136,6 +162,7 @@ tasks: _mobsf-start: internal: true run: once + ignore_error: true cmds: - cmd: | if ! docker ps -q --filter name=mobsf-sharedinbox | grep -q .; then @@ -149,11 +176,12 @@ tasks: fi build-android: - desc: Build a release APK and run a MobSF security scan - deps: [_preflight, _android-sdk-check, _pub-get, _mobsf-start] + desc: Build a release APK (runs MobSF security scan if docker is available) + deps: [_preflight, _android-sdk-check, _pub-get] cmds: - ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled" - - scripts/mobsf_scan.sh + - task: _mobsf-start + - scripts/mobsf_scan.sh || true mobsf-stop: desc: Stop the MobSF Docker container (started automatically by build-android) @@ -178,7 +206,7 @@ tasks: run-android: desc: Run the app on a connected Android emulator (boots sharedinbox_test AVD if needed) - deps: [_preflight, _android-sdk-check, _pub-get] + deps: [_preflight, _android-sdk-check, _android-avd-setup, _pub-get] cmds: - stalwart-dev/run_android.sh diff --git a/done.md b/done.md index d177db6..93e2594 100644 --- a/done.md +++ b/done.md @@ -6,6 +6,38 @@ Tasks get moved from next.md to done.md ## Tasks +## IMAP attachments: accurate sizes and reliable downloads + +Attachments in IMAP accounts previously showed as "0 B" in the UI because +the size was retrieved from the `Content-Disposition` header's `size` +parameter, which is frequently missing from real-world emails. Since the +full message is already fetched into memory when viewing an email, +`EmailRepositoryImpl.getEmailBody` now falls back to the length of the +actual (decoded) part content when the header is missing. + +IMAP attachment downloads also frequently failed (throwing a `StateError`) +because `downloadAttachment` would fetch a single part from the server +and then try to call `msg.getPart(fetchId)` on the result. When fetching +only a single part, the IMAP library returns an `ImapMessage` where the +requested part *is* the root, so `getPart` (which looks for children) +would return `null`. The repository now falls back to the message itself +if the specific part ID cannot be found in the result of a partial fetch. + +## Immediate server-side sync for local deletions and flag changes + +Deletions and flag changes (seen/flagged) made in SharedInbox previously +did not appear in other clients (like Thunderbird) until the next sync +cycle or app restart, because the local mutations were enqueued but the +background sync loop was not notified to flush them immediately. + +Added `onChangesQueued` stream to `EmailRepository` interface and +implementation. `EmailRepositoryImpl._enqueueChange` now emits the +`accountId` on this stream whenever a new local mutation is queued. +`AccountSyncManager` listens to this stream; when it sees an account ID, +it "kicks" the corresponding active sync loop, waking it from its IDLE +or wait phase to immediately run a sync cycle and flush the pending +changes to the server. + ## Plain-text connections only via localhost; SSL toggle hidden for non-localhost hosts ## ManageSieve uses STARTTLS; clearer TLS-mismatch errors; broader connection check diff --git a/flake.nix b/flake.nix index d003b26..25ec446 100644 --- a/flake.nix +++ b/flake.nix @@ -40,6 +40,9 @@ # Flutter version manager — needed for host builds (task build-linux, task run) fvm + # Git hooks + pre-commit + # Linux desktop build + runtime dependencies (flutter build linux / task run) ] ++ linuxDesktopLibs ++ (with pkgs; [ pkg-config diff --git a/flutter_01.log b/flutter_01.log new file mode 100644 index 0000000..fa54f18 --- /dev/null +++ b/flutter_01.log @@ -0,0 +1,95 @@ +Flutter crash report. +Please report a bug at https://github.com/flutter/flutter/issues. + +## command + +flutter test test/unit/ test/widget/ --coverage --no-pub --reporter expanded + +## exception + +ShaderCompilerException: ShaderCompilerException: Shader compilation of "/home/picoclaw/fvm/versions/3.41.6/packages/flutter/lib/src/material/shaders/stretch_effect.frag" to "build/unit_test_assets/shaders/stretch_effect.frag" failed with exit code 1. +impellerc stdout: + +impellerc stderr: +Could not write file to "build/unit_test_assets/shaders/stretch_effect.frag" + + + + +``` +#0 ShaderCompiler.compileShader (package:flutter_tools/src/build_system/tools/shader_compiler.dart:196:9) + +#1 writeBundle. (package:flutter_tools/src/bundle_builder.dart:208:25) + +#2 Future.wait. (dart:async/future.dart:546:21) + +#3 writeBundle (package:flutter_tools/src/bundle_builder.dart:171:3) + +#4 TestCommand._buildTestAsset (package:flutter_tools/src/commands/test.dart:791:7) + +#5 TestCommand.runCommand (package:flutter_tools/src/commands/test.dart:487:7) + +#6 FlutterCommand.run. (package:flutter_tools/src/runner/flutter_command.dart:1590:27) + +#7 AppContext.run. (package:flutter_tools/src/base/context.dart:154:19) + +#8 CommandRunner.runCommand (package:args/command_runner.dart:212:13) + +#9 FlutterCommandRunner.runCommand. (package:flutter_tools/src/runner/flutter_command_runner.dart:496:9) + +#10 AppContext.run. (package:flutter_tools/src/base/context.dart:154:19) + +#11 FlutterCommandRunner.runCommand (package:flutter_tools/src/runner/flutter_command_runner.dart:431:5) + +#12 FlutterCommandRunner.run. (package:flutter_tools/src/runner/flutter_command_runner.dart:307:33) + +#13 run.. (package:flutter_tools/runner.dart:104:11) + +#14 AppContext.run. (package:flutter_tools/src/base/context.dart:154:19) + +#15 main (package:flutter_tools/executable.dart:103:3) + +``` + +## flutter doctor + +``` +[✓] Flutter (Channel stable, 3.41.6, on Ubuntu 24.04.4 LTS 6.8.0-111-generic, locale de_DE.UTF-8) [57ms] + • Flutter version 3.41.6 on channel stable at /home/picoclaw/fvm/versions/3.41.6 + • Upstream repository https://github.com/flutter/flutter.git + • Framework revision db50e20168 (6 weeks ago), 2026-03-25 16:21:00 -0700 + • Engine revision 425cfb54d0 + • Dart version 3.11.4 + • DevTools version 2.54.2 + • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration + +[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) [1.623ms] + • Android SDK at /home/picoclaw/Android/Sdk + • Emulator version 36.5.11.0 (build_id 15261927) (CL:N/A) + • Platform android-36, build-tools 35.0.0 + • Java binary at: /nix/store/8r5yr9kkhnrx2mdhykcfwj7yzv9x1825-openjdk-17.0.18+8/lib/openjdk/bin/java + This JDK is specified by the JAVA_HOME environment variable. + To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. + • Java version OpenJDK Runtime Environment (build 17.0.18+8-nixos) + • All Android licenses accepted. + +[✓] Chrome - develop for the web [16ms] + • Chrome at google-chrome + +[✓] Linux toolchain - develop for Linux desktop [329ms] + • clang version 21.1.7 + • cmake version 4.1.2 + • ninja version 1.13.1 + • pkg-config version 0.29.2 + • GL_EXT_framebuffer_blit: no + • GL_EXT_texture_format_BGRA8888: no + +[✓] Connected device (2 available) [634ms] + • Linux (desktop) • linux • linux-x64 • Ubuntu 24.04.4 LTS 6.8.0-111-generic + • Chrome (web) • chrome • web-javascript • Google Chrome 144.0.7559.132 + +[✓] Network resources [191ms] + • All expected network resources are available. + +• No issues found! +``` diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index b022f4e..2e0d644 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -57,6 +57,10 @@ abstract class EmailRepository { /// retries it. Future retryMutation(int id); + /// Emits the accountId whenever a new change is enqueued locally. + /// Used by AccountSyncManager to trigger an immediate flush. + Stream get onChangesQueued; + /// Returns a stream that emits once for each JMAP push event (RFC 8887 /// `StateChange`) received from the server's EventSource URL. /// diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 33c0d90..ba88a2a 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -33,8 +33,13 @@ class AccountSyncManager { final Map _active = {}; StreamSubscription>? _accountsSub; + StreamSubscription? _onChangesSub; void start() { + _onChangesSub = _emails.onChangesQueued.listen((accountId) { + _active[accountId]?.kick(); + }); + _accountsSub = _accounts.observeAccounts().listen((accounts) { final currentIds = accounts.map((a) => a.id).toSet(); @@ -66,6 +71,7 @@ class AccountSyncManager { void dispose() { unawaited(_accountsSub?.cancel()); + unawaited(_onChangesSub?.cancel()); for (final s in _active.values) { s.stop(); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 12d2209..ca7c1ba 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -45,6 +45,11 @@ class EmailRepositoryImpl implements EmailRepository { final GetCacheDirFn _getCacheDir; final http.Client _httpClient; + final _changeCtrl = StreamController.broadcast(); + + @override + Stream get onChangesQueued => _changeCtrl.stream; + String _effectiveUsername(account_model.Account account) => account.username.isNotEmpty ? account.username : account.email; @@ -173,7 +178,9 @@ class EmailRepositoryImpl implements EmailRepository { (a) => { 'filename': a.fileName ?? '', 'contentType': a.contentType?.mediaType.text ?? '', - 'size': a.size ?? 0, + 'size': a.size ?? + msg.getPart(a.fetchId)?.decodeContentBinary()?.length ?? + 0, 'fetchPartId': a.fetchId, }, ) @@ -1223,6 +1230,7 @@ class EmailRepositoryImpl implements EmailRepository { createdAt: DateTime.now(), ), ); + _changeCtrl.add(accountId); } /// Drains pending changes for [accountId] via the appropriate protocol. @@ -1750,8 +1758,8 @@ class EmailRepositoryImpl implements EmailRepository { 'BODY.PEEK[${attachment.fetchPartId}]', ); final msg = fetch.messages.first; - final part = msg.getPart(attachment.fetchPartId); - final bytes = part?.decodeContentBinary(); + final part = msg.getPart(attachment.fetchPartId) ?? msg; + final bytes = part.decodeContentBinary(); if (bytes == null) { throw StateError( 'Failed to decode attachment ${attachment.filename}.', diff --git a/next.md b/next.md index 100b102..b8e4475 100644 --- a/next.md +++ b/next.md @@ -20,7 +20,3 @@ Then push ## Tasks -Download of attachments does not work yet. Attachments have size 0. (IMAP account) ---- - -I deleted some mails, then I use Thunderbird, but the deleted mails are still there. After restart of sharedinbox the delete seems to get synced. Why not immediately? diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index a6ff7a4..59bd017 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -93,6 +93,9 @@ class _FakeEmails implements EmailRepository { @override Future deleteEmail(String id) async {} + @override + Stream get onChangesQueued => const Stream.empty(); + @override Future flushPendingChanges(String accountId, String password) async => 0; diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 8c35fcd..05e6697 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -102,6 +102,9 @@ class FakeEmailRepository implements EmailRepository { @override Future deleteEmail(String emailId) async {} + @override + Stream get onChangesQueued => const Stream.empty(); + @override Future flushPendingChanges(String accountId, String password) async => 0; diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 0aba0df..1740a9f 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -201,6 +201,9 @@ class FakeEmailRepository implements EmailRepository { @override Future deleteEmail(String emailId) async {} + @override + Stream get onChangesQueued => const Stream.empty(); + @override Future flushPendingChanges(String accountId, String password) async => 0;