Skip to content

Set a card image from an address, typed or pasted - #167

Merged
jvsena42 merged 6 commits into
mainfrom
feat/image-sheet-link-and-paste
Aug 28, 2026
Merged

Set a card image from an address, typed or pasted#167
jvsena42 merged 6 commits into
mainfrom
feat/image-sheet-link-and-paste

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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.parse decides which; an http(s) scheme is required, so dogs.jpg stays 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. Only imgurl is unwrapped, percent-decoded except escapes standing for a space or control character — %20 decoded 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 a data: 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.coerceToText on a content: uri does not format the uri — it opens and reads the stream. That, and getType, 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 ClipDescription and 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, since primaryClip is 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

  • A pick belonged to the screen, not the sheet. The sheet's ViewModel is scoped to the screen, so a committed front-image pick was still there, Done still enabled, when the back sheet opened — one tap from putting the same picture on both sides. It applied to a highlighted grid photo before this branch too.
  • A picked image that will not decode took the app down. compressImage throws, 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:allTests green (+17 new tests). Driven on emulator-5554 with 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 in journeys/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

jvsena42 and others added 6 commits August 28, 2026 11:44
…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 jvsena42 changed the title Set a card image from an address, or paste one straight in Set a card image from an address, typed or pasted Aug 28, 2026
@jvsena42
jvsena42 enabled auto-merge (squash) August 28, 2026 15:17
@jvsena42
jvsena42 merged commit 3242e0e into main Aug 28, 2026
2 checks passed
@jvsena42
jvsena42 deleted the feat/image-sheet-link-and-paste branch August 28, 2026 15:20
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