ReviewOS

craft-native/craft

feat(native): a native screen can keep a private photo but has no way to display one

#414
Closed glennmichael123 opened this 8 days ago ·
8 days ago

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.download and drawn from its path;
  • the composer's own thumbnail, drawn straight from the files.pickImage descriptor 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 copy

It 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.

7 days ago

Implementation is on main in 22afd64 (iOS) and 1e866bb (Android); this is not yet a published-package fix. I verified that the currently published 0.0.358 templates do not include the kept-image loader. The iOS offline/private-image regression passed on iOS 26.5. Follow-up 2107ffe exercises Android source replacement through updateNode on the same mounted ImageView, including file-URL rejection and stale-image suppression; Android instrumentation is pending in the Mobile E2E run. It also documents using the original HTTPS URL after media.keep, not a picker/download path. Keeping this open until native validation and a published version can be reported.

7 days ago

Closing the iOS portion as completed, per the requester’s explicit direction to split Android into its own follow-up: #417.

The iOS fix is on main in 22afd64. UIImageView now reads a valid kept-media copy keyed by the original HTTPS URL before the decoded/network cache. The real iOS 26.5 simulator test testKeptPrivateImageLoadsOfflineAndDoesNotSurviveSourceReplacement passed: offline kept-image loading, file-URL rejection, stale-source suppression, and corrupt-copy failure are covered.

Usage: await craft.media.keep(url, { headers }), then render the original URL as the image source. Arbitrary picker/download paths are not enabled by this change.

Release status is explicit: this is resolved on main, NOT yet shipped in the currently published @craft-native/ios 0.0.358. Consumers need a later published version containing 22afd64 or a build from that main revision. Android verification and publication remain open in #417.

Sign in to comment on this issue.