feat(client): profile picture upload via expo-image-picker (#217) #248

Merged
james merged 1 commit from 217-profile-picture-upload into main 2026-06-23 14:01:58 +00:00
Owner

Summary

  • Wires expo-image-picker into the Profile screen's "Change picture" / "Remove picture" actions, replacing the read-only "manage from web" hint with a working flow on web and Android.
  • Fixes a latent bug in the off-origin URL rewriter (apps/client/lib/apiClient.ts): every outbound body was going through request.text(), which silently corrupts non-UTF-8 bytes inside a multipart payload. The rewriter now picks request.arrayBuffer() when it sees multipart/form-data. Extracted as apps/client/lib/apiClientBody.ts with a regression test that confirms the image's raw bytes survive the clone byte-for-byte (tests/apiClientBody.test.ts).
  • Adds useUpdateProfilePicture / useDeleteProfilePicture hooks to @carol/api-client (useUploadProfilePicture renamed — wasn't used anywhere); both patch the profile bundle queryKey on success.

Multipart through the off-origin rewriter

The off-origin path (Android + Tauri Flatpak) clones each outgoing Request so it can splice the runtime server URL in front of the relative path. The clone reads the body explicitly because RN's whatwg-fetch polyfill doesn't reliably transfer a stream via new Request(url, request). Before this PR, the body was always read via .text(). For JSON that's fine; for multipart it isn't — the 0xFE/0xFF-class bytes inside an image part get mangled into U+FFFD (the UTF-8 replacement char) on the read-back. The fix is a content-type check + .arrayBuffer(). The new test asserts the original bytes appear verbatim in the cloned body.

Files changed

  • apps/client/package.json — add expo-image-picker@~56.0.18 (SDK 56 dep matrix pin).
  • apps/client/app/(app)/profile.tsx — PictureCard now exposes Change/Remove actions, inline confirm on Remove, error states, real avatar render via <Image> with bearer-header fallback.
  • apps/client/lib/apiClient.ts + apps/client/lib/apiClientBody.ts — multipart-aware body extraction.
  • apps/client/lib/profile/pictureUpload.ts — local MIME/byte validation matching the API's 5 MB cap.
  • apps/client/lib/profile/avatarUrl.ts — composes the ?v=<picture_uploaded_at> cache-busted URL.
  • apps/client/README.md — documents the Android manifest entries expo-image-picker brings in (READ_MEDIA_IMAGES for Android 13+, READ_EXTERNAL_STORAGE for ≤ 12) and the local validation thresholds.
  • packages/api-client/src/hooks/picture.ts — hooks now also invalidateQueries on the profile bundle.
  • packages/i18n/messages/en.json — new profile.picture.* keys for the action labels, confirm copy, error states.

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck
  • pnpm -F @carol/api lint
  • pnpm -F @carol/api test (591 passed)
  • pnpm -F @carol/api openapi:check
  • pnpm -F @carol/api-client typecheck / lint / test (23 passed) / check
  • pnpm -F @carol/client typecheck / lint / test (54 passed)
  • pnpm -F @carol/client export:web
  • Manual Expo Go smoke on Android — pick image from library, upload, avatar refreshes from the cache-busted URL; tap Remove, confirm, avatar reverts to initials. (Pending hardware run; the test plan locks the multipart wire format in lieu of device CI.)

Closes #217.

🤖 Generated with Claude Code

## Summary - Wires `expo-image-picker` into the Profile screen's "Change picture" / "Remove picture" actions, replacing the read-only "manage from web" hint with a working flow on web and Android. - Fixes a latent bug in the off-origin URL rewriter (`apps/client/lib/apiClient.ts`): every outbound body was going through `request.text()`, which silently corrupts non-UTF-8 bytes inside a multipart payload. The rewriter now picks `request.arrayBuffer()` when it sees `multipart/form-data`. Extracted as `apps/client/lib/apiClientBody.ts` with a regression test that confirms the image's raw bytes survive the clone byte-for-byte (`tests/apiClientBody.test.ts`). - Adds `useUpdateProfilePicture` / `useDeleteProfilePicture` hooks to `@carol/api-client` (`useUploadProfilePicture` renamed — wasn't used anywhere); both patch the profile bundle queryKey on success. ## Multipart through the off-origin rewriter The off-origin path (Android + Tauri Flatpak) clones each outgoing `Request` so it can splice the runtime server URL in front of the relative path. The clone reads the body explicitly because RN's `whatwg-fetch` polyfill doesn't reliably transfer a stream via `new Request(url, request)`. Before this PR, the body was always read via `.text()`. For JSON that's fine; for multipart it isn't — the `0xFE`/`0xFF`-class bytes inside an image part get mangled into `U+FFFD` (the UTF-8 replacement char) on the read-back. The fix is a content-type check + `.arrayBuffer()`. The new test asserts the original bytes appear verbatim in the cloned body. ## Files changed - `apps/client/package.json` — add `expo-image-picker@~56.0.18` (SDK 56 dep matrix pin). - `apps/client/app/(app)/profile.tsx` — PictureCard now exposes Change/Remove actions, inline confirm on Remove, error states, real avatar render via `<Image>` with bearer-header fallback. - `apps/client/lib/apiClient.ts` + `apps/client/lib/apiClientBody.ts` — multipart-aware body extraction. - `apps/client/lib/profile/pictureUpload.ts` — local MIME/byte validation matching the API's 5 MB cap. - `apps/client/lib/profile/avatarUrl.ts` — composes the `?v=<picture_uploaded_at>` cache-busted URL. - `apps/client/README.md` — documents the Android manifest entries `expo-image-picker` brings in (`READ_MEDIA_IMAGES` for Android 13+, `READ_EXTERNAL_STORAGE` for ≤ 12) and the local validation thresholds. - `packages/api-client/src/hooks/picture.ts` — hooks now also `invalidateQueries` on the profile bundle. - `packages/i18n/messages/en.json` — new `profile.picture.*` keys for the action labels, confirm copy, error states. ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` - [x] `pnpm -F @carol/api lint` - [x] `pnpm -F @carol/api test` (591 passed) - [x] `pnpm -F @carol/api openapi:check` - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` (23 passed) / `check` - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` (54 passed) - [x] `pnpm -F @carol/client export:web` - [ ] Manual Expo Go smoke on Android — pick image from library, upload, avatar refreshes from the cache-busted URL; tap Remove, confirm, avatar reverts to initials. (Pending hardware run; the test plan locks the multipart wire format in lieu of device CI.) Closes #217. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): profile picture upload via expo-image-picker (#217)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 1m30s
PR / pnpm audit (pull_request) Successful in 2m3s
PR / OpenAPI (pull_request) Successful in 2m19s
PR / Static analysis (pull_request) Successful in 2m19s
PR / Lint (pull_request) Successful in 2m52s
PR / Client (web export smoke) (pull_request) Successful in 3m1s
PR / Typecheck (pull_request) Successful in 3m1s
PR / Test (sqlite) (pull_request) Successful in 3m6s
PR / Build (pull_request) Successful in 3m17s
PR / Package age policy (soft) (pull_request) Successful in 1m0s
PR / Test (postgres) (pull_request) Failing after 3m22s
Secrets / gitleaks (pull_request) Successful in 1m1s
PR / Coverage (soft) (pull_request) Successful in 1m50s
PR / Trivy (image) (pull_request) Successful in 2m45s
78f654ec93
Wires up the universal client's Profile screen so users can upload
and remove a profile picture from web and Android. The picker opens
the platform-native chooser on Android and a hidden file input on
web; picked bytes flow through a new useUpdateProfilePicture hook to
the existing /api/profile/picture multipart endpoint.

Also fixes a latent bug in the off-origin URL rewriter: it was
reading every outgoing body via request.text(), which silently
corrupts non-UTF-8 bytes in multipart payloads. The rewriter now
detects multipart/form-data and reads the body as an ArrayBuffer
instead, preserving every byte across the clone.

📊 Test coverage

Patch coverage: no testable lines changed.

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

Metric Value Soft target
Lines 83.3% ≥ 50%
Branches 76.0% ≥ 75%
Functions 91.7% 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 | 83.3% ✅ | ≥ 50% | | Branches | 76.0% ✅ | ≥ 75% | | Functions | 91.7% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 2f06806dbf into main 2026-06-23 14:01:58 +00:00
james deleted branch 217-profile-picture-upload 2026-06-23 14:01:59 +00:00
Sign in to join this conversation.
No description provided.