Skip to content

feat(attachments): offline attachment pipeline with on-device media cache - #107

Merged
deepak-dhwani merged 25 commits into
dhwani-ris:developfrom
Omprakash-48:feat/offline-attachment-pipeline
Aug 26, 2026
Merged

feat(attachments): offline attachment pipeline with on-device media cache#107
deepak-dhwani merged 25 commits into
dhwani-ris:developfrom
Omprakash-48:feat/offline-attachment-pipeline

Conversation

@Omprakash-48

@Omprakash-48 Omprakash-48 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Attachments picked on a device that is offline (or in offline-first mode) are now staged locally, queued, uploaded by the sync pass, and relinked to the real record and field on the server — with the uploaded bytes kept on device as the preview cache, so an attachment stays viewable without connectivity. Previously an attach field decided by connectivity alone: an online device uploaded inline, which put the file outside the offline pipeline entirely, and an offline pick could write the literal pending:<id> marker into Frappe as a field value.

Full design, case matrix and internals: doc/release-2.0/attachments.md — new, 803 lines.

Size: 71 files, +8,744 / −447 — 30 in lib/, 38 tests (28 new), 3 docs. Base branch develop.

What changed

Pipeline and push gate

  • A resolved server_file_url committed to SQLite is now the correctness boundary: the url is written back into docs__<doctype> in the same transaction as the state transition, so payload assembly reads an ordinary file_url. Upload and document push are independent commitments — an uploaded file is never re-uploaded, whether or not the parent ever syncs.
  • A parent may push only when every attachment is done; otherwise it throws BlockedByUpstream naming the file and field. inlinePayload now throws on an unresolved marker instead of shipping it.
  • Terminal/transient split: AttachmentState.rejected (oversized, wrong type, not permitted) is never auto-retried; failed stays transient and is re-armed next dispatch. Unrecognised errors default to transient.
  • Fixed three reproduced paths where a pending:<id> marker reached Frappe verbatim (upload OK → parent POST failed → retry; terminal refusal → blocked → retry, which reported success; any TimestampMismatchError via auto-merge recursion, with no user action).
  • Offline mode now defers uploads regardless of connectivity — AttachField / ImageField / FrappeFormBuilder / FieldFactory gain an isOfflineMode callback; resolvePickedAttachment gains offlineModeEnabled. Both default to previous behaviour.

Media cache and offline previews (schema v6 → v7)

  • New media_cache table + MediaStore, keyed by server file_url (Frappe dedupes by content hash, so per-document caching would double-store and turn deletion into reference counting). Push-uploaded bytes move into the cache; pulled media is cached on first online view. Attach / Attach Image / Image render from disk on a hit; non-image attachments are handed to the external app from the cache. Cache state is non-authoritative — a vanished file is a miss, never an error.
  • Storage layout: staged files at mform_attachments/outbox/<id>/<original filename>, uploaded bytes at mform_attachments/cache/<sha256(file_url)>. Staging is never evicted (it is the only copy); cache always is (re-fetchable).
  • No automatic eviction in this release — the store grows until logout or an explicit clear.

Frappe-side correctness

  • upload_file was sent dt / dn / filename; frappe/handler.py reads doctype / docname / file_name (checked against 16.25.0, 16.26.3, 17.0.0-dev). The old branch had no in-tree caller, so no released behaviour changed.
  • The user's filename now survives to the server. Three places discarded it and all three had to change — staging, the enqueue, and the multipart part name in RestHelper.uploadFile (load-bearing: Frappe reassigns filename from the part whenever a file part is present).

UI

  • Attachments can be discarded, not just replaced (Attach / Attach Image / Image, when editable and non-empty).
  • ImagePickSource host hook — gallery, camera, or both; read live, global, null means both. Attach Image / Image only.
  • Pick-time size guard (default 10 MB, matching Frappe's stock max_file_size), enforced before the durable copy; widgets surface the real limit.
  • A value supplied after a field's first build now renders (hasInteractedByUser distinguishes "user cleared this" from "never touched").

Storage APIs and leak fixes

  • FrappeSDK.mediaStoreUsage() (staged / cached / reclaimable-now) and sweepOrphanedMedia() (exact guards only: unreferenced and not staged this session; never throws; deletes nothing if the reference query fails).
  • clearMediaCache() no longer wipes staged files — it clears cache/ and the index only. logout(clearDatabase: true) is now the only path that clears staged files, where that is the intended security behaviour (previously the store survived logout, leaving one user's photos readable on a shared device).
  • Fixed staged-file leaks on every removal path (hardDeleteLocalMirror, cancelled-INSERT, tombstone → all route through deleteForTopParent), on re-pick, and on a pick that never reached a save.
  • Fixed: a completed upload could resurrect a discarded attachment (writeback is now conditional on the column still holding that row's marker); clearing a field left its queued row alive and blocked the document forever; pending_attachments.size_bytes / .mime_type were never populated; a fresh install wrote sdk_meta.schema_version from a drifted literal.

Also in this PR (not attachments)

  • mobile_uuid adoption on pullpull_apply.dart. A pulled document was stored under a freshly minted mobile_uuid even when the server round-tripped this device's own value: the server value was read as a matching key only, never stored, and both parent write paths minted unconditionally. Invisible in normal operation (a present local row preserved its uuid), it surfaced whenever the row was gone — logout(clearDatabase: true), reinstall, fresh-device onboarding — giving the same server records brand-new local identities and defeating the unique mobile_uuid fallback that exists to reconcile an interrupted server_name writeback. Precedence is now local → incoming → mint through one resolvePulledMobileUuid helper used by all four write sites; empty/absent still mints (Desk-origin rows return null/'', and it is the local PK). Child behaviour is unchanged, only deduplicated onto the helper.
  • Push-engine ordering tests only — link-chain order and same-document operation order (no production change).
  • Barrel exports for ImagePickSource / MediaStoreUsage, which 2.0.0-beta.2 documented as host-facing but never exported, so the pick-source hook could not be wired at all.
  • Doc corrections in attachments.md and CHANGELOG.md (stale "not verified on a device" claims; server-side image shrinkage attributed to the wrong mechanism).

Breaking / migration

Hosts that only call FrappeSDK / SyncService are unaffected — PushEngine wires the pipeline internally.

  • Schema v6 → v7 adds media_cache. Automatic, backfills nothing (an empty cache is just misses); existing pending_attachments rows keep working — they store absolute paths.
  • FrappeSDK.mediaStoreSize()mediaStoreUsage() (a single number cannot say how much is reclaimable). Both are unreleased, so nothing shipped against the old name.
  • AttachmentPipeline.uploadPendingForTopParentresolveForTopParent; the constructor now requires db; resolutionMapFor is new.
  • PendingAttachmentDao.findPendingForTopParentfindUnresolvedForTopParent, plus findAllForTopParent and markRejected.
  • AttachmentPipeline.inlinePayload throws on an unresolved marker (was a silent passthrough).
  • resolvePickedAttachment gained maxBytes and now rethrows a terminal upload failure and discards the staged copy; a transient failure still falls back to staged queueing.
  • Staged path layout changed (see above).

Verification

  • flutter test on this branch: 2338 tests pass (exit 0, Flutter 3.44.2). 28 new test files, 10 updated. test/public_api_surface_test.dart imports only the public entry point, so it fails to compile if an export is dropped — every other test imports src/ directly, which is why the missing exports were invisible.
  • Device run (documented in attachments.md §16): Android 8.0.0 / API 26 debug build against a Frappe v16 bench, offline mode on, two documents × two Attach Image fields (camera + gallery). Confirmed: staging keeps the picked filename, zero upload_file calls before sync, markers never crossed between docs or fields, all four → done with real /private/files/… urls, a server-side LIKE 'pending%' query on both fields returning empty, File relink correct on all four (reconstructed server-side from file_url alone), staged bytes moved into cache/, and logout(clearDatabase: true) removing mform_attachments/ entirely along with every docs__* table and all credentials.
  • The mobile_uuid fix was reproduced and re-verified on a live bench (bulk pull path): divergence before, byte-for-byte adoption after, a Desk-created record with a null server uuid correctly still minting, children parented to the adopted uuid with zero orphans, 603/603 HTTP 200.
  • Not exercised on hardware (unit-tested only): offline capture, offline preview from outbox/, BlockedByUpstream on a gated document, the failed and rejected states, re-pick/discard reclaim, camera lost-capture recovery, OpenFilex handoff.
  • Worth knowing (§16.2): with strip_exif_metadata_from_uploaded_images enabled, Frappe re-encodes JPEGs through PIL — lossy, GPS EXIF destroyed, dimensions unchanged — so the device cache holds the pre-strip original while the server holds the re-encode. Benign, because the cache is non-authoritative, but a cache hit and a miss can render slightly different bytes for the same url. PNG is untouched (the strip is JPEG-gated). This is a site setting, not SDK behaviour or a Frappe default.

Known issue (not fixed here)

ResolveMediaFn is still not exported, so a host cannot write the signature of a FieldFactory.createField override — FieldFactory is exported bare and documented as overridable, but the typedef lives in src/services/media_resolver.dart, and Dart does not re-export a library's imports. Confirmed against a real consumer app that subclasses FieldFactory; it cannot compile without an implementation_imports violation. The fix is one export line. 2.0.0 should not ship claiming that type is host-facing until it landspublic_api_surface_test.dart will guard it afterwards.

Review pointers

  • Correctness boundary: lib/src/sync/attachment_pipeline.dart, lib/src/sync/push_engine.dart (the gate + writeback transaction).
  • Identity: lib/src/sync/pull_apply.dart (resolvePulledMobileUuid precedence).
  • Storage lifetime rules: lib/src/utils/media_store.dart, lib/src/utils/attachment_paths.dart.
  • Regression pins: test/sync/marker_resolution_regression_test.dart, test/sync/attachment_multi_field_test.dart, test/sync/pull_apply_mobile_uuid_adoption_test.dart.

Omprakash-48 and others added 18 commits July 24, 2026 12:01
The offline attachment consumer (AttachmentPipeline + PushEngine) already
shipped, but nothing produced its inputs: PendingAttachmentDao.enqueue had
zero callers and no pending:<id> marker was ever written, so an offline pick
silently failed. Wire the producer + preview:

- Pick time: copy the picked file to a durable app dir (mform_attachments/)
  first (survives cache reclaim / camera-process kill), then upload inline
  when online, else keep the local path for save-time queueing
  (resolvePickedAttachment). Threaded via isOnline.
- Save time: LocalWriter.writeParentInTxn scans Attach/Attach Image/Image
  fields on the parent and every child row, enqueues a pending_attachments
  row with correct coords (child field -> parentUuid=childUuid,
  topParentUuid=parent), and rewrites the stored value to pending:<id>.
  Idempotent per (parent_uuid, parent_fieldname).
- Preview: attachmentDisplaySource resolves pending:<id> -> its durable
  local file (display only; stored marker unchanged) via a per-doc id->path
  map loaded in FormScreen on load/reopen/resume/after-save. Server URLs and
  post-sync file_urls render unchanged via Image.network.
- preserveChildIdentity carries child mobile_uuid/name across the child edit
  sheet so queued child attachments don't orphan on re-save.
- Delete the durable copy after a successful upload.

isLocalAttachmentPath excludes pending: (re-save safety). Threads isOnline +
pendingAttachmentPaths through FormScreen -> FormBuilder -> FieldFactory ->
Attach/Image fields (parent and nested child-table builders).

Full SDK suite green (1745 tests); producer verified on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catch-up merge: this branch carried one commit from 24 Jul, om/bug_fix had
93 commits on top of the same base. Two conflicts, both in the attachment
field widgets, and both the same shape — om/bug_fix hardened the pick
handlers while this branch replaced their core with the offline-aware
resolvePickedAttachment(). Kept both halves in each case.

* attach_field.dart / image_field.dart — took om/bug_fix's structure
  (try/catch around the picker, which throws on a denied storage
  permission; messenger captured before the await; sdkLog plus a
  release-visible SnackBar) and dropped resolvePickedAttachment() in place
  of its inline uploadFile! flow. Imports and widget fields were a union:
  isOnline / pendingAttachmentPaths from here, httpClient from om/bug_fix.

  This deliberately reverses one of om/bug_fix's rules. It stored NOTHING
  on a failed or empty upload — "never store the local path, the server
  expects a file_url" — and warned the user. Under the offline pipeline a
  durable local path IS a valid stored value: the save-time producer queues
  it and rewrites the field once the upload lands. So the null-uploader,
  failed-upload and empty-URL branches all now fall back to the durable
  copy and none of them notifies. The warning survives only where the
  durable copy itself failed, which is the one case with nothing to queue.
  Both replaced comments are quoted at the new call sites so the reversal
  is legible rather than looking like a dropped fix.

* _AttachViewButton.fileName now resolves from displaySource rather than
  the raw field value, so a `pending:<id>` marker shows the real filename.

Auto-merged but checked by hand, since a stale call would have compiled
clean: form_builder.dart still passes parentData: and defaultOnError: to
DependsOnEvaluator.evaluate (the merged evaluator is om/bug_fix's
interpreter, whose new params are both optional — taking this branch's
older two-arg call would silently have reinstated the permanently-mandatory
bug). field_factory.dart carries both sides' new state: capDataLength /
errorTextResolver from om/bug_fix, isOnline / pendingAttachmentPaths from
here, still forwarded to the attachment fields.

Verified: flutter test 2117 passing, against 1745 on this branch before the
merge and 2090 on om/bug_fix — the +27 is this branch's attachment suite,
so nothing was dropped from either side. flutter analyze clean over lib and
test. No lockfile churn beyond what om/bug_fix itself carries.
…line media store

A `pending:<id>` marker could be written into Frappe verbatim as an
attach-field value. The marker lived in docs__<doctype> and nothing ever
rewrote that column, while PayloadAssembler rebuilds the payload from
docs__ on every dispatch — so resolution depended entirely on the pending
query returning the row, and that query selected only pending/uploading.
Once a row left those states the marker resolved to nothing and was sent
as-is. Three ways in, all reproduced against the real save -> upload ->
assemble path:

  1. upload succeeded, parent POST failed, user retried
  2. upload terminally refused, row blocked, user retried — this path
     reported SUCCESS
  3. any TimestampMismatchError, via _autoMergeAndRetry, no user action

A server_file_url committed to SQLite is now the correctness boundary. The
resolved url is written back into docs__ in the same transaction as the
state transition, so assembly reads an ordinary file_url. Upload and
doc-push became independent commitments: once committed the file is
uploaded permanently, and no later attempt re-uploads it. A document may
only push when every attachment is done; anything else throws
BlockedByUpstream naming the file and field. inlinePayload throws on an
unresolved marker so a regression is loud rather than silent.

Also fixed:

- Staged files leaked on all three document-removal paths and on re-pick;
  nothing else reclaims outbox/, so a discarded survey lost its photos to
  dead disk permanently.
- upload_file was called with dt/dn/filename. Frappe reads
  doctype/docname/file_name — verified against 16.25.0, 16.26.3 and
  17.0.0-dev. Unreachable in-tree, but a trap for the next caller.
- Every upload landed server-side named after an internal uuid. Staging now
  keeps the user's filename with uniqueness in the parent directory, and
  the multipart part name carries it — that part name, not
  form_dict.file_name, is what Frappe actually stores.
- The media store survived logout, leaving one user's private survey photos
  readable on a shared device after the next sign-in.
- pending_attachments.size_bytes and .mime_type were never populated.
- onCreate wrote a hardcoded schema_version that had drifted from _version.

Added: a media_cache content store keyed by file_url (Frappe dedupes by
content hash, so per-document keying would double-store shared bytes and
make deletion a refcount problem); offline previews, with uploads becoming
their own cache entry and pulled media cached on first view; a rejected
state so a permanently unuploadable file blocks with a reason instead of
retrying forever; a pick-time size guard; mediaStoreSize/clearMediaCache.

Breaking: uploadPendingForTopParent -> resolveForTopParent,
AttachmentPipeline requires db, findPendingForTopParent ->
findUnresolvedForTopParent, resolvePickedAttachment rethrows terminal
failures, schema v6 -> v7 (automatic).

Not verified on a device: the platform pickers, camera lost-capture
recovery, OpenFilex handoff, and the real logout wipe. Eviction is not in
this release, so media grows until logout or an explicit clear.

2209 tests pass; analyzer clean.
Adds doc/release-2.0/attachments.md covering the full lifecycle from a
camera tap to a linked Frappe File record and back onto the screen, with
six Mermaid diagrams: the end-to-end sequence, the resolution mechanism,
the attachment state machine, the stock relink decision tree, the
parent/child File-linking topology, and the preview resolution path.

The relinking section is the substantive part, because the upload happens
BEFORE the document exists. It traces all three things that have to
converge — the bytes (a File record), the reference (a docfield value) and
the ownership link (File.attached_to_*) — and why the SDK uploads with no
doctype/docname at all.

Grounded in the real Frappe source rather than described loosely, which
surfaced three details worth writing down:

- attach_files_to_document iterates Attach and Attach Image ONLY, so a url
  stored directly in an Image field gets no stock relink.
- is_private is recomputed from the url prefix during relink and overrides
  what was uploaded — the url wins.
- when the unattached row was already claimed, Frappe inserts a SECOND
  File row for the same file_url; bytes stay deduped, records do not.

Also documents why top_parent_uuid is what makes parent and child-row
attachments one operation, and states plainly what the SDK guarantees
versus what depends on mobile_control's relink_mobile_files being present.

Indexed from the release README's TL;DR, folder tree and reading table.
Covers N parent attach fields plus X child rows carrying M each — the
realistic survey shape, and the case the suite had no coverage for. Uses
distinct bytes per field so a mis-keyed writeback surfaces as the wrong url
in a slot instead of passing by coincidence.

Pins the behaviour that was previously only implied by the code:

- One top-parent query finds all N + (X*M) rows at any nesting depth, while
  each writeback still targets its own row — a child's field updates
  docs__order_item WHERE mobile_uuid = <child>, not the parent's table.
- Uploads are serial, so the first failure aborts the pass. Everything
  before it stays done with its column written back (progress is not thrown
  away), everything after stays pending and untouched.
- The next dispatch finishes the set and re-uploads nothing that already
  landed, so total uploads are attempts-on-the-failure plus one per
  attachment that had not yet succeeded — never one per attachment per
  dispatch.
- One terminal rejection out of the set blocks every later dispatch, even
  with a working uploader.

doc/release-2.0/attachments.md gains the matching section, including the
identity split between parent_uuid and top_parent_uuid, the serial-failure
diagram, the cost characteristics, and what happens when the same bytes are
attached to several fields.
The doc named 3 of the 17 test files that guard this pipeline, so a reader
changing any of it had no way to know what would catch them. Adds a table
of all of them with what each one is protecting.

Also states plainly what has NO coverage in this repo and therefore has to
be checked on hardware: the platform pickers, camera lost-capture recovery,
OpenFilex handoff of a cached file, real path_provider paths, and whether
mform_attachments/ is actually gone after a logout. The Frappe-side relink
contract is verified against three checkouts' source, not a running site.

Every file path in the doc is verified to exist.
…source

The previous version described relinking from the SDK's own code comments.
Reading mobile_control/attachment_relink.py and Frappe's
attach_files_to_document directly corrected two claims and added the part
that actually answers "how is each field identified".

file_url is the correlation token, not any device identifier. parent_uuid
and top_parent_uuid route the local writeback only and never cross the wire
for relink purposes; the server re-derives every coordinate by walking the
saved document and reading what each attach field holds. Three properties
follow, and they are why N parent fields and X child rows with M each all
land correctly: child identity never has to survive the round trip (Frappe
assigns child names at insert, and the hook reads them after), order and
reordering are irrelevant because matching is by value, and shared bytes
still resolve one-to-one because each upload produces its own File row and
the hook claims one per field.

Corrections:

- Stock inserts a NEW File when no unattached row remains, and recomputes
  is_private from the url prefix. mobile_control's child hook does neither —
  it skips, leaving the row unlinked, and leaves is_private alone. Both
  differences are now tabulated.
- The earlier "one File record per field because stock inserts a fresh one
  for the rest" was wrong about the mechanism. One upload always produces
  one File row even when bytes are deduped, which is precisely what lets
  each field claim its own.

Also documents the failure arithmetic: the hook claims one File per attach
field it walks, so fewer unattached rows than referencing fields leaves the
surplus unlinked — which is what the push gate exists to prevent.
… keys on

the uuid triple, not file_url

The previous text led with "file_url is the correlation token, not the device's
uuids" under a heading about how fields are identified. That is wrong for the
push pipeline and collapsed two separate stages into one key.

Identification happens twice:

Stage 1, on device, is entirely the uuid triple plus the fieldname, and
file_url is the VALUE written rather than any kind of key:

  top_parent_uuid  -> the set of attachments for one outbox row
  parent_doctype   -> which table (docs__order vs docs__order_item)
  parent_uuid      -> which row, matched against mobile_uuid
  parent_fieldname -> which column

Marker resolution in inlinePayload is likewise by row id, not by url. Also
worth stating: parent_uuid matches mobile_uuid, not `parent` or a server
name, which is what lets the writeback run before the document has ever
reached the server.

Stage 2 is the server's post-save relink hook. None of those four columns
cross the wire, so the hook re-derives the coordinate by walking the saved
document and matching each field's value against File.file_url. That is the
only place file_url is a matching key.

Adds a per-field diagram of the stage-1 fan-out for N parent fields and X
child rows with M each.
…rences

A full-flow verification pass turned up one inaccurate claim and two
dangling references. No behaviour changes.

kDefaultMaxAttachmentBytes was documented as "host-overridable". It is not:
it is the default for resolvePickedAttachment's maxBytes, and the only
callers are this SDK's own AttachField / ImageField, neither of which
exposes an override. A deployment whose System Settings max_file_size
differs cannot change it without threading a parameter through
FormScreen -> FormBuilder -> FieldFactory. Both the dartdoc and the release
doc's limitations section now say so, including what the consequence is in
each direction — refusing files the server would accept, or accepting ones
it rejects, which the pipeline already handles as a terminal rejection.

Stale references cleared: a dartdoc pointing at the renamed
findPendingForTopParent, and a test name still saying
uploadPendingForTopParent.
…che claim

Two problems, both framing rather than behaviour.

1. The doc read as though this were a sync/push feature. It is not — the work
   spans pick, save, push, read/preview, delete/discard and logout, and only
   one of those is the push pipeline. Adds a table of all six plus the
   corollary that matters: for a fully-ONLINE user the push pipeline's
   attachment half is inert. An online pick stores a real file_url,
   isLocalAttachmentPath excludes /files/, so no pending row is ever created
   and the gate passes with nothing to resolve. The corruption this release
   fixes could therefore only ever reach an attachment picked offline or one
   whose inline upload failed transiently — which is also why it survived so
   long. The read path is the mirror image: independent of push, and it runs
   for every document including pulled ones this device never created.

2. The CHANGELOG claimed "media this device uploaded becomes its own cache
   entry". That is true of the PUSH path only. resolvePickedAttachment deletes
   the staged copy after a successful inline upload instead of moving it into
   cache/, so a photo taken while online is uploaded and its local bytes
   discarded — previewing it later costs a download. Verified by driving
   resolvePickedAttachment with injected fakes and asserting the delete.
   Corrected in the CHANGELOG and recorded in the doc's limitations, where it
   is noted as a small change in the pick path rather than a design change:
   the bytes, the url and the store are all already in hand there.
…ectivity

A pick was decided by connectivity alone, so with offline-first mode ON and
the device connected the attachment uploaded inline during data entry. Two
problems with that.

It breaks the offline-first promise: the enumerator waits for a round trip
before the field populates, which is the experience offline mode exists to
prevent.

Worse, it put the attachment OUTSIDE the offline pipeline entirely. An
inline upload stores a real file_url, isLocalAttachmentPath excludes
/files/, so queueIfLocalAttachment never fires and no pending_attachments
row is created — no push gate, no rejected state, no cache entry, and no
record anywhere that the upload happened. A draft discarded afterwards left
an orphaned File on the server that the SDK could not even name, which is
strictly worse than the delete/re-pick orphans, where at least a row exists.
The document was queued while its attachment was already on the wire.

resolvePickedAttachment gains offlineModeEnabled, and AttachField /
ImageField / FrappeFormBuilder / FieldFactory gain an isOfflineMode
callback, read live off the repository so a mid-session toggle flip takes
effect on the next pick. Both default to "not offline mode", so a host that
wires neither keeps the previous inline-upload behaviour.

Under offline mode the document and its attachments now travel together,
which is the property the push gate depends on. The size guard still runs
before staging in both modes.

Covered by five pick-path tests for the mode/connectivity matrix plus an
end-to-end one asserting the row lands in `pending`, carries the top parent
uuid, the original filename and the size, and that the column holds a
marker — i.e. the attachment is genuinely back under the gate.
MediaResolver treated any non-marker value as a server reference. Between
pick and save the field holds a raw staged path, so resolving it prepended
the base url and issued a guaranteed 404 before falling back.

The preview still rendered, because the null result falls through to the
value itself, so this was invisible — but it meant a wasted request per
un-saved pick, and in offline mode it was exactly the network call the mode
exists to avoid. Deferring the upload at pick and then hitting the network
at preview would have left the offline-first promise half kept.

isLocalAttachmentPath already draws this line for the save path — it
excludes /files/, /private/files/, /api/method/, http(s):// and pending: —
so the resolver now uses the same classifier and returns a device path
as-is when the file exists, null when it does not, and never fetches
either way. A cache path is local by the same test and is likewise
returned directly.

Four tests, including one asserting the check does not over-match: a real
/files/ url must still be fetched.
… losing them

Two problems, one of them data loss already on this branch.

clearMediaCache() delegated to a whole-store wipe, so a host wiring an
obvious "Clear cached media" button silently destroyed every staged file —
the only copy of an attachment that had not uploaded yet. The storage split
makes the correct behaviour unambiguous: outbox/ is correctness storage,
cache/ is a performance copy of server media that is always re-fetchable.
It now clears cache/ and the media_cache index only and cannot touch
outbox/ or pending_attachments. clearAll is unchanged and remains correct
for logout and wipe, where clearing staged files is intended — it is now
that path's only caller.

Separately, staged files leaked whenever a pick never reached a save.
Staging happens at pick time, but the only thing that deleted a staged file
was keyed to a pending_attachments row, which is not created until save. So
picking twice without saving left both files on disk, and abandoning or
crashing out of a form stranded one — in outbox/, which is deliberately
never evicted, so nothing short of a logout ever reclaimed them.

The orphan rule is exact in all three clauses: inside outbox/, no
pending_attachments row, and not in the session live-set. The first stops
the SDK deleting a host-supplied gallery path, the second protects queued
work, and the third protects a pick that is live in an open form — which
has no row yet and would otherwise look exactly like an orphan. There is no
age heuristic anywhere, so nothing is ever deleted on a guess.

Two mechanisms use it. Replacing a field value deletes the file it
replaces, immediately. sweepOrphanedMedia() reclaims what nothing can catch
at the time: abandoned and crashed forms. mediaStoreUsage() reports staged,
cached and reclaimable bytes so a host can show "1.4 GB — 240 MB
reclaimable" rather than offering to free an unknown amount; orphanBytes is
a subset of outboxBytes and excluded from totalBytes.

Both reclaim APIs are now non-destructive by construction. The sweep never
throws, and deletes nothing when the referenced-set query fails, because an
empty result must never be mistaken for "everything is an orphan".

isStagedPath uses canonical containment rather than a string prefix: a
prefix match admits ../ escapes and a sibling outbox_old/, and this guard is
the only thing standing between the SDK and a user's gallery.

mediaStoreSize() is replaced by mediaStoreUsage(). Both are part of this
same unreleased release, so nothing has shipped against the old name.

Known and documented rather than silently left: cache orphans (a file in
cache/ with no index row, from a crash between writing and indexing) are
reclaimed by neither the sweep nor eviction, and are folded into Phase 2.
The sweep is host-triggered only; nothing runs on a timer or at startup.
An attachment could previously only be replaced, never removed — even on an
optional field. Both attach fields now show a remove control whenever there
is a value and the field is editable. A mandatory field can still be
cleared; requiredValidator catches it at save, which is the right place, and
blocking the clear would trap a user who wants to replace via
discard-then-pick.

Adding that surfaced three defects the UI alone could not have fixed.

The writeback was unconditional on the column's current value, so an upload
already in flight when the user discarded would complete and rewrite its url
into the field afterwards — restoring a removed attachment, or letting an
old upload claim a new pick's slot. It is now conditional on the column
still holding that row's pending:<id> marker. Same class of defect as the
original marker bug: a write that assumes nothing changed underneath it.

Clearing a field left its queued row alive with the column emptied, and the
push gate would then block that document indefinitely on an attachment
nothing referenced. LocalWriter now drops the row and its staged file when
an attach field arrives empty, scoped to null/empty only — a synced field
holds /files/... and its done row is the writeback backstop.

And making an explicit clear representable broke a value arriving AFTER the
first build: initialValue applies once and a field's key is stable, so an
asynchronously loaded document left the field blank. hasInteractedByUser is
the only signal that separates "the user cleared this" from "never touched";
both cases are now pinned by tests, because fixing either one naively
re-breaks the other.

The field also clears BEFORE the bytes are reclaimed. A failed reclaim
leaves an orphan the sweep collects; a failure aborting the clear would
leave the attachment in place while the user believes it is gone.

ImagePickSource lets the host offer gallery, camera or both. Global across
doctypes and fields and read live, so it can be flipped from a setting
without rebuilding; null means both, so existing hosts are unaffected.
camera removes the gallery route entirely, which is the point when a fresh
capture is required. Attach is unaffected — the system file picker has no
gallery/camera distinction.

onDiscarded was dropped before it shipped: it was never threaded to any
host, and onChanged(null) already carries the same signal.
23 commits: OAuth refresh classification, depends_on onError threading and
the extractEvalDocField fieldname gate, permission-denial query batching,
the format-check CI fix, and 2.0.0-beta.2.

Four conflicts, each resolved on its merits rather than by picking a side:

- attachment_pipeline.dart — kept this branch. Their only change to the file
  was a dart-format rewrap of a return statement; the rewritten pipeline
  supersedes it and nothing of theirs is lost.
- attachment_pipeline_test.dart — kept this branch. `db:` is a required
  parameter of the rewritten pipeline, so dropping it would not compile.
- frappe_sdk.dart — took THEIRS. Their change is a real doc comment
  explaining why sessionHealth is deliberately not disposed; this branch had
  only a format rewrap there.
- CHANGELOG.md — kept both. Each side appended to the same sections.

2315 tests pass, analyzer clean.
…eBytes

The pick-source hook added in 63592f1 could not be wired by any host.
`FormScreen.imagePickSource` takes a callback that must PRODUCE an
`ImagePickSource`, and neither the enum nor its allowsGallery/allowsCamera
extension was exported from the public entry point — so a host had no way to
name the value it was being asked to supply, short of an
`implementation_imports` violation. Every layer inside the package was wired
correctly (FormScreen -> FrappeFormBuilder -> FieldFactory -> ImageField, on
both the parent and child-table paths); the feature was complete internally and
unreachable externally, which is the same shape as an implementation with no
callers.

`MediaStoreUsage`, the return type of `FrappeSDK.mediaStoreUsage()`, was in the
same position but milder: inference still permitted `usage.orphanBytes`, but the
type could not be written in a field, a return type, or a variable declaration.

No existing test could have caught either, because every one of them imports
`src/` directly — exactly what a host may not do. `public_api_surface_test.dart`
imports ONLY the public barrel and fails to compile if either export is dropped.

Also here, all found while verifying the above:

- `test/ui/form/child_uuid_roundtrip_test.dart` failed the format gate. It came
  from df19398, while the sweep in 73a6c3c ran on om/bug_fix where that file did
  not exist — so the merge joined a now-real gate to a file the sweep never saw.
  One 81-column line.
- `MediaStore.storeSizeBytes()` lost its last caller in the mediaStoreSize ->
  mediaStoreUsage rename. `MediaStore.usage().totalBytes` is the same number by
  definition (outboxBytes + cacheBytes). Deleted; its tests go through usage().
- `mediaStoreUsage()` carried the replaced method's docstring above its own, the
  two contradicting each other.
- `RestHelper.uploadFile`'s comment still claimed the staged basename is a
  generated uuid. This branch changed exactly that: staging now keeps the user's
  filename and puts uniqueness in the generated parent directory.
- `late_pending_paths_test.dart` pins the ordering the offline preview depends
  on. FormScreen loads the marker->path map AFTER the first build, and
  MediaResolveBuilder memoises its future and does not restart on a pendingPaths
  change, so nothing recovers the preview except the synchronously recomputed
  attachmentDisplaySource fallback. Correct on both fields, and until now
  untested — removing that fallback would have broken offline preview silently.

flutter analyze clean, dart format gate clean, flutter test 2319/2319 pass.
- Introduced `resolvePulledMobileUuid` function to manage the precedence of local and incoming `mobile_uuid` values during data synchronization.
- Updated the `PullApply` class to utilize the new function for determining the correct `mobile_uuid` for both parent and child rows.
- Added comprehensive tests to ensure that non-empty incoming `mobile_uuid` values are adopted correctly, while empty or absent values result in new UUIDs being minted.
- Ensured that existing local rows retain their UUIDs during re-pulls, maintaining data integrity across sync operations.
@Omprakash-48 Omprakash-48 changed the title Feat/offline attachment pipeline feat(attachments): offline attachment pipeline with on-device media cache Aug 17, 2026
Omprakash-48 and others added 2 commits August 17, 2026 16:48
CI's `dart format --set-exit-if-changed .` gate failed on this file only.
Formatting-only change; `flutter test` still 2338 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…claims

Three statements in CHANGELOG contradicted the branch they ship with:

- "the attachment work ... is verified by tests and source review only —
  not on a device" predates the measured hardware run in attachments.md
  §16, and its test count (2209) predates the current 2338.
- the Added entry for clearMediaCache still warned that it is destructive
  to staged picks; the fix in this same release scoped it to cache/ and the
  media_cache index, so only logout(clearDatabase: true) clears staged files.
- two upgrader notes still named mediaStoreSize(), which the Changed section
  records as replaced by mediaStoreUsage().

Docs only. flutter test 2338 passing; dart format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@deepak-dhwani deepak-dhwani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Round 1 — e665ddf · Request changes

The attachment pipeline is well-built: the commitment model is right, the crash-point ordering is
explicit, and the storage-lifetime rules are the most careful part of the change. The blocker is not
in the attachments — it is the mobile_uuid adoption that rides along with them.

Scope of this review, stated so it isn't overread. Static only — no Dart toolchain on my side, so
the reported 2338 passing tests are not reproduced. I read attachment_pipeline.dart,
push_engine.dart (the gate + writeback), pull_apply.dart, media_store.dart,
media_resolver.dart, attachment_paths.dart, attachment_error_classifier.dart,
pending_attachment_dao.dart, local_writer.dart's producer, the schema/migration, and the barrel.
I did not review image_field.dart (527 lines, the largest single file), attach_field.dart,
form_screen.dart, child_table_field.dart, the 38 test files, or attachments.md. The UI layer is
unreviewed and should not be read as cleared.


🔴 H1 — resolvePulledMobileUuid adopts an unvalidated server string into the PRIMARY KEY

The precedence (local → incoming → mint) is the right fix for the problem described. But mobile_uuid
is TEXT PRIMARY KEY on both parent and child mirrors (parent_schema.dart:23,
child_schema.dart:19), and adoption is unconditional on anything except emptiness:

// pull_apply.dart:64
String resolvePulledMobileUuid({...}) {
  if (localUuid != null && localUuid.isNotEmpty) return localUuid;
  final adopted = incoming?.toString();
  if (adopted != null && adopted.isNotEmpty) return adopted;   // ← no shape, no uniqueness
  return uuidGen.v4();
}

So a server-supplied value now becomes the local primary key. Two things follow, and they are
independent.

(a) Two server documents with the same mobile_uuid — different outcomes per path, one of them silent

The four write sites don't agree on conflict policy:

Site Path Insert Outcome on a duplicate adopted uuid
:420 parent sequential :462 batch.insert (default abort) batch.commit (:559, no continueOnError) throws — page apply fails
:610 parent bulk :645 ConflictAlgorithm.**replace** second row silently overwrites the first
:530 child sequential :555 default abort commit throws
:667 child bulk (localUuid: null) :693 default abort commit throws (:698)

The bulk row is the bad one. mobile_uuid is the PK and the two rows carry different server_names,
so replace means one server document's local mirror is destroyed and replaced by another's, with
no error anywhere
. The document simply isn't on the device. Before this PR that was impossible by
construction — every pulled row minted its own v4.

The sequential and child sites fail loudly instead, but they fail permanently: the page throws on
every retry for as long as the server data stays that way.

The trigger is not exotic. Frappe's Desk Duplicate and Amend copy custom field values,
mobile_uuid included — so duplicating a mobile-created document in Desk produces a second document
with the same parent and child mobile_uuids. A Data Import with a mobile_uuid column does the
same. Then any fresh-install / post-logout(clearDatabase: true) / new-device pull — precisely the
scenario this fix targets, since that is when adoption fires at all — hits it.

existingUuids cannot catch this. It is keyed server_name IN (this page's names)
(:587-592), so it answers "what uuid does this server_name already have", never "is this uuid
already taken by a different one".

(b) It breaks an invariant the codebase states in prose and relies on for identity

uuid_rewriter.dart:73-80 says it outright:

Frappe server names are never UUID-shaped, and SDK mobile_uuids always are — so it catches
local refs populated by paths that never fire onIsLocalChanged (fetch_from, defaults, programmatic
prefill, back-reference Links). Relying on the flag alone let those raw UUIDs reach the server,
which rejects them as unknown names.

Adopt HSFM-2026-00042 and looksLikeMobileUuid returns false, so the rewrite falls back to
__is_local alone — which that same comment documents as insufficient for four specific paths, with
the consequence spelled out. form_screen.dart and link_field.dart gate on the same proxy.

Fix

Gate adoption on shape and uniqueness — both are cheap, and either alone leaves half the hole:

String resolvePulledMobileUuid({
  required String? localUuid,
  required Object? incoming,
  required Uuid uuidGen,
  required bool Function(String candidate) isFree,   // new
}) {
  if (localUuid != null && localUuid.isNotEmpty) return localUuid;
  final adopted = incoming?.toString().trim();
  if (adopted != null &&
      adopted.isNotEmpty &&
      looksLikeMobileUuid(adopted) &&   // restores the uuid_rewriter invariant
      isFree(adopted)) {                // no OTHER local row holds it
    return adopted;
  }
  return uuidGen.v4();
}

isFree wants a real query — SELECT 1 FROM <table> WHERE mobile_uuid = ? excluding this
server_name — not a reverse scan of existingUuids, for the reason above. In the bulk path it can
be batched once per page against the incoming uuid set.

Separately: please make the two parent paths agree on conflict policy. ConflictAlgorithm.replace on
a primary key the server can now influence is a silent-overwrite primitive, and having one path abort
and the other replace means the same server data corrupts differently depending on which code path
ran.

Worth adding to pull_apply_mobile_uuid_adoption_test.dart: two server rows sharing a non-empty
mobile_uuid, asserted through both _applyPageInTxnBulk and _applyPageInTxnSequential, plus a
non-UUID-shaped incoming value asserted to mint rather than adopt.


🔴 H2 — ResolveMediaFn should be exported in this PR, not after it

The description already makes the case, better than I would: FieldFactory is exported bare
(frappe_mobile_sdk.dart:109) and documented as overridable, createField takes
ResolveMediaFn? mediaResolver (field_factory.dart:119), the typedef lives in
src/services/media_resolver.dart, Dart does not re-export a library's imports, and a real consumer
app that subclasses FieldFactory cannot compile without an implementation_imports violation.
Confirmed all of that.

What doesn't follow is leaving it. This PR adds two barrel exports for exactly this reason, in a
single hunk, and the missing line goes two lines below them:

export 'src/models/image_pick_source.dart' show ImagePickSource, ImagePickSourceHelpers;
export 'src/models/media_store_usage.dart' show MediaStoreUsage;
+ export 'src/services/media_resolver.dart' show ResolveMediaFn;   // ← this PR

The description says "2.0.0 should not ship claiming that type is host-facing until it lands" — so
this is a known-broken public API being deferred one line short of fixed, in the file already being
edited. Please add it here and let public_api_surface_test.dart start guarding it now.


🟡 M1 — cachePathFor is called with and without sourcePath, so the index can name a file that was never written

// media_store.dart:140 — inside moveToCache, WITH the source
final dest = await cachePathFor(fileUrl, sourcePath: stagedPath);

// attachment_pipeline.dart:205 — recomputed, WITHOUT it
if (moved) cachedPath = await MediaStore.cachePathFor(fileUrl);

cachePathFor borrows the extension from sourcePath when the url has none — its own docstring names
the case ("a download_file proxy url that carries the name in its query") and
media_store_test.dart:79 pins the branch. For such a url the bytes land at <digest>.jpg while
media_cache.local_path records <digest>.

Consequences: MediaResolver finds the row, File(hit.localPath).exists() is false, treats it as a
miss and re-downloads — every view, forever. And the uploaded original at <digest>.jpg becomes
unreachable bytes that no API reclaims: sweepOrphans walks outbox/ only (media_store.dart:296),
so clearCache() is the only thing that will ever remove it. media_resolver.dart:95 is
self-consistent, so this is the one divergent call site.

Fix: pass sourcePath: p.localPath at attachment_pipeline.dart:205 — or better, have
moveToCache return the destination it actually used, so the two can't drift again.


🟡 M2 — every 417 is terminal, which inverts the classifier's own stated policy

isTerminalAttachmentError sets out the principle and then contradicts it:

Defaults to FALSE for anything unrecognised. A wrongly-transient error costs one retry; a
wrongly-terminal one strands the user's file with no automatic recovery. Fail toward retrying.

if (error is ValidationException) return true;   // ← every 417

417 is not the oversized-file status; it is where every frappe.throw lands. A site storage-quota
throw, a custom File hook, any transient server-side business rule — all become rejected, which is
never auto-retried and blocks the parent document until the user re-picks the file. That is the exact
cost the docstring says to avoid, applied to the broadest error class Frappe has.

_terminalMarkers already names the two genuinely terminal cases, but it is only consulted on the
flattened-exception path at the bottom — after this branch has returned.

Fix: for ValidationException, consult _terminalMarkers against the message and default to
transient. That reuses the list that already exists and makes the behaviour match the doc.


🟡 M3 — the marker prefix has four spellings, two of them literals

kPendingMarkerPrefix exists (attachment_paths.dart:30) and is used at two of the four sites that
must agree byte-for-byte:

Site Spelling
local_writer.dart:307 — the producer 'pending:$id' literal
attachment_pipeline.dart:319-320inlinePayload, the "loud not silent" backstop 'pending:' literal
attachment_paths.dart:19isLocalAttachmentPath's nonLocalPrefixes 'pending:' literal
attachment_pipeline.dart:251 — the writeback WHERE kPendingMarkerPrefix
media_resolver.dart:62 — display resolution kPendingMarkerPrefix

Change the constant and the two halves break in opposite directions, both quietly. The writeback
WHERE starts matching nothing. And MediaResolver stops recognising the marker → falls through to
isLocalAttachmentPath, whose literal still matches → returns not-local → the marker goes to the
cache/network path, and a local id is issued as an HTTP request — the one thing that function's
comment says must never happen ("Falling through to the network for a malformed marker would turn a
local id into a request path"
).

Nothing is wrong today. But this PR exists because markers reached the server, and the invariant that
prevents it is currently spread across four uncoordinated string literals. Using the constant at all
four sites costs nothing and makes the compiler enforce it.


🔵 Low

L1 — kDefaultSyncBackoff means a different attempt count in each of its two consumers.
attachment_pipeline.dart:154 is attempt < backoff.length → 3 attempts, with delays only after
attempts 0 and 1, so the third entry (10s) is never used as a delay. push_engine.dart:568 is
attempt <= networkBackoff.length → 4 attempts. The docstring says "3 attempts at 2s, 5s, 10s",
which is accurate for neither. Trimming the list to [2s, 5s] would silently drop the pipeline to two
attempts, because there the list length is the attempt count.

L2 — downloads are unbounded while picks are capped. A pick is size-guarded at 10 MB before
staging, but MediaFetchFn is Future<List<int>?> and MediaResolver.resolve buffers the whole body
before writeAsBytes. Nothing caps a Desk-uploaded attachment, and the verified device is Android
8.0.0 / API 26. Worth a cap on the resolve path, or a streaming fetch.

L3 — three copies of the fetch-URL logic. frappeFileFetchUrl's own NOTE records that
AttachField._fullFileUrl and ImageField._fullImageUrl still hold private copies, deferred to keep
the change small. Reasonable at the time, but /private/files/ has to route through download_file
to carry auth, so a drift between the three is a private-file 404. Now that the shared helper exists,
worth collapsing onto it.


✅ What holds up

  • The commitment model. "A server_file_url committed to SQLite is the correctness boundary",
    with the four steps numbered by crash point and the explicit note that the pre-commit window
    degrades to a re-upload that Frappe's content-hash dedup absorbs. Upload and document push as
    independent commitments is the right decomposition.
  • The conditional writeback. WHERE mobile_uuid = ? AND "<field>" = 'pending:<id>' with the
    reasoning written down — an unconditional update is the same mistake that produced the original
    bug, and matching nothing when the user re-picked or discarded is the correct outcome. This is the
    best single decision in the PR.
  • isStagedPath uses canonical containment, not a prefix match. p.canonicalize + p.isWithin
    rejects outbox_old/ and resolves .., and the comment says exactly why it matters: callers use it
    to decide whether to delete, and a host may point a field at /sdcard/DCIM/holiday.jpg. Getting
    this wrong is unrecoverable; it is right, and it is right for a stated reason.
  • Register-before-create in stageToOutbox. Adding to _stagedThisSession before the file
    exists closes a window where a sweep could see a file with neither a row nor a live-set entry. Also
    the note that the set is empty after restart and those files are then genuinely orphaned — so they
    become sweepable exactly when they should.
  • deleteOutboxCopy's dirname guard. Only prunes a directory sitting directly under outbox/, so
    an arbitrary path can't delete something else.
  • Sweep guards are exact, not heuristic. No age rule, so a pick sitting in an open form is never at
    risk; and the explicit contract that the caller must not pass an empty referencedPaths when the
    query failed, honoured in sweepOrphanedMedia.
  • The clearCache / clearAll boundary, and the logout fix behind it — the store surviving logout
    and leaving one user's photos readable on a shared device is a real security bug, and the fix puts
    the destructive path where it belongs.
  • Deadlock notes at both pre-resolve sites (attachment_pipeline.dart:218,
    local_writer.dart:232): querying the outer Database while holding a txn hangs on sqflite's
    non-reentrant lock, and it's called out as a Dart deadlock rather than SQLITE_BUSY. That
    distinction saves the next person an afternoon.
  • public_api_surface_test.dart — a test that imports only the public entry point, so a dropped
    export fails to compile. That is the right shape of guard, and the observation that every other test
    imports src/ directly is exactly why the missing exports were invisible.
  • The migration. Properly versioned v6→v7, IF NOT EXISTS throughout, ALTER wrapped for
    re-entry, schema_version written in the same transaction, and a re-entrancy test.
  • The verification section names what was not exercised on hardware — offline capture, offline
    preview from outbox/, BlockedByUpstream, the failed/rejected states, reclaim, camera
    lost-capture, OpenFilex handoff. And §16.2's EXIF-strip note (device cache holds the pre-strip
    original, server holds the re-encode) is the kind of thing most PRs discover in production.

Gate

Area Status
Attachment commitment model
Push gate / marker containment
Writeback atomicity + conditionality
Staged-file lifetime & reclaim
Logout / wipe boundary
Schema migration v6→v7
Cache index ↔ filesystem agreement 🟡 M1
Terminal/transient classification 🟡 M2
Marker constant discipline 🟡 M3
mobile_uuid adoption 🔴 H1
Public API surface 🔴 H2
UI layer (image_field, attach_field, form_screen) ⚪ not reviewed

Requesting changes on H1 and H2. H2 is one line in a file this PR already edits. H1 is the real
one, and I'd genuinely consider splitting it out: the attachment pipeline is independently verified,
well-tested and ready, while the mobile_uuid adoption is a change to primary-key identity that
deserves its own diff, its own collision tests, and its own bench run. It is already flagged as "also
in this PR (not attachments)" — that instinct was right, and the reason it should be separate is that
it is the only part of this change that can silently lose a document.

M1–M3 and L1–L3 are all small and can land in the same round.

Nine fixes from the round-1 review. Kept as one commit because
attachment_pipeline.dart and CHANGELOG.md each carry several of them and
non-interactive hunk splitting would risk mis-staging.

Public API
- Export ResolveMediaFn. FieldFactory is exported bare and documented
  "Override this method to customize field creation", and createField takes
  a ResolveMediaFn? — but the typedef lived in src/ and Dart does not
  re-export a library's imports, so overriding it required an
  implementation_imports violation. Same defect class as the ImagePickSource
  / MediaStoreUsage gap, missed by that fix. public_api_surface_test now
  fails to compile if the export is dropped.

mobile_uuid identity
- Gate PARENT adoption on UUID shape. looksLikeMobileUuid is what
  UuidRewriter calls "the complete detector" for a local Link reference, and
  PushEngine's dependency scan uses the same predicate to tier a row behind
  the row it points at; a non-UUID primary key is invisible to both.
  Children deliberately keep the pre-existing emptiness-only test — child
  adoption is not new and is load-bearing, since a Link can reference a child
  by mobile_uuid and gating it re-breaks the orphan-Link regression pinned by
  pull_apply_test. Uniqueness is not re-checked client-side: the column
  carries a real UNIQUE index server-side (a duplicate write fails with
  MariaDB 1062 on both parent and child tables).
- Send each child's mobile_uuid in the push payload. The parent's was
  re-added after the system-column strip; children were stripped and never
  restored, so the server stored NULL and children had no identity across a
  round trip. Sent on INSERT and UPDATE; the child's own uuid, never the
  parent's (which would collide on that UNIQUE index); omitted when blank,
  because MariaDB permits many NULLs but not many empty strings. Verified
  end to end against a live v16 bench. Rows written before this keep NULL and
  match by position until their parent is next pushed.

Media cache
- Record the cache path moveToCache actually used. cachePathFor borrows the
  extension from the source when the url carries none, so recomputing without
  it named <digest> while the bytes landed at <digest><ext> — a media_cache
  row pointing at a file that was never written, which reads as a permanent
  cache miss and strands the bytes where sweepOrphans (outbox/ only) cannot
  reclaim them. moveToCache now returns String? so the two cannot drift.
- Bound the download path. Picks were capped at 10 MB but downloads were
  unbounded on an API 26 floor. FormScreen's fetcher now streams and refuses
  on Content-Length before reading a chunk, re-checking mid-stream for
  chunked responses; MediaResolver.maxFetchBytes additionally keeps an
  oversized body out of the cache. The download ceiling is deliberately
  higher than the pick ceiling.

Error classification
- Classify 417 by the exception Frappe names, not by the status. Every
  ValidationException was terminal, which inverted this module's own
  "fail toward retrying" policy across Frappe's broadest error class — 417 is
  where all frappe.throw lands. The stated justification was also wrong: an
  oversized upload never reaches 417, because Frappe sets werkzeug's
  max_content_length to the same get_max_file_size() (app.py:194) and the
  HTTP layer rejects with 413 first (confirmed on a live bench with a 30 MB
  upload). exc_type is set unconditionally on a v1 error body and RestHelper
  hands the whole body to ValidationException.errors, so this is structured
  rather than a substring guess; the v2 errors[].type shape is read too.

Consistency
- Use kPendingMarkerPrefix at all five marker sites. Three were literals, so
  changing the constant would have broken the writeback and the display
  resolver in opposite directions, the latter issuing a marker as an HTTP
  request.
- Collapse AttachField._fullFileUrl and ImageField._fullImageUrl onto
  frappeFileFetchUrl after verifying the three were logically identical.
  /private/files/ must route through download_file to carry auth, so a drift
  between copies was a private-file 404. The shared helper had no direct
  coverage; every branch is now pinned.
- Correct kDefaultSyncBackoff's docstring, which claimed an attempt count
  neither consumer had. The loops differ legitimately and are left alone —
  making them agree would change push retry counts. Also retire a stale
  ThreeWayMerge note claiming mobile_control still had to ship mobile_uuid
  onto child doctypes; it already did.

Full suite: 2364 passing. analyze clean.
@Omprakash-48

Copy link
Copy Markdown
Collaborator Author

Round 1 addressed — 2677711

All nine findings actioned. Suite: 2,364 passing, analyze clean.

H1 — took the shape gate, skipped the uniqueness gate. mobile_uuid is unique: 1 with a real enforced index (a duplicate write fails with MariaDB ERROR 1062, parent and child), so two server docs can't share one and the replace path isn't reachable from adoption. Parent adoption now requires looksLikeMobileUuid — H1(b) was the real defect. Children deliberately excluded: pull_apply_test.dart's orphan-Link regression test uses a non-v4 uuid and asserts adoption, and children have always adopted any non-empty value. Gating them would re-break that.

H2 — done. One line. Test fails to compile without it; the consumer app now builds against the barrel alone.

M1 — done. moveToCache returns the destination it used, so the caller can't recompute it wrong.

M2 — done, structurally rather than by substring. exc_type is available (response.py:52 sets it unconditionally; RestHelper passes the whole body through). Also: oversized never reaches 417 — werkzeug's max_content_length is the same get_max_file_size() (app.py:194), so a 30 MB upload returns 413, already terminal via the 4xx rule. Dropping the blanket rule can't weaken it.

M3 — done. All five sites on the constant.

L1 — docstring only. Both loops are correct for their own purpose; only the shared comment fit neither. Left the loops alone.

L2 — bounded in two places. You're right a cap in resolve() doesn't fix the OOM, so the fetcher now streams and refuses on Content-Length. The resolver cap keeps oversized bodies out of the cache.

L3 — collapsed, after diffing all three and pinning every branch of frappeFileFetchUrl first (it had no coverage).

Also here: child mobile_uuid now ships on the wire, closing the round trip. Verified against a live bench.

Two heads-ups: L2/L3 touch form_screen.dart / attach_field.dart / image_field.dart — the layer you didn't review. And H2 must land with the consumer app change; it currently resolves the SDK from a different ref.

`dart format --output=none --set-exit-if-changed .` failed CI on the nine
files touched by 2677711. Formatting only — no behaviour change, and the
suite still reports 2364 passing.

@deepak-dhwani deepak-dhwani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two new commits (2677711, 982a5f7). All nine round-1 items verified fixed in code.

grep -rn "'pending:" lib/ now returns exactly one hit — the constant's own definition.

Two corrections to my round 1

M2 — you corrected my premise. I accepted the docstring's claim that 417 is how an oversized upload arrives and only argued about breadth. You checked: Frappe sets werkzeug's max_content_length to the same get_max_file_size() (app.py:194), so the HTTP layer returns 413 first — verified with a 30 MB upload. The justification was wrong, not merely over-wide, and classifying on exc_type is better than the narrowing I asked for.

H1 — I was wrong to frame the uniqueness fix as "just add the check." The bulk path has no existing.isEmpty branch: ConflictAlgorithm.replace at :688-691 is its upsert, so it can't become abort, and replace can't distinguish a legitimate same-uuid update from a pathological collision. The only client-side discriminator is the per-row query you declined — so relying on the server-side index is the pragmatic call. My Duplicate/Amend trigger doesn't survive it either: with the index in place, Frappe's own Duplicate fails 1062 first.

The child asymmetry I was going to raise — withdrawn. Both child paths set server_name from cr['name'] (:582, :719), so the reachability mitigation extends to children; and the preservation fixture uses a non-v4 uuid because deployments exist with that shape, so gating would break real installs.

Your find beats mine

Child mobile_uuid never reaching the wire is bigger than anything I raised, and it retro-corrects my H1: with children round-tripping as NULL, the two child write sites in my table were inert in practice, not live. My finding was right about the parent paths and wrong to weight the child ones equally.

One residual — a sentence, not code

The CHANGELOG states the precondition (server-side UNIQUE index) but not the consequence if unmet: on the bulk path replace destroys one document's local mirror silently, while the sequential path aborts loudly. Since this SDK ships to pub.dev standalone and the guarantee is owned by mobile_control, a site provisioning mobile_uuid without unique: 1 gets silent single-document loss. Worth one line beside the existing note.

Why this isn't an approve yet

image_field.dart (527 lines) is still unread, along with child_table_field.dart, the 38 test files, and attachments.md. That's the pick / discard / re-pick / preview lifecycle and the hasInteractedByUser change — the part most likely to hold a widget-lifecycle bug and the part with the least review.

…bsent

Round-2 review residual: both notes stated the server-side UNIQUE index as a
precondition but not the consequence if it is unmet. The index is owned by
`mobile_control` (its `_MOBILE_CUSTOM_FIELDS` declares `mobile_uuid` as `Data`
with `"unique": 1`, for workspace doctypes and their children) while this is a
standalone package dependency carrying no `publish_to: none` — so nothing here
can enforce it, and a site provisioning the field without `unique: 1` needs to
know what it gets.

Verified against the real `PullApply.applyPage` with two rows of distinct `name`
and one shared `mobile_uuid`, not reasoned from the source. Three outcomes,
exactly one silent:

* Bulk parent path (initial sync only) — silent. `ConflictAlgorithm.replace`
  drops the first document's mirror with no log and no error. The bulk safety
  pre-check does not prevent it: it counts locally-dirty and local-only rows, so
  a clean `synced` row is replaced without objection. Afterwards the two
  documents share one row and swap on every page carrying either — those swaps
  DO log, via the cross-device-clash tripwire, because the `mobile_uuid`
  fallback query now matches. The bulk page is the only unannounced event.
* Sequential, both rows in one page — loud. The plain `INSERT`s are queued in
  `txn.batch()` and so invisible to that fallback query; the second raises
  `UNIQUE constraint failed: <table>.mobile_uuid` at `batch.commit`, which has
  no `continueOnError`. The page rolls back and both pull paths stop the doctype
  without advancing its cursor — `PullEngine` reports it failed mid-pull,
  `SyncService` raises a `SyncError` — so it re-fails every cycle. Reported, not
  lost.
* Sequential, rows in different pages — neither. The fallback matches the first
  row, the tripwire logs, and the second document overwrites it in place.

Children always take the loud path: the bulk child insert is a plain `INSERT`.

Applied in both places because `resolvePulledMobileUuid`'s docstring carried the
same unqualified "cannot present the same value" claim at the write site, and is
what the next maintainer reads there.

Comments only — no behaviour change. Full suite 2364/2364, analyze and format
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Omprakash-48

Omprakash-48 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Commit ca69c95 → omprakash/feat/offline-attachment-pipeline (PR 107's head). Two files, comments only, no behaviour change: CHANGELOG.md:30 and resolvePulledMobileUuid's docstring in lib/src/sync/pull_apply.dart. Both now state what happens if the server-side mobile_uuid UNIQUE index is missing.

What the verification changed — I probed the real code instead of reasoning about it, which corrected the reviewer once and me three times:

  • Reviewer's "sequential aborts loudly" — true only same-page. Three outcomes, not two: bulk = silent replace; sequential same-page = UNIQUE constraint failed, page rolled back, reported and stalled on both pull paths; sequential across pages = tripwire logs, in-place overwrite.
  • My "nothing backfills it" — wrong. The two documents share one row for the install's life and swap on every page carrying either; those later swaps do log.
  • My assumption the bulk pre-check would catch it — it doesn't. It counts dirty and local-only rows, so a clean synced row gets replaced silently.
  • My "ships to pub.dev standalone" — unverifiable; reworded to what is (no publish_to: none, git-ref dependency, index owned by a separate Frappe app). Also verified mobile_control's "unique": 1 in code rather than inheriting the claim.

@deepak-dhwani

Copy link
Copy Markdown
Contributor

Round 3 — ca69c95 · All findings cleared

Every item from rounds 1 and 2 is now closed. Two of your fixes landed better than what I asked for,
one of your own finds is bigger than anything I raised, and you corrected a premise I had wrong.

Basis: static only — no Dart toolchain here, so the reported 2,364 passing tests are not
reproduced. e665ddf..ca69c95 is 27 files, +866 / −119.


Round-1 items — all verified

ID Finding Fix
H1 Unvalidated server string into the PK parent shape gate; uniqueness declined with evidence
H2 ResolveMediaFn unexported barrel :38 + compile-time guard
M1 cachePathFor called with and without sourcePath moveToCache returns String?
M2 Every 417 terminal classified on exc_type, not status ✅ better
M3 Four spellings of the marker prefix constant at all five sites
L1 kDefaultSyncBackoff attempt count per-consumer table in the docstring
L2 Downloads unbounded streamed, bounded twice
L3 Three copies of the fetch-URL logic both private copies delegate
R2 UNIQUE-index precondition not stated three outcomes enumerated and verified ✅ better

H2 — the guard test is the right shape: it annotates rather than infers, then assigns a
conforming closure, so it breaks both if the export is dropped and if the typedef drifts.

M1 — returning the destination beats threading sourcePath through. The two paths can no longer
disagree, which was the actual defect.

M3grep -rn "'pending:" lib/ now returns exactly one hit: the constant's own definition.

L1 — better than making the loops agree, for the reason you give: forcing agreement would change
push retry counts nobody asked for. The list genuinely is two contracts; documenting both is the fix.

L2Content-Length refusal before the first chunk, mid-stream re-check for chunked responses
that under-report, maxFetchBytes keeping an oversized body out of the cache, and
client?.close() in a finally. The docstring is honest that the second stage bounds the cache,
not the allocation. The 25 MB download ceiling being deliberately above the 10 MB pick ceiling is
right and justified where a reader will find it.


The round-2 residual — closed, and more thoroughly than asked

I asked for one sentence on the consequence of the UNIQUE precondition being unmet. What landed
enumerates three outcomes and marks which is silent, verified rather than reasoned. I checked the two
load-bearing claims:

  • "the pre-check counts locally-dirty and local-only rows, so a clean synced row is replaced
    without objection"
    — confirmed. The bulk pre-check is a single COUNT over
    sync_status IN (_locallyDirtyStatuses + 'deleted'), so a synced row is not counted.
  • "both pull paths stop the doctype without advancing its cursor … reported, not lost"
    confirmed at pull_engine.dart:423: "Mid-pull failure: do NOT persist cursor", and the
    setLastOkCursor write at :406 sits before it on the success path only.

Narrowing the silent window to the bulk page itself — because every later flip does log through the
clash tripwire once the fallback query starts matching — is a sharper statement than my finding was,
and it is the difference between "silently wrong forever" and "one unannounced event". Noting that
children always take the loud path, and closing with "do not relax the server-side index expecting a
client-side net"
, puts the constraint where the next person will hit it.

I also accept that I framed H1's fix wrongly. ConflictAlgorithm.replace at the bulk parent insert
is that path's upsert — it has no existing.isEmpty branch — so it cannot simply become abort,
and replace cannot distinguish a legitimate same-uuid update from a pathological
same-uuid/different-server_name collision. The only client-side discriminator is the per-row query
you declined, so relying on the server-side index is the pragmatic call rather than a shortcut. My
Duplicate/Amend trigger doesn't survive it either: with the index in place, Frappe's own Duplicate
fails 1062 before a second such document can exist.

The child asymmetry — objection withdrawn. I was going to raise that this PR makes child adoption
newly live (it does, since children previously round-tripped as NULL) and that leaving it ungated keeps
the blind spot open for the case you cite as load-bearing. Two things you documented close it: both
child write paths set server_name from cr['name'] (:582, :719), so the reachability mitigation
extends to children; and the preservation fixture uses a deliberately non-v4 uuid because deployments
exist with that shape
, so gating would break real installs. History over taste, as you put it.


Two corrections to my round 1

M2 — you corrected my premise, and the fix is better for it. I accepted the docstring's claim that
417 is how an oversized upload arrives and only argued about breadth. You checked: Frappe sets
werkzeug's max_content_length to the same get_max_file_size() (app.py:194), so the HTTP layer
returns 413 before MaxFileSizeReachedError can be raised — confirmed with a 30 MB upload on a
live bench. The branch's stated justification was wrong, not merely over-wide. Classifying on
exc_type from the decoded body (v2's errors[].type read too, substring check demoted to a fallback
for a body carrying no type) is structured rather than a guess, and 413 lands on the 4xx rule where it
belongs.

Child mobile_uuid never reaching the wire is a better catch than anything in my round 1 — and it
retro-corrects my H1. PayloadAssembler re-added the parent's uuid after the system-column strip and
the child loop never put it back, so, as your CHANGELOG puts it, "the pull path's child adopt branch
could only ever fire for a value someone had set in Desk."
My "four write sites" table therefore
overstated the child half: those two sites were inert in practice, not live. The finding was right
about the parent paths and wrong to weight the child ones equally.

The three deliberate details all check out: the child's own uuid rather than row.mobileUuid (which
would collide on the second child), omitted-when-blank rather than '' (MariaDB permits many NULLs
but not many empty strings in a unique index), and the set_only_once check confirming re-sends are
safe. No backfill, self-healing on next push, is the right migration posture. Retiring the stale
ThreeWayMerge note rather than leaving a comment claiming work already done is the kind of cleanup
that usually gets skipped.


Why this stays CHANGES_REQUESTED — and it is not a finding

Nothing I have reviewed is unresolved. The gate is held for one reason only: a section of this PR
that I have not read.

Unchanged from round 1:

  • image_field.dart — 527 changed lines, the largest single file in the diff, still unread.
  • attach_field.dart, child_table_field.dart — read only where later rounds touched them
    (the frappeFileFetchUrl delegation).
  • form_screen.dart — read only _fetchMediaBytes.
  • The 38 test files and attachments.md (803 lines).

That covers the pick / discard / re-pick / preview lifecycle and the hasInteractedByUser change —
the part of this PR most likely to hold a widget-lifecycle or state bug, and the part with the least
review. It is also the part your own verification section lists as unit-tested only: offline capture,
offline preview from outbox/, re-pick/discard reclaim, camera lost-capture recovery, OpenFilex
handoff.

So: no changes are being requested of you. Either I do that pass next, or you tell me to approve on
the strength of the device run and the 2,364-test suite and I'll do that with the gap stated in the
approval. Your call — please don't sit blocked waiting on me if you'd rather ship it.


Gate

Area R1 R2 R3
Attachment commitment model
Push gate / marker containment
Cache index ↔ filesystem 🟡 M1
Terminal / transient classification 🟡 M2
Download bounds 🟡 L2
Fetch-URL duplication 🟡 L3
mobile_uuid shape invariant 🔴 H1
mobile_uuid uniqueness 🔴 H1 🟡 undocumented ✅ three outcomes stated
Child mobile_uuid round trip ⚪ missed ✅ your find
Public API surface 🔴 H2
UI layer ⚪ not reviewed still not reviewed

Worth keeping from these three rounds: every round-1 finding was two call sites that had to agree and
didn't — a path computed with and without an argument, a prefix spelled two ways, a backoff list read
as two contracts, three copies of a URL builder, two conflict policies. Four of the fixes deleted the
second copy rather than syncing it (moveToCache returning its own destination, the constant, the two
delegations), and the one that couldn't be deduplicated got its asymmetry written down with the
failure mode enumerated. That is the right disposition of the class, and it is why I would not expect
it to recur here.

Discarding or replacing an attachment after saving destroyed the only copy
of bytes a committed pending_attachments row owned, and blocked the
document on a file that no longer existed.

MediaStore.discardReplacedValue deletes any path inside outbox/ and skips
the database on the reasoning that a saved attachment's column holds
pending:<id>, never a path. That is true of the COLUMN, which LocalWriter
rewrites inside the save transaction. It is not true of the value a form
hands over: the field pins what the user picked (hasInteractedByUser beats
a later widget value, deliberately, so an explicit clear survives an async
document load) and nothing writes the marker back into an open form. So
after a save the field still offers the raw staged path while the column
and the queue row have moved on.

Verified rather than reasoned: after didChange('/.../outbox/u1/photo.jpg'),
a rebuild supplying 'pending:42' leaves fieldState.value at the raw path.

The consequence was not a leak. AttachmentPipeline uploads
fileFromPath(localPath); a missing file is not a terminal error, so it
exhausted the backoff, markFailed blocked the parent document, and push
does not auto-retry. A later save repaired it (LocalWriter drops the row
when the field arrives empty); leaving the form without saving, or a push
landing in the gap, did not.

The fix
- ReclaimAttachmentFn, defaulting to MediaStore.discardValue so a host with
  no queue to consult is unchanged.
- OfflineRepository.reclaimDiscardedAttachment consults
  PendingAttachmentDao.referencedLocalPaths() and refuses a referenced
  file. Canonicalised on both sides: a `..` or double-slash difference
  reading as "not referenced" is the verdict that deletes the file.
- Both reclaim sites in both fields call the hook instead of MediaStore.
  Wired at every hop: FormScreen and FrappeFormRenderer.renderForm ->
  FrappeFormBuilder -> FieldFactory -> fields, plus BOTH nested
  child-table FrappeFormBuilders. A child row picks and discards on the
  same path, so a hook stopping at the parent leaves half the fix unwired
  and silent.
- Refusing leaves a staged file until the next save or the orphan sweep
  reclaims it. That is the recoverable side of the trade; the other side
  is a queued upload with no file.

FieldFactory carries the hook as INSTANCE STATE, not as a createField
parameter. Dart requires an override to redeclare every named parameter of
the method it overrides, so a new one breaks every existing subclass at
compile time and a default value does not save it — the reason
capDataLength and errorTextResolver already give for the same choice.
field_factory_override_compat_test pins it with a subclass carrying the
prior signature verbatim; it stops compiling the moment that signature
grows.

Tests, each watched failing first
- offline_repository_attachment_reclaim_test: refuses a referenced file,
  reclaims an unreferenced one.
- attachment_discard_after_save_test: the whole sequence on the real save
  path — stage, saveDocument, assert the column became pending:<id> while
  the queue row kept the raw path, discard the raw path, file survives.
  Run against the pre-fix MediaStore.discardValue it fails with the file
  deleted, so it discriminates the bug rather than the code.
- attachment_reclaim_hook_test: both fields route discard through the hook.
- form_builder_reclaim_wiring_test: the hook reaches parent AND child-row
  fields in BOTH form modes, and a host-supplied customFieldFactory. Each
  hop was mutation-tested — removing any one turns the matching case red.
- form_screen_reclaim_wiring_test: FormScreen supplies the repository's
  reclaim, not the default. Its red read "Actual: discardValue: static".
- public_api_surface_test: ReclaimAttachmentFn is nameable from the barrel.

Docs
- attachments.md: the discard section encoded the same wrong premise —
  its table promised "the save drops the row" for a value the field never
  holds after an in-session save. Rewritten, with the new outcome split
  out. Also retired the stale "ResolveMediaFn is not exported" limitation
  and the §13 pointer beside it; H2 closed that earlier in this branch.
- CHANGELOG: Fixed entry for the defect, Changed entry for the typedef.
@Omprakash-48

Copy link
Copy Markdown
Collaborator Author

Did the UI pass myself rather than have you approve blind — it turned up one real
defect, so don't approve with the gap stated. Fixed in 93cfb7a.

Discard/re-pick after a save deleted the bytes behind a committed queue row.
discardReplacedValue's "a raw staged path was never saved" reasoning is true of the
column, not of the value a form hands over: LocalWriter rewrites the column to
pending:<id>, nothing patches that back into an open form, and hasInteractedByUser
pins the raw path. Confirmed empirically — after didChange('/…/outbox/u1/photo.jpg'),
a rebuild supplying pending:42 leaves fieldState.value at the raw path. Deleting it
strands the queued upload: non-terminal error, backoff exhausted, document blocked, no
auto-retry.

Fixed with a ReclaimAttachmentFn seam wired to
OfflineRepository.reclaimDiscardedAttachment, which refuses a file
referencedLocalPaths() still lists. Default stays MediaStore.discardValue. Wired at
every hop including both child-table paths, and each hop mutation-tested.

Also reproduced what you couldn't: flutter test +2376 (2364 + 12 new), analyze clean,
format 0 changed.

New finding, raising not fixing: this branch added five named params to
createField (isOnline, pendingAttachmentPaths, mediaResolver, isOfflineMode,
imagePickSource) in df19398/28b0f59 — none exist at v2.0.0-beta.2. Each breaks
every subclass override at compile time. CHANGELOG:240 says the signature is frozen and
CHANGELOG:38 says a subclassing consumer analyses clean; both can't hold. Mine went in
as factory instance state instead. Move those five the same way, or correct the two
claims — your call.

@deepak-dhwani deepak-dhwani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First round with a toolchain. I installed Flutter 3.35.7 (the pubspec floor) and actually ran the suite — rounds 1–3 were all static:

flutter test → 2376 passed, exit 0 (reproduces the +2376 claim exactly)
flutter analyze → clean for lib/ (2 pre-existing, neither from this PR)

Every finding below was then confirmed by running probe code, not by reading.

What I found

🔴 H1 — Attach fields download the whole file on render, not on view. MediaResolveBuilder._start() runs from initState, and MediaResolver.resolve fetches on a cache miss. Probe: an AttachField holding /private/files/big-survey-report.pdf issued the fetch with zero taps. For ImageField that's fine (Image.network fetched anyway); for a PDF there's no render-time need at all. Opening a form now pulls every attachment on it, up to 25 MB each — on Android 8 / metered rural connections. And attachments.md:28 bills the read path as "lazy download" while :657 says "No background prefetch — cached on first view." Rendering a form isn't viewing a PDF.

🔴 H2 — Omprakash's own find, confirmed. createField gained five named params on this branch, so every FieldFactory subclass breaks at compile time. CHANGELOG:240 says the signature "should be treated as frozen"; form_builder.dart:431-442 repeats it verbatim in the same file whose :1409 and :2267 call sites pass the new arguments. One thing that strengthens his case: field_factory_override_compat_test cannot catch this — its "prior signature" baseline is ca69c95, four commits after the five params landed.

🟡 M1 — cachePathFor builds the cache filename from an unsanitised URL. p.extension takes the query string too. Probe, with the multi_cloud_storage proxy URL shape the code names by vendor:
basename = .abcdefghijklmnop
with a 300-char signed key → errno 63, File name too long
The catch-all swallows it → no media_cache row → silent permanent cache miss, re-download forever. attach_field.dart:82 already has the guard (_safeExtension) 400 lines away.

🟡 M2 — the pick path reclaims fieldState.value, the discard path reclaims the rendered value; probe shows them diverging (null vs the staged path) on an async document load. 🟡 M3 — form_builder.dart:451 clobbers a host factory's reclaimAttachment with the unsafe default on every build, while :560 uses ??= for the sibling hooks. 🟡 M4 — frappe_attachments/ temp cache is outside clearAll, sweepOrphanedMedia, mediaStoreUsage and logout(clearDatabase: true), which matters because this PR sells the logout wipe as a security fix. 🟡 M5 — _fetchMediaBytes has a connect timeout but no stall timeout; its sibling in attach_field.dart:578 does. Plus three lows.

93cfb7a itself is correct — I have nothing to add to it. Nothing here touches the pipeline; rounds 1–3 held up under an actual suite run.

… wipe paths

H2 — `createField` gained five named parameters, which breaks every existing
`FieldFactory` subclass at compile time: Dart requires an override to redeclare
every named parameter of the method it overrides, and a default value does not
help. Confirmed against a real consumer, whose override fails with
`invalid_override` before this change and analyzes clean after. The five are now
instance state alongside `capDataLength` / `errorTextResolver` /
`reclaimAttachment`. `field_factory_override_compat_test` now pins the
**published** v2.0.0-beta.2 signature; it previously pinned this branch's own,
five parameters included, which is exactly why it passed while the break was
live. They shipped in no tag, so removing them breaks no published API, and an
override may keep declaring them — only dropping one the base declares is illegal.

M3 — `_configureFieldFactoryForMeta` assigned all six capabilities
unconditionally, so a host that configured its own `customFieldFactory` and did
not repeat the values had them overwritten on every build: `reclaimAttachment`
with `MediaStore.discardValue`, which deletes a staged file a queued
`pending_attachments` row still owns — the data loss 93cfb7a fixed, reintroduced
through the wiring — and the other five with null. Now assigned only when
supplied, and `FrappeFormBuilder.reclaimAttachment` is nullable so "not supplied"
is distinguishable from "supplied the destructive default". A plain `??=` was
rejected: it also refuses the update case, and `pendingAttachmentPaths` must keep
changing as picks complete.

H1 — `AttachField` resolved media from `initState`, and `MediaResolver.resolve`
downloads on a cache miss, so opening a form fetched every attachment on it (up
to 25 MB each) before the user asked for any. The widget renders only a button;
resolution moved into its tap handler, which keeps the memoisation property a
builder existed to provide. `ImageField` still resolves eagerly — its preview
paints from the local file.

M1 — `cachePathFor` took `p.extension` of a URL, query string included. For the
cloud-proxy shape a 300-character signed key produced a 373-character filename
against a 255-byte NAME_MAX; the write threw, `resolve`'s catch-all swallowed it,
no `media_cache` row was written, and the attachment re-downloaded on every view
forever. Extensions are now accepted only as a dot plus 1-10 alphanumerics, and
rejected rather than truncated.

M2 — the pick path reclaimed `fieldState.value` while discard reclaimed the
`hasInteractedByUser`-aware value. They diverge before the first interaction, so
replacing an attachment supplied by an async document load reclaimed null and
leaked the staged file it displaced. Both paths in both widgets now share
`liveAttachmentValue`, so they cannot drift again.

M4 — the viewer's temp cache and the camera-capture marker live under the OS temp
root, outside `MediaStore`'s root, so `clearAll`, `sweepOrphans` and `usage`
could never reach them: decrypted copies of private attachments survived
`logout(clearDatabase: true)`, whose entire purpose is that they are gone.
`MediaStore` now owns both. `clearCache` reclaims the temp cache as well, because
it counts toward `MediaStoreUsage.totalBytes` and would otherwise be a reported
figure with no reclaim path. It stays out of `sweepOrphans`: nothing references
those files, so including them would make a reference-based sweep a blanket
delete. The marker's path is deliberately unchanged so an upgrade orphans nothing.

M5 — `_fetchMediaBytes` bounded `client.send` but not the body, so a connection
that went quiet mid-download left the resolve future permanently unresolved. Adds
a per-chunk stall timeout (not a total deadline, so a slow large download still
completes) and extracts the capped read to `readCappedMediaBody`, since the
method builds its own client and left no seam for a hung-server test.

Also closes three separate "two constructions of one path" divergences — the
class of bug behind the earlier `moveToCache` / `resolve` cache-path fix.
`getTemporaryDirectory()` is now resolved in exactly one place in `lib/`, and
`path_provider` became an unused import in both field widgets as a result.

Behaviour changes that need sign-off rather than just review: H1 alters
user-visible fetch timing (a form no longer warms the cache, so a first tap while
offline misses — which is what doc/release-2.0/attachments.md already promised,
and its contradictory summary line is corrected here), and
`MediaStoreUsage.totalBytes` now includes `viewerTempBytes`, so a host showing
that figure will display a larger number than before.

Verified: flutter analyze clean, 2413 tests pass, dart format clean, example/
clean. Both consumer apps analyze with 0 errors and produce test results
identical to their own pinned SDK.
@Omprakash-48

Copy link
Copy Markdown
Collaborator Author

Fixed in 1ee7dfa. All findings confirmed by running probe code first.

H2 — createField break. Confirmed. The five params are now instance state.
Verified against a real app with a FieldFactory subclass: invalid_override
before, 0 errors after. Your point about the compat test was the key one — its
baseline was ca69c95, after the params landed, so it could never have caught
this. Now pins the published v2.0.0-beta.2 signature.

H1 — eager download. Confirmed, and you were more precise than me. I assumed
"defer for non-image"; wrong — AttachField renders only a button, so the
eager resolve was unnecessary for all of it, not just PDFs. Now resolves on tap.
ImageField stays eager (its preview paints from the local file). :28 corrected.

M1 — cache filename. Confirmed: 309-char extension → 373-char filename vs
NAME_MAX 255. Extensions now rejected, not truncated.

M2 — reclaim mismatch. Confirmed. Both paths now share one
liveAttachmentValue helper — the bug was two copies drifting, so one copy is
the fix.

M3 — clobbered host factory. Confirmed, and worse than reported: my own H2
fix had widened it from 1 field to 6 (the five went to null). ??= isn't the
fix — it also blocks the update case. Now assigned only when supplied.

M4 — temp cache. Confirmed, and not a subdir issue: it's under
getTemporaryDirectory() while the store is under
getApplicationDocumentsDirectory(). Different roots. Kept out of
sweepOrphans deliberately; also reclaimed by clearMediaCache().

M5 — no stall timeout. Confirmed. Per-chunk bound, so slow large downloads
still finish.

Found while auditing: three cases of two independent constructions of the
same path — the same class as the earlier moveToCache/resolve bug.
getTemporaryDirectory() is now resolved in one place. The capture marker also
survived logout; fixed, path unchanged.

Needs sign-off (not just review):

  • H1 changes fetch timing — a form no longer warms the cache, so a first tap
    offline misses. This is the one item with schedule risk.
  • MediaStoreUsage.totalBytes now includes viewerTempBytes, so hosts showing
    it will see a bigger number.

Not covered by tests: H1's miss fallback, M2's pick call site
(FilePicker.pickFiles() is static, no seam), M5's send timeout.

Upgrade note: run flutter pub get, don't just bump the ref. open_filex
has been a dep since v2.0.0-beta.2; two apps on older refs fail to compile on
their existing lockfiles. Breaks --offline and vendored pub caches.

analyze clean · 2413 tests pass · format clean · example/ clean. Both consumer
apps: 0 errors, results identical to their own pinned SDK.

@Omprakash-48
Omprakash-48 force-pushed the feat/offline-attachment-pipeline branch from b617791 to 1ee7dfa Compare August 26, 2026 11:28

@deepak-dhwani deepak-dhwani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done, and it's ready to merge

@deepak-dhwani
deepak-dhwani merged commit 0b441e6 into dhwani-ris:develop Aug 26, 2026
4 checks passed
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.

2 participants