fix(client): profile picture doesn't render on android #256

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

Context

After a profile picture is successfully uploaded, the universal client's avatar component on Android shows nothing (or falls back to initials) — the image never renders. Web displays it correctly.

The avatar is served by GET /api/profile/picture, which is an authenticated route. PR #248 deliberately renders the avatar via <Image source={{ uri, headers }}> so the bearer Authorization header is attached on the off-origin fetch — that's the right shape. The failure modes most likely to bite:

  1. The <Image> component's headers prop quietly stops being honored on certain RN versions / Hermes builds. Worth verifying it actually fires the GET with the header.
  2. The uri is relative (/api/profile/picture), and <Image> doesn't run through the off-origin URL rewriter that the typed client uses — so it tries to resolve against an undefined origin and fails. The avatar needs the absolute URL spliced in by getCachedServerUrl() before being passed to <Image>.
  3. Cache-buster querystring — the avatar URL typically includes ?v=<pictureUploadedAt> to invalidate the cache after re-upload. On native, the ?v= query interacts with the server response in a way the typed client handles but <Image> may not.
  4. Content-Type / decode failure — the API serves image/webp. RN's <Image> supports WebP on Android 14+ natively but older Androids need explicit decoder libs. Less likely than #2, but worth checking.

Source

User-reported (June 2026), observed after #248 shipped and pictures can actually be uploaded (once #253 lands).

Approach

Most likely fix: ensure the <Image> uri is the absolute URL with the off-origin server base spliced in, with the Authorization header attached. Both must travel together: relative URL + auth header is the broken combo because <Image> doesn't run the URL rewriter middleware.

Implementation surface:

  • Add apps/client/lib/profile/pictureSrc.ts (or similar): builds the { uri, headers } shape on demand, splicing the runtime server URL ahead of /api/profile/picture?v=<…> on native and falling back to relative on PWA.
  • Update the avatar component to consume the new helper instead of constructing { uri, headers } inline.

Scope

  • New helper getProfilePictureSource(pictureUploadedAt):
    • Web (PWA): returns { uri: \/api/profile/picture?v=${ts}` }`. Same-origin → relative is correct.
    • Native + Tauri: returns { uri: \${serverUrl}/api/profile/picture?v=${ts}`, headers: { Authorization: `Bearer ${token}` } }`.
  • The Avatar component imports the helper and useEffects it whenever pictureUploadedAt changes (because getAuthHeader() is async).
  • Manual smoke on Android: upload a picture → page refreshes → avatar renders.

Acceptance criteria

  • Android: an existing profile picture renders the next time the Profile (or any other page with the avatar) loads.
  • Web: existing path unchanged.
  • No new permissions or deps.
  • If getAuthHeader() returns null (logged out, somehow), the avatar falls back to initials instead of showing a broken-image icon.

Out of scope

  • Switching to a hook-cached/blob-data avatar to avoid the per-render auth-header read.
  • Avatar caching strategy on native (let RN's built-in image cache handle it).
  • Adding a <picture> element with multiple format candidates.

Composes with

  • #217 / PR #248 — original upload work.
  • #253 — Android upload fix. Display can't be tested until uploads work.
  • #234 — native E2E smoke. Would catch this once it lands.
## Context After a profile picture is successfully uploaded, the universal client's avatar component on Android shows nothing (or falls back to initials) — the image never renders. Web displays it correctly. The avatar is served by `GET /api/profile/picture`, which is an authenticated route. PR [#248](https://forge.wynning.tech/james/carol/pulls/248) deliberately renders the avatar via `<Image source={{ uri, headers }}>` so the bearer Authorization header is attached on the off-origin fetch — that's the right shape. The failure modes most likely to bite: 1. **The `<Image>` component's `headers` prop quietly stops being honored on certain RN versions / Hermes builds.** Worth verifying it actually fires the GET with the header. 2. **The `uri` is relative** (`/api/profile/picture`), and `<Image>` doesn't run through the off-origin URL rewriter that the typed client uses — so it tries to resolve against an undefined origin and fails. The avatar needs the **absolute** URL spliced in by `getCachedServerUrl()` before being passed to `<Image>`. 3. **Cache-buster querystring** — the avatar URL typically includes `?v=<pictureUploadedAt>` to invalidate the cache after re-upload. On native, the `?v=` query interacts with the server response in a way the typed client handles but `<Image>` may not. 4. **Content-Type / decode failure** — the API serves `image/webp`. RN's `<Image>` supports WebP on Android 14+ natively but older Androids need explicit decoder libs. Less likely than #2, but worth checking. ## Source User-reported (June 2026), observed after [#248](https://forge.wynning.tech/james/carol/pulls/248) shipped and pictures can actually be uploaded (once [#253](https://forge.wynning.tech/james/carol/issues/253) lands). ## Approach Most likely fix: ensure the `<Image>` `uri` is the **absolute URL** with the off-origin server base spliced in, with the Authorization header attached. Both must travel together: relative URL + auth header is the broken combo because `<Image>` doesn't run the URL rewriter middleware. Implementation surface: - Add `apps/client/lib/profile/pictureSrc.ts` (or similar): builds the `{ uri, headers }` shape on demand, splicing the runtime server URL ahead of `/api/profile/picture?v=<…>` on native and falling back to relative on PWA. - Update the avatar component to consume the new helper instead of constructing `{ uri, headers }` inline. ## Scope - New helper `getProfilePictureSource(pictureUploadedAt)`: - Web (PWA): returns `{ uri: \`/api/profile/picture?v=\${ts}\` }`. Same-origin → relative is correct. - Native + Tauri: returns `{ uri: \`\${serverUrl}/api/profile/picture?v=\${ts}\`, headers: { Authorization: \`Bearer \${token}\` } }`. - The Avatar component imports the helper and `useEffect`s it whenever `pictureUploadedAt` changes (because `getAuthHeader()` is async). - Manual smoke on Android: upload a picture → page refreshes → avatar renders. ## Acceptance criteria - [ ] Android: an existing profile picture renders the next time the Profile (or any other page with the avatar) loads. - [ ] Web: existing path unchanged. - [ ] No new permissions or deps. - [ ] If `getAuthHeader()` returns null (logged out, somehow), the avatar falls back to initials instead of showing a broken-image icon. ## Out of scope - Switching to a hook-cached/blob-data avatar to avoid the per-render auth-header read. - Avatar caching strategy on native (let RN's built-in image cache handle it). - Adding a `<picture>` element with multiple format candidates. ## Composes with - [#217](https://forge.wynning.tech/james/carol/issues/217) / PR [#248](https://forge.wynning.tech/james/carol/pulls/248) — original upload work. - [#253](https://forge.wynning.tech/james/carol/issues/253) — Android upload fix. Display can't be tested until uploads work. - [#234](https://forge.wynning.tech/james/carol/issues/234) — native E2E smoke. Would catch this once it lands.
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#256
No description provided.