Skip to content

feat: bundled attachments#2957

Open
matthiasn wants to merge 7 commits intomainfrom
feat/bundled_attachments
Open

feat: bundled attachments#2957
matthiasn wants to merge 7 commits intomainfrom
feat/bundled_attachments

Conversation

@matthiasn
Copy link
Copy Markdown
Owner

@matthiasn matthiasn commented Apr 16, 2026

Summary by CodeRabbit

  • New Features

    • Optional outbox attachment bundling (config off by default): pending attachments can be packed into a single upload (8 MiB cap) with per-item text events; oversized attachments fall back to individual uploads. Receivers detect bundle-marked uploads and unpack entries to their intended paths.
    • Release: 0.9.956.
  • Documentation

    • Sync docs updated to describe bundling behavior and receiver compatibility.
  • Tests

    • Added comprehensive bundling, unpacking, edge-case and retry coverage.

Wire OutboxProcessor with journalDb + documentsDirectory in
OutboxService so bundling actually activates in production. Extend
_maybeProcessBundle per-item failure path with retry-cap tracking so
poison bundled items surface retryCapReached like the head-only flow
does.

Reuse JSON bytes the enumerator already read for media-path
discovery (AttachmentDescriptor now carries a bytes field) so the
bundler does not re-read the same file, and parallelize media reads
via Future.wait. Move zip encoding and decoding onto worker isolates
so multi-MiB bundles do not block frame work on the sync isolate.

Extract _shouldSkipExistingFile so the single-file and bundle-entry
write paths share the non-agent dedup rule, drop the unused
bundleMaxBytesOverride field, fold AttachmentDescriptor into a
record, and introduce the attachmentBundleDirPrefix constant.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 16, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an optional outbox attachment bundling feature (use_bundled_attachments) that packs multiple pending attachment files into a single size‑capped zip (8 MiB), uploads it as one Matrix file event (com.lotti.bundle), then sends per-item text events that skip bundled paths; receivers unpack bundle entries to safe relative paths. Also adds an attachment enumerator.

Changes

Cohort / File(s) Summary
Docs & metadata
CHANGELOG.md, flatpak/com.matthiasn.lotti.metainfo.xml, lib/features/sync/README.md, pubspec.yaml
Release notes and README updated; package version bumped and archive dependency added.
Flags & tuning
lib/utils/consts.dart, lib/database/journal_db/config_flags.dart, lib/features/sync/tuning.dart, lib/features/sync/matrix/consts.dart
Added use_bundled_attachments flag, bundle constants (com.lotti.bundle, .bundles/) and SyncTuning.outboxBundleMaxBytes = 8 MiB.
Attachment enumeration
lib/features/sync/matrix/attachment_enumerator.dart
New exported AttachmentDescriptor typedef and enumerateAttachments(...) to discover JSON + referenced media files safely (non-throwing, path-hardened).
Matrix send stack
lib/features/sync/matrix/matrix_message_sender.dart, lib/features/sync/matrix/matrix_service.dart
Added sendAttachmentBundle(...), bundle encoding on an isolate, skipAttachmentPaths threaded into send context, and short-circuiting _sendFile for skipped/bundled paths.
Outbox bundling
lib/features/sync/outbox/outbox_processor.dart, lib/features/sync/outbox/outbox_service.dart
Extended OutboxMessageSender API (send signature + sendAttachmentBundle), injected journalDb/documentsDirectory, added _maybeProcessBundle() to enumerate, coalesce, upload a bundle and send per-item messages with skip-paths, plus retry/failure handling.
Attachment ingest (receive)
lib/features/sync/matrix/pipeline/attachment_ingestor.dart
Detects com.lotti.bundle, decodes zip on an isolate, unpacks entries with path-guards and dedupe checks, writes via atomic helpers, and centralizes save-skip logic.
Tests
test/**/*.dart (multiple)
Added/updated tests for enumerator, bundle send, ingestor unpacking (traversal/dedupe/corrupt zip), outbox bundling flows and retries, DB flag init, and adjusted test stubs for the new sender API.

Sequence Diagram(s)

sequenceDiagram
    participant Outbox as OutboxProcessor
    participant Enum as AttachmentEnumerator
    participant Encoder as ZipEncoder/Isolate
    participant Sender as MatrixMessageSender
    participant Room as MatrixRoom

    Outbox->>Outbox: Check useBundledAttachmentsFlag
    Outbox->>Enum: enumerateAttachments(item1)
    Enum-->>Outbox: descriptors
    Outbox->>Enum: enumerateAttachments(item2)
    Enum-->>Outbox: descriptors
    Outbox->>Outbox: Coalesce entries (≤8MiB)
    Outbox->>Encoder: _encodeBundleBytes(entries)
    Encoder-->>Outbox: zipped bytes
    Outbox->>Sender: sendAttachmentBundle(entries)
    Sender->>Room: sendFileEvent(.bundles/{bundleId}.zip, com.lotti.bundle=true)
    Room-->>Sender: eventId
    Sender-->>Outbox: eventId
    Outbox->>Sender: send(item1, skipAttachmentPaths={paths})
    Sender-->>Outbox: success
    Outbox->>Sender: send(item2, skipAttachmentPaths={paths})
    Sender-->>Outbox: success
    Outbox->>Outbox: Mark items sent
Loading
sequenceDiagram
    participant Room as Matrix/Network
    participant Ingestor as AttachmentIngestor
    participant Decoder as ZipDecoder/Isolate
    participant FS as FileSystem

    Room->>Ingestor: Attachment event (com.lotti.bundle: true)
    Ingestor->>Decoder: _decodeBundleEntries(zipBytes) via isolate
    Decoder-->>Ingestor: Map<relativePath, bytes>
    loop For each entry
        Ingestor->>Ingestor: Path traversal check
        Ingestor->>Ingestor: Dedupe check
        alt Safe & writable
            Ingestor->>FS: atomicWriteBytes(targetPath)
            FS-->>Ingestor: written
        else Blocked or exists
            Ingestor-->>Ingestor: log skip
        end
    end
    Ingestor-->>Room: Process complete (success/failure)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • cyberk1ng

Poem

🐇 I stitched a zip with carrots bright,

A tiny bundle snug and light.
One hop uploads, then each text calls,
Entries land safe inside small halls.
Hooray for tidy hops and synced delight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: bundled attachments' directly and specifically describes the main feature added in this PR—a new attachment bundling capability that packs multiple sync attachments into a single zip file.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bundled_attachments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces outbox attachment bundling, allowing multiple pending sync items' files to be packed into a single zip archive (up to 8 MiB) and uploaded as one Matrix file event. This optimization reduces round-trips over slow connections. The implementation includes a new attachment enumerator, updates to the outbox processor to handle bundling logic, and support in the attachment ingestor for unpacking bundles on the receiving side. Review feedback highlighted the need for better error handling during zip encoding and decoding within worker isolates, as well as ensuring type safety when handling archive entry content.

Comment thread lib/features/sync/matrix/pipeline/attachment_ingestor.dart Outdated
Comment thread lib/features/sync/matrix/matrix_message_sender.dart Outdated
Comment thread lib/features/sync/matrix/pipeline/attachment_ingestor.dart
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/features/sync/matrix/pipeline/attachment_ingestor.dart (1)

104-109: ⚠️ Potential issue | 🟠 Major

Handled bundle events will be re-downloaded on every later scan.

For bundle events, rpAny points at the outer .bundles/...zip, but _saveAttachment() never writes that file to disk. After the first successful unpack, _isLocalFileMissingOrEmpty(rpAny) therefore stays true, so every subsequent catch-up/live-scan pass treats the same event as needing repair and downloads the whole bundle again. Please make the repair probe bundle-aware instead of keying it off the unsaved outer zip path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart` around lines 104
- 109, The current repair probe uses rpAny (the outer .bundles/...zip path) with
_isLocalFileMissingOrEmpty, but _saveAttachment never writes that outer zip so
the probe always returns true and triggers re-downloads; change the
shouldRepairLocal logic to detect bundle events (e.g., rpAny containing
".bundles/") and, for those cases, probe the actual saved target produced by
unpacking (the normalized inner key or the unpack directory/marker that
_saveAttachment writes) instead of rpAny — call _isLocalFileMissingOrEmpty on
that inner/unpacked key (and ensure _inFlightSavePaths uses the same normalized
inner key) so alreadyHandled bundles are not repeatedly re-downloaded.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/features/sync/matrix/attachment_enumerator.dart`:
- Around line 71-77: Normalize and validate the paths before reading files:
after constructing jsonFullPath from documentsDirectory.path and
message.jsonPath, call p.normalize on the joined path and then verify
p.isWithin(documentsDirectory.path, normalizedPath) (or equivalent) to reject
any paths that escape the documents directory; do the same validation for the
other media-path construction used later (the logic around the second file read
at the other location referenced in this file), and only perform
File(...).readAsBytes() when the normalized path is within documentsDirectory,
otherwise return or throw a safe error.

In `@lib/features/sync/matrix/matrix_service.dart`:
- Around line 480-495: The sendAttachmentBundle method bypasses the
unverified-device gate that sendMatrixMsg enforces; before calling
_messageSender.sendAttachmentBundle in sendAttachmentBundle, query the same
unverified-device checker (e.g., call getUnverifiedDevices() or the same
verifier used by sendMatrixMsg) and if any unverified devices are present, log
an event via _loggingService (similar message/context as in sendMatrixMsg) and
return null instead of uploading, so bundle uploads are blocked when devices are
unverified.

In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart`:
- Around line 433-441: The _shouldSkipExistingFile helper currently uses async
file operations with an inline ignore for avoid_slow_async_io; replace the async
calls with synchronous ones to conform to background sync file I/O conventions:
use target.existsSync() instead of await target.exists() and target.lengthSync()
instead of await target.length(), remove the ignore and adjust the method to
return the appropriate bool synchronously while keeping the existing
isAgentPayloadPath(relativePath) check and exception handling in the
_shouldSkipExistingFile function.

In `@lib/features/sync/outbox/outbox_processor.dart`:
- Around line 307-316: The bundling loop (using bundled, enumerated, bundleSize)
incorrectly skips non-bundleable entries and continues scanning, allowing later
outbox items to be sent before earlier pending ones; change the loop so that
upon encountering the first non-bundleable entry — e.atts.isEmpty, total >
SyncTuning.outboxBundleMaxBytes, or bundleSize + total >
SyncTuning.outboxBundleMaxBytes — you break out of the loop and fall back to the
head-only send path instead of continuing to consider later enumerated entries;
ensure the rest of the code uses the resulting bundled list and preserves
head-first ordering for OutboxItem/AttachmentDescriptor processing.
- Around line 368-405: The bundled-item loop lacks a try/catch around the send
operation so an exception can escape and abort processQueue; wrap the
send+timeout call for each refreshed item (the call to
_messageSender.send(...).timeout(...) in the for (final e in bundled) loop) in a
try/catch, and in the catch block mirror the head-only handling: capture the
exception via _loggingService.captureException (include domain/subDomain such as
'OUTBOX'/'bundle.send' and stackTrace), await _repository.markRetry(refreshed),
call _recordBundleItemFailure(refreshed) (include timedOut when applicable), set
anyFailure = true, and continue to the next bundled item so retry bookkeeping
always runs.

In `@lib/features/sync/outbox/outbox_service.dart`:
- Around line 1638-1643: The file is missing the dart:typed_data import required
for the Uint8List type used in the sendAttachmentBundle method; add "import
'dart:typed_data';" to the file imports (so the declaration of Future<String?>
sendAttachmentBundle({ required Map<String, Uint8List> entries, }) and any use
of Uint8List in this file compiles) and ensure the existing method forwarder to
_matrixService.sendAttachmentBundle still references the same signature.

In `@pubspec.yaml`:
- Line 4: Update project metadata to match pubspec.yaml's version: add a new
entry for "0.9.956+3931" to CHANGELOG.md under the current version header (copy
the relevant release notes or summary for 0.9.956 from your release notes) and
update flatpak/com.matthiasn.lotti.metainfo.xml to include the same version
string (0.9.956+3931) in its version/release metadata so both files are
synchronized with the pubspec.yaml version.

In `@test/features/sync/matrix/pipeline/attachment_ingestor_test.dart`:
- Line 1543: The test currently reads the host file File('/etc/passwd') which
couples it to the runner OS; replace that with a temporary, controlled file
created within the test (e.g. via Directory.systemTemp.createTempSync or
similar) containing known content and then assert using
expect(File(tempPath).readAsStringSync(), isNot(contains('pwned'))); locate the
offending call to File('/etc/passwd')/expect and change it to create and use a
temp file (or a fixture in the test's temp dir) so the assertion validates
traversal behavior against controlled data rather than the host filesystem.

---

Outside diff comments:
In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart`:
- Around line 104-109: The current repair probe uses rpAny (the outer
.bundles/...zip path) with _isLocalFileMissingOrEmpty, but _saveAttachment never
writes that outer zip so the probe always returns true and triggers
re-downloads; change the shouldRepairLocal logic to detect bundle events (e.g.,
rpAny containing ".bundles/") and, for those cases, probe the actual saved
target produced by unpacking (the normalized inner key or the unpack
directory/marker that _saveAttachment writes) instead of rpAny — call
_isLocalFileMissingOrEmpty on that inner/unpacked key (and ensure
_inFlightSavePaths uses the same normalized inner key) so alreadyHandled bundles
are not repeatedly re-downloaded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db5f5681-f6f0-4c64-9c74-5cb3d3a382ac

📥 Commits

Reviewing files that changed from the base of the PR and between db17f89 and 6ad11e9.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • CHANGELOG.md
  • flatpak/com.matthiasn.lotti.metainfo.xml
  • lib/database/journal_db/config_flags.dart
  • lib/features/sync/README.md
  • lib/features/sync/matrix/attachment_enumerator.dart
  • lib/features/sync/matrix/consts.dart
  • lib/features/sync/matrix/matrix_message_sender.dart
  • lib/features/sync/matrix/matrix_service.dart
  • lib/features/sync/matrix/pipeline/attachment_ingestor.dart
  • lib/features/sync/outbox/outbox_processor.dart
  • lib/features/sync/outbox/outbox_service.dart
  • lib/features/sync/tuning.dart
  • lib/utils/consts.dart
  • pubspec.yaml
  • test/database/database_test.dart
  • test/features/sync/matrix/attachment_enumerator_test.dart
  • test/features/sync/matrix/matrix_message_sender_test.dart
  • test/features/sync/matrix/pipeline/attachment_ingestor_test.dart
  • test/features/sync/outbox/outbox_processor_test.dart
  • test/features/sync/outbox/outbox_retry_cap_db_test.dart

Comment thread lib/features/sync/matrix/attachment_enumerator.dart Outdated
Comment thread lib/features/sync/matrix/matrix_service.dart
Comment thread lib/features/sync/matrix/pipeline/attachment_ingestor.dart Outdated
Comment thread lib/features/sync/outbox/outbox_processor.dart
Comment thread lib/features/sync/outbox/outbox_processor.dart
Comment thread lib/features/sync/outbox/outbox_service.dart
Comment thread pubspec.yaml Outdated
Comment thread test/features/sync/matrix/pipeline/attachment_ingestor_test.dart Outdated
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 16, 2026

Codecov Report

❌ Patch coverage is 93.39623% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.30%. Comparing base (db17f89) to head (69c80af).

Files with missing lines Patch % Lines
lib/features/sync/outbox/outbox_processor.dart 94.04% 5 Missing ⚠️
...ures/sync/matrix/pipeline/attachment_ingestor.dart 90.69% 4 Missing ⚠️
lib/features/settings/ui/pages/flags_page.dart 60.00% 2 Missing ⚠️
lib/features/sync/outbox/outbox_service.dart 60.00% 2 Missing ⚠️
...ib/features/sync/matrix/attachment_enumerator.dart 97.50% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #2957    +/-   ##
========================================
  Coverage   92.30%   92.30%            
========================================
  Files        1104     1105     +1     
  Lines       81336    81542   +206     
========================================
+ Hits        75074    75271   +197     
- Misses       6262     6271     +9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/features/sync/outbox/outbox_processor.dart (1)

313-324: Consider requiring ≥2 items to take the bundle path.

When bundled.length == 1 the zip encoding cost (plus isolate hop and receiver unpack) buys nothing over the head-only path — the event count is the same and the single attachment gets wrapped in zip overhead. Gating on a minimum size avoids this:

♻️ Proposed gate
-    if (bundled.isEmpty) return null;
+    if (bundled.length < 2) return null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/sync/outbox/outbox_processor.dart` around lines 313 - 324, The
bundling path should only be taken when there are at least two items to justify
zip/isolates overhead: after building the list `bundled` (which collects
`({OutboxItem item, List<AttachmentDescriptor> atts})` entries from `enumerated`
and uses `SyncTuning.outboxBundleMaxBytes`), change the early-return check from
`if (bundled.isEmpty) return null;` to require a minimum of two entries (e.g.,
`if (bundled.length < 2) return null;`) so single-item bundles fall back to the
head-only path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart`:
- Around line 378-384: process() currently treats bundle events as "local file
missing" because .bundles/<id>.zip is never persisted, causing shouldRepairLocal
to stay true and re-download bundles every pass; fix by short-circuiting the
repair path for bundle events: inside process(), detect bundle events via
attachmentBundleKey and if the eventId is already in _handledAttachmentEventIds
(or otherwise alreadyHandled), set shouldRepairLocal = false (or return early)
so the code does not enqueue repair/download or call _isLocalFileMissingOrEmpty
for bundles; ensure you still call _unpackBundle only when first handling the
bundle (use _handledAttachmentEventIds, _unpackBundle, and
_shouldSkipExistingFile as needed) so subsequent passes skip re-downloading the
.zip while per-entry dedup remains intact.

---

Nitpick comments:
In `@lib/features/sync/outbox/outbox_processor.dart`:
- Around line 313-324: The bundling path should only be taken when there are at
least two items to justify zip/isolates overhead: after building the list
`bundled` (which collects `({OutboxItem item, List<AttachmentDescriptor> atts})`
entries from `enumerated` and uses `SyncTuning.outboxBundleMaxBytes`), change
the early-return check from `if (bundled.isEmpty) return null;` to require a
minimum of two entries (e.g., `if (bundled.length < 2) return null;`) so
single-item bundles fall back to the head-only path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0b5b31a3-a1bd-491e-a3b6-e6fff8da98eb

📥 Commits

Reviewing files that changed from the base of the PR and between 6ad11e9 and f17f233.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • flatpak/com.matthiasn.lotti.metainfo.xml
  • lib/features/sync/matrix/attachment_enumerator.dart
  • lib/features/sync/matrix/matrix_message_sender.dart
  • lib/features/sync/matrix/matrix_service.dart
  • lib/features/sync/matrix/pipeline/attachment_ingestor.dart
  • lib/features/sync/outbox/outbox_processor.dart
  • test/features/sync/matrix/pipeline/attachment_ingestor_test.dart
✅ Files skipped from review due to trivial changes (2)
  • CHANGELOG.md
  • flatpak/com.matthiasn.lotti.metainfo.xml
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/features/sync/matrix/matrix_service.dart
  • lib/features/sync/matrix/attachment_enumerator.dart

Comment thread lib/features/sync/matrix/pipeline/attachment_ingestor.dart
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/features/sync/outbox/outbox_processor_test.dart (1)

1071-1072: Optional: guard deleteSync against flaky cleanup.

addTearDown(() => tmp.deleteSync(recursive: true)) will throw if the directory is already gone or a file is locked (notably on Windows CI), turning a passing test into a red teardown. Wrapping with try { ... } catch (_) {} or using tmp.existsSync() guard is a cheap safety net and matches common patterns for systemTemp scratch dirs. Same pattern recurs at lines 1175–1176, 1238–1239, 1312–1313, 1391–1392, 1470–1471, 1562–1563.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/features/sync/outbox/outbox_processor_test.dart` around lines 1071 -
1072, The teardown currently calls addTearDown(() => tmp.deleteSync(recursive:
true)) which can throw if the temp directory is already removed or locked
(Windows CI); change the teardown to safely attempt deletion by either wrapping
tmp.deleteSync(recursive: true) in a try/catch (catch and ignore any exception)
or check tmp.existsSync() before deleting, and apply the same fix for the other
tmp tearDowns that create systemTemp directories (the blocks using the tmp
variable at the other noted locations).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/features/sync/outbox/outbox_processor_test.dart`:
- Around line 1559-1629: The test never asserts that the repeated-subject
counters were cleared; add a third tick that fails again for the same subject
and assert the emitted sendFailed diagnostic reports repeats == 1. Concretely:
after Tick 2 (where sendResult = true and proc.processQueue() verified
markSent), set sendResult = false, call await proc.processQueue() again, and
verify the loggingService.captureEvent (MockLoggingService.captureEvent) was
invoked with a payload whose repeats field equals 1 (same pattern used in the
"resets on success for the same subject" test), ensuring the
OutboxProcessor.processQueue() reset of the
_lastFailedSubject/_lastFailedRepeats tracker is exercised.

---

Nitpick comments:
In `@test/features/sync/outbox/outbox_processor_test.dart`:
- Around line 1071-1072: The teardown currently calls addTearDown(() =>
tmp.deleteSync(recursive: true)) which can throw if the temp directory is
already removed or locked (Windows CI); change the teardown to safely attempt
deletion by either wrapping tmp.deleteSync(recursive: true) in a try/catch
(catch and ignore any exception) or check tmp.existsSync() before deleting, and
apply the same fix for the other tmp tearDowns that create systemTemp
directories (the blocks using the tmp variable at the other noted locations).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aeb9b830-37b3-4cea-860a-e007b86048d1

📥 Commits

Reviewing files that changed from the base of the PR and between f17f233 and 907e497.

📒 Files selected for processing (4)
  • test/features/sync/matrix/attachment_enumerator_test.dart
  • test/features/sync/matrix/matrix_service_test.dart
  • test/features/sync/matrix/pipeline/attachment_ingestor_test.dart
  • test/features/sync/outbox/outbox_processor_test.dart
✅ Files skipped from review due to trivial changes (1)
  • test/features/sync/matrix/attachment_enumerator_test.dart

Comment thread test/features/sync/outbox/outbox_processor_test.dart
Add tests for the uncovered lines codecov flagged in the bundle feature:

- MatrixService.sendAttachmentBundle: no-room, unverified-devices, and
  happy-path delegation.
- OutboxProcessor bundle path: head-first break when the first pending
  item is non-bundleable, per-item send exception triggers markRetry plus
  bundle.send captureException, retryCapReached diagnostic surfaces when
  a bundled item's retries hit the cap, and success clears the
  repeated-subject tracker.
- attachment_enumerator path hardening: relative jsonPath with ..
  escapes and absolute jsonPath pointing outside docs both return empty
  without reading from disk.
- attachment_ingestor: bundle unpack tolerates corrupt zip input and
  writes nothing (the try/catch around the isolate decode covers the
  stricter throwing case in archive).

Replace the /etc/passwd traversal assertion with an escape target in a
sibling dir of the test's temp documents directory so the test no longer
couples to host state.
@matthiasn matthiasn force-pushed the feat/bundled_attachments branch from 907e497 to efc9910 Compare April 17, 2026 00:25
Three more targeted tests for the paths codecov still flagged on commit
907e497:

- Bundled-item send timeout: a send() that never completes exercises
  the onTimeout callback and the timedOut=true branch of the bundle
  diagnostic so the timeout path isn't dead on coverage.
- Enumerator empty JSON: a zero-byte jsonPath returns an empty
  descriptor list so the bundler never ships a zero-byte payload.
- Enumerator unparseable JSON: a readable but non-JournalEntity JSON
  returns just the base descriptor (no media), covering the catch
  around JournalEntity.fromJson.

Strengthen the existing "bundled item success resets repeated-subject
diagnostic counters" test with a third tick that fails again for the
same subject, asserting the diagnostic emits repeats=1 (not 2).
Without the Tick 2 success clearing the tracker, Tick 3 would report
repeats=2 — so this actually exercises the reset line on the success
branch instead of just reaching markSent.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/features/sync/matrix/pipeline/attachment_ingestor.dart (1)

358-394: ⚠️ Potential issue | 🟠 Major

Don’t dedupe on the outer .bundles/*.zip path before unpacking.

_shouldSkipExistingFile(file, relativePath) runs before the bundle check, so if an older app version has already written .bundles/<id>.zip locally, this method returns early and _unpackBundle() never runs. That breaks the upgrade path: the zip exists, but the real inner attachment files are still missing.

Handle attachmentBundleKey before the existing-file dedupe, or treat an existing outer zip as input to unpack rather than proof that the bundle has already been applied.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart` around lines 358
- 394, The dedupe check using _shouldSkipExistingFile is currently run before
detecting bundle payloads, causing existing outer .bundles/*.zip files to
prevent _unpackBundle from running; change the flow to check
event.content[attachmentBundleKey] first (or, if a local file exists and
relativePath is a bundle, treat that existing zip as input) so that bundle
handling calls _unpackBundle regardless of _shouldSkipExistingFile; update the
logic around event.downloadAndDecryptAttachment()/downloadedBytes and use
_unpackBundle(bundleRelativePath: relativePath, downloadedBytes: ..., logging:
logging) for bundle cases before returning based on _shouldSkipExistingFile.
♻️ Duplicate comments (1)
test/features/sync/outbox/outbox_processor_test.dart (1)

1872-1942: ⚠️ Potential issue | 🟡 Minor

Add a third tick to prove the bundled success really resets the repeat counter.

Right now this only shows fail -> success. It never fails the same subject again and inspects the next sendFailed diagnostic, so the _lastFailedRepeats reset is still unproven. Please add one more tick with sendResult = false and assert the next failure for this subject logs repeats=1.

As per coding guidelines: "Every test must assert something meaningful beyond findsOneWidget; verify content, state changes, callbacks, or error handling."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/features/sync/outbox/outbox_processor_test.dart` around lines 1872 -
1942, Add a third "tick" after the success to prove the bundled success resets
the repeat counter: after the existing success branch (where sendResult = true
and proc.processQueue() returned and repo.markSent was verified) set sendResult
= false again, call await proc.processQueue(), verify repo.markRetry(item) is
called a second time, and assert that loggingService.captureEvent
(MockLoggingService.captureEvent) was invoked for this failure with a diagnostic
object containing repeats = 1 (or otherwise inspect the captured event argument
to ensure repeats==1); reference OutboxProcessor, the sendResult toggle,
repo.markRetry(item), and log.captureEvent to locate and implement these
assertions.
🧹 Nitpick comments (1)
test/features/sync/matrix/matrix_service_test.dart (1)

87-88: Use shared test doubles for the new Matrix room/device types.

Please move _RoomFake, _MockRoom, _MockDeviceKeys, and the corresponding fallback registration into the shared test helpers instead of adding more inline doubles here. That keeps Matrix test setup consistent and avoids per-file duplication.

As per coding guidelines: "In tests, use centralized mocks from test/mocks/mocks.dart; never define mock classes inline" and "In tests, use centralized fallback values from test/helpers/fallbacks.dart."

Also applies to: 440-444

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/features/sync/matrix/matrix_service_test.dart` around lines 87 - 88, The
test defines local doubles `_RoomFake`, `_MockRoom`, `_MockDeviceKeys` and calls
`registerFallbackValue(_RoomFake())` inline; move those mock/fake classes and
the fallback registration into the shared test helpers so tests reuse
centralized doubles. Specifically, remove the inline definitions and fallback
registration here and instead import and use the shared mocks from the
centralized test helpers (the equivalents provided in `test/mocks/mocks.dart`
and `test/helpers/fallbacks.dart`), replacing local references with the shared
`_RoomFake`, `_MockRoom`, `_MockDeviceKeys` and ensuring `registerFallbackValue`
is called from the centralized setup so no per-file mock/fallback duplication
remains.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart`:
- Around line 104-118: The short-circuit that skips repair for bundle events
(isBundleEvent derived from attachmentBundleKey) causes permanently missing
bundle contents when the initial unpack/download fails because bundle event ids
get added to _handledAttachmentEventIds too early; change the flow so bundle
events are only recorded as handled after a successful unpack/write (i.e., delay
adding to _handledAttachmentEventIds until unpack completes), or alternatively
introduce a separate "bundleApplied" state flag for bundle event ids and keep
the current shouldRepairLocal logic but consult that flag instead of
_handledAttachmentEventIds; update the code paths that set/consult
_handledAttachmentEventIds, the isBundleEvent branch, and any logic that clears
or retries entries in
_inFlightSavePaths/_normalizeKey/_isLocalFileMissingOrEmpty to reflect this new
ordering.

---

Outside diff comments:
In `@lib/features/sync/matrix/pipeline/attachment_ingestor.dart`:
- Around line 358-394: The dedupe check using _shouldSkipExistingFile is
currently run before detecting bundle payloads, causing existing outer
.bundles/*.zip files to prevent _unpackBundle from running; change the flow to
check event.content[attachmentBundleKey] first (or, if a local file exists and
relativePath is a bundle, treat that existing zip as input) so that bundle
handling calls _unpackBundle regardless of _shouldSkipExistingFile; update the
logic around event.downloadAndDecryptAttachment()/downloadedBytes and use
_unpackBundle(bundleRelativePath: relativePath, downloadedBytes: ..., logging:
logging) for bundle cases before returning based on _shouldSkipExistingFile.

---

Duplicate comments:
In `@test/features/sync/outbox/outbox_processor_test.dart`:
- Around line 1872-1942: Add a third "tick" after the success to prove the
bundled success resets the repeat counter: after the existing success branch
(where sendResult = true and proc.processQueue() returned and repo.markSent was
verified) set sendResult = false again, call await proc.processQueue(), verify
repo.markRetry(item) is called a second time, and assert that
loggingService.captureEvent (MockLoggingService.captureEvent) was invoked for
this failure with a diagnostic object containing repeats = 1 (or otherwise
inspect the captured event argument to ensure repeats==1); reference
OutboxProcessor, the sendResult toggle, repo.markRetry(item), and
log.captureEvent to locate and implement these assertions.

---

Nitpick comments:
In `@test/features/sync/matrix/matrix_service_test.dart`:
- Around line 87-88: The test defines local doubles `_RoomFake`, `_MockRoom`,
`_MockDeviceKeys` and calls `registerFallbackValue(_RoomFake())` inline; move
those mock/fake classes and the fallback registration into the shared test
helpers so tests reuse centralized doubles. Specifically, remove the inline
definitions and fallback registration here and instead import and use the shared
mocks from the centralized test helpers (the equivalents provided in
`test/mocks/mocks.dart` and `test/helpers/fallbacks.dart`), replacing local
references with the shared `_RoomFake`, `_MockRoom`, `_MockDeviceKeys` and
ensuring `registerFallbackValue` is called from the centralized setup so no
per-file mock/fallback duplication remains.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11a3b4a8-aa56-4e04-83a7-50a94a557483

📥 Commits

Reviewing files that changed from the base of the PR and between 907e497 and efc9910.

📒 Files selected for processing (6)
  • lib/features/sync/matrix/pipeline/attachment_ingestor.dart
  • test/features/sync/matrix/attachment_enumerator_test.dart
  • test/features/sync/matrix/matrix_message_sender_test.dart
  • test/features/sync/matrix/matrix_service_test.dart
  • test/features/sync/matrix/pipeline/attachment_ingestor_test.dart
  • test/features/sync/outbox/outbox_processor_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/features/sync/matrix/attachment_enumerator_test.dart
  • test/features/sync/matrix/matrix_message_sender_test.dart

Comment thread lib/features/sync/matrix/pipeline/attachment_ingestor.dart Outdated
The earlier re-download-every-pass fix gated the repair heuristic on
!isBundleEvent, which short-circuited bundles once their event id
landed in _handledAttachmentEventIds at the top of process(). Because
that set is populated before the download+unpack, a first-pass
failure (download throws, zip decode throws, etc.) left the id in
place and no subsequent catch-up pass would ever retry. The bundled
items' text events could not recover: their inner relativePaths were
never registered in AttachmentIndex so DescriptorDownloader had
nothing to pull, and SyncSequenceLogService saw the counters as
received so BackfillRequestService never fired.

Introduce _appliedBundleEventIds, populated only after _unpackBundle
successfully decodes the zip. For bundle events the repair gate now
consults that set instead of _isLocalFileMissingOrEmpty, so the
retry path fires until the bundle is applied, then stops. Mirrors
the existing _handledAttachmentEventIds LRU bookkeeping to keep
memory bounded.

Add a regression test that drives three passes through process() for
the same bundle event: pass 1 fails mid-download, pass 2 succeeds and
persists the inner entry, pass 3 must not trigger another download.
@matthiasn matthiasn force-pushed the feat/bundled_attachments branch from 5353118 to c87d6a4 Compare April 17, 2026 00:44
Add the config flag to FlagsPage.displayedItems alongside its icon
(Icons.inventory_2_outlined), localized title, and description so the
toggle is actually reachable from the UI. The gzip flag landed in the
page during 0.9.954; the bundle flag was added in 0.9.956 but missed
this surface.

Add configFlagUseBundledAttachments / ...Description strings to all
six ARB files (en, de, fr, es, cs, ro) with informal phrasing per the
project convention, and regenerate the l10n Dart bindings via
`make l10n`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant