Skip to content

fix(mobile): photo upload fails on iOS 26 Release build#36

Merged
GSTJ merged 1 commit into
mainfrom
fix/photo-upload-ios-26-release
May 20, 2026
Merged

fix(mobile): photo upload fails on iOS 26 Release build#36
GSTJ merged 1 commit into
mainfrom
fix/photo-upload-ios-26-release

Conversation

@GSTJ
Copy link
Copy Markdown
Owner

@GSTJ GSTJ commented May 20, 2026

Summary

CreateProfile and EditProfile photo upload silently failed on the iOS 26 Release build, blocking all new-user onboarding. Maestro flow 20-account-creation-journey reproduces it (see ~/.maestro/tests/2026-05-19_210900/): the user picks a photo, the in-app toast "Couldn't upload image. Please try again." appears, and the photo slot is cleared because the catch in AddUserPhoto.handleAdd calls handleDelete(). The flow never advances past CreateProfile because uploads are mandatory.

Root cause

ProfileImageUploader/utils/index.ts → formatImage() ran this on every iOS pick:

const pictureUri = Platform.OS === "ios" ? image.uri.replace("file://", "") : image.uri;

That replace is a legacy workaround from the React Native FormData / multipart-upload era, where iOS used to want a bare path. Two SDK / API shifts since then have made it actively harmful:

  1. The current uploader (added later) uses expo-file-system/legacy uploadAsync with FileSystemUploadType.BINARY_CONTENT (see components/AddUserPhoto/index.tsx L82). On Expo SDK 55 / RN 0.83 / iOS 26 the native side passes the string straight into NSURL, which silently fails to resolve a bare absolute path (no scheme = nil URL = "Could not read file").
  2. expo-image-manipulator's manipulateAsync (called from compressImage, L79) has the same NSURL requirement.

So every iOS upload after the FormData migration was throwing at the compress stage (or upload stage if compress somehow got past it), the catch fired, and the slot got cleared. In __DEV__ builds this would have shown the stage in the toast thanks to PR #26's instrumentation, but in Release we only got the generic "Couldn't upload image" text — exactly what Maestro caught.

The PHPicker UI in the failure screenshots ("Photos / Collections" tabs, screenshot 20-02/20-03) also confirmed we're on the new iOS 14+ picker, which on iOS 26 occasionally hands back ph:// URIs even with allowsEditing: true. Those also can't be read by uploadAsync / manipulateAsync and would have been a latent second failure the moment Apple flipped the picker default for any subset of users.

The fix

apps/mobile/src/components/ProfileImageUploader/utils/index.ts:

  • Drop the .replace("file://", "") strip outright.
  • Add normaliseAssetUri(uri) that guarantees a file:// URI for every shape the picker can return:
    • ph:// and assets-library:// references → materialised to a file:// path in cacheDirectory via FileSystem.copyAsync (the only way to feed them to uploadAsync / manipulateAsync).
    • Bare absolute paths (/var/mobile/...) → re-attach file://.
    • Already-file:// URIs → pass through.
  • Call getInfoAsync after normalising and fail fast with a descriptive error if the file isn't readable, so any future regression surfaces in Bugsnag (and in the dev-mode magicToast added in fix(mobile): surface real error when EditProfile image upload fails #26) with a precise message instead of the cryptic native "Could not read file".
  • formatImage is now async; pickImage / takeImage already returned its result so no call-site changes were needed.

Manual verification status

Honesty note for reviewers — please read.

I was unable to run a full Release-scheme build end-to-end in this sandbox: the project is Continuous-Native-Generation (no ios/ directory checked in), a real Release build needs Apple signing certificates, and the backend signedUrl route requires live AWS credentials. What I did verify:

  • pnpm typecheck for apps/mobile passes cleanly with the change.
  • oxlint and oxfmt --check on the touched files pass (no new findings; pre-existing react-perf warnings on AddUserPhoto/index.tsx are unrelated).
  • Confirmed in node_modules/expo-image-picker/ios/MediaHandler.swift that the picker emits targetUrl.absoluteString (file://...) — so the strip was always wrong for the modern uploader; this isn't speculation.
  • Confirmed in node_modules/expo-file-system/src/legacy/FileSystem.ts uploadAsync jsdoc + native call that the fileUri argument is expected to be a proper local URI.

Before merge or release, please run the Maestro flow on an iOS 26 Release build sim (maestro test apps/mobile/.maestro/20-account-creation-journey.yaml). The expected result is: after Choose is tapped on the photo edit screen, the ActivityIndicator shows for ~1–2s, the photo appears in slot 0, and the flow advances through Create Profile → CompleteProfile → AskForLocation → Swipe tab with swipe-screen asserted visible. No toast, no cleared slot.

If by any chance the toast still fires post-fix, the dev-build toast text (from #26) will include [stage] reason — that tells us immediately whether it's presign (tRPC reachability), compress (still a URI issue), upload (S3 ACL / network / 413 size), or finalize.

Files changed

  • apps/mobile/src/components/ProfileImageUploader/utils/index.ts — the fix above, with inline jsdoc explaining the historical context so this doesn't get re-introduced.

Test plan

  • maestro test apps/mobile/.maestro/20-account-creation-journey.yaml on iOS 26 Release sim (iPhone 17 Pro Max) passes through to swipe-screen.
  • Manually run EditProfile flow on iOS 26 Release sim — add a photo, confirm slot fills and no toast fires.
  • Manually run the flow on Android Release build (the change is no-op on Android — the picker already returns file:// URIs and the strip never ran — but worth sanity-checking).
  • Verify pnpm typecheck and pnpm lint are green in CI.

CreateProfile/EditProfile photo upload silently failed on the iOS 26
Release build, blocking all new-user onboarding (Maestro flow
`20-account-creation-journey` reproduces with toast "Couldn't upload
image. Please try again." and post-failure empty photo slots).

Root cause: `formatImage()` in ProfileImageUploader/utils stripped the
`file://` scheme on iOS — a legacy workaround from the React Native
FormData-multipart era — before passing the URI into the modern
`expo-file-system` `uploadAsync` (BINARY_CONTENT) and the
`expo-image-manipulator` `manipulateAsync` calls. On Expo SDK 55 / iOS
26, both APIs hand the string straight to `NSURL`, which silently
fails to resolve a bare absolute path. Result: every upload threw,
the catch block cleared the slot, the user was stuck.

Fix:
- Drop the `.replace("file://", "")` hack outright.
- Introduce `normaliseAssetUri()` that returns a `file://` URI in every
  case the picker can produce: `ph://`/`assets-library://` asset
  references are materialised into the cache dir via
  `FileSystem.copyAsync`; bare paths get the scheme re-attached;
  proper URIs pass through unchanged.
- Verify the file actually exists at the normalised path before
  returning — turns the cryptic native "Could not read file" into a
  precise error visible to Bugsnag and (in dev) the magicToast stage
  instrumentation added in #26.

`formatImage` is now async; `pickImage`/`takeImage` already returned
its result so no call-site changes were needed.
Copilot AI review requested due to automatic review settings May 20, 2026 13:30
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes iOS 26 Release-build failures in profile photo upload by ensuring the selected image URI is always readable by expo-image-manipulator and expo-file-system’s uploadAsync.

Changes:

  • Removed legacy iOS file:// stripping and introduced normaliseAssetUri() to produce a usable local file:// URI (including materializing ph:// / assets-library:// assets into app storage).
  • Added a fast-fail readability check via getInfoAsync() to surface a clear error early when the resulting URI is not readable.
  • Made formatImage() async to support URI normalization before returning picker results.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +108 to +112
return (extHint ?? "jpg").toLowerCase();
})();
const destination = `${targetDir}picker-${Date.now()}.${extension}`;
await copyAsync({ from: uri, to: destination });
return destination;
@GSTJ GSTJ merged commit a076df4 into main May 20, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants