Skip to content

feat(drop): add the YARD Drop Android companion - #56

Merged
Dinip merged 20 commits into
mainfrom
feat/drop-android-app
Sep 7, 2026
Merged

Dinip merged 20 commits into
mainfrom
feat/drop-android-app

Conversation

@Dinip

@Dinip Dinip commented Sep 5, 2026

Copy link
Copy Markdown
Owner

A Flutter app that registers as an Android share target. A tester shares files
from any app on a reserved handset, picks YARD Drop, and sends them either to
the device's Downloads or to an inbox the YARD console reads back. It gets a
file off a phone without a cable; install already covers the other way.

Kotlin owns URI grants, staging and MediaStore. Dart owns screen state. File
bytes never cross the platform channel, the console reads the inbox straight
from the provider, and nothing is stored anywhere: no table, no artifact, no
coordinator scratch. The app holds no storage permission and only touches
MediaStore rows it created.

The batch contract

Send to YARD browser writes one directory under
Download/YARD Drop/Inbox/<timestamp>-<uuid>/ holding the files, a versioned
batch.json, and _YARD_READY written last.

The marker is the whole handshake. A reader that sees it is promised every file
the manifest names. Nothing else about the directory is safe to poll on, because
MediaStore makes a row visible the moment IS_PENDING clears.

A batch is all-or-nothing. If a file, the manifest or the marker fails, the rows
already published are deleted and the files go back to pending, and a retry
replays the whole share into a fresh directory. Staged bytes are kept for that.
It is the one place the app withdraws files it already wrote: half a batch is
worse than none, since nothing would read it and the bytes would sit there until
the reservation ended.

In the console

Receive shared files polls the inbox while open. The decision logic sits in
packages/web/src/lib/drop-inbox.ts behind a list/read source so it tests
without a browser. Two things read like bugs and are not:

  • A listing failure on the inbox is an empty inbox. Until somebody shares, the
    directory does not exist.
  • A manifest is read once per batch and cached across polls, because reading one
    is an audited file pull and a dialog left open would otherwise fill the audit
    log with its own polling.

Downloads stream to disk through the provider's Content-Disposition rather
than being read into a Blob. A hidden iframe, not an anchor: an error response
carries no disposition, and an anchor would navigate the whole tab to it.

Verified on an SM-S901B

29 instrumentation and 41 Dart tests green on the handset. Real shares from
Samsung My Files, the gallery and a Chrome download. A 400 MB file round-trips
byte-identical with flat heaps; 3 GB is refused at the 512 MB limit before
staging; a cancel mid-ingestion leaves nothing behind. A three-file batch pulled
through the console matched the device's md5sum on every file, and five polls
cost one manifest read. Releasing the reservation removes the inbox and leaves
the companion installed, with cleanup.wipeFolders on.

Not exercised: low free space, which means filling a handset and is better done
on a farm device than a personal one.

Open

iOS, designed in docs/DROP.md and not started.

🤖 Generated with Claude Code

Dinip and others added 20 commits September 4, 2026 18:02
Increment 0 and 1 of docs/DROP.md. The application ID, minimum API level and
share limits are settled in apps/yard_drop/README.md, and the app itself is a
home screen, an About screen carrying the CI build identity, and the
ShareGateway seam with an in-memory fake for tests.

Flutter stays out of the root Bun scripts and gets its own workflow, so a
contributor who never touches the app needs no Flutter toolchain. The
pubspec version joins the root release-please extra-files: the companion ships
with the farm, not on its own line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Increment 2 of docs/DROP.md. The screen now runs the whole state machine —
empty, receiving, ready as a single file or a batch, saving, saved, failed —
against the in-memory fake, so every transition is testable before Android
exists.

Shares queue instead of replacing each other: a user can share twice before
answering the first prompt, and the native side has already copied both out of
their temporary URI grants. A native event for a share whose save is in flight
cannot walk it back to ready, and a resume re-reads the pending list, because
an event emitted while Dart was not listening is gone.

Only Save to Downloads is offered. The browser destination waits for the
storage behaviour behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Increment 3 of docs/DROP.md. MainActivity registers an ACTION_SEND target for
*/*, and the bridge turns an intent into a pending share: a URI from
EXTRA_STREAM or, failing that, from ClipData, described through ContentResolver
with fallbacks for a missing name, size or MIME type.

The intent is marked consumed once, so a rotation that reconfigures the engine
does not duplicate the share, and the store is a process-level queue so a
recreate does not drop one. Dart reads the queue on startup and resume; an
event is only a nudge, never the source of truth.

A text-only share is rejected as a share with no files and no retry, rather
than opening an empty screen. Nothing in the native path logs a URI, a
filename or file contents.

Saving still answers `unimplemented`; MediaStore lands in increment 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Increment 4 of docs/DROP.md. A share now lands as `receiving` and the bytes are
copied into cacheDir/incoming/<share-id>/ on a single worker thread before the
user is asked anything: an ACTION_SEND grant lives only as long as the sending
app keeps it, so a pending choice must not depend on one.

The copy streams through a 64 KB buffer and counts what it actually read, so
the 512 MB file and 2 GB batch limits hold whatever size the sender claimed. A
file is renamed off its .part name only once it is whole, and any failure takes
the whole batch directory with it.

On-disk names are ids, never a name the sender chose, with a manifest beside
them carrying the display names and types. Startup purges orphaned .part files
and batches past the 24h expiry, and skips anything the store still holds.
Files already published to Downloads are MediaStore's and are never touched.

Events now hop to the main thread, since staging reports from the worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Increment 5 of docs/DROP.md. MediaStoreWriter publishes a staged file into
Download/YARD Drop/Saved with IS_PENDING held until the copy finishes, so a
half-written file is never visible to the Files app or to a farm cleanup pass,
and a failure deletes the row it created. No storage permission is requested:
the app only ever touches rows it owns.

Display names come from whatever app did the sharing, so they are sanitized
before they reach MediaStore — no separators, no traversal, no empty name.

Saving reports progress through the store rather than the method result, which
gives the screen something to show during a batch. A failure part-way names how
many files were already saved instead of implying none were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found on a real device. A withdrawn URI grant surfaces from openInputStream as
a SecurityException, not an IOException, so it escaped the staging catch, killed
the worker thread and took the process down. Staging now fails the share for any
throwable, and every background task is wrapped so nothing on that thread can
end the app.

A failure before the user chose a destination now reads as "Share not received"
with no retry, because retrying would need the URI grant that is already gone.
"Not saved" is kept for a save that actually failed.

Adds structural logging under the YardDrop tag — an action, a count, a share id
— and nothing else. A URI, a filename and file bytes stay out of logcat: a farm
device's logs are not a private place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Increment 6 of docs/DROP.md. The manifest now also accepts ACTION_SEND_MULTIPLE,
and URIs are collected from EXTRA_STREAM and ClipData together, deduplicated and
kept in the sender's order — a URI carried in both places is one attachment.

Staging and saving became per file. One unreadable attachment among good ones no
longer condemns the batch: it is marked failed with its reason, the rest are
offered, and the screen lists each file's outcome. A file that reached the YARD
Drop folder is never rewritten or withdrawn because a later one failed, and its
staged copy is the only one deleted, so a retry has something to retry with.

Downloads renames a duplicate, so the saved name is read back from the row and
shown: sharing the same batch twice produces "a (1).txt", and the user is told
that rather than the name they asked for.

Verified on a Galaxy S22: a three-file batch from My Files staged and saved
whole, and a second identical batch landed beside the first with (1) suffixes.

Files are written to Download/YARD Drop/Saved, never the Downloads root, which
the ready screen now says. One folder is also one cleanup path when a
reservation ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The share receiver is only true on a device, so a fake ContentProvider in
the test APK serves the streams a real sender produces: an unknown length,
a missing display name, a stream that dies partway, a URI the app may not
read, and a 64 MB file that must never reach the heap.

The saver's tests write through the real MediaStore, which is the only way
to see what Downloads does with a duplicate name.

CI runs them on API 29, the floor the app supports, and API 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The companion belongs to a device's baseline: installing it mid-session
makes it something cleanup uninstalls on release. Saved files land in
Download/YARD Drop/Saved, which /sdcard/Download already covers for a farm
that wipes all of Downloads and nobody covers for a farm that does not.

Also corrects the cleanup_paths example in CLEANUP.md, which nested the key
under options where the provider reads it one level up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The system hosts a ContentProvider in its own package's process, and the
test APK has no Kotlin runtime there, so the fake sender died on its first
query with NoClassDefFoundError on kotlin.jvm.internal.Intrinsics. Moving
it to src/debug puts it in a process that has the stdlib, drops the
exported="true" the cross-UID call needed, and keeps it out of release.

Found by running the suite on an SM-S901B: 23 tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The text-only share was the one screen saying "YARD Drop", which is the
repo's name for the companion. Everything a user sees elsewhere — the share
sheet entry, the app bar, the empty state — says YARD - Device Farm, and
those two screens are one tap apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 512 MB attachment takes long enough to copy that walking away is a real
answer, but the receiving screen offered nothing to walk away with.

Cancelling has to reach a copy that is already running. The bridge's worker
is single-threaded, so a discard queued behind the staging it means to stop
would only run once that staging had finished, and the share would come back
as a ready prompt holding bytes nobody asked to keep. The stager now keeps a
set of cancelled ids, checked between copy buffers and again before the batch
is published; discardShare marks the id on the calling thread, ahead of the
queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Send to YARD browser publishes a share as one batch directory under
Download/YARD Drop/Inbox, holding the files, a versioned batch.json and
a _YARD_READY marker written last. A reader that sees the marker is
promised every file the manifest names.

A batch is all-or-nothing: if a file, the manifest or the marker fails,
the rows already published are deleted and the files return to pending
so a retry replays the whole share from staging into a fresh directory.
Staged bytes are kept until the marker lands for exactly that reason.

The build identity travels from Dart with the save call, so batch.json
and the About screen can never name different builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Receive shared files opens an Android-only dialog that polls the device's
YARD Drop inbox and offers each complete batch for download. It reads
through the same authenticated file plane as the file browser, so the
coordinator only mints the token and the pull lands in the audit log.

The rule that decides what may be shown lives in drop-inbox.ts, away from
React: a batch counts only once _YARD_READY is there, its manifest parses,
and its schema is one this console understands. Files the manifest names
but the device no longer has are dropped rather than offered.

A manifest is read once per batch and cached across polls. Reading one is
an audited file pull, so without the cache a dialog left open would fill
the audit log with its own polling; a batch cannot change once its marker
is there.

A listing failure on the inbox directory is an empty inbox, not an error.
Until somebody shares, it does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A share is whatever the tester had on their phone, so it is sized like a
build artifact rather than a screenshot. Reading one into a Blob first
meant the tab held the whole file before the user had anywhere to put it.

streamDeviceFile mints a token, builds the provider URL and hands it to
the browser. The provider already sent Content-Disposition and a length,
so nothing changed on the wire; it now streams straight to disk.

A hidden iframe rather than an anchor: an error response carries no
disposition, and an anchor would navigate the whole tab to it, losing a
live session over a file cleanup had already removed.

The browser owns the transfer from that point, so the per-file spinner
went with it. Minting the token is the only part left that can fail here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mobile.yml is a reusable workflow called by release.yml, so the version
comes from release-please rather than from anything the build decides for
itself. The APK is attached to the GitHub release; a farm installs it per
device as part of the baseline, so there is nowhere to push it.

The build stamps the version, the CI run number and the short commit
through --dart-define. BuildInfo reads them, which puts them on the About
screen and in every batch manifest the app writes — a file pulled off a
device now names the build that put it there, where before every APK said
dev+0 (local).

Signing degrades on purpose: with the farm's keystore secrets set Gradle
signs with it, without them it falls back to debug keys. The secrets are
the operator's to add, and nothing in the repository can create them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There is no release keystore, so the release APK is debug-signed and the
signing plumbing was carrying its own weight for nothing. A device must
uninstall before it can take a differently signed build, which is why
provisioning installs the companion as part of a baseline.

The instrumentation matrix on API 29 and 35 is gone too. Those tests need
a real device to be worth anything, and the app is signed off on a handset
either way; they are run by hand from apps/yard_drop/android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dinip
Dinip merged commit 5d59bbf into main Sep 7, 2026
9 checks passed
@Dinip
Dinip deleted the feat/drop-android-app branch September 7, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant