feat(drop): add the YARD Drop Android companion - #56
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;
installalready 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 browserwrites one directory underDownload/YARD Drop/Inbox/<timestamp>-<uuid>/holding the files, a versionedbatch.json, and_YARD_READYwritten 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_PENDINGclears.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 filespolls the inbox while open. The decision logic sits inpackages/web/src/lib/drop-inbox.tsbehind alist/readsource so it testswithout a browser. Two things read like bugs and are not:
directory does not exist.
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-Dispositionratherthan being read into a
Blob. A hidden iframe, not an anchor: an error responsecarries 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
md5sumon every file, and five pollscost one manifest read. Releasing the reservation removes the inbox and leaves
the companion installed, with
cleanup.wipeFolderson.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.mdand not started.🤖 Generated with Claude Code