Set a card image from an address, typed or pasted - #167
Merged
Conversation
…arch term An image someone already has the address of had no way into a card: the sheet offered an Unsplash grid and the gallery, and nothing else. The field was the obvious place to paste one, and pasting there searched Unsplash for the URL — a grid of nothing, explaining nothing. `ImageLink.parse` decides what the field holds. Anything carrying an http(s) scheme is an address and cancels the search; a scheme is required, so `dogs.jpg` stays a perfectly good search term. Two shapes come back, because copying a picture off the web gives you either of two things: - an address, saved as a remote ref exactly like an Unsplash pick, and - the bytes of a `data:` URI, which is what a results-page thumbnail usually is and is not fetchable later, so it has to be uploaded. Copying the address of a Google Images result gives you `imgres?imgurl=…` — a viewer page, which loads as HTML and never as an image. That one parameter is unwrapped and percent-decoded, minus the escapes standing for a space or a control character: `%20` decoded is a string that is no longer a URL. A link drops the highlighted photo as it arrives, so the sheet never has two picks waiting to be committed, and it clears a standing Unsplash error — an address needs no API key to work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copying a picture off a web page produces one of two entirely different clips, and the difference is not something the person copying chose knowingly: Chrome's "Copy image" writes bytes behind a `content:` uri, "Copy image address" writes a URL as text. Both mean "I copied that image", so Paste — new, beside "From gallery" — accepts either. Bytes are the half no picker can reach at all. An address lands in the field, which now previews it in the grid's place. Done is gated on that preview loading, where a grid photo needs only to be highlighted: the preview goes through the same Coil stack that will later draw the card, so an address that cannot be shown here is one that would have been saved as a permanently blank card face. The usual mistake — the page address rather than the image's — is caught by exactly this, and told what to copy instead. An image clip is never fallen back to as text. `coerceToText` renders an unreadable uri as the string `content://com.android.chrome/…`, which would land in the field and be searched for: a failure looking like the app misunderstanding the paste rather than failing it. The clipboard is read only on the button press, since from Android 12 that raises the system's "pasted from clipboard" toast. Also stops a picked image that will not decode from taking the app down with it — `compressImage` throws, and the gallery path was not catching it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Architecture.md §8.0 grows the three rules the link path gets right that a reimplementation would not (the preview gate, the imgres unwrap, decoding `data:` rather than storing it), and journey 08 grows the steps that check them on a device — including the two Chrome copies, which are the whole reason the Paste button exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image sheet's ViewModel is scoped to the screen that raises it, not to the sheet, so a committed front-image pick was still sitting in state when the *back* image sheet opened — the address still in the field, Done still enabled, one tap from putting the same picture on both sides without anyone choosing it. It applied to a highlighted grid photo before this branch; a pasted address, which fills the field visibly, made it obvious. The sheet clears the pick as it opens, and skips the reload when there was nothing to clear. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the app Pasting a Chrome "Copy image" clip hung Loopky. `ClipData.Item.coerceToText` does not format a `content:` uri, it opens and reads the stream, and it ran on the main thread — as did `getType`. Both are IPC into the app that wrote the clip, which materializes the image on demand. Taking those bytes is a path of its own and is dropped for now (#168). An image clip is identified from `ClipDescription` and the item's uri — local metadata that arrived with the clip, no provider call — and refused with a line pointing at the two routes that do work: paste the address, or pick from the gallery. The remaining read is off the main thread and time-bounded anyway, since `primaryClip` is itself a binder call, and the button shows a spinner rather than looking dead while it runs. Paste keeps doing what it was verified doing: an address lands in the field and previews, plain text stays a search term. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jvsena42
enabled auto-merge (squash)
August 28, 2026 15:17
This was referenced Aug 29, 2026
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.
An image someone already has the address of had no way into a card: the image sheet offered the Unsplash grid and the gallery, and nothing else. Pasting a URL into the field searched Unsplash for it — a grid of nothing, explaining nothing.
What changed
The field takes an address as well as a search term.
ImageLink.parsedecides which; anhttp(s)scheme is required, sodogs.jpgstays a perfectly good search. A link replaces the grid with a preview of itself.Done is gated on that preview loading, where a grid photo needs only to be highlighted. The preview goes through the same Coil stack that will later draw the card, so an address that cannot be shown here — the page address copied instead of the image's, a host that refuses to serve the app — is one that would otherwise have been saved as a permanently blank card face. The failure says what to copy instead.
A Google Images result address is unwrapped. "Copy image address" on a result hands over
google.com/imgres?imgurl=…, a viewer page that loads as HTML and never as an image. Onlyimgurlis unwrapped, percent-decoded except escapes standing for a space or control character —%20decoded is a string that is no longer a URL.A
data:URI is decoded and uploaded as a blob, since a results-page thumbnail is routinely inline and adata:URI in a manifest is an image nobody else can fetch.Paste, beside "From gallery", puts a copied address into the field.
Pasting an image as bytes is deliberately not here — #168
It froze the app, and finding out why is the useful part: a clip from another app is a handle to its content provider, and
ClipData.Item.coerceToTexton acontent:uri does not format the uri — it opens and reads the stream. That, andgetType, were running on the main thread. Chrome materializes the copied image on demand, so the UI thread sat waiting on Chrome.An image clip is now identified from
ClipDescriptionand the item's uri — local metadata that arrived with the clip, no provider call — and refused with a line pointing at the address and the gallery. The remaining read is off the main thread and time-bounded anyway, sinceprimaryClipis itself a binder call, and the button shows a spinner rather than looking dead. #168 carries what taking the bytes would need.Two fixes found on the way
compressImagethrows, and the gallery path was not catching it.The Unsplash credit line is hidden while a link preview is up: crediting a photographer under someone's pasted address attributes an image that is not theirs.
Verified
./gradlew detektAll :shared:allTestsgreen (+17 new tests). Driven onemulator-5554with android-cli: a typed address previews and commits onto the card, a non-image address leaves Done disabled with the error panel, clear restores the grid and the credit line, Paste with an address previews it, Paste with plain text stays a search term, and the back sheet opens empty after a front pick. Results table injourneys/RESULTS.md; journey 08 rewritten to match.Not verifiable on this AVD: Chrome's "Copy image" wedged the emulator four times, including once with Loopky not in the foreground and Paste never pressed — the emulator, not the app. The image-clip refusal and the empty-clipboard notice want a run on real hardware.
🤖 Generated with Claude Code