fix: use in-app VisionCamera for chat attachments on Android and iOS (v2)#86621
fix: use in-app VisionCamera for chat attachments on Android and iOS (v2)#86621srikarparsi merged 26 commits intomainfrom
Conversation
On Android, tapping "Take photo" in the attachment picker launches the system camera intent, which backgrounds Expensify. The OS can reclaim the app's memory during post-capture processing, causing a crash when returning from the camera. Replace the external camera intent with an in-app VisionCamera modal on Android. This keeps Expensify in the foreground during photo capture, eliminating the memory reclaim window. The change only affects Android; iOS continues to use the existing external camera. Fixed Issues: #84018 Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The AttachmentCamera component imports react-native-vision-camera which tries to initialize native modules that don't exist in the Jest test environment. This causes all test suites to fail with "system/camera-module-not-found" error. Add a mock similar to other native module mocks in the jest setup file. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Remove the Platform.OS === 'android' gate so the in-app camera is used on iOS as well, instead of falling back to the external system camera. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Replace the empty spacer in the bottom control bar with a camera flip button using the Rotate icon. Adds cameraPosition state to toggle between 'back' and 'front' devices via useCameraDevice. Flash visibility automatically adapts since front cameras typically lack a flash (already handled by the hasFlash check). Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The camera view was rendering behind the status bar because the viewfinder container had no top padding. Add paddingTop using the safe area inset so the camera content starts below the status bar. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The back arrow overlay on the camera viewfinder is not needed. The modal can still be dismissed via the Android hardware back button (onRequestClose). Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
jest-expo auto-mocks NativeModulesProxy functions to async () => {},
which returns undefined. When getCurrentPosition accesses
foregroundPermissionResponse.status, it crashes with TypeError.
This adds a proper expo-location mock returning valid PermissionResponse
objects.
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The jest.mock('expo-location', factory) in jest/setup.ts does not
reliably intercept module imports in the test file's module sandbox
with the jest-expo preset. The added await waitForBatchedUpdatesWithAct()
triggers a getCurrentPosition call that crashes when expo-location's
requestForegroundPermissionsAsync() returns undefined from the jest-expo
auto-mock. Adding the mock directly in the test file ensures it is
hoisted and applied within the test's module sandbox.
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The global expo-location mock in jest/setup.ts was overriding the jest-expo auto-mock, causing Accuracy named imports to resolve as undefined across 40+ test suites. Removed the global mock so jest-expo handles expo-location auto-mocking as before, and added the Accuracy enum to the local mock in IOURequestStepConfirmationPageTest. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The recent merge of main introduced GPS tracking code that imports from expo-location, whose native module is unavailable in Jest. This caused 40+ test suites to fail at module load time. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Add the provided camera-flip SVG asset and use it in the AttachmentCamera component instead of the generic Rotate icon. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Uses the standard HeaderWithBackButton component in normal document flow above the camera viewfinder, consistent with how other screens handle back navigation. Safe area top padding moved to the outer container so the header sits below the status bar. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
The ActivityIndicator component on main requires the reasonAttributes prop for skeleton span telemetry. This commit adds the prop to the AttachmentCamera usage and reverts the accidental change that made reasonAttributes optional in the type definition. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Remove extra changes that modified REPORT_VIOLATIONS handling — these are unrelated to the in-app camera feature. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
…pture, handleCameraClose Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
|
@ShridharGoel Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
I checked the current PR diff and there are no files under
Could you double-check which files you'd like reverted? It's possible the |
jest/setup.ts
Outdated
| // Mock expo-location — the native ExpoLocation module is unavailable in Jest | ||
| jest.mock('expo-location', () => ({ | ||
| Accuracy: {Lowest: 1, Low: 2, Balanced: 3, High: 4, Highest: 5, BestForNavigation: 6}, | ||
| PermissionStatus: {GRANTED: 'granted', UNDETERMINED: 'undetermined', DENIED: 'denied'}, | ||
| getCurrentPositionAsync: jest.fn(), | ||
| requestForegroundPermissionsAsync: jest.fn(() => Promise.resolve({status: 'granted', granted: true, canAskAgain: true, expires: 'never'})), | ||
| requestBackgroundPermissionsAsync: jest.fn(() => Promise.resolve({status: 'granted', granted: true, canAskAgain: true, expires: 'never'})), | ||
| getForegroundPermissionsAsync: jest.fn(() => Promise.resolve({status: 'undetermined', granted: false, canAskAgain: true, expires: 'never'})), | ||
| getBackgroundPermissionsAsync: jest.fn(() => Promise.resolve({status: 'undetermined', granted: false, canAskAgain: true, expires: 'never'})), | ||
| hasStartedLocationUpdatesAsync: jest.fn(() => Promise.resolve(false)), | ||
| startLocationUpdatesAsync: jest.fn(() => Promise.resolve()), | ||
| stopLocationUpdatesAsync: jest.fn(() => Promise.resolve()), | ||
| hasServicesEnabledAsync: jest.fn(() => Promise.resolve(true)), | ||
| reverseGeocodeAsync: jest.fn(() => Promise.resolve([])), | ||
| })); |
There was a problem hiding this comment.
@MelvinBot revert these 'expo-location' changes
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
|
Reverted the |
|
@MelvinBot remove the first line under "Explanation of change" in the description |
|
Done — removed the first line ("This PR is a clean subset of...") from the Explanation of Change section. |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppScreen.Recording.2026-03-20.at.2.35.16.AM.movAndroid: mWeb ChromeiOS: mWeb SafariMacOS: Chrome / Safari |
|
Triggering an Ad Hoc build to test |
|
🚧 @srikarparsi has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
joekaufmanexpensify
left a comment
There was a problem hiding this comment.
Good for product
|
🚧 @srikarparsi has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚀 Deployed to staging by https://github.com/srikarparsi in version: 9.3.51-0 🚀
Bundle Size Analysis (Sentry): |
|
👋 I reviewed the changes in this PR against all help site articles under SummaryNo help site changes are required for this PR. AnalysisThis PR replaces the external system camera intent with an in-app VisionCamera modal for the "Take photo" option in the chat attachment picker on mobile (Android & iOS). Here's what I found after reviewing all relevant articles: Chat documentationThe main article covering chat attachments —
It does not reference "Take photo," camera behavior, or the system camera specifically. The user-facing flow (tap + → Add attachment → Take photo) remains the same — only the underlying camera implementation changed (system camera → in-app camera). The existing documentation is accurate at its current level of abstraction. Receipt scanning documentationArticles like ConclusionSince the user-facing action ("Take photo" in the attachment picker) didn't change — only the camera implementation behind it did — no documentation updates are needed. |
|
PR was reverted to resolve blocker here |
|
🚀 Deployed to production by https://github.com/jasperhuangg in version: 9.3.51-10 🚀
|

Explanation of Change
On Android, tapping "Take photo" in the chat attachment picker launches the system camera via an external intent, which backgrounds the Expensify app. When the OS is under memory pressure, it can reclaim Expensify's process while the system camera is active — particularly during post-capture processing when memory usage spikes. This causes a crash when the user returns from the camera.
This PR replaces the external camera intent with an in-app camera modal powered by
react-native-vision-camera(VisionCamera), which is already used in the receipt scan flow (IOURequestStepScan). By keeping Expensify in the foreground during photo capture, we eliminate the window in which the OS can reclaim the app's memory. The in-app camera is used on both Android and iOS for a consistent experience.Changes:
AttachmentCameracomponent (AttachmentCamera.tsx): A full-screen modal with VisionCamera viewfinder, shutter button, flash toggle, and back button. Handles camera permissions using the sameCameraPermissionmodule already used by receipt scan.AttachmentPicker/index.native.tsx: The "Take photo" menu item now opens the in-app camera modal instead of launching the external system camera on both Android and iOS. The captured photo flows through the samepickAttachmentprocessing pipeline (resize, validation, upload) as before.Fixed Issues
$ #84018
Tests
+button, then "Add attachment" → "Take photo"Offline tests
+→ "Add attachment" → "Take photo"QA Steps
+→ "Add attachment" → "Take photo"PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps./** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari