What happens
craft.media.keep(url, { headers }) fetches a file with the account's sign-in
on the request and keeps a copy on the phone, keyed by its remote address. The
native Video element reads that copy back, so a kept video plays from the
same src it was authored with — CraftNativeScreen.swift:1025:
// The copy `craft.media.keep` put on the phone plays in place of
// the address, so a kept video plays with no signal.
let kept = CraftNativeMedia.local(uri)Image never does. CraftNativeMedia appears exactly once in
CraftNativeScreen.swift, in the video path above. updateImage goes straight
from a data: URI to imageURL(for:) and the network, so a photo that is
already on the phone is fetched again — and when the address needs a bearer
token, it cannot be fetched at all, because an Image carries no headers.
Nothing else covers it. loadsImage allows only https and http from a loopback
host, on both platforms (CraftNativeScreen.swift:8290,
MainActivityNative.kt.template:6426), and that is deliberate and documented:
public HTTP, file URLs, and other schemes are rejected
So a file on disk has no route to the screen, which leaves no way at all to draw a photo that only an authorized request can obtain.
keep itself is already content-agnostic: store takes any 200 response body
with no type check (CraftNativeFiles.swift:586), and file(for:) names the
copy from the address's own extension rather than the body
(CraftNativeFiles.swift:538). It would hold a JPEG today; only the reader is
missing. Both platforms advertise the capability (CraftNativeActions.swift,
CraftNativeServices.kt.template:106).
Why this bites
HQ.training added photos in direct messages. The photo is private to the two people in the thread: the serve action checks conversation membership before it answers, so the fetch has to carry the sign-in. The only shape available was to download the bytes first and draw the local copy:
// ... an `Image` cannot carry a bearer token, so the file has to be fetched
// before it is rendered rather than by the view itself.
const local = await downloadMessageAttachment(session, conversationId, file)That cannot work, and on a device it fails silently. Every photo bubble renders
as an empty rounded rect — right size, no pixels — because the path handed to
<img src> is schemeless, so it resolves against the app origin (as #378
intended) and 404s. Two independent paths fail the same way, which is what made
it obvious the view was never the problem:
- a photo fetched with
files.downloadand drawn from its path; - the composer's own thumbnail, drawn straight from the
files.pickImagedescriptor with no download in between.
It took a device to see. The unit tests assert source.uri on a fake host, so
they record the prop and pass; nothing in a harness knows which URIs a real
UIImageView will accept.
This is the other half of #341. A native screen can pick a photo and upload one; it still cannot display one it had to ask for.
What would help
Have updateImage consult the kept copy before the network path, exactly as
the video path does — CraftNativeMedia.local(uri) first, then
imageURL(for:) as today. The authored src stays an https address, so the
documented scheme policy is untouched; the only change is that an image, like a
video, prefers the copy already on the phone.
That makes the whole flow expressible with what Craft already ships:
await craft.media.keep(url, { headers: { Authorization: `Bearer ${token}` } })
// <img :src="url"> now draws the kept copyIt also removes a second fetch for every image an app has already kept, which
is the offline story media exists for.
Android would want the same in MainActivityNative.kt.template so the two stay
level. One small thing if images do join: store reports cancellation as "The
video cache was cleared", which would want generalising.