fix(client): profile picture upload + display on android (#253, #256) #257

Merged
james merged 2 commits from 253-android-profile-picture into main 2026-06-23 16:49:20 +00:00
Owner

Two related Android-only bugs in the universal client's profile-picture flow. Bundled together because they share apps/client/app/(app)/profile.tsx and you can't verify the display fix without the upload working first.

Fix 1 — upload fails on Android (#253)

Root cause: fetchPickedAsBlob(uri) called fetch(uri) to read the picker URI. React Native's whatwg-fetch polyfill does NOT understand file:/// schemes — every Android upload bubbled up as "Upload failed. Try again." without ever reaching the server.

Fix: new buildPictureFormData() helper that branches on Platform.OS. On web it keeps the existing blob: / data: fetch path. On native it appends the RN-idiomatic file-descriptor { uri, name, type } shape to FormData, which RN's fetch polyfill recognises and streams off disk at send time. useUpdateProfilePicture()'s mutation signature changes from Blob | File to FormData so each platform builds the body in the shape its fetch understands.

Fix 2 — set picture doesn't render on Android (#256)

Root cause: the avatar's <Image> does NOT go through the typed client's URL-rewriter middleware (apiClient.ts's onRequest). On Android and inside the Tauri shell the bundle is loaded off-origin from the API, so a relative /api/profile/picture resolved against tauri://localhost or Expo Go's bundle URL — garbage. Headers were attached but the URL itself was wrong.

Fix: new pictureSrc.ts helper that splices the runtime server URL in front of the path on native + Tauri and attaches the bearer token via source.headers. PWA stays relative — cookie covers auth. Missing server URL or bearer → return null so the consumer falls back to initials rather than racing a doomed image fetch. Added a sync getCachedAccessToken() to lib/auth/storage.ts so the avatar source resolves without a setState round-trip; the in-memory cache is populated by the boot-time auth-header path.

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck / lint / test
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/client typecheck / lint / test / export:web
  • New unit tests for both branches of buildPictureFormData (Platform.OS web vs android) and all three branches of getProfilePictureSource (no picture / PWA relative / off-origin absolute + headers + missing-piece fallback).
  • Manual Expo Go smoke on Android: pick → upload → avatar shows new image; reload app → avatar still shows; remove → reverts to initials. Not exercised — no Android device or Expo Go runtime available in this worktree's environment.

Closes #253.
Closes #256.

Two related Android-only bugs in the universal client's profile-picture flow. Bundled together because they share `apps/client/app/(app)/profile.tsx` and you can't verify the display fix without the upload working first. ## Fix 1 — upload fails on Android (#253) **Root cause:** `fetchPickedAsBlob(uri)` called `fetch(uri)` to read the picker URI. React Native's whatwg-fetch polyfill does NOT understand `file:///` schemes — every Android upload bubbled up as "Upload failed. Try again." without ever reaching the server. **Fix:** new `buildPictureFormData()` helper that branches on `Platform.OS`. On web it keeps the existing `blob:` / `data:` fetch path. On native it appends the RN-idiomatic file-descriptor `{ uri, name, type }` shape to FormData, which RN's fetch polyfill recognises and streams off disk at send time. `useUpdateProfilePicture()`'s mutation signature changes from `Blob | File` to `FormData` so each platform builds the body in the shape its fetch understands. ## Fix 2 — set picture doesn't render on Android (#256) **Root cause:** the avatar's `<Image>` does NOT go through the typed client's URL-rewriter middleware (`apiClient.ts`'s `onRequest`). On Android and inside the Tauri shell the bundle is loaded off-origin from the API, so a relative `/api/profile/picture` resolved against `tauri://localhost` or Expo Go's bundle URL — garbage. Headers were attached but the URL itself was wrong. **Fix:** new `pictureSrc.ts` helper that splices the runtime server URL in front of the path on native + Tauri and attaches the bearer token via `source.headers`. PWA stays relative — cookie covers auth. Missing server URL or bearer → return null so the consumer falls back to initials rather than racing a doomed image fetch. Added a sync `getCachedAccessToken()` to `lib/auth/storage.ts` so the avatar source resolves without a `setState` round-trip; the in-memory cache is populated by the boot-time auth-header path. ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` / `lint` / `test` - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check` - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` / `export:web` - [x] New unit tests for both branches of `buildPictureFormData` (Platform.OS web vs android) and all three branches of `getProfilePictureSource` (no picture / PWA relative / off-origin absolute + headers + missing-piece fallback). - [ ] Manual Expo Go smoke on Android: pick → upload → avatar shows new image; reload app → avatar still shows; remove → reverts to initials. *Not exercised — no Android device or Expo Go runtime available in this worktree's environment.* Closes #253. Closes #256.
Android's `expo-image-picker` returns a `file:///` URI for the picked
image, and React Native's whatwg-fetch polyfill cannot `fetch()` that
scheme — every Android upload bubbled up as "Upload failed. Try again."
without reaching the server.

Build the multipart body via the RN-idiomatic file-descriptor shape:

  form.append("file", { uri, name, type } as never)

which RN recognises and streams off disk at send time. Web keeps its
existing `blob:` / `data:` fetch path, gated on `Platform.OS`. The
helper lives next to the existing picker validation so the screen
just calls `buildPictureFormData(picked)` and passes the FormData to
the mutation. `useUpdateProfilePicture()` accepts FormData directly
(was `Blob | File`) so each platform builds the body in the shape its
fetch understands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(client): profile picture renders on android (#256)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 7s
PR / Static analysis (pull_request) Successful in 1m47s
PR / OpenAPI (pull_request) Successful in 1m52s
PR / Lint (pull_request) Successful in 2m2s
PR / OSV-Scanner (pull_request) Successful in 1m54s
PR / Typecheck (pull_request) Successful in 2m1s
PR / Test (postgres) (pull_request) Failing after 2m14s
PR / pnpm audit (pull_request) Successful in 2m34s
PR / Client (web export smoke) (pull_request) Successful in 2m55s
PR / Package age policy (soft) (pull_request) Successful in 55s
PR / Build (pull_request) Successful in 4m6s
Secrets / gitleaks (pull_request) Successful in 2m3s
PR / Test (sqlite) (pull_request) Successful in 4m6s
PR / Coverage (soft) (pull_request) Successful in 2m17s
PR / Trivy (image) (pull_request) Successful in 2m50s
9832f118de
The avatar's `<Image>` does NOT run through the typed client's
URL-rewriter middleware in `apiClient.ts`, so a relative
`/api/profile/picture` resolved against `tauri://localhost` (Flatpak)
or Expo Go's bundle URL — garbage. The previous code passed only the
relative path plus headers; the platform image loader had no real
URL to dial.

Build the source explicitly per platform in a small `pictureSrc.ts`
helper:

  - PWA (same-origin web): `{ uri: "/api/profile/picture?v=…" }` —
    cookie covers auth.
  - Off-origin (Android + Tauri): splice the runtime server URL in
    front and attach the bearer via `source.headers`. Missing URL or
    bearer → return null so the consumer falls back to initials
    rather than racing a doomed image fetch.

Drop the previous async bearer-resolution `useEffect` from the Avatar
in favour of a sync read against the in-memory access-token cache
(`getCachedAccessToken()`, mirror of the SecureStore-cache fix). The
cache is populated by the boot-time auth-header path, so by the time
the profile screen renders the bearer is available without a `setState`
round-trip. The old `avatarUrl.ts` helper went unreferenced and is
removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 82.9% ≥ 50%
Branches 75.6% ≥ 75%
Functions 91.8% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 82.9% ✅ | ≥ 50% | | Branches | 75.6% ✅ | ≥ 75% | | Functions | 91.8% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 052daf3a04 into main 2026-06-23 16:49:20 +00:00
james deleted branch 253-android-profile-picture 2026-06-23 16:49:20 +00:00
Sign in to join this conversation.
No description provided.