fix(mobile): photo upload fails on iOS 26 Release build#36
Merged
Conversation
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.
There was a problem hiding this comment.
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 introducednormaliseAssetUri()to produce a usable localfile://URI (including materializingph:///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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CreateProfile and EditProfile photo upload silently failed on the iOS 26 Release build, blocking all new-user onboarding. Maestro flow
20-account-creation-journeyreproduces 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 inAddUserPhoto.handleAddcallshandleDelete(). The flow never advances past CreateProfile because uploads are mandatory.Root cause
ProfileImageUploader/utils/index.ts → formatImage()ran this on every iOS pick:That
replaceis 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:expo-file-system/legacyuploadAsyncwithFileSystemUploadType.BINARY_CONTENT(seecomponents/AddUserPhoto/index.tsxL82). On Expo SDK 55 / RN 0.83 / iOS 26 the native side passes the string straight intoNSURL, which silently fails to resolve a bare absolute path (no scheme = nil URL = "Could not read file").expo-image-manipulator'smanipulateAsync(called fromcompressImage, L79) has the sameNSURLrequirement.So every iOS upload after the FormData migration was throwing at the
compressstage (oruploadstage 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 backph://URIs even withallowsEditing: true. Those also can't be read byuploadAsync/manipulateAsyncand 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:.replace("file://", "")strip outright.normaliseAssetUri(uri)that guarantees afile://URI for every shape the picker can return:ph://andassets-library://references → materialised to afile://path incacheDirectoryviaFileSystem.copyAsync(the only way to feed them touploadAsync/manipulateAsync)./var/mobile/...) → re-attachfile://.file://URIs → pass through.getInfoAsyncafter 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".formatImageis now async;pickImage/takeImagealready returned its result so no call-site changes were needed.Manual verification status
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 backendsignedUrlroute requires live AWS credentials. What I did verify:pnpm typecheckforapps/mobilepasses cleanly with the change.oxlintandoxfmt --checkon the touched files pass (no new findings; pre-existingreact-perfwarnings onAddUserPhoto/index.tsxare unrelated).node_modules/expo-image-picker/ios/MediaHandler.swiftthat the picker emitstargetUrl.absoluteString(file://...) — so the strip was always wrong for the modern uploader; this isn't speculation.node_modules/expo-file-system/src/legacy/FileSystem.tsuploadAsyncjsdoc + native call that thefileUriargument 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: afterChooseis tapped on the photo edit screen, theActivityIndicatorshows for ~1–2s, the photo appears in slot 0, and the flow advances throughCreate Profile → CompleteProfile → AskForLocation → Swipe tabwithswipe-screenasserted 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'spresign(tRPC reachability),compress(still a URI issue),upload(S3 ACL / network / 413 size), orfinalize.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.yamlon iOS 26 Release sim (iPhone 17 Pro Max) passes through toswipe-screen.file://URIs and the strip never ran — but worth sanity-checking).pnpm typecheckandpnpm lintare green in CI.