fix: use in-app VisionCamera for chat attachments with proper permission handling#86981
fix: use in-app VisionCamera for chat attachments with proper permission handling#86981
Conversation
…ion handling Replace the external camera intent with an in-app camera modal using react-native-vision-camera for the chat attachment photo flow. This keeps the app in the foreground during capture, preventing OS memory reclaim crashes. Key fix from the previous attempt (PR 86621): the capturePhoto function now checks permission status BEFORE the camera ref, matching the pattern used by IOURequestStepScan. Previously, !camera.current returned early before reaching the permission check, so tapping the shutter with denied permissions did nothing. Also adds an AppState listener to refresh permission status when returning from OS Settings, ensuring the camera view updates automatically after the user grants permission. Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
🦜 Polyglot Parrot! 🦜Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues: View the translation diffdiff --git a/src/languages/fr.ts b/src/languages/fr.ts
index c39eac6c..c5d6fd26 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -882,7 +882,7 @@ const translations: TranslationDeepObject<typeof en> = {
beginningOfChatHistory: (users: string) => `Cette discussion est avec ${users}.`,
beginningOfChatHistoryPolicyExpenseChat: (workspaceName: string, submitterDisplayName: string) =>
`C’est ici que <strong>${submitterDisplayName}</strong> soumettra des dépenses à <strong>${workspaceName}</strong>. Utilisez simplement le bouton +.`,
- beginningOfChatHistoryPolicyExpenseChatTrack: 'C\u2019est ici que vous suivrez vos dépenses',
+ beginningOfChatHistoryPolicyExpenseChatTrack: 'C’est ici que vous suivrez vos dépenses',
beginningOfChatHistorySelfDM: 'Ceci est votre espace personnel. Utilisez-le pour vos notes, tâches, brouillons et rappels.',
beginningOfChatHistorySystemDM: 'Bienvenue ! Procédons à la configuration.',
chatWithAccountManager: 'Discutez avec votre gestionnaire de compte ici',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index e5857cf2..337a3726 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1096,15 +1096,12 @@ const translations: TranslationDeepObject<typeof en> = {
if (!added && !updated) {
return 'Nessuna categoria è stata aggiunta o aggiornata.';
}
-
if (added && updated) {
return `${added} ${added === 1 ? 'categoria aggiunta' : 'categorie aggiunte'}, ${updated} ${updated === 1 ? 'categoria aggiornata' : 'categorie aggiornate'}.`;
}
-
if (added) {
return added === 1 ? 'È stata aggiunta 1 categoria.' : `Sono state aggiunte ${added} categorie.`;
}
-
return updated === 1 ? 'È stata aggiornata 1 categoria.' : `Sono state aggiornate ${updated} categorie.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index ce36d9e7..5b5e0485 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1079,15 +1079,12 @@ const translations: TranslationDeepObject<typeof en> = {
if (!added && !updated) {
return 'カテゴリーは追加も更新もされていません。';
}
-
if (added && updated) {
return `${added}件のカテゴリーを追加し、${updated}件のカテゴリーを更新しました。`;
}
-
if (added) {
return added === 1 ? 'カテゴリーを1件追加しました。' : `${added}件のカテゴリーを追加しました。`;
}
-
return updated === 1 ? 'カテゴリーを1件更新しました。' : `${updated}件のカテゴリーを更新しました。`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index d183191f..98535eae 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1095,15 +1095,12 @@ const translations: TranslationDeepObject<typeof en> = {
if (!added && !updated) {
return 'Er zijn geen categorieën toegevoegd of bijgewerkt.';
}
-
if (added && updated) {
return `${added} ${added === 1 ? 'categorie' : 'categorieën'} toegevoegd, ${updated} ${updated === 1 ? 'categorie' : 'categorieën'} bijgewerkt.`;
}
-
if (added) {
return added === 1 ? '1 categorie is toegevoegd.' : `${added} categorieën zijn toegevoegd.`;
}
-
return updated === 1 ? '1 categorie is bijgewerkt.' : `${updated} categorieën zijn bijgewerkt.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
@@ -1166,7 +1163,7 @@ const translations: TranslationDeepObject<typeof en> = {
flash: 'flits',
multiScan: 'meerscannen',
shutter: 'sluiter',
- flipCamera: 'camera omdraaien',
+ flipCamera: 'camera draaien',
gallery: 'galerij',
deleteReceipt: 'Bon verwijderen',
deleteConfirmation: 'Weet je zeker dat je deze bon wilt verwijderen?',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 251ed56f..39984583 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1096,15 +1096,12 @@ const translations: TranslationDeepObject<typeof en> = {
if (!added && !updated) {
return 'Nie dodano ani nie zaktualizowano żadnych kategorii.';
}
-
if (added && updated) {
return `Dodano ${added} ${added === 1 ? 'kategorię' : 'kategorie'}, zaktualizowano ${updated} ${updated === 1 ? 'kategorię' : 'kategorie'}.`;
}
-
if (added) {
return added === 1 ? 'Dodano 1 kategorię.' : `Dodano ${added} kategorie.`;
}
-
return updated === 1 ? 'Zaktualizowano 1 kategorię.' : `Zaktualizowano ${updated} kategorie.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `Dodano ${transactions} transakcji.` : 'Dodano 1 transakcję.'),
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 066c11d7..02f3ca56 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1094,15 +1094,12 @@ const translations: TranslationDeepObject<typeof en> = {
if (!added && !updated) {
return 'Nenhuma categoria foi adicionada ou atualizada.';
}
-
if (added && updated) {
return `${added} ${added === 1 ? 'categoria' : 'categorias'} adicionada${added === 1 ? '' : 's'}, ${updated} ${updated === 1 ? 'categoria' : 'categorias'} atualizada${updated === 1 ? '' : 's'}.`;
}
-
if (added) {
return added === 1 ? '1 categoria foi adicionada.' : `${added} categorias foram adicionadas.`;
}
-
return updated === 1 ? '1 categoria foi atualizada.' : `${updated} categorias foram atualizadas.`;
},
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 7ebd3c33..808a2ae4 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1126,7 +1126,7 @@ const translations: TranslationDeepObject<typeof en> = {
flash: '闪光',
multiScan: '多重扫描',
shutter: '快门',
- flipCamera: '翻转摄像头',
+ flipCamera: '切换摄像头',
gallery: '图库',
deleteReceipt: '删除收据',
deleteConfirmation: '确定要删除这张收据吗?',
Note You can apply these changes to your branch by copying the patch to your clipboard, then running |
Co-authored-by: Shridhar Goel <ShridharGoel@users.noreply.github.com>
|
Fixed the failing |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
@MelvinBot Mark this as ready for review |
|
Done! This PR has been marked as ready for review. ✅ |
|
@MelvinBot It is still in draft |
|
@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] |
|
Apologies for the earlier miss — the PR is now marked as ready for review. ✅ |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppScreen.Recording.2026-03-20.at.2.35.16.AM.movAndroid: mWeb ChromeiOS: mWeb SafariMacOS: Chrome / Safari |

Explanation of Change
This is a re-land of PR #86621 with a critical fix for the camera permission flow (deploy blocker #86852).
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 — causing a crash when the user returns. 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.Key fix from the previous attempt: The
capturePhotofunction inAttachmentCameranow checks permission status before the camera ref, matching the pattern used byIOURequestStepScan. In the reverted PR,!camera.currentreturned early before reaching the permission check — so when camera permission was denied/blocked and the VisionCamera component wasn't rendered (meaningcamera.currentwas null), tapping the shutter button did nothing instead of triggering the permission request or showing the "Camera access" settings alert.Additional improvement: Added an
AppStatelistener to refresh the camera permission status when the app returns to the foreground (e.g., after granting permission in OS Settings), so the camera view updates automatically — matching the behavior already present inIOURequestStepScan.Fixed Issues
$ #84018
Tests
+button, then "Add attachment" → "Take photo"Permission flow tests:
8. Revoke camera permission in device Settings
9. Tap
+→ "Add attachment" → "Take photo"10. Verify the "Camera access" permission view is shown (Hand icon + "Camera access is required" text + "Continue" button)
11. Tap "Continue" — verify the system permission dialog appears (if not permanently blocked)
12. Dismiss the system dialog without granting
13. Tap the shutter button — verify it triggers the permission request again (or shows the "Camera access" alert if blocked)
14. When permanently denied, verify the alert with "Settings" button appears and opens OS Settings
15. Grant camera permission in OS Settings and return to the app
16. Verify the camera viewfinder appears automatically without needing to reopen
Camera flip test:
17. In the in-app camera, tap the camera flip button (bottom right)
18. Verify the camera switches between front and back
Offline tests
N/A — Camera functionality requires an active device and is not network-dependent. The photo capture and attachment upload flow handles offline scenarios via the existing attachment upload pipeline.
QA Steps
Same as Tests above.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand 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.Screenshots/Videos
Android: Native
Android: mWeb Chrome
N/A — uses native camera
iOS: Native
iOS: mWeb Safari
N/A — uses native camera
MacOS: Chrome / Safari
N/A — this change is native-only (AttachmentPicker/index.native.tsx)
Screenshots/Videosundefined