fix(client): profile picture upload fails on android (fetch() doesn't read file:// URIs) #253

Closed
opened 2026-06-23 16:13:32 +00:00 by james · 0 comments
Owner

Context

Uploading a profile picture from the universal client works on web but fails on Android with the generic "Upload failed. Try again." surfaced by the screen's error path.

Root cause: apps/client/lib/profile/pictureUpload.tsfetchPickedAsBlob(uri) does fetch(uri).then(r => r.blob()) to convert the picker URI to a Blob, then useUpdateProfilePicture (packages/api-client/src/hooks/picture.ts) appends the Blob to FormData.

This works on web because expo-image-picker returns a blob: or data: URL there, and fetch() happily reads either. On Android the picker returns a file:///storage/emulated/0/... URI, and React Native's fetch does NOT natively read file:// URIs — it throws a network error. The thrown error is caught by the screen's generic setError("uploadFailed") branch, so the user sees no useful diagnostic.

The RN-idiomatic upload pattern doesn't go through fetch at all on native: you build FormData with a file-descriptor object { uri, name, type } and append it directly. RN's whatwg-fetch polyfill recognises that shape and streams the file at send time without ever materialising a JS Blob.

Source

User-reported (June 2026) after PR #248 shipped. PR #248's local gates all passed because the new tests construct FormData with web-style Blobs and the agent had no Android device to exercise the picker URI path.

Scope

  1. apps/client/lib/profile/pictureUpload.ts (or a sibling) grows a buildPictureFormData() helper that branches on Platform.OS:
    • Web: keep the current fetchPickedAsBlobform.append("file", blob, name) path.
    • Native: skip the fetch entirely. form.append("file", { uri, name, type } as any) — the cast is necessary because RN's ambient FormData typings don't expose the file-descriptor shape.
  2. useUpdateProfilePicture's mutationFn signature changes from (file: Blob | File) to (form: FormData). The screen builds the FormData via buildPictureFormData(picked).
  3. The screen (apps/client/app/(app)/profile.tsx) calls the new helper instead of fetchPickedAsBlob directly.
  4. Unit tests cover both branches with mocked Platform.OS.
  5. Manual smoke on Android via Expo Go: pick image → upload → avatar refreshes. Document in the PR.

Acceptance criteria

  • Android via Expo Go: pick a JPEG / PNG from the gallery, tap upload, the avatar refreshes within the request round-trip.
  • Web: existing happy path unchanged.
  • Unit tests cover the helper's web + native branches.
  • No new permissions added to the Android manifest beyond what #248 already documents.

Out of scope

  • iOS — out of scope per idea.md.
  • Cropping / rotation in-app — the API's sharp pipeline handles resize.
  • Surfacing the actual error message (network error vs validation) instead of the generic "Upload failed" — separate UX polish ticket if anyone wants it.

Composes with

  • #217 / PR #248 — the original upload work.
  • #234 — native E2E smoke (would catch this class of bug going forward).
## Context Uploading a profile picture from the universal client works on web but fails on Android with the generic "Upload failed. Try again." surfaced by the screen's error path. Root cause: `apps/client/lib/profile/pictureUpload.ts` → `fetchPickedAsBlob(uri)` does `fetch(uri).then(r => r.blob())` to convert the picker URI to a Blob, then `useUpdateProfilePicture` (`packages/api-client/src/hooks/picture.ts`) appends the Blob to FormData. This works on web because `expo-image-picker` returns a `blob:` or `data:` URL there, and `fetch()` happily reads either. On Android the picker returns a `file:///storage/emulated/0/...` URI, and React Native's `fetch` does NOT natively read `file://` URIs — it throws a network error. The thrown error is caught by the screen's generic `setError("uploadFailed")` branch, so the user sees no useful diagnostic. The RN-idiomatic upload pattern doesn't go through `fetch` at all on native: you build FormData with a file-descriptor object `{ uri, name, type }` and append it directly. RN's `whatwg-fetch` polyfill recognises that shape and streams the file at send time without ever materialising a JS Blob. ## Source User-reported (June 2026) after PR [#248](https://forge.wynning.tech/james/carol/pulls/248) shipped. PR #248's local gates all passed because the new tests construct FormData with web-style Blobs and the agent had no Android device to exercise the picker URI path. ## Scope 1. `apps/client/lib/profile/pictureUpload.ts` (or a sibling) grows a `buildPictureFormData()` helper that branches on `Platform.OS`: - **Web**: keep the current `fetchPickedAsBlob` → `form.append("file", blob, name)` path. - **Native**: skip the fetch entirely. `form.append("file", { uri, name, type } as any)` — the cast is necessary because RN's ambient FormData typings don't expose the file-descriptor shape. 2. `useUpdateProfilePicture`'s `mutationFn` signature changes from `(file: Blob | File)` to `(form: FormData)`. The screen builds the FormData via `buildPictureFormData(picked)`. 3. The screen (`apps/client/app/(app)/profile.tsx`) calls the new helper instead of `fetchPickedAsBlob` directly. 4. Unit tests cover both branches with mocked `Platform.OS`. 5. Manual smoke on Android via Expo Go: pick image → upload → avatar refreshes. Document in the PR. ## Acceptance criteria - [ ] Android via Expo Go: pick a JPEG / PNG from the gallery, tap upload, the avatar refreshes within the request round-trip. - [ ] Web: existing happy path unchanged. - [ ] Unit tests cover the helper's web + native branches. - [ ] No new permissions added to the Android manifest beyond what #248 already documents. ## Out of scope - iOS — out of scope per `idea.md`. - Cropping / rotation in-app — the API's `sharp` pipeline handles resize. - Surfacing the actual error message (network error vs validation) instead of the generic "Upload failed" — separate UX polish ticket if anyone wants it. ## Composes with - [#217](https://forge.wynning.tech/james/carol/issues/217) / PR [#248](https://forge.wynning.tech/james/carol/pulls/248) — the original upload work. - [#234](https://forge.wynning.tech/james/carol/issues/234) — native E2E smoke (would catch this class of bug going forward).
james closed this issue 2026-06-23 16:49:20 +00:00
james reopened this issue 2026-06-23 17:27:39 +00:00
james closed this issue 2026-06-23 20:37:57 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#253
No description provided.