From e76e6749686813674ddae139d5b9b64ff5f52876 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 2 Oct 2026 15:56:26 +0200 Subject: [PATCH 01/11] Upgrade react-native-vision-camera from v4 to v5 --- __mocks__/react-native-vision-camera.ts | 4 +- android/gradle.properties | 4 - contributingGuides/HYBRID_APP.md | 2 +- ios/Podfile.lock | 72 ++++-- package-lock.json | 41 ++-- package.json | 3 +- patches/react-native-vision-camera/details.md | 28 --- ...camera-teardown-hang-and-config-leak.patch | 221 ------------------ .../AttachmentPicker/AttachmentCamera.tsx | 61 +++-- src/hooks/useCameraPhotoAspectRatio.ts | 26 +++ src/hooks/useNativeCamera.ts | 30 ++- src/libs/ReceiptStorage/index.native.ts | 1 - src/libs/cameraCapture/getWideLensZoom.ts | 15 ++ src/libs/cameraCapture/index.ts | 57 +++++ .../cameraCapture/selectWideCameraDevice.ts | 18 ++ src/libs/cameraCapture/types.ts | 8 + .../getPhotoSource/index.android.ts | 5 - src/libs/fileDownload/getPhotoSource/index.ts | 3 +- .../index.android.ts | 9 - .../index.ios.ts | 11 - .../getVideoResolutionFormatFilter/index.ts | 9 - .../getVideoResolutionFormatFilter/types.ts | 9 - src/libs/telemetry/ReceiptObservability.ts | 11 +- .../index.native.tsx | 38 +-- .../captureReceipt/index.ts | 33 +-- .../captureReceipt/types.ts | 8 +- .../components/Camera/index.native.tsx | 69 +++--- .../components/CameraViewport.tsx | 47 ++-- .../NavigationAwareCamera/Camera.tsx | 7 - .../components/NavigationAwareCamera/types.ts | 8 +- .../getCameraAspectRatio.ts | 11 +- .../hooks/useCameraInitTelemetry.ts | 22 +- .../utils/getCameraCapabilityAttributes.ts | 49 ++-- tests/ui/DistanceRequestStartPageTest.tsx | 2 + .../ui/IOURequestRedirectToStartPageTest.tsx | 2 + tests/ui/IOURequestStartPageManualTabTest.tsx | 2 + tests/ui/IOURequestStartPageTest.tsx | 2 + tests/ui/IOURequestStepScanTest.tsx | 2 +- tests/ui/ScanSkipConfirmationTest.tsx | 2 +- tests/ui/components/AttachmentCameraTest.tsx | 23 +- .../unit/getCameraCapabilityAttributesTest.ts | 130 ++++------- .../hooks/useCameraPhotoAspectRatioTest.ts | 46 ++++ tests/unit/libs/cameraCaptureDeviceTest.ts | 76 ++++++ 43 files changed, 605 insertions(+), 622 deletions(-) delete mode 100644 patches/react-native-vision-camera/details.md delete mode 100644 patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch create mode 100644 src/hooks/useCameraPhotoAspectRatio.ts create mode 100644 src/libs/cameraCapture/getWideLensZoom.ts create mode 100644 src/libs/cameraCapture/index.ts create mode 100644 src/libs/cameraCapture/selectWideCameraDevice.ts create mode 100644 src/libs/cameraCapture/types.ts delete mode 100644 src/libs/fileDownload/getPhotoSource/index.android.ts delete mode 100644 src/libs/getVideoResolutionFormatFilter/index.android.ts delete mode 100644 src/libs/getVideoResolutionFormatFilter/index.ios.ts delete mode 100644 src/libs/getVideoResolutionFormatFilter/index.ts delete mode 100644 src/libs/getVideoResolutionFormatFilter/types.ts create mode 100644 tests/unit/hooks/useCameraPhotoAspectRatioTest.ts create mode 100644 tests/unit/libs/cameraCaptureDeviceTest.ts diff --git a/__mocks__/react-native-vision-camera.ts b/__mocks__/react-native-vision-camera.ts index e50233a6c540..f5fb4eb1c46c 100644 --- a/__mocks__/react-native-vision-camera.ts +++ b/__mocks__/react-native-vision-camera.ts @@ -1,7 +1,7 @@ const useCameraDevice = jest.fn(() => null); const useCameraDevices = jest.fn(() => []); -const useCameraFormat = jest.fn(() => null); const useCameraPermission = jest.fn(() => ({hasPermission: false, requestPermission: jest.fn(() => Promise.resolve(false))})); +const usePhotoOutput = jest.fn(() => ({capturePhoto: jest.fn(), capturePhotoToFile: jest.fn()})); const Camera = Object.assign( jest.fn(() => null), @@ -11,4 +11,4 @@ const Camera = Object.assign( }, ); -export {Camera, useCameraDevice, useCameraDevices, useCameraFormat, useCameraPermission}; +export {Camera, useCameraDevice, useCameraDevices, useCameraPermission, usePhotoOutput}; diff --git a/android/gradle.properties b/android/gradle.properties index 42d4ff32632a..ccec9ab1f86f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -53,10 +53,6 @@ MYAPP_UPLOAD_STORE_FILE=my-upload-key.keystore # Key Information MYAPP_UPLOAD_KEY_ALIAS=ReactNativeChat-Key-Alias -# Disable Frame Processors for VisionCamera. -# We might want to re-enable them if we need QR code scanning or other frame processing features (maybe in VisionCamera V3) -disableFrameProcessors=true - android.nonTransitiveRClass=false org.gradle.parallel=true diff --git a/contributingGuides/HYBRID_APP.md b/contributingGuides/HYBRID_APP.md index b6b0e61a1af1..6a67cea30b06 100644 --- a/contributingGuides/HYBRID_APP.md +++ b/contributingGuides/HYBRID_APP.md @@ -149,7 +149,7 @@ Executing `npm run clean` clears cache for React Native, Android, and iOS. It me - **YAPL**: `npm run clean -- --npm` - `npm`, `Mobile-Expensify/node_modules` (OldDot-specific) ### I use VSCode and the patches are constantly failing during `npm install` -In case you use VSCode and are having problems during `npm install` caused by patch errors, e.g. `@onfido/react-native-sdk` and/or `react-native-vision-camera`, **please check if you have any Java-related extensions enabled in your editor**. In case you have it, try **disabling** them and running the steps specified above to have a clean install. +In case you use VSCode and are having problems during `npm install` caused by patch errors, e.g. `@onfido/react-native-sdk`, **please check if you have any Java-related extensions enabled in your editor**. In case you have it, try **disabling** them and running the steps specified above to have a clean install. The reason is that some Java extensions are constantly creating files inside `node_modules` libraries and thus messing up with `patch-package` logic, so it’s advisable to keep them disabled in order to avoid such problems. ## Android diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 22d60fe1c79e..b557c07eec75 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -407,6 +407,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - NitroImage (0.15.2): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - NitroModules (0.37.1): - hermes-engine - RCTRequired @@ -3029,11 +3053,6 @@ PODS: - Turf - Yoga - RNNitroSQLite (9.8.3): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - hermes-engine - NitroModules - RCTRequired @@ -3426,12 +3445,31 @@ PODS: - Turf (4.0.0) - TweetNacl (1.0.2) - UMAppLoader (57.0.1) - - VisionCamera (4.7.2): - - VisionCamera/Core (= 4.7.2) - - VisionCamera/React (= 4.7.2) - - VisionCamera/Core (4.7.2) - - VisionCamera/React (4.7.2): + - VisionCamera (5.2.3): + - hermes-engine + - NitroImage + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - Yoga (0.0.0) DEPENDENCIES: @@ -3465,6 +3503,7 @@ DEPENDENCIES: - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - lottie-react-native (from `../node_modules/lottie-react-native`) - NitroFetch (from `../node_modules/react-native-nitro-fetch`) + - NitroImage (from `../node_modules/react-native-nitro-image`) - NitroModules (from `../node_modules/react-native-nitro-modules`) - NWWebSocket (~> 0.5.10) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" @@ -3692,6 +3731,8 @@ EXTERNAL SOURCES: :path: "../node_modules/lottie-react-native" NitroFetch: :path: "../node_modules/react-native-nitro-fetch" + NitroImage: + :path: "../node_modules/react-native-nitro-image" NitroModules: :path: "../node_modules/react-native-nitro-modules" onfido-react-native-sdk: @@ -3995,6 +4036,7 @@ SPEC CHECKSUMS: MapboxMaps: 22340ae0e0d47b2e4761019b1d6c569ab04e088d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroFetch: 42521ebe6c3f291b2be0e1d73c7fe95c4e4ea0b8 + NitroImage: 03c650b246b841609b53236df5c03549d156e9ad NitroModules: 6232dc95a28a7a221227e618733ce0e674e8753b NWWebSocket: 873eb4b18acd0317da70cdba388d792a3187ce00 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 @@ -4061,7 +4103,7 @@ SPEC CHECKSUMS: react-native-skia: b3c25cbcfdc2d4f68b73390ff1b50ef2a9bc5968 react-native-view-shot: 6da2aaa0404b8d617ab1689550acdb251d759af9 react-native-wallet: 69c8fde0b0e16899b95b2689f7cb737f4ec247f9 - react-native-webview: 36fd14012dd0352c90b960ea7860fbc53876593b + react-native-webview: 006d1ead1d2708b2fd1b6639a759139963b39e55 React-NativeModulesApple: a092d89b58f635ebfab88048b0eda9fb516819fd React-networking: ee8e1504d7c1c73a3655bf545ca1184bebae4c7f React-oscompat: 0b72a7e926954a0415ccd83e0748b6561fe45367 @@ -4097,7 +4139,7 @@ SPEC CHECKSUMS: ReactCodegen: cd296de5e1c422deebafff34134d1721b5c29704 ReactCommon: d5c1bb4427bf51c443de5926aac332c89ddd9363 ReactNativeBiometrics: 1fb6ad206cfd39900e952b1abcdcdba22bdd6fd9 - ReactNativeDependencies: fa0a54b3f5319ae0e3b9aff32bfee7a424b88e66 + ReactNativeDependencies: 4227659c64db3eab64c6ea87b5695fd329922798 ReactNativeHybridApp: a35e7f25b54a370ada94c01309ccb1ff0954b949 RNAppleAuthentication: 9027af8aa92b4719ef1b6030a8e954d37079473a RNCClipboard: 7a7d4557bfd3370b35c99dfecd92ae7b9fc4948a @@ -4108,10 +4150,10 @@ SPEC CHECKSUMS: RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: 7b07d9192bc65c6883fb37fdecc05dc6b61c814f RNGoogleSignin: 89877c73f0fbf6af2038fbdb7b73b5a25b8330cc - RNLiveMarkdown: f4c383d846d5d756f42bdf6006e85d5baf0ebfc8 + RNLiveMarkdown: ae654419aef52e634a04551805c9e5ab8690442d RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce - RNNitroSQLite: c986393b7cb0e5e4587d0c9f7108e56ee8db9627 + RNNitroSQLite: 56c1f2b950788d3923ca6163c7a8117b6c31cc0d RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 RNReanimated: f093c30aa11e45a8044dcc19417f44cffa1c4e1d @@ -4130,7 +4172,7 @@ SPEC CHECKSUMS: Turf: c9eb11a65d96af58cac523460fd40fec5061b081 TweetNacl: 3abf4d1d2082b0114e7a67410e300892448951e6 UMAppLoader: 173cb90a3039aa9c1b84bace4487e1907a4a5a39 - VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe + VisionCamera: c0e2c308592613de270de5f6e722b53d04a26f0d Yoga: c30c859b7e9b75e547b600b486fe33165f60de00 PODFILE CHECKSUM: 64b30fca02abc208520075c2e1275b24533fa805 diff --git a/package-lock.json b/package-lock.json index 348dfd221adf..9bf5cda9e685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "react-native-keyboard-controller": "1.22.4", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", + "react-native-nitro-image": "0.15.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "9.8.3", "react-native-onyx": "3.0.113", @@ -145,7 +146,7 @@ "react-native-tab-view": "^4.3.0", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "5.1.0", - "react-native-vision-camera": "^4.7.2", + "react-native-vision-camera": "5.2.3", "react-native-web": "0.21.2", "react-native-webview": "16.0.0", "react-native-worklets": "0.11.1", @@ -37683,6 +37684,25 @@ "node": ">= 8" } }, + "node_modules/react-native-nitro-image": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/react-native-nitro-image/-/react-native-nitro-image-0.15.2.tgz", + "integrity": "sha512-WiaGnORmfNIIqtRJW76TWYNet1YZx1MWDlSOxd0jfcIBN4KjkVVMDVpvO01eCfpxKVG0ez9nA2iLQkPVlGFj+A==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + }, + "peerDependenciesMeta": { + "react-native-nitro-modules": { + "optional": true + } + } + }, "node_modules/react-native-nitro-modules": { "version": "0.37.1", "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.37.1.tgz", @@ -38303,25 +38323,18 @@ } }, "node_modules/react-native-vision-camera": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.2.tgz", - "integrity": "sha512-C+5PvlSunN6I4aYplSask+v3jfhgduZumIVw6H6lG+Afpf8boIcG3uFSsSfVgj+hxI7fx6qM6bsciEhzgxEUYg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.2.3.tgz", + "integrity": "sha512-OHjgM8BPzxppTqbF1FEltj+fxvisj4xdghM7ypoT1p6cOe28HByhOJP1c7LkBiUiZuotbsdbQwGdJ8nU7ZvPzA==", "license": "MIT", "peerDependencies": { - "@shopify/react-native-skia": "*", "react": "*", "react-native": "*", - "react-native-reanimated": "*", - "react-native-worklets-core": "*" + "react-native-nitro-image": "*", + "react-native-nitro-modules": "*" }, "peerDependenciesMeta": { - "@shopify/react-native-skia": { - "optional": true - }, - "react-native-reanimated": { - "optional": true - }, - "react-native-worklets-core": { + "react-native-nitro-modules": { "optional": true } } diff --git a/package.json b/package.json index 7d58029bc1a8..f4fe5d004e78 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,7 @@ "react-native-keyboard-controller": "1.22.4", "react-native-localize": "^3.5.4", "react-native-nitro-fetch": "1.6.2", + "react-native-nitro-image": "0.15.2", "react-native-nitro-modules": "0.37.1", "react-native-nitro-sqlite": "9.8.3", "react-native-onyx": "3.0.113", @@ -221,7 +222,7 @@ "react-native-tab-view": "^4.3.0", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "5.1.0", - "react-native-vision-camera": "^4.7.2", + "react-native-vision-camera": "5.2.3", "react-native-web": "0.21.2", "react-native-webview": "16.0.0", "react-native-worklets": "0.11.1", diff --git a/patches/react-native-vision-camera/details.md b/patches/react-native-vision-camera/details.md deleted file mode 100644 index 2152b6fb0dc7..000000000000 --- a/patches/react-native-vision-camera/details.md +++ /dev/null @@ -1,28 +0,0 @@ -# `react-native-vision-camera` patches - -### [react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch](react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch) - -This patch carries two related iOS fixes to `CameraSession.configure`. The second addresses a crash that the first one exposed. - -- Reason (1) — **main-thread hangs when leaving a camera screen**: VisionCamera could deallocate its preview while `AVCaptureSession` was still busy configuring, causing the main thread to wait on the capture-session lock. - - - **How it works**: Deactivates camera resources before teardown, uses a thread-safe counter to cancel stale configuration calls, and adds logging for slow or completed configurations. - - **Upstream source**: Ports the three-file solution from [react-native-vision-camera PR #3664](https://github.com/mrousavy/react-native-vision-camera/pull/3664). The library's creator and maintainer reviewed that PR, and the author revised it in response to the feedback. It was never merged and was ultimately closed as obsolete after VisionCamera V5 rewrote the upstream codebase. - - **Local correction**: Omits the upstream counter increment from `CameraView.deinit`. Normal configuration closures retain `CameraView` and therefore prevent `deinit`, while the teardown closure does not. Incrementing the counter from `deinit` could consequently invalidate and skip the queued teardown that disables the active camera session. - - **Why it was patched locally**: As explained in the [decision to patch locally](https://github.com/Expensify/App/issues/91293#issuecomment-4917304380), this was a known bug in the VisionCamera version used when the patch was introduced. At that time, upgrading to V5 was not a targeted alternative because it was a major rewrite that was still receiving similar reports, so porting the reviewed fix to the existing version was the lower-risk approach. - -- Reason (2) — **`NSGenericException` from an unbalanced capture-session configuration**: `-[AVCaptureSession stopRunning] stopRunning may not be called between calls to beginConfiguration and commitConfiguration`, thrown from `CameraSession.checkIsActive` on the camera queue. - - - **How it works**: `configure` calls `beginConfiguration()` inside one `if difference.isSessionConfigurationDirty` block and `commitConfiguration()` inside a second block further down, so that input, output and device changes batch into a single hardware update. Any throw between the two — `configureDevice`, `configureOutputs`, the `noDevice` guard, or any of the device-configuration steps that follow it — skipped the commit and left the session's configuration counter permanently held. Every later `startRunning()` or `stopRunning()` on that session then threw, for the remainder of its lifetime. The patch tracks whether the configuration was opened and commits it on the error path, preserving the existing batching on the success path. - - **Relationship to fix 1**: The defect is upstream and predates this patch. Fix 1 added `deactivateCameraSession()`, which issues a `configure` call during teardown that reaches `checkIsActive` — turning a previously latent leak into a crash on the way out of the camera screen. Closing the upstream hole is preferred to reverting fix 1, which would reintroduce the hangs while leaving the leak in place. - - **Scope**: Video capture session only. The audio block in the same function has the identical defect, but it is unreachable in this app: `audio` defaults to `false` natively, no call site enables it, `startRecording` is never called, and every throw site in `configureAudioSession` sits behind `if enableAudio`. Revisit if either of those changes. - -- Upstream PR/issue: - - Fix 1: https://github.com/mrousavy/react-native-vision-camera/issues/3636, https://github.com/mrousavy/react-native-vision-camera/pull/3664 - - Fix 2: N/A. The defect is real upstream, but `4.7.2` belongs to the V4 line, which is no longer supported — VisionCamera V5 rewrote this code, so a fix against V4 would not be accepted and a report against the rewritten V5 code would not describe the same source. This is the same situation that closed [PR #3664](https://github.com/mrousavy/react-native-vision-camera/pull/3664) as obsolete. The patch will be dropped if and when we move to V5. -- E/App issue: - - Fix 1: https://github.com/Expensify/App/issues/91293 - - Fix 2: https://github.com/Expensify/App/issues/97103 -- PR introducing patch: - - Fix 1: https://github.com/Expensify/App/pull/95984 - - Fix 2: https://github.com/Expensify/App/pull/97499 diff --git a/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch b/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch deleted file mode 100644 index 2f8d4f0eed14..000000000000 --- a/patches/react-native-vision-camera/react-native-vision-camera+4.7.2+001+fix-ios-camera-teardown-hang-and-config-leak.patch +++ /dev/null @@ -1,221 +0,0 @@ -diff --git a/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift b/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift -index 10b0f33..5d33236 100644 ---- a/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift -+++ b/node_modules/react-native-vision-camera/ios/Core/CameraSession.swift -@@ -113,8 +113,22 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat - - VisionLogger.log(level: .info, message: "configure { ... }: Waiting for lock...") - -+ let slowConfigurationWarning = DispatchWorkItem { -+ VisionLogger.log(level: .warning, message: "configure { ... }: is still running after 2 seconds.") -+ } -+ CameraQueues.cameraQueue.asyncAfter(deadline: .now() + .seconds(2), execute: slowConfigurationWarning) -+ -+ let completionGroup = DispatchGroup() -+ -+ completionGroup.enter() -+ completionGroup.notify(queue: CameraQueues.cameraQueue) { -+ slowConfigurationWarning.cancel() -+ VisionLogger.log(level: .info, message: "configure { ... }: completed.") -+ } -+ - // Set up Camera (Video) Capture Session (on camera queue, acts like a lock) - CameraQueues.cameraQueue.async { -+ defer { completionGroup.leave() } - // Let caller configure a new configuration for the Camera. - let config = CameraConfiguration(copyOf: self.configuration) - do { -@@ -131,10 +145,16 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat - - VisionLogger.log(level: .info, message: "configure { ... }: Updating CameraSession Configuration... \(difference)") - -+ // beginConfiguration() and commitConfiguration() are split across two separate -+ // isSessionConfigurationDirty blocks so device changes batch into a single commit. -+ // Anything that throws in between would otherwise leave the session's configuration -+ // counter permanently held, and every later start/stop then throws NSGenericException. -+ var isConfigurationOpen = false - do { - // If needed, configure the AVCaptureSession (inputs, outputs) - if difference.isSessionConfigurationDirty { - self.captureSession.beginConfiguration() -+ isConfigurationOpen = true - - // 1. Update input device - if difference.inputChanged { -@@ -193,6 +213,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat - // We commit the session config updates AFTER the device config, - // that way we can also batch those changes into one update instead of doing two updates. - self.captureSession.commitConfiguration() -+ isConfigurationOpen = false - } - - // 10. Start or stop the session if needed -@@ -210,12 +231,19 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat - // After configuring, set this to the new configuration. - self.configuration = config - } catch { -+ if isConfigurationOpen { -+ // Release the configuration lock before reporting, otherwise the session stays -+ // wedged for the rest of its lifetime and every startRunning/stopRunning throws. -+ self.captureSession.commitConfiguration() -+ } - self.onConfigureError(error) - } - - // Set up Audio Capture Session (on audio queue) - if difference.audioSessionChanged { -+ completionGroup.enter() - CameraQueues.audioQueue.async { -+ defer { completionGroup.leave() } - do { - // Lock Capture Session for configuration - VisionLogger.log(level: .info, message: "Beginning AudioSession configuration...") -@@ -234,7 +262,9 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat - - // Set up Location streaming (on location queue) - if difference.locationChanged { -+ completionGroup.enter() - CameraQueues.locationQueue.async { -+ defer { completionGroup.leave() } - do { - VisionLogger.log(level: .info, message: "Beginning Location Output configuration...") - try self.configureLocationOutput(configuration: config) -@@ -265,7 +295,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat - } - } - -- public final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { -+ final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - switch captureOutput { - case is AVCaptureVideoDataOutput: - onVideoFrame(sampleBuffer: sampleBuffer, orientation: connection.orientation, isMirrored: connection.isVideoMirrored) -diff --git a/node_modules/react-native-vision-camera/ios/React/CameraView.swift b/node_modules/react-native-vision-camera/ios/React/CameraView.swift -index c773975..44514da 100644 ---- a/node_modules/react-native-vision-camera/ios/React/CameraView.swift -+++ b/node_modules/react-native-vision-camera/ios/React/CameraView.swift -@@ -101,7 +101,7 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat - var cameraSession = CameraSession() - var previewView: PreviewView? - var isMounted = false -- private var currentConfigureCall: DispatchTime? -+ private let currentConfigureCall: Counter = .init() - private let fpsSampleCollector = FpsSampleCollector() - - // CameraView+Zoom -@@ -135,10 +135,16 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat - onViewReadyEvent?(nil) - } - } else { -+ deactivateCameraSession() - fpsSampleCollector.stop() - } - } - -+ deinit { -+ // Allow phone to sleep -+ UIApplication.shared.isIdleTimerDisabled = false -+ } -+ - override public func layoutSubviews() { - if let previewView { - previewView.frame = frame -@@ -181,17 +187,18 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat - // pragma MARK: Props updating - override public final func didSetProps(_ changedProps: [String]!) { - VisionLogger.log(level: .info, message: "Updating \(changedProps.count) props: [\(changedProps.joined(separator: ", "))]") -- let now = DispatchTime.now() -- currentConfigureCall = now -+ let currentConfigureCall = self.currentConfigureCall -+ let now = currentConfigureCall.increment() - - cameraSession.configure { [self] config in - // Check if we're still the latest call to configure { ... } -- guard currentConfigureCall == now else { -+ guard currentConfigureCall.check(now) else { - // configure waits for a lock, and if a new call to update() happens in the meantime we can drop this one. - // this works similar to how React implemented concurrent rendering, the newer call to update() has higher priority. -- VisionLogger.log(level: .info, message: "A new configure { ... } call arrived, aborting this one...") -+ VisionLogger.log(level: .info, message: "A new configure { ... } call arrived, aborting this one [\(now)]…") - throw CameraConfiguration.AbortThrow.abort - } -+ VisionLogger.log(level: .info, message: "configure { ... } [\(now)]") - - // Input Camera Device - config.cameraId = cameraId as? String -@@ -283,6 +290,36 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat - UIApplication.shared.isIdleTimerDisabled = isActive - } - -+ private func deactivateCameraSession() { -+ // Allow phone to sleep -+ UIApplication.shared.isIdleTimerDisabled = false -+ -+ #if VISION_CAMERA_ENABLE_FRAME_PROCESSORS -+ frameProcessor = nil -+ #endif -+ -+ let currentConfigureCall = self.currentConfigureCall -+ let now = currentConfigureCall.increment() -+ -+ cameraSession.configure { config in -+ // Check if we're still the latest call to configure { ... } -+ guard currentConfigureCall.check(now) else { -+ // configure waits for a lock, and if a new call to update() happens in the meantime we can drop this one. -+ // this works similar to how React implemented concurrent rendering, the newer call to update() has higher priority. -+ VisionLogger.log(level: .info, message: "A new configure { ... } call arrived, aborting this one [\(now)]…") -+ throw CameraConfiguration.AbortThrow.abort -+ } -+ VisionLogger.log(level: .info, message: "configure { ... } [\(now)]") -+ config.photo = .disabled -+ config.video = .disabled -+ config.audio = .disabled -+ config.codeScanner = .disabled -+ config.enableLocation = false -+ config.torch = .off -+ config.isActive = false -+ } -+ } -+ - func updatePreview() { - if preview && previewView == nil { - // Create PreviewView and add it -diff --git a/node_modules/react-native-vision-camera/ios/React/Utils/Counter.swift b/node_modules/react-native-vision-camera/ios/React/Utils/Counter.swift -new file mode 100644 -index 0000000..35dd931 ---- /dev/null -+++ b/node_modules/react-native-vision-camera/ios/React/Utils/Counter.swift -@@ -0,0 +1,34 @@ -+import os.lock -+ -+final class Counter { -+ /** -+ * https://forums.swift.org/t/atomic-property-wrapper-for-standard-library/30468/18 -+ */ -+ private var unfair_lock: os_unfair_lock_t -+ private var count = 1 -+ init() { -+ unfair_lock = .allocate(capacity: 1) -+ unfair_lock.initialize(to: os_unfair_lock()) -+ } -+ deinit { -+ unfair_lock.deinitialize(count: 1) -+ unfair_lock.deallocate() -+ } -+ private func lock() { -+ os_unfair_lock_lock(unfair_lock) -+ } -+ private func unlock() { -+ os_unfair_lock_unlock(unfair_lock) -+ } -+ func increment() -> Int { -+ lock() -+ defer { unlock() } -+ count &+= 1 -+ return count -+ } -+ func check(_ count: Int) -> Bool { -+ lock() -+ defer { unlock() } -+ return self.count == count -+ } -+} diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index 757baa712c2a..1bc486921823 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -10,6 +10,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -20,27 +21,30 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {capturePhotoToPath} from '@libs/cameraCapture'; +import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; +import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; import {getFileName} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; -import getVideoResolutionFormatFilter from '@libs/getVideoResolutionFormatFilter'; import isInLandscapeMode from '@libs/isInLandscapeMode'; import {logCameraCaptureFailed, logCameraRuntimeError} from '@libs/telemetry/ReceiptObservability'; import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; -import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {Camera, CameraRuntimeError, PhotoFile} from 'react-native-vision-camera'; +import type {CameraRef} from 'react-native-vision-camera'; import React, {useEffect, useRef, useState} from 'react'; import {Alert, AppState, View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import {RESULTS} from 'react-native-permissions'; import Animated from 'react-native-reanimated'; -import {useCameraDevice, useCameraDevices, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; +import {useCameraDevice, useCameraDevices, usePhotoOutput, Camera as VisionCamera} from 'react-native-vision-camera'; + +const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; type CapturedPhoto = { uri: string; @@ -81,29 +85,20 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null); const isCapturing = useRef(false); const isActiveRef = useRef(false); - const cameraRef = useRef(null); - - const device = useCameraDevice(cameraPosition, { - physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], - }); + const cameraRef = useRef(null); const cameraDevices = useCameraDevices(); + const defaultDevice = useCameraDevice(cameraPosition); + const device = selectWideCameraDevice(cameraDevices, cameraPosition, defaultDevice); const canFlipCamera = cameraDevices.some((d) => d.position === 'front') && cameraDevices.some((d) => d.position === 'back'); - const format = useCameraFormat(device, [ - {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, - { - photoResolution: { - width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, - height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT, - }, - }, - getVideoResolutionFormatFilter(windowWidth, windowHeight), - ]); + // Owned by the component that renders the camera: an output shared between sessions aborts (VisionCamera #4156). + // v5's default 'native' container is HEIC on iOS. + const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization: 'quality'}); const hasFlash = !!device?.hasFlash; - const cameraAspectRatio = getCameraAspectRatio(format, isLandscape); + const {cameraAspectRatio, updatePhotoResolution} = useCameraPhotoAspectRatio(photoOutput, PHOTO_TARGET_RESOLUTION, isLandscape); - const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, device?.supportsFocus ?? false); + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, device?.supportsFocusMetering ?? false); const askForPermissions = () => requestCameraPermission(translate, setCameraPermissionStatus); @@ -164,13 +159,11 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme isCapturing.current = true; - cameraRef.current - .takePhoto({ - flash: flash && hasFlash ? 'on' : 'off', - enableShutterSound: !isPlatformMuted, - }) - .then((photo: PhotoFile) => { - // Discard capture if the camera was closed while takePhoto was in-flight + capturePhotoToPath(photoOutput, { + flashMode: flash && hasFlash ? 'on' : 'off', + enableShutterSound: !isPlatformMuted, + }) + .then((photo) => { if (!isActiveRef.current) { return; } @@ -199,12 +192,12 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme }); }; - const handleCameraError = (error: CameraRuntimeError) => { + const handleCameraError = (error: Error) => { if (!isActiveRef.current) { return; } Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); - logCameraRuntimeError({code: error.code, message: error.message}); + logCameraRuntimeError(error); }; const handleClose = () => { @@ -283,12 +276,12 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme diff --git a/src/hooks/useCameraPhotoAspectRatio.ts b/src/hooks/useCameraPhotoAspectRatio.ts new file mode 100644 index 000000000000..404e58b3f482 --- /dev/null +++ b/src/hooks/useCameraPhotoAspectRatio.ts @@ -0,0 +1,26 @@ +import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; + +import type {CameraPhotoOutput, Size} from 'react-native-vision-camera'; + +import {useState} from 'react'; + +/** + * Viewfinder aspect ratio of the photo the camera saves. v5 negotiates the photo size, which is only known from + * `currentResolution` once configured; on Android it can come out 16:9 despite a 4:3 target. + */ +function useCameraPhotoAspectRatio(photoOutput: CameraPhotoOutput, targetResolution: Size, isInLandscapeMode: boolean) { + const [photoResolution, setPhotoResolution] = useState(undefined); + + const updatePhotoResolution = () => { + const resolution = photoOutput.currentResolution; + // iOS can report 0x0 before the output is connected. + if (!resolution || resolution.width <= 0 || resolution.height <= 0) { + return; + } + setPhotoResolution((previous) => (previous?.width === resolution.width && previous?.height === resolution.height ? previous : {width: resolution.width, height: resolution.height})); + }; + + return {cameraAspectRatio: getCameraAspectRatio(photoResolution ?? targetResolution, isInLandscapeMode), updatePhotoResolution}; +} + +export default useCameraPhotoAspectRatio; diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts index cfe43f063ab0..cd61ddce8311 100644 --- a/src/hooks/useNativeCamera.ts +++ b/src/hooks/useNativeCamera.ts @@ -1,13 +1,14 @@ import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; import Log from '@libs/Log'; import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission'; import type React from 'react'; -import type {Camera, Point} from 'react-native-vision-camera'; +import type {CameraRef, Point} from 'react-native-vision-camera'; import {useFocusEffect} from '@react-navigation/core'; import {useCallback, useRef, useState} from 'react'; @@ -15,7 +16,7 @@ import {AppState} from 'react-native'; import {Gesture} from 'react-native-gesture-handler'; import {RESULTS} from 'react-native-permissions'; import {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated'; -import {useCameraDevice} from 'react-native-vision-camera'; +import {useCameraDevice, useCameraDevices} from 'react-native-vision-camera'; import {scheduleOnRN} from 'react-native-worklets'; import useIsPlatformMuted from './useIsPlatformMuted'; @@ -54,9 +55,9 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) const {isLoaderVisible} = useFullScreenLoaderState(); const {setIsLoaderVisible} = useFullScreenLoaderActions(); - const device = useCameraDevice('back', { - physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], - }); + const cameraDevices = useCameraDevices(); + const defaultDevice = useCameraDevice('back'); + const device = selectWideCameraDevice(cameraDevices, 'back', defaultDevice); const isPlatformMuted = useIsPlatformMuted(); @@ -65,11 +66,11 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) const [flash, setFlash] = useState(false); const [didCapturePhoto, setDidCapturePhoto] = useState(false); const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false); - const camera = useRef(null); + const camera = useRef(null); const askForPermissions = useCallback(() => requestCameraPermission(translate, setCameraPermissionStatus), [translate]); - const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(camera, device?.supportsFocus ?? false); + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(camera, device?.supportsFocusMetering ?? false); // Refresh camera permission on screen focus and app state changes useFocusEffect( @@ -126,20 +127,25 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) * Module-level so React Compiler never sees the `cameraRef.current` read. Doing it inside a hook body * trips the "no ref access during render" rule, making OXC bail on the file and diverge from Babel. */ -function focusCameraAtPoint(cameraRef: React.RefObject, point: Point) { +// How CameraX (Android) and VisionCamera (iOS) reject a focus request that a newer tap replaced. +const FOCUS_CANCELLED_MESSAGES = ['Cancelled by another startFocusAndMetering()', 'The metering operation has been canceled!']; + +function focusCameraAtPoint(cameraRef: React.RefObject, point: Point) { if (!cameraRef.current) { return; } - cameraRef.current.focus(point).catch((error: Record) => { - if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') { + // Android throws "MeteringModes cannot be empty!" for the default options. + cameraRef.current.focusTo(point, {modes: ['AF', 'AE']}).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (FOCUS_CANCELLED_MESSAGES.some((cancelled) => message.includes(cancelled))) { return; } - Log.warn('Error focusing camera', error); + Log.warn('Error focusing camera', {message: message.split('\n').at(0)}); }); } -function useTapToFocusGesture(cameraRef: React.RefObject, supportsFocus: boolean) { +function useTapToFocusGesture(cameraRef: React.RefObject, supportsFocus: boolean) { const focusIndicatorOpacity = useSharedValue(0); const focusIndicatorScale = useSharedValue(2); const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index f735b5bebfd4..6498c1b58495 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -28,7 +28,6 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { const sourcePath = fileURIToPath(uriOrPath); - // vision-camera writes straight into the receipts folder through its `path` option. const existingName = toDurableName(uriOrPath); if (existingName) { return verify(dir, existingName); diff --git a/src/libs/cameraCapture/getWideLensZoom.ts b/src/libs/cameraCapture/getWideLensZoom.ts new file mode 100644 index 000000000000..d331b3d90b1f --- /dev/null +++ b/src/libs/cameraCapture/getWideLensZoom.ts @@ -0,0 +1,15 @@ +import type {CameraDevice} from 'react-native-vision-camera'; + +/** + * The zoom of the wide-angle lens, v4's `device.neutralZoom`, which v5 dropped. On an iOS virtual device zoom 1 is the + * widest lens, and `zoomLensSwitchFactors` follows `physicalDevices`. Android reports no switch factors. + */ +function getWideLensZoom(device: Pick): number { + const wideLensIndex = device.physicalDevices.findIndex((physicalDevice) => physicalDevice.type === 'wide-angle'); + if (wideLensIndex <= 0) { + return 1; + } + return device.zoomLensSwitchFactors.at(wideLensIndex - 1) ?? 1; +} + +export default getWideLensZoom; diff --git a/src/libs/cameraCapture/index.ts b/src/libs/cameraCapture/index.ts new file mode 100644 index 000000000000..b2ce8c18b2d0 --- /dev/null +++ b/src/libs/cameraCapture/index.ts @@ -0,0 +1,57 @@ +import fileURIToPath from '@libs/fileURIToPath'; + +import type {CameraOrientation, CameraPhotoOutput, CameraRef, CapturePhotoSettings, Photo} from 'react-native-vision-camera'; + +import RNFS from 'react-native-fs'; + +import type CapturedPhoto from './types'; + +const SNAPSHOT_JPEG_QUALITY = 85; + +function isSideways(orientation: CameraOrientation) { + return orientation === 'left' || orientation === 'right'; +} + +// `Photo` reports the sensor buffer's size; the saved JPEG carries the rotation in EXIF. +function getDisplaySize(photo: Photo) { + return isSideways(photo.orientation) ? {width: photo.height, height: photo.width} : {width: photo.width, height: photo.height}; +} + +// `Photo.saveToFileAsync` neither strips `file://` nor creates the folder. +function preparePath(filePath: string) { + const path = fileURIToPath(filePath); + return RNFS.mkdir(path.slice(0, path.lastIndexOf('/'))).then(() => path); +} + +/** + * Takes a full photo and writes it to `filePath`, or to a temporary file. + * + * Chained with `.then`, not `await`: on Android (Hermes) an `await` on a Nitro promise resumed with `undefined` before + * the native capture finished, while `.then` on the same promise received the result. + */ +function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePhotoSettings, filePath?: string): Promise { + return photoOutput.capturePhoto(settings, {}).then((photo) => { + const save = filePath ? preparePath(filePath).then((path) => photo.saveToFileAsync(path).then(() => path)) : photo.saveToTemporaryFileAsync(); + + return save + .then((path) => ({path, ...getDisplaySize(photo)})) + .finally(() => { + photo.dispose(); + }); + }); +} + +/** Android only: VisionCamera v5's `takeSnapshot` throws on iOS. */ +function captureSnapshotToPath(camera: CameraRef, filePath: string): Promise { + const path = fileURIToPath(filePath); + return camera.takeSnapshot().then((image) => + image + .saveToFileAsync(path, 'jpg', SNAPSHOT_JPEG_QUALITY) + .then(() => ({path, width: image.width, height: image.height})) + .finally(() => { + image.dispose(); + }), + ); +} + +export {capturePhotoToPath, captureSnapshotToPath}; diff --git a/src/libs/cameraCapture/selectWideCameraDevice.ts b/src/libs/cameraCapture/selectWideCameraDevice.ts new file mode 100644 index 000000000000..c84e35dbb23a --- /dev/null +++ b/src/libs/cameraCapture/selectWideCameraDevice.ts @@ -0,0 +1,18 @@ +import type {CameraDevice, CameraPosition} from 'react-native-vision-camera'; + +/** + * Prefers the virtual wide + ultra-wide camera. v5's `physicalDevices` filter can't pick it: single-lens devices report + * no physical devices and Android reports every lens as 'unknown', so the first device in the list wins. + */ +function selectWideCameraDevice(devices: CameraDevice[], position: CameraPosition, defaultDevice: CameraDevice | undefined): CameraDevice | undefined { + const wideAndUltraWide = devices.find((device) => { + if (device.position !== position || device.physicalDevices.length !== 2) { + return false; + } + const types = new Set(device.physicalDevices.map((physicalDevice) => physicalDevice.type)); + return types.has('wide-angle') && types.has('ultra-wide-angle'); + }); + return wideAndUltraWide ?? defaultDevice; +} + +export default selectWideCameraDevice; diff --git a/src/libs/cameraCapture/types.ts b/src/libs/cameraCapture/types.ts new file mode 100644 index 000000000000..23afd52eeacf --- /dev/null +++ b/src/libs/cameraCapture/types.ts @@ -0,0 +1,8 @@ +/** A captured image written to disk. */ +type CapturedPhoto = { + path: string; + width: number; + height: number; +}; + +export default CapturedPhoto; diff --git a/src/libs/fileDownload/getPhotoSource/index.android.ts b/src/libs/fileDownload/getPhotoSource/index.android.ts deleted file mode 100644 index 2d44d7fbf2db..000000000000 --- a/src/libs/fileDownload/getPhotoSource/index.android.ts +++ /dev/null @@ -1,5 +0,0 @@ -function getPhotoSource(filePath: string): string { - return `file://${filePath}`; -} - -export default getPhotoSource; diff --git a/src/libs/fileDownload/getPhotoSource/index.ts b/src/libs/fileDownload/getPhotoSource/index.ts index efbdeb54a063..2ece94bf2bbd 100644 --- a/src/libs/fileDownload/getPhotoSource/index.ts +++ b/src/libs/fileDownload/getPhotoSource/index.ts @@ -1,5 +1,6 @@ +/** VisionCamera v5 returns plain paths on both platforms, where v4 returned a `file://` URL on iOS. */ function getPhotoSource(filePath: string): string { - return filePath; + return filePath.startsWith('file://') ? filePath : `file://${filePath}`; } export default getPhotoSource; diff --git a/src/libs/getVideoResolutionFormatFilter/index.android.ts b/src/libs/getVideoResolutionFormatFilter/index.android.ts deleted file mode 100644 index 38b9aaf488fe..000000000000 --- a/src/libs/getVideoResolutionFormatFilter/index.android.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type GetVideoResolutionFormatFilter from './types'; - -// Size the preview to the screen to avoid an oversized preview surface. Format dimensions are -// landscape, so the window dimensions are swapped. -const getVideoResolutionFormatFilter: GetVideoResolutionFormatFilter = (windowWidth, windowHeight) => ({ - videoResolution: {width: windowHeight, height: windowWidth}, -}); - -export default getVideoResolutionFormatFilter; diff --git a/src/libs/getVideoResolutionFormatFilter/index.ios.ts b/src/libs/getVideoResolutionFormatFilter/index.ios.ts deleted file mode 100644 index 496b243435aa..000000000000 --- a/src/libs/getVideoResolutionFormatFilter/index.ios.ts +++ /dev/null @@ -1,11 +0,0 @@ -import CONST from '@src/CONST'; - -import type GetVideoResolutionFormatFilter from './types'; - -// Match the photo target. Otherwise the format selector pairs that photo size with a low video -// resolution and the viewfinder looks grainy. -const getVideoResolutionFormatFilter: GetVideoResolutionFormatFilter = () => ({ - videoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}, -}); - -export default getVideoResolutionFormatFilter; diff --git a/src/libs/getVideoResolutionFormatFilter/index.ts b/src/libs/getVideoResolutionFormatFilter/index.ts deleted file mode 100644 index 5016ae8c08e4..000000000000 --- a/src/libs/getVideoResolutionFormatFilter/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type GetVideoResolutionFormatFilter from './types'; - -// The in-app camera only mounts on iOS and Android. This default exists so the import resolves on -// other platforms, and it mirrors the Android sizing. -const getVideoResolutionFormatFilter: GetVideoResolutionFormatFilter = (windowWidth, windowHeight) => ({ - videoResolution: {width: windowHeight, height: windowWidth}, -}); - -export default getVideoResolutionFormatFilter; diff --git a/src/libs/getVideoResolutionFormatFilter/types.ts b/src/libs/getVideoResolutionFormatFilter/types.ts deleted file mode 100644 index 86f37b49d634..000000000000 --- a/src/libs/getVideoResolutionFormatFilter/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {FormatFilter} from 'react-native-vision-camera'; - -/** - * Builds the videoResolution filter for the in-app camera. The live viewfinder renders from the video - * pipeline, so this controls preview quality only. Capture always uses the photo resolution. - */ -type GetVideoResolutionFormatFilter = (windowWidth: number, windowHeight: number) => FormatFilter; - -export default GetVideoResolutionFormatFilter; diff --git a/src/libs/telemetry/ReceiptObservability.ts b/src/libs/telemetry/ReceiptObservability.ts index b6c7d594bea4..928422298e7f 100644 --- a/src/libs/telemetry/ReceiptObservability.ts +++ b/src/libs/telemetry/ReceiptObservability.ts @@ -262,12 +262,15 @@ function logCameraCaptureFailed(error: unknown) { }); } -/** VisionCamera reported a runtime error, which usually means the preview never became usable. */ -function logCameraRuntimeError({code, message}: {code: string; message: string}) { +/** + * VisionCamera reported a runtime error. v5 errors have no code, and on Android the message is the whole Java stack + * trace, so the first line is what groups them. + */ +function logCameraRuntimeError(error: Error) { Log.alert(`${RECEIPT_LOG_PREFIX} camera runtime error`, { event: 'cameraRuntimeError', - code, - error: message, + code: error.message.split('\n').at(0)?.trim() ?? '', + error: error.message, }); } diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index 6253bfcf9abb..78bf4b19fbbb 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -4,6 +4,7 @@ import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; +import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; import useFilesValidation from '@hooks/useFilesValidation'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -13,12 +14,14 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import {capturePhotoToPath} from '@libs/cameraCapture'; import {getMimeTypeFromUri} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; +import {rand64} from '@libs/NumberUtils'; import {getOdometerImageUri} from '@libs/OdometerUtils'; import ReceiptStorage from '@libs/ReceiptStorage'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -29,7 +32,6 @@ import CameraViewport from '@pages/iou/request/step/IOURequestStepScan/component import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar'; import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; -import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; import type {WithFullTransactionOrNotFoundProps} from '@pages/iou/request/step/withFullTransactionOrNotFound'; @@ -42,14 +44,15 @@ import type SCREENS from '@src/SCREENS'; import type {FileObject} from '@src/types/utils/Attachment'; import type {LayoutRectangle} from 'react-native'; -import type {PhotoFile} from 'react-native-vision-camera'; import React, {useRef} from 'react'; import {Alert, View} from 'react-native'; import ReactNativeBlobUtil from 'react-native-blob-util'; import {RESULTS} from 'react-native-permissions'; import {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; -import {useCameraFormat} from 'react-native-vision-camera'; +import {usePhotoOutput} from 'react-native-vision-camera'; + +const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; @@ -94,6 +97,9 @@ function IOURequestStepOdometerImage({ }); const {setIsLoaderVisible} = useFullScreenLoaderActions(); + // v5's default 'native' container is HEIC on iOS. + const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization: 'quality'}); + const title = imageType === 'start' ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle'); const snapPhotoText = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.snapPhotoStart') : translate('distance.odometer.snapPhotoEnd'); const icon = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? lazyIcons.OdometerStart : lazyIcons.OdometerEnd; @@ -204,13 +210,15 @@ function IOURequestStepOdometerImage({ Log.warn('Error checking if the directory exists', error); }) .then(() => { - camera?.current - ?.takePhoto({ - flash: flash && hasFlash ? 'on' : 'off', + capturePhotoToPath( + photoOutput, + { + flashMode: flash && hasFlash ? 'on' : 'off', enableShutterSound: !isPlatformMuted, - path, - }) - .then((photo: PhotoFile) => { + }, + `${path}/odometer_${Date.now()}_${rand64()}.jpg`, + ) + .then((photo) => { const imageObject: ImageObject = {file: photo, filename: photo.path, source: getPhotoSource(photo.path)}; cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height) .then(({file, filename, source}) => @@ -250,12 +258,7 @@ function IOURequestStepOdometerImage({ }); }; - const format = useCameraFormat(device, [ - {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, - {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, - ]); - - const cameraAspectRatio = getCameraAspectRatio(format, isInLandscapeMode); + const {cameraAspectRatio, updatePhotoResolution} = useCameraPhotoAspectRatio(photoOutput, PHOTO_TARGET_RESOLUTION, isInLandscapeMode); // Wait for camera permission status to render if (cameraPermissionStatus == null) { @@ -293,7 +296,10 @@ function IOURequestStepOdometerImage({ ): boolean { - return (flash && hasFlash) || isInLandscapeMode; + return Platform.OS === 'ios' || (flash && hasFlash) || isInLandscapeMode; } -const captureReceipt: CaptureReceipt = (camera, options) => { - const {flash, hasFlash, isPlatformMuted, path} = options; - if (shouldTakePhoto(options)) { - return camera.takePhoto({ - flash: flash && hasFlash ? 'on' : 'off', - enableShutterSound: !isPlatformMuted, - path, - }); +const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, isInLandscapeMode, path}) => { + const filePath = `${path}/receipt_${Date.now()}_${rand64()}.jpg`; + + if (!shouldTakePhoto({flash, hasFlash, isInLandscapeMode})) { + return captureSnapshotToPath(camera, filePath); } - return camera.takeSnapshot({quality: 85, path}); + return capturePhotoToPath( + photoOutput, + { + flashMode: flash && hasFlash ? 'on' : 'off', + enableShutterSound: Platform.OS === 'ios' ? false : !isPlatformMuted, + }, + filePath, + ); }; export default captureReceipt; diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts index 9e68e9887c60..3313cbb1bbd6 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts @@ -1,13 +1,15 @@ -import type {Camera, PhotoFile} from 'react-native-vision-camera'; +import type CapturedPhoto from '@libs/cameraCapture/types'; + +import type {CameraPhotoOutput, CameraRef} from 'react-native-vision-camera'; type CaptureReceiptOptions = { flash: boolean; hasFlash: boolean; isPlatformMuted: boolean | undefined; - path: string; isInLandscapeMode: boolean; + path: string; }; -type CaptureReceipt = (camera: Camera, options: CaptureReceiptOptions) => Promise; +type CaptureReceipt = (camera: CameraRef, photoOutput: CameraPhotoOutput, options: CaptureReceiptOptions) => Promise; export type {CaptureReceipt, CaptureReceiptOptions}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 01e29aeea8ff..c3c6c78e8a6a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -1,12 +1,13 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useNativeCamera from '@hooks/useNativeCamera'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; +import type CapturedPhoto from '@libs/cameraCapture/types'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import HapticFeedback from '@libs/HapticFeedback'; import Log from '@libs/Log'; @@ -20,25 +21,28 @@ import {useMultiScanActions, useMultiScanState} from '@pages/iou/request/step/IO import MultiScanEducationalModal from '@pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal'; import ReceiptPreviews from '@pages/iou/request/step/IOURequestStepScan/components/ReceiptPreviews'; import ScannerControlsBar from '@pages/iou/request/step/IOURequestStepScan/components/ScannerControlsBar'; -import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; import useCameraInitTelemetry from '@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry'; import startReceiptPrepareSpan from '@pages/iou/request/step/IOURequestStepScan/utils/startReceiptPrepareSpan'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {PhotoFile} from 'react-native-vision-camera'; - import React, {useRef} from 'react'; import {Alert, Platform, View} from 'react-native'; import {RESULTS} from 'react-native-permissions'; import {useAnimatedStyle, useSharedValue, withSequence, withTiming} from 'react-native-reanimated'; -import {useCameraFormat} from 'react-native-vision-camera'; +import {usePhotoOutput} from 'react-native-vision-camera'; import type {CameraProps} from './types'; const BLINK_DURATION_MS = 80; +const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; + +const QUALITY_PRIORITIZATION = Platform.OS === 'ios' ? 'speed' : 'quality'; + +const PREPARED_IOS_PHOTO_SETTINGS = [{flashMode: 'off' as const, enableShutterSound: false}]; + /** * Camera — native capture variant. * Renders a react-native-vision-camera viewfinder with shutter, flash toggle, gallery picker, and focus gesture. @@ -49,7 +53,6 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const styles = useThemeStyles(); const {translate} = useLocalize(); const isInLandscapeMode = useIsInLandscapeMode(); - const {windowWidth, windowHeight} = useWindowDimensions(); const {isMultiScanEnabled, canUseMultiScan} = useMultiScanState(); const {toggleMultiScan} = useMultiScanActions(); @@ -83,22 +86,12 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou cameraFocusIndicatorAnimatedStyle, } = useNativeCamera({onFocusStart, onFocusCleanup}); - // Prioritize photoResolution so the format selector picks the configured PHOTO_WIDTH/PHOTO_HEIGHT - // format. videoResolution is platform-specific: - // - iOS: match the photo target — `takeSnapshot` reads from the video pipeline, so a smaller - // video resolution would degrade the snapshot capture quality. - // - Android: keep screen dimensions — `takeSnapshot` is a GPU screenshot of the preview surface - // and doesn't depend on video resolution; constraining to screen size avoids burning GPU on a - // higher-than-needed preview. - const format = useCameraFormat(device, [ - {photoAspectRatio: CONST.RECEIPT_CAMERA.PHOTO_ASPECT_RATIO}, - {photoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}}, - Platform.OS === 'ios' - ? {videoResolution: {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}} - : {videoResolution: {width: windowHeight, height: windowWidth}}, - ]); - const cameraAspectRatio = getCameraAspectRatio(format, isInLandscapeMode); - const fps = format ? Math.min(Math.max(30, format.minFps), format.maxFps) : 30; + // v5's default 'native' container is HEIC on iOS. + const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization: QUALITY_PRIORITIZATION}); + const outputs = [photoOutput]; + // `` already adds a `resolutionBias` for every output. + const constraints = [{fps: 30}]; + const {cameraAspectRatio, updatePhotoResolution} = useCameraPhotoAspectRatio(photoOutput, PHOTO_TARGET_RESOLUTION, isInLandscapeMode); // Blink animation for shutter feedback const blinkOpacity = useSharedValue(0); @@ -111,7 +104,19 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou HapticFeedback.press(); }; - const {handleCameraInitialized} = useCameraInitTelemetry({cameraPermissionStatus, device, format}); + const {handleCameraInitialized, handleSessionConfigSelected} = useCameraInitTelemetry({cameraPermissionStatus, device}); + + // VisionCamera replaces the prepared settings on every reconfigure. + const handleCameraConfigured = () => { + updatePhotoResolution(); + handleCameraInitialized(); + if (Platform.OS !== 'ios') { + return; + } + photoOutput.prepareSettings(PREPARED_IOS_PHOTO_SETTINGS).catch((error: unknown) => { + Log.info('[Camera] could not prepare the photo settings', false, {error: error instanceof Error ? error.message : String(error)}); + }); + }; const maybeCancelShutterSpan = () => { if (isMultiScanEnabled) { @@ -169,10 +174,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou isCapturingPhoto.current = true; showBlink(); - const path = getReceiptsUploadFolderPath(); - - captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) - .then((photo: PhotoFile) => { + captureReceipt(camera.current, photoOutput, {flash, hasFlash, isPlatformMuted, isInLandscapeMode, path: getReceiptsUploadFolderPath()}) + .then((photo: CapturedPhoto) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, @@ -198,11 +201,11 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou onCapture(cameraFile, source); }) - .catch((error: string) => { + .catch((error: unknown) => { isCapturingPhoto.current = false; maybeCancelShutterSpan(); showCameraAlert(); - Log.warn('Error taking photo', error); + Log.warn('Error taking photo', {message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined}); }); }; @@ -237,8 +240,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou ; + camera: RefObject; /** The active camera device descriptor */ device: CameraDevice; - /** The selected camera format (resolution / FPS) */ - format: CameraDeviceFormat | undefined; + outputs: CameraOutput[]; - /** Target frames-per-second for the camera preview */ - fps?: number; + constraints?: Constraint[]; /** Aspect ratio used to size the camera viewfinder */ cameraAspectRatio: number | undefined; @@ -63,8 +64,11 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto?: boolean; - /** Callback fired when the camera finishes initializing */ - onInitialized?: () => void; + onConfigured?: () => void; + + onStarted?: () => void; + + onSessionConfigSelected?: (config: CameraSessionConfig) => void; /** Callback fired when the camera preview is laid out */ onLayout?: (event: LayoutChangeEvent) => void; @@ -92,8 +96,8 @@ type CameraViewportProps = { function CameraViewport({ camera, device, - format, - fps, + outputs, + constraints, cameraAspectRatio, isInLandscapeMode, shouldFillPortraitViewport = true, @@ -102,7 +106,9 @@ function CameraViewport({ blinkStyle, isAttachmentPickerActive, didCapturePhoto = false, - onInitialized, + onConfigured, + onStarted, + onSessionConfigSelected, onLayout, shouldShowFlashButton, flashSentryLabel = CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH, @@ -125,17 +131,22 @@ function CameraViewport({ Log.warn('[Camera] VisionCamera error', {code: error.message.split('\n').at(0)?.trim(), message: error.message})} onLayout={onLayout} - // Use TextureView on Android to fix partially blank images for takeSnapshot() - androidPreviewViewType="texture-view" + // With 'device', a phone held flat keeps its last landscape reading and the photo is saved + // rotated. Following the UI also matches v4, whose snapshots took the preview's orientation. + orientationSource="interface" + implementationMode="compatible" /> diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/types.ts index c2dec67dad68..d2cd156bdda8 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/types.ts @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import type {Camera, CameraProps, Camera as VisionCamera} from 'react-native-vision-camera'; +import type {CameraRef, CameraViewProps} from 'react-native-vision-camera'; import type {WebcamProps} from 'react-webcam'; import type Webcam from 'react-webcam'; @@ -7,12 +7,12 @@ type WebcamScreenshotProps = 'forceScreenshotSourceSize' | 'imageSmoothing' | 's type NavigationAwareCameraProps = Omit & Partial> & { - ref?: ForwardedRef; + ref?: ForwardedRef; }; -type NavigationAwareCameraNativeProps = Omit & { +type NavigationAwareCameraNativeProps = Omit & { cameraTabIndex: number; - ref?: ForwardedRef; + ref?: ForwardedRef; forceInactive?: boolean; }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts index d7cffe111c24..cb8f02a21869 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts @@ -1,14 +1,15 @@ -import type {CameraDeviceFormat} from 'react-native-vision-camera'; +import type {Size} from 'react-native-vision-camera'; -function getCameraAspectRatio(format: CameraDeviceFormat | undefined, isInLandscapeMode: boolean): number | undefined { - if (!format) { +/** Viewfinder aspect ratio for a photo of the given sensor-oriented (landscape) size. */ +function getCameraAspectRatio(photoSize: Size | undefined, isInLandscapeMode: boolean): number | undefined { + if (!photoSize) { return undefined; } if (isInLandscapeMode) { - return format.photoWidth / format.photoHeight; + return photoSize.width / photoSize.height; } - return format.photoHeight / format.photoWidth; + return photoSize.height / photoSize.width; } export default getCameraAspectRatio; diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts index bb2346528619..80665eff032f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts @@ -6,9 +6,10 @@ import getCameraCapabilityAttributes from '@pages/iou/request/step/IOURequestSte import CONST from '@src/CONST'; -import type {CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; +import type {AutoFocusSystem, CameraDevice, CameraSessionConfig} from 'react-native-vision-camera'; import {useEffect, useRef} from 'react'; +import {Platform} from 'react-native'; import ReactNativeBlobUtil from 'react-native-blob-util'; import {RESULTS} from 'react-native-permissions'; @@ -18,9 +19,6 @@ type UseCameraInitTelemetryParams = { /** The active camera device descriptor, undefined while loading */ device: CameraDevice | undefined; - - /** The format the camera is running at, undefined until one is selected */ - format: CameraDeviceFormat | undefined; }; /** @@ -28,9 +26,10 @@ type UseCameraInitTelemetryParams = { * Handles starting spans when permission is granted, cancelling when denied, * and cleaning up on unmount. */ -function useCameraInitTelemetry({cameraPermissionStatus, device, format}: UseCameraInitTelemetryParams) { +function useCameraInitTelemetry({cameraPermissionStatus, device}: UseCameraInitTelemetryParams) { const cameraInitSpanStarted = useRef(false); const cameraInitialized = useRef(false); + const autoFocusSystem = useRef(undefined); // End navigation span and start ready span on mount useEffect(() => { @@ -96,7 +95,7 @@ function useCameraInitTelemetry({cameraPermissionStatus, device, format}: UseCam cameraInitialized.current = true; // Only end camera init span if it was actually started if (cameraInitSpanStarted.current) { - endSpanWithAttributes(CONST.TELEMETRY.SPAN_CAMERA_INIT, getCameraCapabilityAttributes(device, format)); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_CAMERA_INIT, getCameraCapabilityAttributes(device, autoFocusSystem.current)); } endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); endSpan(CONST.TELEMETRY.SPAN_ENTRY_TO_SCAN_READY); @@ -123,7 +122,16 @@ function useCameraInitTelemetry({cameraPermissionStatus, device, format}: UseCam }); }; - return {handleCameraInitialized}; + // VisionCamera calls this during configure, before `onConfigured` ends the span. Android always reports + // 'phase-detection' (hard-coded in VisionCamera), so only iOS reports a real autofocus system. + const handleSessionConfigSelected = (config: CameraSessionConfig) => { + if (Platform.OS !== 'ios') { + return; + } + autoFocusSystem.current = config.autoFocusSystem; + }; + + return {handleCameraInitialized, handleSessionConfigSelected}; } export default useCameraInitTelemetry; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts index 89cba1d51cea..6892befb99ca 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts @@ -1,49 +1,26 @@ -/** - * Derives telemetry attributes describing the camera's capabilities (autofocus system, lens count, focus distance, zoom) - * so they can be attached to the SPAN_CAMERA_INIT span. Reads values the device already exposes; no behavior change. - */ +import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; + import CONST from '@src/CONST'; import type {SpanAttributeValue} from '@sentry/core'; -import type {CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; - -type CameraCapabilities = Pick; +import type {AutoFocusSystem, CameraDevice} from 'react-native-vision-camera'; -function isInterchangeable(candidate: CameraDeviceFormat, format: CameraDeviceFormat): boolean { - return ( - candidate.photoWidth === format.photoWidth && - candidate.photoHeight === format.photoHeight && - candidate.videoWidth === format.videoWidth && - candidate.videoHeight === format.videoHeight && - candidate.minFps === format.minFps && - candidate.maxFps === format.maxFps && - candidate.fieldOfView === format.fieldOfView && - candidate.minISO === format.minISO && - candidate.maxISO === format.maxISO - ); -} +type CameraCapabilities = Pick; -function getCameraCapabilityAttributes(device: CameraCapabilities | undefined, format: CameraDeviceFormat | undefined): Record { +/** Camera capabilities attached to the SPAN_CAMERA_INIT span. VisionCamera v5 has no formats, so those attributes are undefined. */ +function getCameraCapabilityAttributes(device: CameraCapabilities | undefined, autoFocusSystem?: AutoFocusSystem): Record { if (!device) { return {}; } - let hasInterchangeablePhaseFormat: boolean | undefined; - if (format) { - hasInterchangeablePhaseFormat = - format.autoFocusSystem !== 'phase-detection' && device.formats.some((candidate) => candidate.autoFocusSystem === 'phase-detection' && isInterchangeable(candidate, format)); - } - return { - [CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: hasInterchangeablePhaseFormat, - [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: format?.autoFocusSystem, - [CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]: device.formats.filter((candidate) => candidate.autoFocusSystem === 'phase-detection').length, - - // Vision Camera reports 0 when the real distance is unavailable, which is not a distance. - [CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]: device.minFocusDistance > 0 ? device.minFocusDistance : undefined, - - [CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]: device.physicalDevices.length, - [CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]: device.neutralZoom, + [CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: undefined, + [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: autoFocusSystem, + [CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]: undefined, + [CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]: undefined, + // v5 reports no physical devices for a single-lens device; v4 reported itself. + [CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]: Math.max(device.physicalDevices.length, 1), + [CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]: getWideLensZoom(device), }; } diff --git a/tests/ui/DistanceRequestStartPageTest.tsx b/tests/ui/DistanceRequestStartPageTest.tsx index 5067fc7285ac..7629a245df44 100644 --- a/tests/ui/DistanceRequestStartPageTest.tsx +++ b/tests/ui/DistanceRequestStartPageTest.tsx @@ -54,6 +54,8 @@ jest.mock('react-native-tab-view', () => ({ jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(), + useCameraDevices: jest.fn(() => []), + usePhotoOutput: jest.fn(() => ({capturePhoto: jest.fn()})), })); // Plain objects, not jest mocks, so they aren't cleared by `jest.clearAllMocks()`. Both mocks below delegate to the diff --git a/tests/ui/IOURequestRedirectToStartPageTest.tsx b/tests/ui/IOURequestRedirectToStartPageTest.tsx index f8bc469546af..b3f7f5ee7b05 100644 --- a/tests/ui/IOURequestRedirectToStartPageTest.tsx +++ b/tests/ui/IOURequestRedirectToStartPageTest.tsx @@ -38,6 +38,8 @@ jest.mock('react-native-tab-view', () => ({ jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(), + useCameraDevices: jest.fn(() => []), + usePhotoOutput: jest.fn(() => ({capturePhoto: jest.fn()})), })); // This page navigates on mount. Keep the real Navigation module (many modules depend on it) but stub the two diff --git a/tests/ui/IOURequestStartPageManualTabTest.tsx b/tests/ui/IOURequestStartPageManualTabTest.tsx index 41372ebb5502..81240864d6ae 100644 --- a/tests/ui/IOURequestStartPageManualTabTest.tsx +++ b/tests/ui/IOURequestStartPageManualTabTest.tsx @@ -33,6 +33,8 @@ jest.mock('@rnmapbox/maps', () => ({ jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(), + useCameraDevices: jest.fn(() => []), + usePhotoOutput: jest.fn(() => ({capturePhoto: jest.fn()})), })); // The page is rendered outside a navigator screen, so give the hooks that read the route a static one. diff --git a/tests/ui/IOURequestStartPageTest.tsx b/tests/ui/IOURequestStartPageTest.tsx index 1f6dc514090f..996ebf341be1 100644 --- a/tests/ui/IOURequestStartPageTest.tsx +++ b/tests/ui/IOURequestStartPageTest.tsx @@ -38,6 +38,8 @@ jest.mock('react-native-tab-view', () => ({ jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(), + useCameraDevices: jest.fn(() => []), + usePhotoOutput: jest.fn(() => ({capturePhoto: jest.fn()})), })); describe('IOURequestStartPage', () => { diff --git a/tests/ui/IOURequestStepScanTest.tsx b/tests/ui/IOURequestStepScanTest.tsx index 0a6676cde985..794972495cf5 100644 --- a/tests/ui/IOURequestStepScanTest.tsx +++ b/tests/ui/IOURequestStepScanTest.tsx @@ -57,7 +57,7 @@ jest.mock('@hooks/useFilesValidation', () => { jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(() => null), useCameraDevices: jest.fn(() => []), - useCameraFormat: jest.fn(() => null), + usePhotoOutput: jest.fn(() => ({capturePhoto: jest.fn()})), })); jest.mock('@pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams', () => ({ diff --git a/tests/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 17fea09fdfc1..ac31669bbeb6 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -67,7 +67,7 @@ jest.mock('react-native-permissions', () => ({ jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(() => null), useCameraDevices: jest.fn(() => []), - useCameraFormat: jest.fn(() => null), + usePhotoOutput: jest.fn(() => ({capturePhoto: jest.fn()})), })); jest.mock('@pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams', () => ({ diff --git a/tests/ui/components/AttachmentCameraTest.tsx b/tests/ui/components/AttachmentCameraTest.tsx index a952e47d01cd..3483974cbd1f 100644 --- a/tests/ui/components/AttachmentCameraTest.tsx +++ b/tests/ui/components/AttachmentCameraTest.tsx @@ -16,7 +16,10 @@ import createMock from '../../utils/createMock'; import {translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; -const mockTakePhoto = jest.fn(() => Promise.resolve({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032})); +function createMockPhoto() { + return {width: 4032, height: 3024, orientation: 'right', saveToTemporaryFileAsync: jest.fn(() => Promise.resolve('/tmp/photos/shot.jpg')), dispose: jest.fn()}; +} +const mockCapturePhoto = jest.fn(() => Promise.resolve(createMockPhoto())); let mockPermissionStatus = 'granted'; jest.mock('@libs/isInLandscapeMode'); @@ -42,16 +45,16 @@ jest.mock('react-native-vision-camera', () => { return { useCameraDevice: jest.fn(), useCameraDevices: jest.fn(() => []), - useCameraFormat: jest.fn(() => null), + usePhotoOutput: jest.fn(() => ({capturePhoto: mockCapturePhoto})), Camera: actualReact.forwardRef((_props: Record, ref: React.ForwardedRef) => { - actualReact.useImperativeHandle(ref, () => ({takePhoto: mockTakePhoto, focus: jest.fn(() => Promise.resolve())})); + actualReact.useImperativeHandle(ref, () => ({focusTo: jest.fn(() => Promise.resolve())})); return null; }), }; }); -const BACK_DEVICE = createMock({id: 'back', position: 'back', hasFlash: true, supportsFocus: true, neutralZoom: 1}); -const FRONT_DEVICE = createMock({id: 'front', position: 'front', hasFlash: false, supportsFocus: true, neutralZoom: 1}); +const BACK_DEVICE = createMock({id: 'back', position: 'back', hasFlash: true, supportsFocusMetering: true, physicalDevices: [], zoomLensSwitchFactors: []}); +const FRONT_DEVICE = createMock({id: 'front', position: 'front', hasFlash: false, supportsFocusMetering: true, physicalDevices: [], zoomLensSwitchFactors: []}); const mockedUseCameraDevice = jest.mocked(useCameraDevice); const mockedUseCameraDevices = jest.mocked(useCameraDevices); @@ -87,7 +90,7 @@ describe('AttachmentCamera', () => { beforeEach(async () => { jest.clearAllMocks(); mockPermissionStatus = 'granted'; - mockTakePhoto.mockResolvedValue({path: '/tmp/photos/shot.jpg', width: 3024, height: 4032}); + mockCapturePhoto.mockImplementation(() => Promise.resolve(createMockPhoto())); mockedUseCameraDevice.mockReturnValue(BACK_DEVICE); mockedUseCameraDevices.mockReturnValue([BACK_DEVICE, FRONT_DEVICE]); mockedIsInLandscapeMode.mockReturnValue(false); @@ -122,7 +125,7 @@ describe('AttachmentCamera', () => { fireEvent.press(screen.getByLabelText(translateLocal('receipt.shutter'))); await waitForBatchedUpdatesWithAct(); - expect(mockTakePhoto).toHaveBeenCalledTimes(1); + expect(mockCapturePhoto).toHaveBeenCalledTimes(1); expect(onCapture).toHaveBeenCalledWith([expect.objectContaining({fileName: 'shot.jpg', type: 'image/jpeg', width: 3024, height: 4032})]); }); @@ -135,11 +138,11 @@ describe('AttachmentCamera', () => { fireEvent.press(shutter); await waitForBatchedUpdatesWithAct(); - expect(mockTakePhoto).toHaveBeenCalledTimes(1); + expect(mockCapturePhoto).toHaveBeenCalledTimes(1); }); it('surfaces a capture failure instead of failing silently', async () => { - mockTakePhoto.mockRejectedValueOnce(new Error('capture failed')); + mockCapturePhoto.mockRejectedValueOnce(new Error('capture failed')); const {onCapture} = renderCamera(); await waitForBatchedUpdatesWithAct(); @@ -185,7 +188,7 @@ describe('AttachmentCamera', () => { fireEvent.press(screen.getByLabelText(translateLocal('receipt.shutter'))); await waitForBatchedUpdatesWithAct(); - expect(mockTakePhoto).not.toHaveBeenCalled(); + expect(mockCapturePhoto).not.toHaveBeenCalled(); expect(onCapture).not.toHaveBeenCalled(); }); diff --git a/tests/unit/getCameraCapabilityAttributesTest.ts b/tests/unit/getCameraCapabilityAttributesTest.ts index 106b556317d6..d8a2cdea43a6 100644 --- a/tests/unit/getCameraCapabilityAttributesTest.ts +++ b/tests/unit/getCameraCapabilityAttributesTest.ts @@ -2,116 +2,70 @@ import getCameraCapabilityAttributes from '@pages/iou/request/step/IOURequestSte import CONST from '@src/CONST'; -import type {CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; - -type FormatOverrides = Partial< - Pick ->; -type DeviceOverrides = Partial>; - -function createFormat(overrides: FormatOverrides = {}): CameraDeviceFormat { - return { - photoWidth: 2880, - photoHeight: 2160, - videoWidth: 2880, - videoHeight: 2160, - minFps: 30, - maxFps: 30, - autoFocusSystem: 'contrast-detection', - minISO: 34, - maxISO: 3264, - fieldOfView: 68, - supportsVideoHdr: false, - supportsPhotoHdr: false, - supportsDepthCapture: false, - videoStabilizationModes: [], - ...overrides, - }; -} +import type {CameraDevice, DeviceType} from 'react-native-vision-camera'; -function createDevice(overrides: DeviceOverrides = {}): Required { - return { - formats: [], - minFocusDistance: 12, - physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], - neutralZoom: 2, - ...overrides, - }; +function createLens(type: DeviceType): CameraDevice { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return {type} as CameraDevice; } -const CONTRAST_FORMAT = createFormat(); -const INTERCHANGEABLE_PHASE_FORMAT = createFormat({autoFocusSystem: 'phase-detection'}); +function createDevice(lensTypes: DeviceType[], zoomLensSwitchFactors: number[]): Pick { + return {physicalDevices: lensTypes.map(createLens), zoomLensSwitchFactors}; +} describe('getCameraCapabilityAttributes', () => { it('reports nothing while the device is still loading', () => { - expect(getCameraCapabilityAttributes(undefined, CONTRAST_FORMAT)).toEqual({}); + // Given no device yet + // When the attributes are derived + // Then the span gets no camera attributes, rather than guessed ones + expect(getCameraCapabilityAttributes(undefined)).toEqual({}); }); - it('reports the lens facts that say whether a lens switch is even possible', () => { - const attributes = getCameraCapabilityAttributes(createDevice({formats: [CONTRAST_FORMAT]}), CONTRAST_FORMAT); - - expect(attributes).toMatchObject({ - [CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]: 12, - [CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]: 2, - [CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]: 2, - }); - }); + it('reports the lens count and the zoom of the wide lens on a virtual device', () => { + // Given an iPhone's ultra-wide + wide virtual device, which switches to the wide lens at 2x + const device = createDevice(['ultra-wide-angle', 'wide-angle'], [2]); - it('records no focus distance when the device reports 0, which means unknown rather than zero', () => { - const attributes = getCameraCapabilityAttributes(createDevice({formats: [CONTRAST_FORMAT], minFocusDistance: 0}), CONTRAST_FORMAT); + // When the attributes are derived + const attributes = getCameraCapabilityAttributes(device); - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]).toBeUndefined(); + // Then the neutral zoom is the wide lens, which is what the camera now opens at + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]).toBe(2); + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]).toBe(2); }); - it('flags an interchangeable phase-detection format when one exists', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, INTERCHANGEABLE_PHASE_FORMAT]}); - - expect(getCameraCapabilityAttributes(device, CONTRAST_FORMAT)).toMatchObject({ - [CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]: 1, - [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: 'contrast-detection', - [CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: true, - }); - }); + it('counts a single-lens device as one lens', () => { + // Given a physical device, which VisionCamera v5 reports with no physical devices (v4 reported itself) + const device = createDevice([], []); - it('does not flag a phase-detection format that differs in resolution', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, createFormat({autoFocusSystem: 'phase-detection', photoWidth: 1920, photoHeight: 1440})]}); + // When the attributes are derived + const attributes = getCameraCapabilityAttributes(device); - expect(getCameraCapabilityAttributes(device, CONTRAST_FORMAT)).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: false}); + // Then the count stays comparable with the v4 data, and the zoom is 1 + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]).toBe(1); + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]).toBe(1); }); - it('does not flag a phase-detection format that differs in frame rate range', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, createFormat({autoFocusSystem: 'phase-detection', maxFps: 60})]}); + it('reports the autofocus system of the configuration the session picked', () => { + // Given the session picked a phase-detection configuration + const device = createDevice(['ultra-wide-angle', 'wide-angle'], [2]); - expect(getCameraCapabilityAttributes(device, CONTRAST_FORMAT)).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: false}); - }); + // When the attributes are derived with that autofocus system + const attributes = getCameraCapabilityAttributes(device, 'phase-detection'); - it('does not flag a phase-detection format that would change the framing', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, createFormat({autoFocusSystem: 'phase-detection', fieldOfView: 52})]}); - - expect(getCameraCapabilityAttributes(device, CONTRAST_FORMAT)).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: false}); + // Then it fills the attribute v4 took from the selected format, so the autofocus dashboards keep working + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]).toBe('phase-detection'); }); - it('does not flag a phase-detection format with a narrower ISO range, which would capture differently in low light', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, createFormat({autoFocusSystem: 'phase-detection', maxISO: 1600})]}); - - expect(getCameraCapabilityAttributes(device, CONTRAST_FORMAT)).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: false}); - }); + it('leaves the format attributes empty, since VisionCamera v5 has no formats list', () => { + // Given any device + const device = createDevice(['ultra-wide-angle', 'wide-angle'], [2]); - it('does not flag anything when the selected format already focuses by phase detection', () => { - const device = createDevice({formats: [INTERCHANGEABLE_PHASE_FORMAT, CONTRAST_FORMAT]}); + // When the attributes are derived + const attributes = getCameraCapabilityAttributes(device); - expect(getCameraCapabilityAttributes(device, INTERCHANGEABLE_PHASE_FORMAT)).toMatchObject({ - [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: 'phase-detection', - [CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: false, - }); - }); - - it('leaves the format questions unanswered when no format is selected yet', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, INTERCHANGEABLE_PHASE_FORMAT]}); - const attributes = getCameraCapabilityAttributes(device, undefined); - - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]).toBeUndefined(); + // Then the attributes that came from v4 formats are explicitly undefined, so dashboards see them as missing + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]).toBeUndefined(); expect(attributes[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]).toBeUndefined(); - expect(attributes).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]: 1}); + expect(attributes[CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]).toBeUndefined(); }); }); diff --git a/tests/unit/hooks/useCameraPhotoAspectRatioTest.ts b/tests/unit/hooks/useCameraPhotoAspectRatioTest.ts new file mode 100644 index 000000000000..d26e72391530 --- /dev/null +++ b/tests/unit/hooks/useCameraPhotoAspectRatioTest.ts @@ -0,0 +1,46 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; + +import type {CameraPhotoOutput, Size} from 'react-native-vision-camera'; + +const TARGET = {width: 2880, height: 2160}; + +function createOutput(currentResolution: Size | undefined): CameraPhotoOutput { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return {currentResolution} as CameraPhotoOutput; +} + +describe('useCameraPhotoAspectRatio', () => { + it('frames the requested 4:3 size until the session reports the real one', () => { + // Given a session that has not configured yet + const {result} = renderHook(() => useCameraPhotoAspectRatio(createOutput(undefined), TARGET, false)); + + // When nothing has been reported + // Then the portrait viewfinder uses the requested 4:3 size + expect(result.current.cameraAspectRatio).toBeCloseTo(3 / 4); + }); + + it('switches to the size the camera actually saves once configured', () => { + // Given an Android device whose closest supported size is 16:9 + const output = createOutput({width: 1920, height: 1080}); + const {result} = renderHook(() => useCameraPhotoAspectRatio(output, TARGET, false)); + + // When the session reports its configuration + act(() => result.current.updatePhotoResolution()); + + // Then the viewfinder frames the 16:9 photo it will save + expect(result.current.cameraAspectRatio).toBeCloseTo(9 / 16); + }); + + it('ignores the empty size iOS can report before the output is connected', () => { + // Given an output that still reports 0x0 + const {result} = renderHook(() => useCameraPhotoAspectRatio(createOutput({width: 0, height: 0}), TARGET, false)); + + // When the size is read + act(() => result.current.updatePhotoResolution()); + + // Then the viewfinder keeps the requested size instead of a broken ratio + expect(result.current.cameraAspectRatio).toBeCloseTo(3 / 4); + }); +}); diff --git a/tests/unit/libs/cameraCaptureDeviceTest.ts b/tests/unit/libs/cameraCaptureDeviceTest.ts new file mode 100644 index 000000000000..2d939fa28b5d --- /dev/null +++ b/tests/unit/libs/cameraCaptureDeviceTest.ts @@ -0,0 +1,76 @@ +import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; +import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; + +import type {CameraDevice, CameraPosition, DeviceType} from 'react-native-vision-camera'; + +function createDevice({ + id, + position = 'back', + lensTypes = [], + zoomLensSwitchFactors = [], +}: { + id: string; + position?: CameraPosition; + lensTypes?: DeviceType[]; + zoomLensSwitchFactors?: number[]; +}): CameraDevice { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const lenses = lensTypes.map((type) => ({type}) as CameraDevice); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return {id, position, physicalDevices: lenses, zoomLensSwitchFactors} as unknown as CameraDevice; +} + +describe('getWideLensZoom', () => { + it('opens a wide + ultra-wide device on the wide lens, not the ultra-wide', () => { + // Given an iPhone virtual device whose ultra-wide lens is zoom 1 and whose wide lens starts at 2 + const device = createDevice({id: 'dual-wide', lensTypes: ['ultra-wide-angle', 'wide-angle'], zoomLensSwitchFactors: [2]}); + + // When the opening zoom is derived + // Then it is the wide lens, the 1x of the Camera app, as v4's neutralZoom was + expect(getWideLensZoom(device)).toBe(2); + }); + + it('opens on the wide lens of a triple-camera device', () => { + // Given an ultra-wide + wide + telephoto device switching at 2 and 6 + const device = createDevice({id: 'triple', lensTypes: ['ultra-wide-angle', 'wide-angle', 'telephoto'], zoomLensSwitchFactors: [2, 6]}); + + // When the opening zoom is derived + // Then it skips past the ultra-wide only + expect(getWideLensZoom(device)).toBe(2); + }); + + it('uses zoom 1 when the wide lens is already the first lens, or there are no lenses to switch', () => { + // Given a wide + telephoto device, and a single-lens device (which v5 reports with no physical devices) + const wideAndTele = createDevice({id: 'dual', lensTypes: ['wide-angle', 'telephoto'], zoomLensSwitchFactors: [2]}); + const singleLens = createDevice({id: 'single'}); + + // When the opening zoom is derived + // Then both open at 1, which is already the wide lens + expect(getWideLensZoom(wideAndTele)).toBe(1); + expect(getWideLensZoom(singleLens)).toBe(1); + }); +}); + +describe('selectWideCameraDevice', () => { + it('prefers the wide + ultra-wide device for the requested side', () => { + // Given a list where the dual-wide device is not first, as v5's own filter would pick on a tie + const triple = createDevice({id: 'triple', lensTypes: ['ultra-wide-angle', 'wide-angle', 'telephoto']}); + const dualWide = createDevice({id: 'dual-wide', lensTypes: ['ultra-wide-angle', 'wide-angle']}); + const fallback = createDevice({id: 'default'}); + + // When the back camera is chosen + // Then it is the wide + ultra-wide device that v4's physicalDevices filter selected + expect(selectWideCameraDevice([triple, dualWide], 'back', fallback)?.id).toBe('dual-wide'); + }); + + it('falls back to the platform default camera when there is no wide + ultra-wide device', () => { + // Given a single-lens phone, and an Android-style list whose lenses report no usable types + const singleLens = createDevice({id: 'single'}); + const fallback = createDevice({id: 'default'}); + const frontDualWide = createDevice({id: 'front-dual', position: 'front', lensTypes: ['ultra-wide-angle', 'wide-angle']}); + + // When the back camera is chosen + // Then the default back camera is used, and a front device is never picked for the back + expect(selectWideCameraDevice([singleLens, frontDualWide], 'back', fallback)?.id).toBe('default'); + }); +}); From 554b22cae7f72a29f32531061b7a166ce423bb63 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 2 Oct 2026 16:06:02 +0200 Subject: [PATCH 02/11] Fix spelling in cameraCapture comment --- src/libs/cameraCapture/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/cameraCapture/index.ts b/src/libs/cameraCapture/index.ts index b2ce8c18b2d0..ec040bb6d5bd 100644 --- a/src/libs/cameraCapture/index.ts +++ b/src/libs/cameraCapture/index.ts @@ -12,7 +12,7 @@ function isSideways(orientation: CameraOrientation) { return orientation === 'left' || orientation === 'right'; } -// `Photo` reports the sensor buffer's size; the saved JPEG carries the rotation in EXIF. +// `Photo` reports the sensor buffer's size; the saved JPEG carries the rotation in its metadata. function getDisplaySize(photo: Photo) { return isSideways(photo.orientation) ? {width: photo.height, height: photo.width} : {width: photo.width, height: photo.height}; } From c68577912eeca7373e94ac93523b61fd64db00b1 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 6 Oct 2026 14:37:41 +0200 Subject: [PATCH 03/11] Address review: catch synchronous capture errors, restore iOS shutter sound, skip redundant zoom --- .../AttachmentPicker/AttachmentCamera.tsx | 4 +- src/libs/cameraCapture/getWideLensZoom.ts | 10 +++ src/libs/cameraCapture/index.ts | 24 ++++-- .../captureReceipt/index.ts | 7 +- .../components/CameraViewport.tsx | 4 +- tests/unit/captureReceiptTest.ts | 82 +++++++++++++++++++ tests/unit/libs/cameraCaptureDeviceTest.ts | 16 +++- tests/unit/libs/cameraCaptureTest.ts | 72 ++++++++++++++++ 8 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 tests/unit/captureReceiptTest.ts create mode 100644 tests/unit/libs/cameraCaptureTest.ts diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index 1bc486921823..51c8ad7fef51 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -22,7 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {capturePhotoToPath} from '@libs/cameraCapture'; -import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; +import {getZoomProp} from '@libs/cameraCapture/getWideLensZoom'; import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; import {getFileName} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; @@ -280,7 +280,7 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme onConfigured={updatePhotoResolution} onStarted={updatePhotoResolution} style={styles.flex1} - zoom={getWideLensZoom(device)} + zoom={getZoomProp(device)} isActive={isVisible} onError={handleCameraError} /> diff --git a/src/libs/cameraCapture/getWideLensZoom.ts b/src/libs/cameraCapture/getWideLensZoom.ts index d331b3d90b1f..f682938bbec3 100644 --- a/src/libs/cameraCapture/getWideLensZoom.ts +++ b/src/libs/cameraCapture/getWideLensZoom.ts @@ -12,4 +12,14 @@ function getWideLensZoom(device: Pick): number | undefined { + const zoom = getWideLensZoom(device); + return zoom === 1 ? undefined : zoom; +} + export default getWideLensZoom; +export {getZoomProp}; diff --git a/src/libs/cameraCapture/index.ts b/src/libs/cameraCapture/index.ts index ec040bb6d5bd..a6c1d7a6143a 100644 --- a/src/libs/cameraCapture/index.ts +++ b/src/libs/cameraCapture/index.ts @@ -24,14 +24,21 @@ function preparePath(filePath: string) { } /** - * Takes a full photo and writes it to `filePath`, or to a temporary file. + * Starts a Nitro call inside a promise, so a synchronous throw (as from `takeSnapshot`) becomes a rejection. * - * Chained with `.then`, not `await`: on Android (Hermes) an `await` on a Nitro promise resumed with `undefined` before - * the native capture finished, while `.then` on the same promise received the result. + * Followed with `.then`, not `await`: with a frame output running on a worklet runtime, an `await` on a Nitro promise + * resumed with `undefined` before the native call finished on Android, while `.then` received the result. */ +function fromNitro(start: () => Promise): Promise { + return new Promise((resolve, reject) => { + start().then(resolve, reject); + }); +} + +/** Takes a full photo and writes it to `filePath`, or to a temporary file. */ function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePhotoSettings, filePath?: string): Promise { - return photoOutput.capturePhoto(settings, {}).then((photo) => { - const save = filePath ? preparePath(filePath).then((path) => photo.saveToFileAsync(path).then(() => path)) : photo.saveToTemporaryFileAsync(); + return fromNitro(() => photoOutput.capturePhoto(settings, {})).then((photo) => { + const save = filePath ? preparePath(filePath).then((path) => fromNitro(() => photo.saveToFileAsync(path)).then(() => path)) : fromNitro(() => photo.saveToTemporaryFileAsync()); return save .then((path) => ({path, ...getDisplaySize(photo)})) @@ -41,12 +48,11 @@ function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePho }); } -/** Android only: VisionCamera v5's `takeSnapshot` throws on iOS. */ +/** Android only: VisionCamera v5's `takeSnapshot` throws on iOS, and throws synchronously before the preview attaches. */ function captureSnapshotToPath(camera: CameraRef, filePath: string): Promise { const path = fileURIToPath(filePath); - return camera.takeSnapshot().then((image) => - image - .saveToFileAsync(path, 'jpg', SNAPSHOT_JPEG_QUALITY) + return fromNitro(() => camera.takeSnapshot()).then((image) => + fromNitro(() => image.saveToFileAsync(path, 'jpg', SNAPSHOT_JPEG_QUALITY)) .then(() => ({path, width: image.width, height: image.height})) .finally(() => { image.dispose(); diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts index b9c2a15a78c4..0be9f204b87a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts @@ -17,11 +17,14 @@ const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, i return captureSnapshotToPath(camera, filePath); } + const isUsingFlash = flash && hasFlash; + const isSnapshotReplacement = Platform.OS === 'ios' && !isUsingFlash && !isInLandscapeMode; + return capturePhotoToPath( photoOutput, { - flashMode: flash && hasFlash ? 'on' : 'off', - enableShutterSound: Platform.OS === 'ios' ? false : !isPlatformMuted, + flashMode: isUsingFlash ? 'on' : 'off', + enableShutterSound: isSnapshotReplacement ? false : !isPlatformMuted, }, filePath, ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index c46e0ed5eca2..cc6387244743 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -7,7 +7,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; +import {getZoomProp} from '@libs/cameraCapture/getWideLensZoom'; import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -134,7 +134,7 @@ function CameraViewport({ outputs={outputs} constraints={constraints} style={styles.flex1} - zoom={getWideLensZoom(device)} + zoom={getZoomProp(device)} cameraTabIndex={1} forceInactive={isAttachmentPickerActive || didCapturePhoto} onConfigured={onConfigured} diff --git a/tests/unit/captureReceiptTest.ts b/tests/unit/captureReceiptTest.ts new file mode 100644 index 000000000000..f46640c42647 --- /dev/null +++ b/tests/unit/captureReceiptTest.ts @@ -0,0 +1,82 @@ +import type CapturedPhoto from '@libs/cameraCapture/types'; + +import captureReceipt from '@pages/iou/request/step/IOURequestStepScan/captureReceipt'; + +import type {CameraPhotoOutput, CameraRef} from 'react-native-vision-camera'; + +import {Platform} from 'react-native'; + +const mockCapturePhotoToPath = jest.fn, unknown[]>(() => Promise.resolve({path: '/receipts/receipt.jpg', width: 1944, height: 2592})); +const mockCaptureSnapshotToPath = jest.fn, unknown[]>(() => Promise.resolve({path: '/receipts/receipt.jpg', width: 1080, height: 1440})); + +jest.mock('@libs/cameraCapture', () => ({ + capturePhotoToPath: (...args: unknown[]) => mockCapturePhotoToPath(...args), + captureSnapshotToPath: (...args: unknown[]) => mockCaptureSnapshotToPath(...args), +})); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const camera = {} as CameraRef; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const photoOutput = {} as CameraPhotoOutput; +const BASE_OPTIONS = {flash: false, hasFlash: true, isPlatformMuted: false, isInLandscapeMode: false, path: '/receipts'}; + +function getSettings() { + return mockCapturePhotoToPath.mock.calls.at(-1)?.at(1); +} + +describe('captureReceipt', () => { + const originalOS = Platform.OS; + + afterEach(() => { + Platform.OS = originalOS; + jest.clearAllMocks(); + }); + + describe('on iOS', () => { + beforeEach(() => { + Platform.OS = 'ios'; + }); + + it('takes a silent photo for a plain portrait scan, like the v4 snapshot it replaces', () => { + // Given a portrait scan without flash, which was a silent snapshot on VisionCamera v4 + // When the receipt is captured + captureReceipt(camera, photoOutput, BASE_OPTIONS); + + // Then it is a full photo with no shutter sound + expect(mockCaptureSnapshotToPath).not.toHaveBeenCalled(); + expect(getSettings()).toEqual({flashMode: 'off', enableShutterSound: false}); + }); + + it('plays the shutter sound for flash and landscape photos unless the phone is muted', () => { + // Given the flash and landscape captures, which were sounding photos on v4 as well + // When each is captured, unmuted and then muted + captureReceipt(camera, photoOutput, {...BASE_OPTIONS, flash: true}); + const flashSettings = getSettings(); + captureReceipt(camera, photoOutput, {...BASE_OPTIONS, isInLandscapeMode: true}); + const landscapeSettings = getSettings(); + captureReceipt(camera, photoOutput, {...BASE_OPTIONS, flash: true, isPlatformMuted: true}); + const mutedSettings = getSettings(); + + // Then the sound follows the mute switch, as v4's takePhoto did + expect(flashSettings).toEqual({flashMode: 'on', enableShutterSound: true}); + expect(landscapeSettings).toEqual({flashMode: 'off', enableShutterSound: true}); + expect(mutedSettings).toEqual({flashMode: 'on', enableShutterSound: false}); + }); + }); + + describe('on Android', () => { + beforeEach(() => { + Platform.OS = 'android'; + }); + + it('keeps the preview snapshot for a plain portrait scan', () => { + // Given a portrait scan without flash, where a full photo is slow on many Android cameras + // When the receipt is captured + captureReceipt(camera, photoOutput, BASE_OPTIONS); + + // Then the fast snapshot is used, written into the receipts folder + expect(mockCapturePhotoToPath).not.toHaveBeenCalled(); + expect(mockCaptureSnapshotToPath).toHaveBeenCalledWith(camera, expect.stringMatching(/^\/receipts\/receipt_\d+_\d+\.jpg$/)); + }); + }); +}); diff --git a/tests/unit/libs/cameraCaptureDeviceTest.ts b/tests/unit/libs/cameraCaptureDeviceTest.ts index 2d939fa28b5d..c108171e7c63 100644 --- a/tests/unit/libs/cameraCaptureDeviceTest.ts +++ b/tests/unit/libs/cameraCaptureDeviceTest.ts @@ -1,4 +1,4 @@ -import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; +import getWideLensZoom, {getZoomProp} from '@libs/cameraCapture/getWideLensZoom'; import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; import type {CameraDevice, CameraPosition, DeviceType} from 'react-native-vision-camera'; @@ -74,3 +74,17 @@ describe('selectWideCameraDevice', () => { expect(selectWideCameraDevice([singleLens, frontDualWide], 'back', fallback)?.id).toBe('default'); }); }); + +describe('getZoomProp', () => { + it('leaves zoom unset when the wide lens is already zoom 1, and sets it when the camera would open on the ultra-wide', () => { + // Given an Android-style device with no switch factors, and an iPhone dual-wide device that switches at 2 + const android = createDevice({id: 'android'}); + const dualWide = createDevice({id: 'dual-wide', lensTypes: ['ultra-wide-angle', 'wide-angle'], zoomLensSwitchFactors: [2]}); + + // When the zoom prop is derived + // Then Android gets none, so VisionCamera skips the setZoom call that fails before the camera starts, + // while the iPhone still opens on the wide lens + expect(getZoomProp(android)).toBeUndefined(); + expect(getZoomProp(dualWide)).toBe(2); + }); +}); diff --git a/tests/unit/libs/cameraCaptureTest.ts b/tests/unit/libs/cameraCaptureTest.ts new file mode 100644 index 000000000000..2385f045dc45 --- /dev/null +++ b/tests/unit/libs/cameraCaptureTest.ts @@ -0,0 +1,72 @@ +import {capturePhotoToPath, captureSnapshotToPath} from '@libs/cameraCapture'; + +import type {CameraPhotoOutput, CameraRef, Photo} from 'react-native-vision-camera'; + +jest.mock('react-native-fs', () => ({mkdir: jest.fn(() => Promise.resolve())})); + +function createPhoto() { + const saveToFileAsync = jest.fn(() => Promise.resolve()); + const dispose = jest.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const photo = { + width: 4032, + height: 3024, + orientation: 'right', + saveToFileAsync, + saveToTemporaryFileAsync: jest.fn(() => Promise.resolve('/tmp/photo.jpg')), + dispose, + } as unknown as Photo; + return {photo, saveToFileAsync, dispose}; +} + +function createOutput(capturePhoto: () => Promise): CameraPhotoOutput { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return {capturePhoto} as unknown as CameraPhotoOutput; +} + +describe('captureSnapshotToPath', () => { + it('rejects instead of throwing when the preview is not attached yet', async () => { + // Given a camera whose takeSnapshot throws synchronously, as VisionCamera v5 does before the preview attaches + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const camera = { + takeSnapshot: () => { + throw new Error('Camera Preview is not yet ready!'); + }, + } as unknown as CameraRef; + + // When a snapshot is captured + const capture = captureSnapshotToPath(camera, '/receipts/receipt.jpg'); + + // Then the error arrives as a rejection, so the caller's catch can reset the shutter and show the alert + await expect(capture).rejects.toThrow('Camera Preview is not yet ready!'); + }); +}); + +describe('capturePhotoToPath', () => { + it('rejects instead of throwing when the capture fails synchronously', async () => { + // Given a photo output that throws before it returns a promise + const output = createOutput(() => { + throw new Error('Photo Output is not yet attached'); + }); + + // When a photo is captured + const capture = capturePhotoToPath(output, {flashMode: 'off'}); + + // Then the caller sees a rejection rather than an exception that skips its catch + await expect(capture).rejects.toThrow('Photo Output is not yet attached'); + }); + + it('saves to the given path, reports the displayed size and frees the photo', async () => { + // Given a portrait capture, which the camera reports as a sideways sensor buffer + const {photo, saveToFileAsync, dispose} = createPhoto(); + const output = createOutput(() => Promise.resolve(photo)); + + // When it is captured into the receipts folder + const result = await capturePhotoToPath(output, {flashMode: 'off'}, 'file:///receipts/receipt.jpg'); + + // Then it is written to the plain path, sized as displayed, and its native buffer is released + expect(saveToFileAsync).toHaveBeenCalledWith('/receipts/receipt.jpg'); + expect(result).toEqual({path: '/receipts/receipt.jpg', width: 3024, height: 4032}); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); From 29ec5c39a5d51c72975ac00c0b766c6b826b9094 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 9 Oct 2026 12:46:58 +0200 Subject: [PATCH 04/11] Remove always-undefined camera capability attributes --- src/CONST/index.ts | 3 --- .../utils/getCameraCapabilityAttributes.ts | 5 +---- tests/unit/getCameraCapabilityAttributesTest.ts | 13 ------------- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 41376e1a296a..788ce6beef43 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2538,10 +2538,7 @@ const CONST = { ATTRIBUTE_PHOTO_WIDTH: 'photo_width', ATTRIBUTE_PHOTO_HEIGHT: 'photo_height', ATTRIBUTE_SOURCE: 'source', - ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT: 'phase_detection_format_count', ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM: 'selected_format_af_system', - ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT: 'has_interchangeable_phase_format', - ATTRIBUTE_MIN_FOCUS_DISTANCE: 'min_focus_distance', ATTRIBUTE_PHYSICAL_DEVICE_COUNT: 'physical_device_count', ATTRIBUTE_NEUTRAL_ZOOM: 'neutral_zoom', ATTRIBUTE_ODOMETER_IMAGE_TYPE: 'odometer_image_type', diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts index 6892befb99ca..9b7c7fbe45cc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts @@ -7,17 +7,14 @@ import type {AutoFocusSystem, CameraDevice} from 'react-native-vision-camera'; type CameraCapabilities = Pick; -/** Camera capabilities attached to the SPAN_CAMERA_INIT span. VisionCamera v5 has no formats, so those attributes are undefined. */ +/** Camera capabilities attached to the SPAN_CAMERA_INIT span. */ function getCameraCapabilityAttributes(device: CameraCapabilities | undefined, autoFocusSystem?: AutoFocusSystem): Record { if (!device) { return {}; } return { - [CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: undefined, [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: autoFocusSystem, - [CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]: undefined, - [CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]: undefined, // v5 reports no physical devices for a single-lens device; v4 reported itself. [CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]: Math.max(device.physicalDevices.length, 1), [CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]: getWideLensZoom(device), diff --git a/tests/unit/getCameraCapabilityAttributesTest.ts b/tests/unit/getCameraCapabilityAttributesTest.ts index d8a2cdea43a6..85fb7fcdf8c4 100644 --- a/tests/unit/getCameraCapabilityAttributesTest.ts +++ b/tests/unit/getCameraCapabilityAttributesTest.ts @@ -55,17 +55,4 @@ describe('getCameraCapabilityAttributes', () => { // Then it fills the attribute v4 took from the selected format, so the autofocus dashboards keep working expect(attributes[CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]).toBe('phase-detection'); }); - - it('leaves the format attributes empty, since VisionCamera v5 has no formats list', () => { - // Given any device - const device = createDevice(['ultra-wide-angle', 'wide-angle'], [2]); - - // When the attributes are derived - const attributes = getCameraCapabilityAttributes(device); - - // Then the attributes that came from v4 formats are explicitly undefined, so dashboards see them as missing - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]).toBeUndefined(); - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]).toBeUndefined(); - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]).toBeUndefined(); - }); }); From dcb026698c2fbdbcd61fead442b50427255b237c Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 16:41:33 +0200 Subject: [PATCH 05/11] Move the receipt upgrade to VisionCamera v5 --- src/CONST/index.ts | 3 - .../hooks/usePhotoUpgrade.ts | 15 +- .../utils/rotatePhotoToUpright.ts | 19 +-- tests/ui/ScanScreenLocationTest.tsx | 2 +- .../components/NavigationAwareCameraTest.tsx | 2 +- tests/unit/hooks/usePhotoUpgrade.test.ts | 149 +++++++++--------- tests/unit/rotatePhotoToUprightTest.ts | 18 --- 7 files changed, 91 insertions(+), 117 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 932c19d4c615..f5bf625703f6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -427,9 +427,6 @@ const CONST = { RECEIPT_CAMERA: { PHOTO_WIDTH: 2880, PHOTO_HEIGHT: 2160, - // iPhones offer no 4:3 photo size between 3 MP and 12 MP, so the 6 MP target above selects 2016x1512 - IOS_STILL_WIDTH: 4032, - IOS_STILL_HEIGHT: 3024, PHOTO_ASPECT_RATIO: 4 / 3, }, diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index a3a389751940..b7d5db0e5e0a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -2,6 +2,8 @@ * Captures a full-resolution photo alongside the fast snapshot and, once a receipt exists, swaps the * higher-quality photo in behind it. Owns capture deadlines, rotation, and upgrade telemetry. */ +import {capturePhotoToPath} from '@libs/cameraCapture'; +import type CapturedPhoto from '@libs/cameraCapture/types'; import Log from '@libs/Log'; import ReceiptStorage from '@libs/ReceiptStorage'; import {finish as finishUpgrade, isClaimedForRead as wasReceiptClaimed, recordUpgrade, start as startUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; @@ -11,10 +13,9 @@ import rotatePhotoToUpright from '@pages/iou/request/step/IOURequestStepScan/uti import CONST from '@src/CONST'; -import type {Camera, PhotoFile} from 'react-native-vision-camera'; +import type {CameraPhotoOutput} from 'react-native-vision-camera'; import {useEffect, useRef, useState} from 'react'; -import RNFS from 'react-native-fs'; const PHOTO_CAPTURE_TIMEOUT_MS = 3000; @@ -74,7 +75,7 @@ function withDeadline(promise: Promise, capMs: number, step: string, disca } type PendingPhoto = { - promise: Promise; + promise: Promise; discard: () => void; @@ -122,7 +123,7 @@ function usePhotoUpgrade() { setHasPendingPhotoCapture(false); }; - const startPhotoCapture = (camera: Camera) => { + const startPhotoCapture = (photoOutput: CameraPhotoOutput) => { const orphaned = pendingPhotoRef.current; pendingPhotoRef.current = undefined; if (orphaned) { @@ -130,7 +131,7 @@ function usePhotoUpgrade() { } // Abandoning a capture does not stop it: `discard` only marks the result for deletion, and - // vision-camera cannot cancel a `takePhoto`. A second one started over a capture still running + // vision-camera cannot cancel a `capturePhoto`. A second one started over a capture still running // makes them contend, and the next `takeSnapshot` pays for it. 6.6s measured against an 80ms // baseline. A receipt keeping its snapshot is the cheaper loss. if (photosInFlightRef.current > 0) { @@ -148,7 +149,7 @@ function usePhotoUpgrade() { let failureOutcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; let isDiscarded = false; - const nativeCapture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}); + const nativeCapture = capturePhotoToPath(photoOutput, {flashMode: 'off', enableShutterSound: false}); photosInFlightRef.current += 1; const releasePhotoSlot = () => { @@ -239,7 +240,7 @@ function usePhotoUpgrade() { return undefined; } - const rotation = rotatePhotoToUpright(photo.path, photo.orientation).catch((error: unknown) => { + const rotation = rotatePhotoToUpright(photo.path).catch((error: unknown) => { throw new UpgradeStepError('rotate', `failed: ${error instanceof Error ? error.message : String(error)}`, false); }); return withDeadline(rotation, ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 6af92dbb4aa8..f90ebfae8078 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,13 +1,7 @@ -/** - * Rotates a captured still so the receipt reads upright, reconciling the decoded frame's orientation - * with VisionCamera's display orientation. Returns undefined when the photo is already upright. - */ import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import Log from '@libs/Log'; -import type {Orientation} from 'react-native-vision-camera'; - import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; @@ -17,18 +11,15 @@ type PhotoSize = { rotation?: number; }; -function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): number | undefined { +function getUprightRotation({width, height, rotation = 0}: PhotoSize): number | undefined { // The image loader applies the file's rotation metadata while decoding, so measure the frame the way it // hands it over: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920. const isSideways = rotation === 90 || rotation === 270; const decodedWidth = isSideways ? height : width; const decodedHeight = isSideways ? width : height; - // VisionCamera's `PhotoFile.orientation` is the display orientation relative to the sensor, as a - // counter-clockwise angle (see its `RotationHelper`): a portrait capture off a landscape sensor reports - // `landscape-left`, so 270 counter-clockwise, so 90 clockwise. The opposite hold turns the other way. - const quarterTurn = orientation === 'landscape-right' ? 270 : 90; - return decodedWidth > decodedHeight ? quarterTurn : undefined; + // VisionCamera v5 writes the rotation into the file, so this only catches a still saved without it. + return decodedWidth > decodedHeight ? 90 : undefined; } /** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ @@ -40,12 +31,12 @@ function releaseQuietly(releasable: {release: () => void}) { } } -function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { +function rotatePhotoToUpright(stillPath: string): Promise { const sourceUri = getPhotoSource(stillPath); const startedAt = Date.now(); return ImageSize.getSize(sourceUri).then((imageSize) => { - const angle = getUprightRotation(imageSize, orientation); + const angle = getUprightRotation(imageSize); if (angle === undefined) { return undefined; } diff --git a/tests/ui/ScanScreenLocationTest.tsx b/tests/ui/ScanScreenLocationTest.tsx index b111b4fdc2c1..9a820d191ffc 100644 --- a/tests/ui/ScanScreenLocationTest.tsx +++ b/tests/ui/ScanScreenLocationTest.tsx @@ -74,7 +74,7 @@ jest.mock('@components/Modal/Global/ModalContext', () => { jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(() => null), useCameraDevices: jest.fn(() => []), - useCameraFormat: jest.fn(() => null), + usePhotoOutput: jest.fn(() => ({})), })); jest.mock('@pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams', () => ({ diff --git a/tests/unit/components/NavigationAwareCameraTest.tsx b/tests/unit/components/NavigationAwareCameraTest.tsx index 671afe33beef..06bda100a73e 100644 --- a/tests/unit/components/NavigationAwareCameraTest.tsx +++ b/tests/unit/components/NavigationAwareCameraTest.tsx @@ -48,7 +48,7 @@ describe('NavigationAwareCamera', () => { }); it('keeps the session running once a capture navigates away and the screen blurs', () => { - // Closing it here cancels an in-flight `takePhoto` with "Camera is closed.", which is the whole + // Closing it here cancels an in-flight `capturePhoto` with "Camera is closed.", which is the whole // reason a full-resolution capture can outlive the shutter. expect(renderIsActive({isFocused: false, shouldStayActiveWhenBlurred: true})).toBe(true); }); diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts index 0e0335bc08b6..f2c5faaab8aa 100644 --- a/tests/unit/hooks/usePhotoUpgrade.test.ts +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -1,10 +1,12 @@ import {act, renderHook} from '@testing-library/react-native'; +import type CapturedPhoto from '@libs/cameraCapture/types'; + import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; import CONST from '@src/CONST'; -import type {Camera, Orientation, PhotoFile} from 'react-native-vision-camera'; +import type {CameraPhotoOutput} from 'react-native-vision-camera'; const mockReplace = jest.fn, [string, string, (() => boolean) | undefined]>(); const mockDiscard = jest.fn, [string]>(); @@ -39,14 +41,16 @@ jest.mock('@libs/telemetry/activeSpans', () => ({ getSpan: (spanId: string) => mockGetSpan(spanId), })); -const mockRotate = jest.fn, [string, Orientation | undefined]>(); +const mockRotate = jest.fn, [string]>(); jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright', () => ({ __esModule: true, - default: (path: string, orientation?: Orientation) => mockRotate(path, orientation), + default: (path: string) => mockRotate(path), })); -jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); +jest.mock('@libs/cameraCapture', () => ({ + capturePhotoToPath: (photoOutput: {capture: () => Promise}) => photoOutput.capture(), +})); const UPGRADE_SPAN_ID = `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_receipt_1234.jpg`; const DURABLE_NAME = 'receipt_1234.jpg'; @@ -55,26 +59,25 @@ const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; const PHOTO_CAPTURE_TIMEOUT_MS = 3000; const ROTATE_TIMEOUT_MS = 5000; -function buildPhoto(path = PHOTO_PATH): PhotoFile { - // A portrait capture off a landscape sensor reports `landscape-left`, per VisionCamera's own docs. - return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'landscape-left', isMirrored: false}; +function buildPhoto(path = PHOTO_PATH): CapturedPhoto { + return {path, width: 1920, height: 1440}; } -/** A camera whose captures the test resolves or rejects on demand, in the order they were started. */ -function buildCamera() { - const started: Array<{resolve: (photo: PhotoFile) => void; reject: (error: Error) => void}> = []; - const takePhoto = jest.fn( +/** A photo output whose captures the test resolves or rejects on demand, in the order they were started. */ +function buildPhotoOutput() { + const started: Array<{resolve: (photo: CapturedPhoto) => void; reject: (error: Error) => void}> = []; + const capturePhoto = jest.fn( () => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { started.push({resolve, reject}); }), ); return { - // The hook only ever calls `takePhoto`, so a stub of the one method is all the test needs. + // The mocked `capturePhotoToPath` only calls `capture`, so a stub of the one method is all the test needs. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - camera: {takePhoto} as unknown as Camera, - takePhoto, + photoOutput: {capture: capturePhoto} as unknown as CameraPhotoOutput, + capturePhoto, landPhoto: async (photo = buildPhoto(), index = 0) => { await act(async () => { started.at(index)?.resolve(photo); @@ -104,13 +107,13 @@ describe('usePhotoUpgrade', () => { }); it('holds the camera session open from the shutter until the photo lands', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); expect(result.current.hasPendingPhotoCapture).toBe(false); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); expect(result.current.hasPendingPhotoCapture).toBe(true); @@ -119,11 +122,11 @@ describe('usePhotoUpgrade', () => { }); it('swaps the upright photo onto the receipt and cleans up the file it rotated from', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -131,7 +134,7 @@ describe('usePhotoUpgrade', () => { await landPhoto(); // The hold the photo was taken in decides which way it is turned, so it has to be passed along. - expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH, 'landscape-left'); + expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH); expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH, expect.any(Function)); expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); // Only a receipt whose bytes really changed gets a new version, which is what makes a view reload it. @@ -142,11 +145,11 @@ describe('usePhotoUpgrade', () => { it('swaps the photo as captured when it needs no rotation, leaving nothing to clean up', async () => { mockRotate.mockResolvedValue(undefined); - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -158,11 +161,11 @@ describe('usePhotoUpgrade', () => { }); it('keeps the snapshot and releases the session when the photo misses the deadline', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -186,11 +189,11 @@ describe('usePhotoUpgrade', () => { it('leaves the receipt alone once an upload has claimed it', async () => { mockWasClaimed.mockReturnValue(true); - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -206,11 +209,11 @@ describe('usePhotoUpgrade', () => { it('keeps the snapshot when the rotate never settles, rather than holding the upgrade open', async () => { mockRotate.mockReturnValue(new Promise(() => {})); - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -235,11 +238,11 @@ describe('usePhotoUpgrade', () => { landRotate = resolve; }), ); - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -261,11 +264,11 @@ describe('usePhotoUpgrade', () => { }); it('hands the swap a guard that still reports a claim made after the swap began', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -285,11 +288,11 @@ describe('usePhotoUpgrade', () => { it('records a swap that backed out for an upload as claimed rather than failed', async () => { mockReplace.mockRejectedValue(new Error('a reader stopped waiting')); mockWasClaimed.mockReturnValue(false); - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -305,11 +308,11 @@ describe('usePhotoUpgrade', () => { it('deletes the rotated copy when the swap fails, so failed upgrades do not pile up', async () => { mockReplace.mockRejectedValue(new Error('no space left on device')); - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -321,11 +324,11 @@ describe('usePhotoUpgrade', () => { }); it('keeps the snapshot when the capture itself fails', async () => { - const {camera, failCapture} = buildCamera(); + const {photoOutput, failCapture} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -340,11 +343,11 @@ describe('usePhotoUpgrade', () => { }); it('deletes the photo when the receipt it was meant for never arrived', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.discardPendingPhoto(); @@ -356,20 +359,20 @@ describe('usePhotoUpgrade', () => { }); it('deletes the photo of a capture the next shutter replaced before it was claimed', async () => { - const {camera, takePhoto, landPhoto} = buildCamera(); + const {photoOutput, capturePhoto, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); // Two shutters, and the first was never handed a receipt — so nothing else will clean up after it. act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); // The second shutter replaces the first capture without starting one of its own, since the camera // is still busy with it. - expect(takePhoto).toHaveBeenCalledTimes(1); + expect(capturePhoto).toHaveBeenCalledTimes(1); // The abandoned photo still lands, and is still deleted, even though nothing replaced it. await landPhoto(buildPhoto('/tmp/orphaned.jpg'), 0); @@ -378,11 +381,11 @@ describe('usePhotoUpgrade', () => { }); it('deletes an unclaimed photo when the scan screen goes away', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result, unmount} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); unmount(); @@ -392,11 +395,11 @@ describe('usePhotoUpgrade', () => { }); it('does not start a second photo over one the camera is still taking, since the next snapshot pays for it', async () => { - const {camera, takePhoto, landPhoto} = buildCamera(); + const {photoOutput, capturePhoto, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -405,9 +408,9 @@ describe('usePhotoUpgrade', () => { // The user came back to the camera and shot again before the first photo landed. Starting a // second capture here is what made a later `takeSnapshot` take seconds on device. act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); - expect(takePhoto).toHaveBeenCalledTimes(1); + expect(capturePhoto).toHaveBeenCalledTimes(1); // The receipt this shutter produced keeps its snapshot rather than waiting on a contended camera. act(() => { @@ -419,11 +422,11 @@ describe('usePhotoUpgrade', () => { }); it('takes a photo again once the camera has finished the previous one', async () => { - const {camera, takePhoto, landPhoto} = buildCamera(); + const {photoOutput, capturePhoto, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -432,9 +435,9 @@ describe('usePhotoUpgrade', () => { // The camera is free, so the guard lets the next shutter upgrade as normal. act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); - expect(takePhoto).toHaveBeenCalledTimes(2); + expect(capturePhoto).toHaveBeenCalledTimes(2); }); describe('telemetry', () => { /** The outcome of the last ended upgrade span, which is what a dashboard groups by. */ @@ -444,11 +447,11 @@ describe('usePhotoUpgrade', () => { } it('reports an upgrade that landed, with the resolution it won', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -469,11 +472,11 @@ describe('usePhotoUpgrade', () => { }); it('separates a capture that missed its deadline from one that failed', async () => { - const {camera, failCapture} = buildCamera(); + const {photoOutput, failCapture} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -485,10 +488,10 @@ describe('usePhotoUpgrade', () => { expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT}); mockEndSpanWithAttributes.mockClear(); - const second = buildCamera(); + const second = buildPhotoOutput(); const {result: secondResult} = renderHook(() => usePhotoUpgrade()); act(() => { - secondResult.current.startPhotoCapture(second.camera); + secondResult.current.startPhotoCapture(second.photoOutput); }); act(() => { secondResult.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -500,11 +503,11 @@ describe('usePhotoUpgrade', () => { }); it('reports a receipt an upload claimed, which is a backed-out swap rather than a failure', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -516,12 +519,12 @@ describe('usePhotoUpgrade', () => { }); it('reports a rotate that never settled, the one failure seen repeatedly on device', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); mockRotate.mockReturnValue(new Promise(() => {})); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -536,13 +539,13 @@ describe('usePhotoUpgrade', () => { it('reports a rotate that failed outright apart from the swap, so the outcomes stay comparable', async () => { // Given a rotate that rejects before the swap starts, e.g. the photo size cannot be read - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); mockRotate.mockRejectedValue(new Error('could not read the image size')); // When the photo lands and the upgrade runs act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -555,11 +558,11 @@ describe('usePhotoUpgrade', () => { }); it('marks the attempt on the capture span, so a lost outcome is still countable', () => { - const {camera} = buildCamera(); + const {photoOutput} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); // The capture span ends in milliseconds, so it survives the background that would cancel the @@ -569,7 +572,7 @@ describe('usePhotoUpgrade', () => { }); it('reports one outcome per upgrade, even when the swap rejects after it already reported', async () => { - const {camera, landPhoto} = buildCamera(); + const {photoOutput, landPhoto} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); // `recordUpgrade` runs in the same `then` as the reported outcome, so throwing there is what // actually reaches the outer catch. A rejected `discard` would not: `discardPhoto` swallows it. @@ -580,7 +583,7 @@ describe('usePhotoUpgrade', () => { }); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -599,18 +602,18 @@ describe('usePhotoUpgrade', () => { }); it('records a shutter that skipped the upgrade on the capture span, since no receipt exists yet', () => { - const {camera} = buildCamera(); + const {photoOutput} = buildPhotoOutput(); const {result} = renderHook(() => usePhotoUpgrade()); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); }); // The first capture is still running, so this shutter keeps its snapshot. act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts index 6b36f4beadce..2f7623d940ee 100644 --- a/tests/unit/rotatePhotoToUprightTest.ts +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -11,24 +11,6 @@ describe('getUprightRotation', () => { expect(getUprightRotation({width: 1920, height: 1440, rotation: 180})).toBe(90); }); - it('turns the opposite hold the other way, rather than leaving it 180 out', () => { - // `PhotoFile.orientation` is counter-clockwise, so `landscape-left` (270 CCW) is the 90 clockwise - // the Galaxy S20 FE needs, and `landscape-right` (90 CCW) has to go the other way. - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-left')).toBe(90); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-right')).toBe(270); - }); - - it('keeps the verified quarter-turn for a hold that cannot make a landscape frame upright', () => { - // Only a quarter-turn makes a landscape frame portrait, so these fall back rather than turning 0 or - // 180 and leaving the receipt on its side. - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait')).toBe(90); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait-upside-down')).toBe(90); - }); - - it('ignores the hold for a frame that already decodes upright, since only the decoded frame matters', () => { - expect(getUprightRotation({width: 1440, height: 1920, rotation: 0}, 'landscape-right')).toBeUndefined(); - }); - it('leaves a photo that is already upright with no metadata alone, rather than re-encoding it', () => { // iOS, where react-native-image-size reports the displayed size and no rotation. expect(getUprightRotation({width: 2160, height: 2880})).toBeUndefined(); From fe9d6eeb1255368e08273e8d37f253626093e2f6 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 14:22:15 +0200 Subject: [PATCH 06/11] Fix the blurry iOS viewfinder when returning to the camera --- .../components/CameraViewport.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index db00506b91d0..7e949b564dab 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -21,8 +21,8 @@ import type {PermissionStatus} from 'react-native-permissions'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {CameraDevice, CameraOutput, CameraRef, CameraSessionConfig, Constraint} from 'react-native-vision-camera'; -import React from 'react'; -import {StyleSheet, View} from 'react-native'; +import React, {useState} from 'react'; +import {Platform, StyleSheet, View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import {RESULTS} from 'react-native-permissions'; import Animated from 'react-native-reanimated'; @@ -127,6 +127,10 @@ function CameraViewport({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt']); + const [isPreviewLive, setIsPreviewLive] = useState(false); + // iOS keeps showing the previous session's last frame until the restarted session delivers a new one. Restarting on the + // ultra-wide (zoom 1) also lets close receipts focus at once, instead of waiting on the wide lens to hand over to macro. + const shouldHidePreview = Platform.OS === 'ios' && !isPreviewLive; return ( @@ -137,13 +141,15 @@ function CameraViewport({ device={device} outputs={outputs} constraints={constraints} - style={styles.flex1} - zoom={getZoomProp(device)} + style={[styles.flex1, shouldHidePreview && styles.opacity0]} + zoom={shouldHidePreview ? 1 : getZoomProp(device)} cameraTabIndex={1} forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onConfigured={onConfigured} onStarted={onStarted} + onPreviewStarted={() => setIsPreviewLive(true)} + onPreviewStopped={() => setIsPreviewLive(false)} onSessionConfigSelected={onSessionConfigSelected} // VisionCamera's default handler is console.error, which pops LogBox over the camera. onError={(error) => Log.warn('[Camera] VisionCamera error', {code: error.message.split('\n').at(0)?.trim(), message: error.message})} From 4624f39e03685ce54ac5ec72feeaca1a2884aaeb Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 14 Oct 2026 15:05:54 +0200 Subject: [PATCH 07/11] fix comments --- src/libs/cameraCapture/index.ts | 2 +- .../IOURequestStepScan/utils/getCameraCapabilityAttributes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/cameraCapture/index.ts b/src/libs/cameraCapture/index.ts index a6c1d7a6143a..c9c449ed418b 100644 --- a/src/libs/cameraCapture/index.ts +++ b/src/libs/cameraCapture/index.ts @@ -12,7 +12,7 @@ function isSideways(orientation: CameraOrientation) { return orientation === 'left' || orientation === 'right'; } -// `Photo` reports the sensor buffer's size; the saved JPEG carries the rotation in its metadata. +// `Photo` reports the sensor buffer's size. The saved JPEG carries the rotation in its metadata. function getDisplaySize(photo: Photo) { return isSideways(photo.orientation) ? {width: photo.height, height: photo.width} : {width: photo.width, height: photo.height}; } diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts index 9b7c7fbe45cc..b281152db230 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts @@ -15,7 +15,7 @@ function getCameraCapabilityAttributes(device: CameraCapabilities | undefined, a return { [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: autoFocusSystem, - // v5 reports no physical devices for a single-lens device; v4 reported itself. + // v5 reports no physical devices for a single-lens device. v4 reported itself. [CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]: Math.max(device.physicalDevices.length, 1), [CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]: getWideLensZoom(device), }; From 7b4abc215227dbdc15a06aeb8b05e618167525cc Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 19 Oct 2026 15:27:35 +0200 Subject: [PATCH 08/11] Address review comments --- .../AttachmentPicker/AttachmentCamera.tsx | 12 +--- src/hooks/useCameraPhotoAspectRatio.ts | 26 -------- src/hooks/useNativeCamera.ts | 12 ++-- src/hooks/useReceiptPhotoOutput.ts | 38 ++++++++++++ src/libs/ReceiptStorage/index.native.ts | 1 + .../cameraCapture}/getCameraAspectRatio.ts | 1 - src/libs/cameraCapture/getFirstLine.ts | 6 ++ src/libs/cameraCapture/index.ts | 20 +++---- .../cameraCapture/platformConfig/index.ios.ts | 10 ++++ .../cameraCapture/platformConfig/index.ts | 11 ++++ .../cameraCapture/platformConfig/types.ts | 11 ++++ src/libs/telemetry/ReceiptObservability.ts | 8 +-- .../index.native.tsx | 12 +--- .../captureReceipt/getReceiptFilePath.ts | 7 +++ .../captureReceipt/index.ios.ts | 29 +++++++++ .../captureReceipt/index.ts | 18 +++--- .../captureReceipt/types.ts | 2 +- .../components/Camera/index.native.tsx | 38 +++++------- .../components/CameraViewport.tsx | 21 +++---- .../hooks/useCameraInitTelemetry.ts | 11 ++-- .../utils/getCameraCapabilityAttributes.ts | 7 ++- .../utils/rotatePhotoToUpright.ts | 3 + tests/unit/captureReceiptTest.ts | 29 ++++----- .../hooks/useCameraPhotoAspectRatioTest.ts | 46 --------------- tests/unit/hooks/usePhotoUpgrade.test.ts | 2 - tests/unit/hooks/useReceiptPhotoOutputTest.ts | 59 +++++++++++++++++++ 26 files changed, 252 insertions(+), 188 deletions(-) delete mode 100644 src/hooks/useCameraPhotoAspectRatio.ts create mode 100644 src/hooks/useReceiptPhotoOutput.ts rename src/{pages/iou/request/step/IOURequestStepScan => libs/cameraCapture}/getCameraAspectRatio.ts (81%) create mode 100644 src/libs/cameraCapture/getFirstLine.ts create mode 100644 src/libs/cameraCapture/platformConfig/index.ios.ts create mode 100644 src/libs/cameraCapture/platformConfig/index.ts create mode 100644 src/libs/cameraCapture/platformConfig/types.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/captureReceipt/getReceiptFilePath.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts delete mode 100644 tests/unit/hooks/useCameraPhotoAspectRatioTest.ts create mode 100644 tests/unit/hooks/useReceiptPhotoOutputTest.ts diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index 6d079ac0e453..14b17b238293 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -12,11 +12,11 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; -import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; import useIsPlatformMuted from '@hooks/useIsPlatformMuted'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import {requestCameraPermission, useTapToFocusGesture} from '@hooks/useNativeCamera'; +import useReceiptPhotoOutput from '@hooks/useReceiptPhotoOutput'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -46,9 +46,7 @@ import {Alert, AppState, StyleSheet, View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import {RESULTS} from 'react-native-permissions'; import Animated from 'react-native-reanimated'; -import {useCameraDevice, useCameraDevices, usePhotoOutput, Camera as VisionCamera} from 'react-native-vision-camera'; - -const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; +import {useCameraDevice, useCameraDevices, Camera as VisionCamera} from 'react-native-vision-camera'; type CapturedPhoto = { uri: string; @@ -106,11 +104,8 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme const device = selectWideCameraDevice(cameraDevices, cameraPosition, defaultDevice); const canFlipCamera = cameraDevices.some((d) => d.position === 'front') && cameraDevices.some((d) => d.position === 'back'); - // Owned by the component that renders the camera: an output shared between sessions aborts (VisionCamera #4156). - // v5's default 'native' container is HEIC on iOS. - const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization: 'quality'}); + const {photoOutput, cameraAspectRatio, updatePhotoResolution} = useReceiptPhotoOutput('balanced', isLandscape); const hasFlash = !!device?.hasFlash; - const {cameraAspectRatio, updatePhotoResolution} = useCameraPhotoAspectRatio(photoOutput, PHOTO_TARGET_RESOLUTION, isLandscape); const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, !!device?.supportsFocusMetering && !capturedPhoto); @@ -322,7 +317,6 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme device={device} outputs={[photoOutput]} onConfigured={updatePhotoResolution} - onStarted={updatePhotoResolution} style={styles.flex1} zoom={getZoomProp(device)} isActive={isVisible && !capturedPhoto} diff --git a/src/hooks/useCameraPhotoAspectRatio.ts b/src/hooks/useCameraPhotoAspectRatio.ts deleted file mode 100644 index 404e58b3f482..000000000000 --- a/src/hooks/useCameraPhotoAspectRatio.ts +++ /dev/null @@ -1,26 +0,0 @@ -import getCameraAspectRatio from '@pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio'; - -import type {CameraPhotoOutput, Size} from 'react-native-vision-camera'; - -import {useState} from 'react'; - -/** - * Viewfinder aspect ratio of the photo the camera saves. v5 negotiates the photo size, which is only known from - * `currentResolution` once configured; on Android it can come out 16:9 despite a 4:3 target. - */ -function useCameraPhotoAspectRatio(photoOutput: CameraPhotoOutput, targetResolution: Size, isInLandscapeMode: boolean) { - const [photoResolution, setPhotoResolution] = useState(undefined); - - const updatePhotoResolution = () => { - const resolution = photoOutput.currentResolution; - // iOS can report 0x0 before the output is connected. - if (!resolution || resolution.width <= 0 || resolution.height <= 0) { - return; - } - setPhotoResolution((previous) => (previous?.width === resolution.width && previous?.height === resolution.height ? previous : {width: resolution.width, height: resolution.height})); - }; - - return {cameraAspectRatio: getCameraAspectRatio(photoResolution ?? targetResolution, isInLandscapeMode), updatePhotoResolution}; -} - -export default useCameraPhotoAspectRatio; diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts index 4cac6d132108..c8c3ff8625ac 100644 --- a/src/hooks/useNativeCamera.ts +++ b/src/hooks/useNativeCamera.ts @@ -1,7 +1,9 @@ import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import getFirstLine from '@libs/cameraCapture/getFirstLine'; import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; +import {getErrorMessage} from '@libs/ErrorUtils'; import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils'; import Log from '@libs/Log'; @@ -123,13 +125,13 @@ function useNativeCamera({onFocusStart, onFocusCleanup}: UseNativeCameraOptions) }; } +// How CameraX (Android) and VisionCamera (iOS) reject a focus request that a newer tap replaced. +const FOCUS_CANCELLED_MESSAGES = ['Cancelled by another startFocusAndMetering()', 'The metering operation has been canceled!']; + /** * Module-level so React Compiler never sees the `cameraRef.current` read. Doing it inside a hook body * trips the "no ref access during render" rule, making OXC bail on the file and diverge from Babel. */ -// How CameraX (Android) and VisionCamera (iOS) reject a focus request that a newer tap replaced. -const FOCUS_CANCELLED_MESSAGES = ['Cancelled by another startFocusAndMetering()', 'The metering operation has been canceled!']; - function focusCameraAtPoint(cameraRef: React.RefObject, point: Point) { if (!cameraRef.current) { return; @@ -137,11 +139,11 @@ function focusCameraAtPoint(cameraRef: React.RefObject, point: // Android throws "MeteringModes cannot be empty!" for the default options. cameraRef.current.focusTo(point, {modes: ['AF', 'AE']}).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); + const message = getErrorMessage(error); if (FOCUS_CANCELLED_MESSAGES.some((cancelled) => message.includes(cancelled))) { return; } - Log.warn('Error focusing camera', {message: message.split('\n').at(0)}); + Log.warn('Error focusing camera', {message: getFirstLine(message)}); }); } diff --git a/src/hooks/useReceiptPhotoOutput.ts b/src/hooks/useReceiptPhotoOutput.ts new file mode 100644 index 000000000000..b57d01516b4c --- /dev/null +++ b/src/hooks/useReceiptPhotoOutput.ts @@ -0,0 +1,38 @@ +/** + * JPEG photo output for receipt captures, with the viewfinder aspect ratio of the photo it will save. + * + * v5 negotiates the photo size, which is only known from `currentResolution` once configured; on Android it can come + * out 16:9 despite a 4:3 target. + * + * The output must be owned by the component that renders the camera: an output shared between sessions aborts + * (VisionCamera #4156). + */ +import getCameraAspectRatio from '@libs/cameraCapture/getCameraAspectRatio'; + +import CONST from '@src/CONST'; + +import type {QualityPrioritization, Size} from 'react-native-vision-camera'; + +import {useState} from 'react'; +import {usePhotoOutput} from 'react-native-vision-camera'; + +const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; + +function useReceiptPhotoOutput(qualityPrioritization: QualityPrioritization, isInLandscapeMode: boolean) { + // v5's default 'native' container is HEIC on iOS. + const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization}); + const [photoResolution, setPhotoResolution] = useState(undefined); + + const updatePhotoResolution = () => { + const resolution = photoOutput.currentResolution; + // iOS reports 0x0 until the output is configured. + if (!resolution?.width || !resolution.height) { + return; + } + setPhotoResolution((previous) => (previous?.width === resolution.width && previous?.height === resolution.height ? previous : {width: resolution.width, height: resolution.height})); + }; + + return {photoOutput, cameraAspectRatio: getCameraAspectRatio(photoResolution ?? PHOTO_TARGET_RESOLUTION, isInLandscapeMode), updatePhotoResolution}; +} + +export default useReceiptPhotoOutput; diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 40b3d15644c5..f6007e6602b8 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -50,6 +50,7 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { const sourcePath = fileURIToPath(uriOrPath); + // The in-app camera saves captures straight into the receipts folder (see `captureReceipt`). const existingName = toDurableName(uriOrPath); if (existingName) { return verify(dir, existingName); diff --git a/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts b/src/libs/cameraCapture/getCameraAspectRatio.ts similarity index 81% rename from src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts rename to src/libs/cameraCapture/getCameraAspectRatio.ts index cb8f02a21869..35a7d2b29788 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts +++ b/src/libs/cameraCapture/getCameraAspectRatio.ts @@ -1,6 +1,5 @@ import type {Size} from 'react-native-vision-camera'; -/** Viewfinder aspect ratio for a photo of the given sensor-oriented (landscape) size. */ function getCameraAspectRatio(photoSize: Size | undefined, isInLandscapeMode: boolean): number | undefined { if (!photoSize) { return undefined; diff --git a/src/libs/cameraCapture/getFirstLine.ts b/src/libs/cameraCapture/getFirstLine.ts new file mode 100644 index 000000000000..27dbbf52eff7 --- /dev/null +++ b/src/libs/cameraCapture/getFirstLine.ts @@ -0,0 +1,6 @@ +// On Android a VisionCamera error message is the whole Java stack trace. +function getFirstLine(message: string): string { + return message.split('\n').at(0)?.trim() ?? ''; +} + +export default getFirstLine; diff --git a/src/libs/cameraCapture/index.ts b/src/libs/cameraCapture/index.ts index c9c449ed418b..cd3980df3da9 100644 --- a/src/libs/cameraCapture/index.ts +++ b/src/libs/cameraCapture/index.ts @@ -23,22 +23,18 @@ function preparePath(filePath: string) { return RNFS.mkdir(path.slice(0, path.lastIndexOf('/'))).then(() => path); } -/** - * Starts a Nitro call inside a promise, so a synchronous throw (as from `takeSnapshot`) becomes a rejection. - * - * Followed with `.then`, not `await`: with a frame output running on a worklet runtime, an `await` on a Nitro promise - * resumed with `undefined` before the native call finished on Android, while `.then` received the result. - */ -function fromNitro(start: () => Promise): Promise { +/** Starts the call inside a promise, so a synchronous throw (as from `takeSnapshot`) becomes a rejection. */ +function callAsPromise(start: () => Promise): Promise { return new Promise((resolve, reject) => { start().then(resolve, reject); }); } -/** Takes a full photo and writes it to `filePath`, or to a temporary file. */ function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePhotoSettings, filePath?: string): Promise { - return fromNitro(() => photoOutput.capturePhoto(settings, {})).then((photo) => { - const save = filePath ? preparePath(filePath).then((path) => fromNitro(() => photo.saveToFileAsync(path)).then(() => path)) : fromNitro(() => photo.saveToTemporaryFileAsync()); + return callAsPromise(() => photoOutput.capturePhoto(settings, {})).then((photo) => { + const save = filePath + ? preparePath(filePath).then((path) => callAsPromise(() => photo.saveToFileAsync(path)).then(() => path)) + : callAsPromise(() => photo.saveToTemporaryFileAsync()); return save .then((path) => ({path, ...getDisplaySize(photo)})) @@ -51,8 +47,8 @@ function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePho /** Android only: VisionCamera v5's `takeSnapshot` throws on iOS, and throws synchronously before the preview attaches. */ function captureSnapshotToPath(camera: CameraRef, filePath: string): Promise { const path = fileURIToPath(filePath); - return fromNitro(() => camera.takeSnapshot()).then((image) => - fromNitro(() => image.saveToFileAsync(path, 'jpg', SNAPSHOT_JPEG_QUALITY)) + return callAsPromise(() => camera.takeSnapshot()).then((image) => + callAsPromise(() => image.saveToFileAsync(path, 'jpg', SNAPSHOT_JPEG_QUALITY)) .then(() => ({path, width: image.width, height: image.height})) .finally(() => { image.dispose(); diff --git a/src/libs/cameraCapture/platformConfig/index.ios.ts b/src/libs/cameraCapture/platformConfig/index.ios.ts new file mode 100644 index 000000000000..2a149a6c3069 --- /dev/null +++ b/src/libs/cameraCapture/platformConfig/index.ios.ts @@ -0,0 +1,10 @@ +import type CameraPlatformConfig from './types'; + +const cameraPlatformConfig: CameraPlatformConfig = { + scanQualityPrioritization: 'speed', + preparedPhotoSettings: [{flashMode: 'off', enableShutterSound: false}], + shouldHidePreviewUntilLive: true, + reportsAutoFocusSystem: true, +}; + +export default cameraPlatformConfig; diff --git a/src/libs/cameraCapture/platformConfig/index.ts b/src/libs/cameraCapture/platformConfig/index.ts new file mode 100644 index 000000000000..e606c6f38832 --- /dev/null +++ b/src/libs/cameraCapture/platformConfig/index.ts @@ -0,0 +1,11 @@ +import type CameraPlatformConfig from './types'; + +const cameraPlatformConfig: CameraPlatformConfig = { + scanQualityPrioritization: 'balanced', + preparedPhotoSettings: undefined, + shouldHidePreviewUntilLive: false, + // VisionCamera hard-codes 'phase-detection' on Android. + reportsAutoFocusSystem: false, +}; + +export default cameraPlatformConfig; diff --git a/src/libs/cameraCapture/platformConfig/types.ts b/src/libs/cameraCapture/platformConfig/types.ts new file mode 100644 index 000000000000..783a881e9ff4 --- /dev/null +++ b/src/libs/cameraCapture/platformConfig/types.ts @@ -0,0 +1,11 @@ +import type {CapturePhotoSettings, QualityPrioritization} from 'react-native-vision-camera'; + +/** Camera behavior that differs between iOS and Android. */ +type CameraPlatformConfig = { + scanQualityPrioritization: QualityPrioritization; + preparedPhotoSettings: CapturePhotoSettings[] | undefined; + shouldHidePreviewUntilLive: boolean; + reportsAutoFocusSystem: boolean; +}; + +export default CameraPlatformConfig; diff --git a/src/libs/telemetry/ReceiptObservability.ts b/src/libs/telemetry/ReceiptObservability.ts index 928422298e7f..67670cf03d01 100644 --- a/src/libs/telemetry/ReceiptObservability.ts +++ b/src/libs/telemetry/ReceiptObservability.ts @@ -1,5 +1,6 @@ import {getAll as getAllPersistedRequests, getOngoingRequest} from '@libs/actions/PersistedRequests'; import {WRITE_COMMANDS} from '@libs/API/types'; +import getFirstLine from '@libs/cameraCapture/getFirstLine'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {getIsOffline} from '@libs/NetworkState'; @@ -262,14 +263,11 @@ function logCameraCaptureFailed(error: unknown) { }); } -/** - * VisionCamera reported a runtime error. v5 errors have no code, and on Android the message is the whole Java stack - * trace, so the first line is what groups them. - */ +/** VisionCamera reported a runtime error. v5 errors have no code, so the message's first line is what groups them. */ function logCameraRuntimeError(error: Error) { Log.alert(`${RECEIPT_LOG_PREFIX} camera runtime error`, { event: 'cameraRuntimeError', - code: error.message.split('\n').at(0)?.trim() ?? '', + summary: getFirstLine(error.message), error: error.message, }); } diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index 78bf4b19fbbb..be1a1635b6a9 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -4,12 +4,12 @@ import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; -import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; import useFilesValidation from '@hooks/useFilesValidation'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNativeCamera from '@hooks/useNativeCamera'; +import useReceiptPhotoOutput from '@hooks/useReceiptPhotoOutput'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -50,9 +50,6 @@ import {Alert, View} from 'react-native'; import ReactNativeBlobUtil from 'react-native-blob-util'; import {RESULTS} from 'react-native-permissions'; import {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; -import {usePhotoOutput} from 'react-native-vision-camera'; - -const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; @@ -97,8 +94,7 @@ function IOURequestStepOdometerImage({ }); const {setIsLoaderVisible} = useFullScreenLoaderActions(); - // v5's default 'native' container is HEIC on iOS. - const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization: 'quality'}); + const {photoOutput, cameraAspectRatio, updatePhotoResolution} = useReceiptPhotoOutput('balanced', isInLandscapeMode); const title = imageType === 'start' ? translate('distance.odometer.startTitle') : translate('distance.odometer.endTitle'); const snapPhotoText = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? translate('distance.odometer.snapPhotoStart') : translate('distance.odometer.snapPhotoEnd'); @@ -258,8 +254,6 @@ function IOURequestStepOdometerImage({ }); }; - const {cameraAspectRatio, updatePhotoResolution} = useCameraPhotoAspectRatio(photoOutput, PHOTO_TARGET_RESOLUTION, isInLandscapeMode); - // Wait for camera permission status to render if (cameraPermissionStatus == null) { return null; @@ -297,9 +291,7 @@ function IOURequestStepOdometerImage({ camera={camera} device={device} outputs={[photoOutput]} - constraints={[{fps: 30}]} onConfigured={updatePhotoResolution} - onStarted={updatePhotoResolution} cameraAspectRatio={cameraAspectRatio} isInLandscapeMode={isInLandscapeMode} shouldFillPortraitViewport={false} diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/getReceiptFilePath.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/getReceiptFilePath.ts new file mode 100644 index 000000000000..eead04eb3bf6 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/getReceiptFilePath.ts @@ -0,0 +1,7 @@ +import {rand64} from '@libs/NumberUtils'; + +function getReceiptFilePath(folderPath: string): string { + return `${folderPath}/receipt_${Date.now()}_${rand64()}.jpg`; +} + +export default getReceiptFilePath; diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts new file mode 100644 index 000000000000..457552113cb8 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts @@ -0,0 +1,29 @@ +import {capturePhotoToPath} from '@libs/cameraCapture'; + +import type {CaptureReceipt} from './types'; + +import getReceiptFilePath from './getReceiptFilePath'; + +// VisionCamera v5 has no snapshot on iOS. +function shouldTakePhoto(): boolean { + return true; +} + +const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) => { + const filePath = getReceiptFilePath(path); + const isUsingFlash = flash && hasFlash; + // A plain portrait scan stands in for the silent v4 snapshot. + const isSnapshotReplacement = !isUsingFlash && !isInLandscapeMode; + + return capturePhotoToPath( + photoOutput, + { + flashMode: isUsingFlash ? 'on' : 'off', + enableShutterSound: isSnapshotReplacement ? false : !isPlatformMuted, + }, + filePath, + ); +}; + +export default captureReceipt; +export {shouldTakePhoto}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts index 0be9f204b87a..6218b8e67cdd 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts @@ -1,30 +1,30 @@ import {capturePhotoToPath, captureSnapshotToPath} from '@libs/cameraCapture'; -import {rand64} from '@libs/NumberUtils'; - -import {Platform} from 'react-native'; import type {CaptureReceipt, CaptureReceiptOptions} from './types'; -// VisionCamera v5 has no snapshot on iOS. +import getReceiptFilePath from './getReceiptFilePath'; + +/** + * Whether `captureReceipt` will take a full photo (as opposed to a preview snapshot) for the given options. + */ function shouldTakePhoto({flash, hasFlash, isInLandscapeMode}: Pick): boolean { - return Platform.OS === 'ios' || (flash && hasFlash) || isInLandscapeMode; + return (flash && hasFlash) || isInLandscapeMode; } -const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, isInLandscapeMode, path}) => { - const filePath = `${path}/receipt_${Date.now()}_${rand64()}.jpg`; +const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) => { + const filePath = getReceiptFilePath(path); if (!shouldTakePhoto({flash, hasFlash, isInLandscapeMode})) { return captureSnapshotToPath(camera, filePath); } const isUsingFlash = flash && hasFlash; - const isSnapshotReplacement = Platform.OS === 'ios' && !isUsingFlash && !isInLandscapeMode; return capturePhotoToPath( photoOutput, { flashMode: isUsingFlash ? 'on' : 'off', - enableShutterSound: isSnapshotReplacement ? false : !isPlatformMuted, + enableShutterSound: !isPlatformMuted, }, filePath, ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts index 3313cbb1bbd6..3e90bfd47ce5 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts @@ -6,8 +6,8 @@ type CaptureReceiptOptions = { flash: boolean; hasFlash: boolean; isPlatformMuted: boolean | undefined; - isInLandscapeMode: boolean; path: string; + isInLandscapeMode: boolean; }; type CaptureReceipt = (camera: CameraRef, photoOutput: CameraPhotoOutput, options: CaptureReceiptOptions) => Promise; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index b448f9c23dcc..d450fac48afe 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -1,13 +1,15 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useNativeCamera from '@hooks/useNativeCamera'; +import useReceiptPhotoOutput from '@hooks/useReceiptPhotoOutput'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import cameraPlatformConfig from '@libs/cameraCapture/platformConfig'; import type CapturedPhoto from '@libs/cameraCapture/types'; +import {getErrorMessage} from '@libs/ErrorUtils'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import HapticFeedback from '@libs/HapticFeedback'; import Log from '@libs/Log'; @@ -29,21 +31,14 @@ import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import React, {useRef} from 'react'; -import {Alert, Platform, View} from 'react-native'; +import {Alert, View} from 'react-native'; import {RESULTS} from 'react-native-permissions'; import {useAnimatedStyle, useSharedValue, withSequence, withTiming} from 'react-native-reanimated'; -import {usePhotoOutput} from 'react-native-vision-camera'; import type {CameraProps} from './types'; const BLINK_DURATION_MS = 80; -const PHOTO_TARGET_RESOLUTION = {width: CONST.RECEIPT_CAMERA.PHOTO_WIDTH, height: CONST.RECEIPT_CAMERA.PHOTO_HEIGHT}; - -const QUALITY_PRIORITIZATION = Platform.OS === 'ios' ? 'speed' : 'quality'; - -const PREPARED_IOS_PHOTO_SETTINGS = [{flashMode: 'off' as const, enableShutterSound: false}]; - /** * Camera — native capture variant. * Renders a react-native-vision-camera viewfinder with shutter, flash toggle, gallery picker, and focus gesture. @@ -87,12 +82,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou cameraFocusIndicatorAnimatedStyle, } = useNativeCamera({onFocusStart, onFocusCleanup}); - // v5's default 'native' container is HEIC on iOS. - const photoOutput = usePhotoOutput({targetResolution: PHOTO_TARGET_RESOLUTION, containerFormat: 'jpeg', qualityPrioritization: QUALITY_PRIORITIZATION}); + const {photoOutput, cameraAspectRatio, updatePhotoResolution} = useReceiptPhotoOutput(cameraPlatformConfig.scanQualityPrioritization, isInLandscapeMode); const outputs = [photoOutput]; - // `` already adds a `resolutionBias` for every output. - const constraints = [{fps: 30}]; - const {cameraAspectRatio, updatePhotoResolution} = useCameraPhotoAspectRatio(photoOutput, PHOTO_TARGET_RESOLUTION, isInLandscapeMode); // Blink animation for shutter feedback const blinkOpacity = useSharedValue(0); @@ -105,17 +96,18 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou HapticFeedback.press(); }; - const {handleCameraInitialized, handleSessionConfigSelected} = useCameraInitTelemetry({cameraPermissionStatus, device}); + const {handleCameraConfigured, handleSessionConfigSelected} = useCameraInitTelemetry({cameraPermissionStatus, device}); // VisionCamera replaces the prepared settings on every reconfigure. - const handleCameraConfigured = () => { + const finishCameraSetup = () => { updatePhotoResolution(); - handleCameraInitialized(); - if (Platform.OS !== 'ios') { + handleCameraConfigured(); + const {preparedPhotoSettings} = cameraPlatformConfig; + if (!preparedPhotoSettings) { return; } - photoOutput.prepareSettings(PREPARED_IOS_PHOTO_SETTINGS).catch((error: unknown) => { - Log.info('[Camera] could not prepare the photo settings', false, {error: error instanceof Error ? error.message : String(error)}); + photoOutput.prepareSettings(preparedPhotoSettings).catch((error: unknown) => { + Log.info('[Camera] could not prepare the photo settings', false, {error: getErrorMessage(error)}); }); }; @@ -215,7 +207,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou isCapturingPhoto.current = false; maybeCancelShutterSpan(); showCameraAlert(); - Log.warn('Error taking photo', {message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined}); + Log.warn('Error taking photo', {message: getErrorMessage(error), stack: error instanceof Error ? error.stack : undefined}); discardPendingPhoto(); }); @@ -256,7 +248,6 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou camera={camera} device={device} outputs={outputs} - constraints={constraints} cameraAspectRatio={cameraAspectRatio} isInLandscapeMode={isInLandscapeMode} tapGesture={tapGesture} @@ -265,8 +256,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou isAttachmentPickerActive={isAttachmentPickerActive} didCapturePhoto={didCapturePhoto} hasPendingPhotoCapture={hasPendingPhotoCapture} - onConfigured={handleCameraConfigured} - onStarted={updatePhotoResolution} + onConfigured={finishCameraSetup} onSessionConfigSelected={handleSessionConfigSelected} shouldShowFlashButton={canUseMultiScan} cameraPermissionStatus={cameraPermissionStatus} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index cab51142119d..99d5d9fa62c6 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -7,7 +7,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getFirstLine from '@libs/cameraCapture/getFirstLine'; import {getZoomProp} from '@libs/cameraCapture/getWideLensZoom'; +import cameraPlatformConfig from '@libs/cameraCapture/platformConfig'; import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -19,10 +21,10 @@ import type {LayoutChangeEvent, ViewStyle} from 'react-native'; import type {TapGesture} from 'react-native-gesture-handler'; import type {PermissionStatus} from 'react-native-permissions'; import type {AnimatedStyle} from 'react-native-reanimated'; -import type {CameraDevice, CameraOutput, CameraRef, CameraSessionConfig, Constraint} from 'react-native-vision-camera'; +import type {CameraDevice, CameraOutput, CameraRef, CameraSessionConfig} from 'react-native-vision-camera'; import React, {useState} from 'react'; -import {Platform, StyleSheet, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import {RESULTS} from 'react-native-permissions'; import Animated from 'react-native-reanimated'; @@ -36,10 +38,9 @@ type CameraViewportProps = { /** The active camera device descriptor */ device: CameraDevice; + /** Outputs the session streams to, such as the photo output captures are taken from */ outputs: CameraOutput[]; - constraints?: Constraint[]; - /** Aspect ratio used to size the camera viewfinder */ cameraAspectRatio: number | undefined; @@ -67,10 +68,10 @@ type CameraViewportProps = { /** Whether a full-resolution capture is still running; keeps the camera session active even after didCapturePhoto so the capture is not cancelled */ hasPendingPhotoCapture?: boolean; + /** Callback fired each time the session finishes configuring, so the outputs' negotiated sizes can be read */ onConfigured?: () => void; - onStarted?: () => void; - + /** Callback fired with the session config VisionCamera picked, before `onConfigured` */ onSessionConfigSelected?: (config: CameraSessionConfig) => void; /** Callback fired when the camera preview is laid out */ @@ -100,7 +101,6 @@ function CameraViewport({ camera, device, outputs, - constraints, cameraAspectRatio, isInLandscapeMode, shouldFillPortraitViewport = true, @@ -111,7 +111,6 @@ function CameraViewport({ didCapturePhoto = false, hasPendingPhotoCapture = false, onConfigured, - onStarted, onSessionConfigSelected, onLayout, shouldShowFlashButton, @@ -130,7 +129,7 @@ function CameraViewport({ const [isPreviewLive, setIsPreviewLive] = useState(false); // iOS keeps showing the previous session's last frame until the restarted session delivers a new one. Restarting on the // ultra-wide (zoom 1) also lets close receipts focus at once, instead of waiting on the wide lens to hand over to macro. - const shouldHidePreview = Platform.OS === 'ios' && !isPreviewLive; + const shouldHidePreview = cameraPlatformConfig.shouldHidePreviewUntilLive && !isPreviewLive; return ( @@ -140,19 +139,17 @@ function CameraViewport({ ref={camera} device={device} outputs={outputs} - constraints={constraints} style={[styles.flex1, shouldHidePreview && styles.opacity0]} zoom={shouldHidePreview ? 1 : getZoomProp(device)} cameraTabIndex={1} forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onConfigured={onConfigured} - onStarted={onStarted} onPreviewStarted={() => setIsPreviewLive(true)} onPreviewStopped={() => setIsPreviewLive(false)} onSessionConfigSelected={onSessionConfigSelected} // VisionCamera's default handler is console.error, which pops LogBox over the camera. - onError={(error) => Log.warn('[Camera] VisionCamera error', {code: error.message.split('\n').at(0)?.trim(), message: error.message})} + onError={(error) => Log.warn('[Camera] VisionCamera error', {summary: getFirstLine(error.message), message: error.message})} onLayout={onLayout} // With 'device', a phone held flat keeps its last landscape reading and the photo is saved // rotated. Following the UI also matches v4, whose snapshots took the preview's orientation. diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts index 80665eff032f..c75cfc355bc4 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry.ts @@ -1,3 +1,4 @@ +import cameraPlatformConfig from '@libs/cameraCapture/platformConfig'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; import Log from '@libs/Log'; import {cancelSpan, endSpan, endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -9,7 +10,6 @@ import CONST from '@src/CONST'; import type {AutoFocusSystem, CameraDevice, CameraSessionConfig} from 'react-native-vision-camera'; import {useEffect, useRef} from 'react'; -import {Platform} from 'react-native'; import ReactNativeBlobUtil from 'react-native-blob-util'; import {RESULTS} from 'react-native-permissions'; @@ -87,7 +87,7 @@ function useCameraInitTelemetry({cameraPermissionStatus, device}: UseCameraInitT }; }, []); - const handleCameraInitialized = () => { + const handleCameraConfigured = () => { // Prevent duplicate span endings if callback fires multiple times if (cameraInitialized.current) { return; @@ -122,16 +122,15 @@ function useCameraInitTelemetry({cameraPermissionStatus, device}: UseCameraInitT }); }; - // VisionCamera calls this during configure, before `onConfigured` ends the span. Android always reports - // 'phase-detection' (hard-coded in VisionCamera), so only iOS reports a real autofocus system. + // VisionCamera calls this during configure, before `onConfigured` ends the span. const handleSessionConfigSelected = (config: CameraSessionConfig) => { - if (Platform.OS !== 'ios') { + if (!cameraPlatformConfig.reportsAutoFocusSystem) { return; } autoFocusSystem.current = config.autoFocusSystem; }; - return {handleCameraInitialized, handleSessionConfigSelected}; + return {handleCameraConfigured, handleSessionConfigSelected}; } export default useCameraInitTelemetry; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts index b281152db230..4865b1648684 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts @@ -1,3 +1,7 @@ +/** + * Derives telemetry attributes describing the camera's capabilities (autofocus system, lens count, zoom) so they can be + * attached to the SPAN_CAMERA_INIT span. Reads values the device already exposes; no behavior change. + */ import getWideLensZoom from '@libs/cameraCapture/getWideLensZoom'; import CONST from '@src/CONST'; @@ -7,12 +11,13 @@ import type {AutoFocusSystem, CameraDevice} from 'react-native-vision-camera'; type CameraCapabilities = Pick; -/** Camera capabilities attached to the SPAN_CAMERA_INIT span. */ function getCameraCapabilityAttributes(device: CameraCapabilities | undefined, autoFocusSystem?: AutoFocusSystem): Record { if (!device) { return {}; } + // The attribute names are v4's (device formats, neutral zoom), kept so Sentry queries span the upgrade. The values + // are v5's equivalents: the session config's autofocus system and the wide lens's zoom factor. return { [CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]: autoFocusSystem, // v5 reports no physical devices for a single-lens device. v4 reported itself. diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index f90ebfae8078..9c0d76557af4 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,3 +1,6 @@ +/** + * Rotates a captured still so the receipt reads upright. Returns undefined when the photo is already upright. + */ import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import Log from '@libs/Log'; diff --git a/tests/unit/captureReceiptTest.ts b/tests/unit/captureReceiptTest.ts index f46640c42647..7e81ffc559dd 100644 --- a/tests/unit/captureReceiptTest.ts +++ b/tests/unit/captureReceiptTest.ts @@ -1,11 +1,10 @@ import type CapturedPhoto from '@libs/cameraCapture/types'; -import captureReceipt from '@pages/iou/request/step/IOURequestStepScan/captureReceipt'; +import captureReceiptIOS from '@pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios'; +import type {CaptureReceipt} from '@pages/iou/request/step/IOURequestStepScan/captureReceipt/types'; import type {CameraPhotoOutput, CameraRef} from 'react-native-vision-camera'; -import {Platform} from 'react-native'; - const mockCapturePhotoToPath = jest.fn, unknown[]>(() => Promise.resolve({path: '/receipts/receipt.jpg', width: 1944, height: 2592})); const mockCaptureSnapshotToPath = jest.fn, unknown[]>(() => Promise.resolve({path: '/receipts/receipt.jpg', width: 1080, height: 1440})); @@ -14,6 +13,9 @@ jest.mock('@libs/cameraCapture', () => ({ captureSnapshotToPath: (...args: unknown[]) => mockCaptureSnapshotToPath(...args), })); +// Jest resolves the bare path to index.ios.ts, so the Android implementation is loaded by its file name. +const captureReceiptAndroid = jest.requireActual<{default: CaptureReceipt}>('@pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts').default; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const camera = {} as CameraRef; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -25,22 +27,15 @@ function getSettings() { } describe('captureReceipt', () => { - const originalOS = Platform.OS; - afterEach(() => { - Platform.OS = originalOS; jest.clearAllMocks(); }); describe('on iOS', () => { - beforeEach(() => { - Platform.OS = 'ios'; - }); - it('takes a silent photo for a plain portrait scan, like the v4 snapshot it replaces', () => { // Given a portrait scan without flash, which was a silent snapshot on VisionCamera v4 // When the receipt is captured - captureReceipt(camera, photoOutput, BASE_OPTIONS); + captureReceiptIOS(camera, photoOutput, BASE_OPTIONS); // Then it is a full photo with no shutter sound expect(mockCaptureSnapshotToPath).not.toHaveBeenCalled(); @@ -50,11 +45,11 @@ describe('captureReceipt', () => { it('plays the shutter sound for flash and landscape photos unless the phone is muted', () => { // Given the flash and landscape captures, which were sounding photos on v4 as well // When each is captured, unmuted and then muted - captureReceipt(camera, photoOutput, {...BASE_OPTIONS, flash: true}); + captureReceiptIOS(camera, photoOutput, {...BASE_OPTIONS, flash: true}); const flashSettings = getSettings(); - captureReceipt(camera, photoOutput, {...BASE_OPTIONS, isInLandscapeMode: true}); + captureReceiptIOS(camera, photoOutput, {...BASE_OPTIONS, isInLandscapeMode: true}); const landscapeSettings = getSettings(); - captureReceipt(camera, photoOutput, {...BASE_OPTIONS, flash: true, isPlatformMuted: true}); + captureReceiptIOS(camera, photoOutput, {...BASE_OPTIONS, flash: true, isPlatformMuted: true}); const mutedSettings = getSettings(); // Then the sound follows the mute switch, as v4's takePhoto did @@ -65,14 +60,10 @@ describe('captureReceipt', () => { }); describe('on Android', () => { - beforeEach(() => { - Platform.OS = 'android'; - }); - it('keeps the preview snapshot for a plain portrait scan', () => { // Given a portrait scan without flash, where a full photo is slow on many Android cameras // When the receipt is captured - captureReceipt(camera, photoOutput, BASE_OPTIONS); + captureReceiptAndroid(camera, photoOutput, BASE_OPTIONS); // Then the fast snapshot is used, written into the receipts folder expect(mockCapturePhotoToPath).not.toHaveBeenCalled(); diff --git a/tests/unit/hooks/useCameraPhotoAspectRatioTest.ts b/tests/unit/hooks/useCameraPhotoAspectRatioTest.ts deleted file mode 100644 index d26e72391530..000000000000 --- a/tests/unit/hooks/useCameraPhotoAspectRatioTest.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import useCameraPhotoAspectRatio from '@hooks/useCameraPhotoAspectRatio'; - -import type {CameraPhotoOutput, Size} from 'react-native-vision-camera'; - -const TARGET = {width: 2880, height: 2160}; - -function createOutput(currentResolution: Size | undefined): CameraPhotoOutput { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return {currentResolution} as CameraPhotoOutput; -} - -describe('useCameraPhotoAspectRatio', () => { - it('frames the requested 4:3 size until the session reports the real one', () => { - // Given a session that has not configured yet - const {result} = renderHook(() => useCameraPhotoAspectRatio(createOutput(undefined), TARGET, false)); - - // When nothing has been reported - // Then the portrait viewfinder uses the requested 4:3 size - expect(result.current.cameraAspectRatio).toBeCloseTo(3 / 4); - }); - - it('switches to the size the camera actually saves once configured', () => { - // Given an Android device whose closest supported size is 16:9 - const output = createOutput({width: 1920, height: 1080}); - const {result} = renderHook(() => useCameraPhotoAspectRatio(output, TARGET, false)); - - // When the session reports its configuration - act(() => result.current.updatePhotoResolution()); - - // Then the viewfinder frames the 16:9 photo it will save - expect(result.current.cameraAspectRatio).toBeCloseTo(9 / 16); - }); - - it('ignores the empty size iOS can report before the output is connected', () => { - // Given an output that still reports 0x0 - const {result} = renderHook(() => useCameraPhotoAspectRatio(createOutput({width: 0, height: 0}), TARGET, false)); - - // When the size is read - act(() => result.current.updatePhotoResolution()); - - // Then the viewfinder keeps the requested size instead of a broken ratio - expect(result.current.cameraAspectRatio).toBeCloseTo(3 / 4); - }); -}); diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts index f2c5faaab8aa..e98d5ccb3d96 100644 --- a/tests/unit/hooks/usePhotoUpgrade.test.ts +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -63,7 +63,6 @@ function buildPhoto(path = PHOTO_PATH): CapturedPhoto { return {path, width: 1920, height: 1440}; } -/** A photo output whose captures the test resolves or rejects on demand, in the order they were started. */ function buildPhotoOutput() { const started: Array<{resolve: (photo: CapturedPhoto) => void; reject: (error: Error) => void}> = []; const capturePhoto = jest.fn( @@ -74,7 +73,6 @@ function buildPhotoOutput() { ); return { - // The mocked `capturePhotoToPath` only calls `capture`, so a stub of the one method is all the test needs. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion photoOutput: {capture: capturePhoto} as unknown as CameraPhotoOutput, capturePhoto, diff --git a/tests/unit/hooks/useReceiptPhotoOutputTest.ts b/tests/unit/hooks/useReceiptPhotoOutputTest.ts new file mode 100644 index 000000000000..553dad9583a4 --- /dev/null +++ b/tests/unit/hooks/useReceiptPhotoOutputTest.ts @@ -0,0 +1,59 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReceiptPhotoOutput from '@hooks/useReceiptPhotoOutput'; + +import type {CameraPhotoOutput, Size} from 'react-native-vision-camera'; + +import {usePhotoOutput} from 'react-native-vision-camera'; + +function mockOutput(currentResolution: Size | undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + jest.mocked(usePhotoOutput).mockReturnValue({currentResolution} as CameraPhotoOutput); +} + +describe('useReceiptPhotoOutput', () => { + it('requests a 4:3 JPEG so iOS does not save HEIC', () => { + // Given a receipt camera + mockOutput(undefined); + + // When the output is created + renderHook(() => useReceiptPhotoOutput('quality', false)); + + // Then it asks for the receipt size in a JPEG container, since v5 defaults to HEIC on iOS + expect(usePhotoOutput).toHaveBeenLastCalledWith({targetResolution: {width: 2880, height: 2160}, containerFormat: 'jpeg', qualityPrioritization: 'quality'}); + }); + + it('frames the requested 4:3 size until the session reports the real one', () => { + // Given a session that has not configured yet + mockOutput(undefined); + const {result} = renderHook(() => useReceiptPhotoOutput('quality', false)); + + // When nothing has been reported + // Then the portrait viewfinder uses the requested 4:3 size + expect(result.current.cameraAspectRatio).toBeCloseTo(3 / 4); + }); + + it('switches to the size the camera actually saves once configured', () => { + // Given an Android device whose closest supported size is 16:9 + mockOutput({width: 1920, height: 1080}); + const {result} = renderHook(() => useReceiptPhotoOutput('quality', false)); + + // When the session reports its configuration + act(() => result.current.updatePhotoResolution()); + + // Then the viewfinder frames the 16:9 photo it will save + expect(result.current.cameraAspectRatio).toBeCloseTo(9 / 16); + }); + + it('ignores the 0x0 size iOS reports before the output is configured', () => { + // Given an output that still reports 0x0 + mockOutput({width: 0, height: 0}); + const {result} = renderHook(() => useReceiptPhotoOutput('quality', false)); + + // When the size is read + act(() => result.current.updatePhotoResolution()); + + // Then the viewfinder keeps the requested size instead of a broken ratio + expect(result.current.cameraAspectRatio).toBeCloseTo(3 / 4); + }); +}); From 6db0e3da67f57a5e916d29affbc79db3d9a695c5 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 20 Oct 2026 12:24:02 +0200 Subject: [PATCH 09/11] Patch VisionCamera to restore shutter muting --- patches/react-native-vision-camera/details.md | 22 +++ ...+5.2.3+001+restore-v4-shutter-muting.patch | 130 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 patches/react-native-vision-camera/details.md create mode 100644 patches/react-native-vision-camera/react-native-vision-camera+5.2.3+001+restore-v4-shutter-muting.patch diff --git a/patches/react-native-vision-camera/details.md b/patches/react-native-vision-camera/details.md new file mode 100644 index 000000000000..dd6cd8c44d40 --- /dev/null +++ b/patches/react-native-vision-camera/details.md @@ -0,0 +1,22 @@ +# `react-native-vision-camera` patches + +### [react-native-vision-camera+5.2.3+001+restore-v4-shutter-muting.patch](react-native-vision-camera+5.2.3+001+restore-v4-shutter-muting.patch) + +- Reason: + + ``` + VisionCamera v5 dropped two ways v4 kept the photo shutter silent, so the upgrade made receipt, odometer and + attachment photos play the shutter sound where they did not before: + + - iOS: `enableShutterSound: false` only works on iOS 18+, through `isShutterSoundSuppressionEnabled`. On iOS 16.4-17, + plain receipt scans and every photo taken with "Mute all sounds from Expensify" on now play the shutter sound. + The patch restores v4's fallback below iOS 18: when the sound is disabled, it disposes the system shutter sound + (`AudioServicesDisposeSystemSoundID(1108)`) right before capture. iOS 18+ keeps v5's own suppression. + - Android: v4 skipped the shutter sound while the ringer was on silent or vibrate (the fix for + https://github.com/Expensify/App/issues/39241). v5 only checks `enableShutterSound`, so the click plays on silent. + The patch restores the ringer check. Regions that legally require the sound still play it. + ``` + +- Upstream PR/issue: The maintainer offered to take the fix into V5 (https://github.com/Expensify/App/pull/102898#discussion_r4315528609). Upstream PR: TODO. Drop this patch once a release with it is installed. +- E/App issue: https://github.com/Expensify/App/issues/102514 +- PR introducing patch: https://github.com/Expensify/App/pull/102898 diff --git a/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+001+restore-v4-shutter-muting.patch b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+001+restore-v4-shutter-muting.patch new file mode 100644 index 000000000000..441b5bcdf02f --- /dev/null +++ b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+001+restore-v4-shutter-muting.patch @@ -0,0 +1,130 @@ +diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt +index f3bdfe3..f100b86 100644 +--- a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt ++++ b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt +@@ -1,5 +1,7 @@ + package com.margelo.nitro.camera.hybrids.outputs + ++import android.content.Context ++import android.media.AudioManager + import android.media.MediaActionSound + import android.util.Log + import androidx.camera.core.CameraInfo +@@ -7,6 +9,7 @@ import androidx.camera.core.CameraSelector + import androidx.camera.core.ImageCapture + import androidx.camera.core.resolutionselector.ResolutionSelector + import androidx.camera.core.takePicture ++import com.margelo.nitro.NitroModules + import com.margelo.nitro.camera.CameraOrientation + import com.margelo.nitro.camera.CapturePhotoCallbacks + import com.margelo.nitro.camera.CapturePhotoSettings +@@ -148,6 +151,19 @@ class HybridPhotoOutput( + } + } + ++ private fun isRingerSilent(): Boolean { ++ val context = NitroModules.applicationContext ?: return false ++ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false ++ return audioManager.ringerMode != AudioManager.RINGER_MODE_NORMAL ++ } ++ ++ private fun shouldPlayShutterSound(settings: CapturePhotoSettings): Boolean { ++ if (CameraInfo.mustPlayShutterSound()) { ++ return true ++ } ++ return (settings.enableShutterSound ?: true) && !isRingerSilent() ++ } ++ + override fun capturePhoto( + settings: CapturePhotoSettings, + callbacks: CapturePhotoCallbacks, +@@ -161,7 +177,7 @@ class HybridPhotoOutput( + + // 1. Get settings + val isMirrored = shouldMirror() +- val enableShutterSound = (settings.enableShutterSound ?: true) || CameraInfo.mustPlayShutterSound() ++ val enableShutterSound = shouldPlayShutterSound(settings) + imageCapture.flashMode = settings.flashMode?.toFlashMode() ?: ImageCapture.FLASH_MODE_OFF + val location = + if (settings.location != null) { +@@ -228,8 +244,7 @@ class HybridPhotoOutput( + + // 1. Get settings + val isMirrored = shouldMirror() +- val enableShutterSound = +- (settings.enableShutterSound ?: true) || CameraInfo.mustPlayShutterSound() ++ val enableShutterSound = shouldPlayShutterSound(settings) + imageCapture.flashMode = settings.flashMode?.toFlashMode() ?: ImageCapture.FLASH_MODE_OFF + val location = + if (settings.location != null) { +diff --git a/node_modules/react-native-vision-camera/ios/Delegates/CapturePhotoDelegate.swift b/node_modules/react-native-vision-camera/ios/Delegates/CapturePhotoDelegate.swift +index 58fdb4e..3990930 100644 +--- a/node_modules/react-native-vision-camera/ios/Delegates/CapturePhotoDelegate.swift ++++ b/node_modules/react-native-vision-camera/ios/Delegates/CapturePhotoDelegate.swift +@@ -5,6 +5,7 @@ + // Created by Marc Rousavy on 29.10.25. + // + ++import AudioToolbox + import AVFoundation + import Foundation + +@@ -15,9 +16,11 @@ final class CapturePhotoDelegate: NSObject, AVCapturePhotoCaptureDelegate { + private let onCaptured: (AVCapturePhoto, MediaSampleMetadata) -> Void + private let onError: (any Error) -> Void + private let callbacks: CapturePhotoCallbacks ++ private let shouldDisposeShutterSound: Bool + private var didResolve = false + + init( ++ shouldDisposeShutterSound: Bool, + onCaptured: @escaping (AVCapturePhoto, MediaSampleMetadata) -> Void, + onError: @escaping (any Error) -> Void, + callbacks: CapturePhotoCallbacks +@@ -25,6 +28,7 @@ final class CapturePhotoDelegate: NSObject, AVCapturePhotoCaptureDelegate { + self.onCaptured = onCaptured + self.onError = onError + self.callbacks = callbacks ++ self.shouldDisposeShutterSound = shouldDisposeShutterSound + super.init() + // Add a static strong reference while capture is in progress + CapturePhotoDelegate.delegates.append(self) +@@ -43,6 +47,10 @@ final class CapturePhotoDelegate: NSObject, AVCapturePhotoCaptureDelegate { + _ output: AVCapturePhotoOutput, + willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings + ) { ++ if shouldDisposeShutterSound { ++ // disable system shutter sound (see https://stackoverflow.com/a/55235949/5281431) ++ AudioServicesDisposeSystemSoundID(1108) ++ } + callbacks.onWillCapturePhoto?() + } + +diff --git a/node_modules/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift b/node_modules/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift +index e5249f2..97c08e7 100644 +--- a/node_modules/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift ++++ b/node_modules/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift +@@ -115,6 +115,15 @@ final class HybridCameraPhotoOutput: HybridCameraPhotoOutputSpec, NativeCameraOu + self.output.setPreparedPhotoSettingsArray([avSettings]) + } + ++ // Below iOS 18 `isShutterSoundSuppressionEnabled` is unavailable, so the system shutter sound is disposed instead. ++ private func shouldDisposeShutterSound(for settings: CapturePhotoSettings) -> Bool { ++ guard settings.enableShutterSound == false else { return false } ++ if #available(iOS 18.0, *) { ++ return false ++ } ++ return true ++ } ++ + func capturePhoto( + settings: CapturePhotoSettings, + callbacks: CapturePhotoCallbacks +@@ -129,6 +138,7 @@ final class HybridCameraPhotoOutput: HybridCameraPhotoOutputSpec, NativeCameraOu + let promise = Promise() + let resultingFormat = PhotoContainerFormat(targetFormat: self.options.containerFormat) + let delegate = CapturePhotoDelegate( ++ shouldDisposeShutterSound: shouldDisposeShutterSound(for: settings), + onCaptured: { photo, metadata in + // We received a Photo! + // 1. Check if we have a Preview Image (if it was requested) From 26c9088b4706b83565181e92650679cf3d54a7dd Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 21 Oct 2026 14:56:22 +0200 Subject: [PATCH 10/11] Address review comments: camera regressions, blink timing, review fixes --- ios/Podfile.lock | 33 ++++++-- patches/react-native-vision-camera/details.md | 34 ++++++++- ...ra+5.2.3+002+unbind-own-session-only.patch | 75 +++++++++++++++++++ ...3+003+restore-v4-ios-session-restart.patch | 73 ++++++++++++++++++ src/libs/cameraCapture/index.ts | 8 +- src/libs/cameraCapture/types.ts | 3 + .../captureReceipt/index.ios.ts | 3 +- .../captureReceipt/index.ts | 3 +- .../captureReceipt/types.ts | 1 + .../components/Camera/index.native.tsx | 17 +++-- .../components/CameraViewport.tsx | 35 ++++++++- .../hooks/usePhotoUpgrade.ts | 2 +- .../IOURequestStepScan/isCameraClosedError.ts | 23 +++--- .../utils/rotatePhotoToUpright.ts | 17 +++-- tests/unit/captureReceiptTest.ts | 17 +++++ .../ScanCameraCancelledCaptureTest.tsx | 2 +- tests/unit/isCameraClosedErrorTest.ts | 20 +++-- tests/unit/libs/cameraCaptureTest.ts | 2 +- tests/unit/rotatePhotoToUprightTest.ts | 13 ++++ 19 files changed, 332 insertions(+), 49 deletions(-) create mode 100644 patches/react-native-vision-camera/react-native-vision-camera+5.2.3+002+unbind-own-session-only.patch create mode 100644 patches/react-native-vision-camera/react-native-vision-camera+5.2.3+003+restore-v4-ios-session-restart.patch diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f2e26933e1f4..0c5e270fb3e0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -27,7 +27,7 @@ PODS: - AppAuth/ExternalUserAgent (1.7.6): - AppAuth/Core - AppLogs (0.1.0) - - callstack-repack (5.3.0): + - callstack-repack (5.4.1): - hermes-engine - JWTDecode (~> 3.0.0) - RCTRequired @@ -176,6 +176,11 @@ PODS: - ExpoModulesWorklets (57.0.15): - ExpoModulesCore - ExpoModulesJSI + - ExpoModulesWorkletsAdapter (57.0.15): + - ExpoModulesCore + - ExpoModulesJSI + - ExpoModulesWorklets + - RNWorklets - ExpoStoreReview (57.0.1): - ExpoModulesCore - ExpoTaskManager (57.0.6): @@ -530,6 +535,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -551,6 +557,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -569,6 +576,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -589,6 +597,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -608,6 +617,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -627,6 +637,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -646,6 +657,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -665,6 +677,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -684,6 +697,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -703,6 +717,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -722,6 +737,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -741,6 +757,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -760,6 +777,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -779,6 +797,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -3492,6 +3511,7 @@ DEPENDENCIES: - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoModulesJSI (from `../node_modules/expo-modules-jsi/apple`) - ExpoModulesWorklets (from `../node_modules/expo-modules-core`) + - ExpoModulesWorkletsAdapter (from `../node_modules/expo-modules-core`) - ExpoStoreReview (from `../node_modules/expo-store-review/ios`) - ExpoTaskManager (from `../node_modules/expo-task-manager/ios`) - ExpoVideo (from `../node_modules/expo-video/ios`) @@ -3709,6 +3729,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-modules-jsi/apple" ExpoModulesWorklets: :path: "../node_modules/expo-modules-core" + ExpoModulesWorkletsAdapter: + :path: "../node_modules/expo-modules-core" ExpoStoreReview: :path: "../node_modules/expo-store-review/ios" ExpoTaskManager: @@ -3989,7 +4011,7 @@ SPEC CHECKSUMS: AirshipServiceExtension: 50d11b2f62c4a490d4e81a1c36f70e2ecb70a27e AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 - callstack-repack: 2c8ad8507fd93905f0956af245ca29cf2d040016 + callstack-repack: 391af9d65585f106b57ca693880593dc2c89db5c EXConstants: fa5647482320b7bde855057f8b86580456d4b9f0 expensify-react-native-background-task: 36103a7c2d62b3f5682a6e2773186d08e46338c5 ExpensifyNitroUtils: d627f58d9dd38c2dd4833f59b2c078cf61ad0171 @@ -4005,6 +4027,7 @@ SPEC CHECKSUMS: ExpoModulesCore: 9c02efc618119023146b9b8db9c0930e43ac977d ExpoModulesJSI: 4f849c58e99b57c2438b21ed6bc2fcb6f984a0d1 ExpoModulesWorklets: 5e70b46d570c5b1b702c18bc8ac8d1af0171450e + ExpoModulesWorkletsAdapter: 536e81cd66f1b69c9301c5df6099b4e172a3702d ExpoStoreReview: 2a0f6b8e04112aea2f6e3e1cb84109d78f4d041f ExpoTaskManager: f68326227852a30148700965c37c058c1473c6a2 ExpoVideo: b1d9a325ddcd4f3ed8ccb374c01b688e98bc6a04 @@ -4055,7 +4078,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 3eaed17dbddb0b989208b062ea14c44d412b9780 React: 2574546f2d017abd14d0c9b48cf2b6a0547c2591 React-callinvoker: 03cd4b931d1d583d87aae99b8f7b6fe26bf571ee - React-Core: 1c824d9c7dd8aa760b5f1b50d5a54c2a3f598f87 + React-Core: c89de4458bd868093d796fef3f4589ff4798df61 React-Core-prebuilt: df2215d5387f03898c940262af212a63f190c9fe React-CoreModules: 2f9ed75bca7f6dea2b70e8a1f4a5ca9b6be52d76 React-cxxreact: 7103d5ba69848c039e11079e74ceede05efe795e @@ -4108,7 +4131,7 @@ SPEC CHECKSUMS: React-NativeModulesApple: a092d89b58f635ebfab88048b0eda9fb516819fd React-networking: ee8e1504d7c1c73a3655bf545ca1184bebae4c7f React-oscompat: 0b72a7e926954a0415ccd83e0748b6561fe45367 - React-perflogger: 865984e492514aa6e5279fc3e663132cfa4d5022 + React-perflogger: 2498c798fb4209483cab9ee8ffa334b72a3b6f1e React-performancecdpmetrics: 88a3c32833d5d50bd35be98577298253f0404a34 React-performancetimeline: 74b781fef76a2e8d461cc899760e9a52b6323d8c React-RCTActionSheet: 902c79deec52f99cc48b1051b59bcbe86787d339 @@ -4124,7 +4147,7 @@ SPEC CHECKSUMS: React-RCTSettings: b97727ec8c55c35bd284457a647c938c040b942b React-RCTText: 4d1b88f6d3e1a43afe46706d956ec6664c87b984 React-RCTVibration: 415d14d6a1a64bf947fcef6b193915a494431168 - React-rendererconsistency: 98a2cc5c4fdae126d9446afa5edd061e5a7eb105 + React-rendererconsistency: c13799138f5208bbaad0afad6b9595357af8fbfb React-renderercss: cd4e8a7a9e41d02a0bc968c64182bbfe2e29cb8d React-rendererdebug: 31560fc7e22ca8cca17382767d9a1bf31311f915 React-RuntimeApple: d879aefebf1067ad37de2fdd886b47a62cda9896 diff --git a/patches/react-native-vision-camera/details.md b/patches/react-native-vision-camera/details.md index dd6cd8c44d40..47940e03c885 100644 --- a/patches/react-native-vision-camera/details.md +++ b/patches/react-native-vision-camera/details.md @@ -17,6 +17,38 @@ The patch restores the ringer check. Regions that legally require the sound still play it. ``` -- Upstream PR/issue: The maintainer offered to take the fix into V5 (https://github.com/Expensify/App/pull/102898#discussion_r4315528609). Upstream PR: TODO. Drop this patch once a release with it is installed. +- Upstream PR/issue: The maintainer offered to take the fix into V5 (https://github.com/Expensify/App/pull/102898#discussion_r4315528609). Upstream PR: https://github.com/margelo/react-native-vision-camera/pull/4228. Drop this patch once a release with it is installed. +- E/App issue: https://github.com/Expensify/App/issues/102514 +- PR introducing patch: https://github.com/Expensify/App/pull/102898 + +### [react-native-vision-camera+5.2.3+002+unbind-own-session-only.patch](react-native-vision-camera+5.2.3+002+unbind-own-session-only.patch) + +- Reason: + + ``` + On Android, every V5 CameraSession calls `cameraProvider.unbindAll()` when it configures and when it is disposed. + The provider is shared by the whole app, so opening one camera unbinds every other mounted camera. The Scan screen + stays mounted (inactive) under the confirmation page, so after Replace receipt opens and closes its own camera, Scan + comes back with nothing bound and the viewfinder stays black until the screen remounts. V4 only unbound its own use + cases. The patch makes each session remember what it bound (its `SessionConfig`, or its use cases for multi-camera) + and unbind only that. + ``` + +- Upstream PR/issue: https://github.com/margelo/react-native-vision-camera/pull/4229. Drop this patch once a release with it is installed. +- E/App issue: https://github.com/Expensify/App/issues/102514 +- PR introducing patch: https://github.com/Expensify/App/pull/102898 + +### [react-native-vision-camera+5.2.3+003+restore-v4-ios-session-restart.patch](react-native-vision-camera+5.2.3+003+restore-v4-ios-session-restart.patch) + +- Reason: + + ``` + AVFoundation stops the iOS capture session on a runtime error (for example when media services are reset) and does + not restart it. V4 called `startRunning()` again while the camera was active. V5 only forwards the error to JS, so the + Scan viewfinder stays empty until the user leaves the screen. The patch restarts the session if JS still wants it + running, at most once per 5 s so a camera that keeps failing does not restart in a loop. + ``` + +- Upstream PR/issue: https://github.com/margelo/react-native-vision-camera/pull/4230. Drop this patch once a release with it is installed. - E/App issue: https://github.com/Expensify/App/issues/102514 - PR introducing patch: https://github.com/Expensify/App/pull/102898 diff --git a/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+002+unbind-own-session-only.patch b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+002+unbind-own-session-only.patch new file mode 100644 index 000000000000..7aef0ad18398 --- /dev/null +++ b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+002+unbind-own-session-only.patch @@ -0,0 +1,75 @@ +diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt +index ab21e76..884fd77 100644 +--- a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt ++++ b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraSession.kt +@@ -5,6 +5,8 @@ import android.util.Log + import androidx.annotation.UiThread + import androidx.camera.core.Camera + import androidx.camera.core.ConcurrentCamera ++import androidx.camera.core.SessionConfig ++import androidx.camera.core.UseCase + import androidx.camera.core.UseCaseGroup + import androidx.camera.lifecycle.ProcessCameraProvider + import com.facebook.react.bridge.ReactApplicationContext +@@ -44,6 +46,11 @@ class HybridCameraSession( + get() = activeSession?.isRunning ?: false + + private var activeSession: ActiveCameraSession? = null ++ ++ // `cameraProvider` is shared by every CameraSession in the app, so only unbind what this session bound. ++ // `unbindAll()` would also unbind another mounted (but inactive) Camera, which then stays black when it becomes active again. ++ private var boundSessionConfig: SessionConfig? = null ++ private var boundUseCases: List = emptyList() + private var onStartedListeners = arrayListOf<() -> Unit>() + private var onStoppedListeners = arrayListOf<() -> Unit>() + private var onErrorListeners = arrayListOf<(Throwable) -> Unit>() +@@ -62,8 +69,8 @@ class HybridCameraSession( + // could avoid unbindAll() + bindToLifecycle() calls. + // I think SessionConfig can be rebound directly for faster switches? + +- // 1. Unbind all inputs/outputs +- cameraProvider.unbindAll() ++ // 1. Unbind this session's inputs/outputs ++ unbindOwnUseCases() + activeSession?.close() + activeSession = null + +@@ -88,6 +95,7 @@ class HybridCameraSession( + } + + val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraInfo.cameraSelector, config.sessionConfig) ++ boundSessionConfig = config.sessionConfig + // Notify outputs that their use-cases are now attached to the Camera + config.preparedUseCases.forEach { it.notifyAttached() } + activeSession = ActiveCameraSessionSingle(camera, this) +@@ -122,6 +130,7 @@ class HybridCameraSession( + } + Log.i(TAG, "Binding configs: ${configs.joinToString { it.useCaseGroup.useCases.joinToString { it.name } }}") + val concurrentCamera = cameraProvider.bindToLifecycle(configs) ++ boundUseCases = allPreparedUseCases.map { it.useCase } + // Notify all outputs that their use-cases are now attached to the Camera + allPreparedUseCases.forEach { it.notifyAttached() } + activeSession = ActiveCameraSessionMulti(concurrentCamera, this) +@@ -153,11 +162,21 @@ class HybridCameraSession( + Promise.async(uiScope) { + lifecycleOwner.destroy() + activeSession?.close() +- cameraProvider.unbindAll() ++ unbindOwnUseCases() + } + DirectByteBufferPool.Shared.clear() + } + ++ @UiThread ++ private fun unbindOwnUseCases() { ++ boundSessionConfig?.let { cameraProvider.unbind(it) } ++ boundSessionConfig = null ++ if (boundUseCases.isNotEmpty()) { ++ cameraProvider.unbind(*boundUseCases.toTypedArray()) ++ } ++ boundUseCases = emptyList() ++ } ++ + @UiThread + private fun applyInitialConfig( + camera: Camera, diff --git a/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+003+restore-v4-ios-session-restart.patch b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+003+restore-v4-ios-session-restart.patch new file mode 100644 index 000000000000..20543412a8a8 --- /dev/null +++ b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+003+restore-v4-ios-session-restart.patch @@ -0,0 +1,73 @@ +diff --git a/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift b/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift +index c74356e..761841f 100644 +--- a/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift ++++ b/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift +@@ -17,6 +17,11 @@ final class HybridCameraSession: HybridCameraSessionSpec { + autoreleaseFrequency: .inherit, + target: nil) + ++ // Whether JS wants the session running, so it can be restarted after a runtime error (as V4 did). ++ private var shouldBeRunning = false ++ private var lastRuntimeErrorRestart: Date? ++ private var runtimeErrorObserver: NSObjectProtocol? ++ + init(enableMultiCam: Bool) { + if enableMultiCam { + self.session = AVCaptureMultiCamSession() +@@ -27,6 +32,18 @@ final class HybridCameraSession: HybridCameraSessionSpec { + // session's preset-based format selection upfront. + self.session.sessionPreset = .inputPriority + super.init() ++ // AVFoundation stops the session on a runtime error (e.g. when media services are reset) and never restarts it. ++ self.runtimeErrorObserver = NotificationCenter.default.addObserver( ++ forName: AVCaptureSession.runtimeErrorNotification, object: self.session, queue: nil ++ ) { [weak self] _ in ++ self?.restartAfterRuntimeError() ++ } ++ } ++ ++ deinit { ++ if let runtimeErrorObserver { ++ NotificationCenter.default.removeObserver(runtimeErrorObserver) ++ } + } + + var isRunning: Bool { +@@ -213,6 +230,7 @@ final class HybridCameraSession: HybridCameraSessionSpec { + // pragma MARK: Start/Stop + func start() -> Promise { + return Promise.parallel(Self.queue) { ++ self.shouldBeRunning = true + if self.session.isRunning { + return + } +@@ -222,6 +240,7 @@ final class HybridCameraSession: HybridCameraSessionSpec { + + func stop() -> Promise { + return Promise.parallel(Self.queue) { ++ self.shouldBeRunning = false + if !self.session.isRunning { + return + } +@@ -229,6 +248,21 @@ final class HybridCameraSession: HybridCameraSessionSpec { + } + } + ++ private func restartAfterRuntimeError() { ++ Self.queue.async { [weak self] in ++ guard let self, self.shouldBeRunning, !self.session.isRunning else { ++ return ++ } ++ // At most one restart per burst of errors, so a camera that keeps failing doesn't restart in a loop. ++ if let lastRestart = self.lastRuntimeErrorRestart, Date().timeIntervalSince(lastRestart) < 5 { ++ return ++ } ++ self.lastRuntimeErrorRestart = Date() ++ logger.info("Restarting CameraSession after a runtime error...") ++ self.session.startRunning() ++ } ++ } ++ + // pragma MARK: Helpers + /** + * Detach all preview layers that are not listed in the [targetConnections] array diff --git a/src/libs/cameraCapture/index.ts b/src/libs/cameraCapture/index.ts index cd3980df3da9..95736452762b 100644 --- a/src/libs/cameraCapture/index.ts +++ b/src/libs/cameraCapture/index.ts @@ -1,6 +1,6 @@ import fileURIToPath from '@libs/fileURIToPath'; -import type {CameraOrientation, CameraPhotoOutput, CameraRef, CapturePhotoSettings, Photo} from 'react-native-vision-camera'; +import type {CameraOrientation, CameraPhotoOutput, CameraRef, CapturePhotoCallbacks, CapturePhotoSettings, Photo} from 'react-native-vision-camera'; import RNFS from 'react-native-fs'; @@ -30,14 +30,14 @@ function callAsPromise(start: () => Promise): Promise { }); } -function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePhotoSettings, filePath?: string): Promise { - return callAsPromise(() => photoOutput.capturePhoto(settings, {})).then((photo) => { +function capturePhotoToPath(photoOutput: CameraPhotoOutput, settings: CapturePhotoSettings, filePath?: string, callbacks: CapturePhotoCallbacks = {}): Promise { + return callAsPromise(() => photoOutput.capturePhoto(settings, callbacks)).then((photo) => { const save = filePath ? preparePath(filePath).then((path) => callAsPromise(() => photo.saveToFileAsync(path)).then(() => path)) : callAsPromise(() => photo.saveToTemporaryFileAsync()); return save - .then((path) => ({path, ...getDisplaySize(photo)})) + .then((path) => ({path, ...getDisplaySize(photo), orientation: photo.orientation})) .finally(() => { photo.dispose(); }); diff --git a/src/libs/cameraCapture/types.ts b/src/libs/cameraCapture/types.ts index 23afd52eeacf..3f5e0e21c4e4 100644 --- a/src/libs/cameraCapture/types.ts +++ b/src/libs/cameraCapture/types.ts @@ -1,8 +1,11 @@ +import type {CameraOrientation} from 'react-native-vision-camera'; + /** A captured image written to disk. */ type CapturedPhoto = { path: string; width: number; height: number; + orientation?: CameraOrientation; }; export default CapturedPhoto; diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts index 457552113cb8..ddc840956cb9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ios.ts @@ -9,7 +9,7 @@ function shouldTakePhoto(): boolean { return true; } -const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) => { +const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode, onWillCapturePhoto}) => { const filePath = getReceiptFilePath(path); const isUsingFlash = flash && hasFlash; // A plain portrait scan stands in for the silent v4 snapshot. @@ -22,6 +22,7 @@ const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, i enableShutterSound: isSnapshotReplacement ? false : !isPlatformMuted, }, filePath, + {onWillCapturePhoto}, ); }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts index 6218b8e67cdd..f798fc2f274e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts @@ -11,7 +11,7 @@ function shouldTakePhoto({flash, hasFlash, isInLandscapeMode}: Pick { +const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode, onWillCapturePhoto}) => { const filePath = getReceiptFilePath(path); if (!shouldTakePhoto({flash, hasFlash, isInLandscapeMode})) { @@ -27,6 +27,7 @@ const captureReceipt: CaptureReceipt = (camera, photoOutput, {flash, hasFlash, i enableShutterSound: !isPlatformMuted, }, filePath, + {onWillCapturePhoto}, ); }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts index 3e90bfd47ce5..8dd8029e307c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts @@ -8,6 +8,7 @@ type CaptureReceiptOptions = { isPlatformMuted: boolean | undefined; path: string; isInLandscapeMode: boolean; + onWillCapturePhoto?: () => void; }; type CaptureReceipt = (camera: CameraRef, photoOutput: CameraPhotoOutput, options: CaptureReceiptOptions) => Promise; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx index 0d102c5e84a8..bf2a16230c85 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/index.native.tsx @@ -96,7 +96,6 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const showBlink = () => { blinkOpacity.set(withSequence(withTiming(1, {duration: BLINK_DURATION_MS}), withTiming(0, {duration: BLINK_DURATION_MS}))); - HapticFeedback.press(); }; const {handleCameraConfigured, handleSessionConfigSelected} = useCameraInitTelemetry({cameraPermissionStatus, device}); @@ -156,26 +155,30 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou return; } + const isPhotoCapture = shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + startSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { name: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, op: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, parentSpan: getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION), attributes: { [CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: CONST.TELEMETRY.SPAN_PLATFORM.NATIVE, - [CONST.TELEMETRY.ATTRIBUTE_CAPTURE_METHOD]: shouldTakePhoto({flash, hasFlash, isInLandscapeMode}) - ? CONST.TELEMETRY.CAPTURE_METHOD.PHOTO - : CONST.TELEMETRY.CAPTURE_METHOD.SNAPSHOT, + [CONST.TELEMETRY.ATTRIBUTE_CAPTURE_METHOD]: isPhotoCapture ? CONST.TELEMETRY.CAPTURE_METHOD.PHOTO : CONST.TELEMETRY.CAPTURE_METHOD.SNAPSHOT, [CONST.TELEMETRY.ATTRIBUTE_FLASH_USED]: flash && hasFlash, }, }); isCapturingPhoto.current = true; - showBlink(); + HapticFeedback.press(); + // A full photo is taken well after the tap when the flash has to meter first, so its blink waits for the real capture. + if (!isPhotoCapture) { + showBlink(); + } - const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !isPhotoCapture; // The snapshot goes first so its request reaches the native queue ahead of the still. - captureReceipt(camera.current, photoOutput, {flash, hasFlash, isPlatformMuted, isInLandscapeMode, path: getReceiptsUploadFolderPath()}) + captureReceipt(camera.current, photoOutput, {flash, hasFlash, isPlatformMuted, isInLandscapeMode, path: getReceiptsUploadFolderPath(), onWillCapturePhoto: showBlink}) .then((photo: CapturedPhoto) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index 99d5d9fa62c6..e2a8a6c7d957 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getFirstLine from '@libs/cameraCapture/getFirstLine'; import {getZoomProp} from '@libs/cameraCapture/getWideLensZoom'; import cameraPlatformConfig from '@libs/cameraCapture/platformConfig'; +import {getErrorMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -23,7 +24,7 @@ import type {PermissionStatus} from 'react-native-permissions'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {CameraDevice, CameraOutput, CameraRef, CameraSessionConfig} from 'react-native-vision-camera'; -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; import {RESULTS} from 'react-native-permissions'; @@ -130,6 +131,34 @@ function CameraViewport({ // iOS keeps showing the previous session's last frame until the restarted session delivers a new one. Restarting on the // ultra-wide (zoom 1) also lets close receipts focus at once, instead of waiting on the wide lens to hand over to macro. const shouldHidePreview = cameraPlatformConfig.shouldHidePreviewUntilLive && !isPreviewLive; + const previewStartCount = useRef(0); + + // VisionCamera applies the `zoom` prop in an effect after the render that reveals the preview, so revealing first + // would show a frame or two of the ultra-wide lens. Switch to the wide lens through the controller, then reveal. + const revealPreviewOnWideLens = () => { + const startCount = previewStartCount.current; + const reveal = () => { + if (startCount !== previewStartCount.current) { + return; + } + setIsPreviewLive(true); + }; + const wideLensZoom = getZoomProp(device); + const controller = camera.current?.controller; + if (!cameraPlatformConfig.shouldHidePreviewUntilLive || wideLensZoom === undefined || !controller) { + reveal(); + return; + } + controller.setZoom(wideLensZoom).then(reveal, (error: unknown) => { + Log.info('[Camera] could not switch to the wide lens before revealing the preview', false, {error: getErrorMessage(error)}); + reveal(); + }); + }; + + const hidePreview = () => { + previewStartCount.current += 1; + setIsPreviewLive(false); + }; return ( @@ -145,8 +174,8 @@ function CameraViewport({ forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onConfigured={onConfigured} - onPreviewStarted={() => setIsPreviewLive(true)} - onPreviewStopped={() => setIsPreviewLive(false)} + onPreviewStarted={revealPreviewOnWideLens} + onPreviewStopped={hidePreview} onSessionConfigSelected={onSessionConfigSelected} // VisionCamera's default handler is console.error, which pops LogBox over the camera. onError={(error) => Log.warn('[Camera] VisionCamera error', {summary: getFirstLine(error.message), message: error.message})} diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index b7d5db0e5e0a..22a7d44aedce 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -240,7 +240,7 @@ function usePhotoUpgrade() { return undefined; } - const rotation = rotatePhotoToUpright(photo.path).catch((error: unknown) => { + const rotation = rotatePhotoToUpright(photo.path, photo.orientation).catch((error: unknown) => { throw new UpgradeStepError('rotate', `failed: ${error instanceof Error ? error.message : String(error)}`, false); }); return withDeadline(rotation, ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts b/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts index f3b3c8526bce..a25ae3bbddc5 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/isCameraClosedError.ts @@ -1,19 +1,20 @@ -// CameraX rejects an in-flight capture with this message when the camera closes, and vision-camera passes it through as `unknown/unknown`. -const CAMERA_CLOSED_MESSAGE = 'Camera is closed'; - -// vision-camera rejects with this code when the session is already gone by the time the capture reaches native. -const CAMERA_SESSION_GONE_CODE = 'session/camera-not-ready'; +// VisionCamera v5 errors carry no code, so a capture cancelled by leaving the screen is recognised by its message: +// CameraX closing the camera mid-capture, a photo output that is no longer attached to the session (iOS, then Android), +// and an Android snapshot taken after the preview is gone. +const CAMERA_CLOSED_MESSAGES = [ + 'Camera is closed', + 'PhotoOutput is not yet connected to the CameraSession!', + 'Photo Output is not yet attached to the CameraSession!', + "PreviewView isn't ready yet", +]; function isCameraClosedError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) { + if (typeof error !== 'object' || error === null || !('message' in error) || typeof error.message !== 'string') { return false; } - if ('code' in error && error.code === CAMERA_SESSION_GONE_CODE) { - return true; - } - - return 'message' in error && typeof error.message === 'string' && error.message.includes(CAMERA_CLOSED_MESSAGE); + const {message} = error; + return CAMERA_CLOSED_MESSAGES.some((closedMessage) => message.includes(closedMessage)); } export default isCameraClosedError; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 9c0d76557af4..57d84fc810da 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -5,6 +5,8 @@ import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import Log from '@libs/Log'; +import type {CameraOrientation} from 'react-native-vision-camera'; + import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; @@ -14,15 +16,20 @@ type PhotoSize = { rotation?: number; }; -function getUprightRotation({width, height, rotation = 0}: PhotoSize): number | undefined { +function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: CameraOrientation): number | undefined { // The image loader applies the file's rotation metadata while decoding, so measure the frame the way it // hands it over: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920. const isSideways = rotation === 90 || rotation === 270; const decodedWidth = isSideways ? height : width; const decodedHeight = isSideways ? width : height; - // VisionCamera v5 writes the rotation into the file, so this only catches a still saved without it. - return decodedWidth > decodedHeight ? 90 : undefined; + // VisionCamera v5 writes the rotation into the file, so this only catches a still saved without it. The size alone + // can't tell a quarter turn from three quarters, so follow the capture's orientation: CameraX reports a 90 degree + // turn as 'left' and 270 as 'right'. + if (decodedWidth <= decodedHeight) { + return undefined; + } + return orientation === 'right' ? 270 : 90; } /** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ @@ -34,12 +41,12 @@ function releaseQuietly(releasable: {release: () => void}) { } } -function rotatePhotoToUpright(stillPath: string): Promise { +function rotatePhotoToUpright(stillPath: string, orientation?: CameraOrientation): Promise { const sourceUri = getPhotoSource(stillPath); const startedAt = Date.now(); return ImageSize.getSize(sourceUri).then((imageSize) => { - const angle = getUprightRotation(imageSize); + const angle = getUprightRotation(imageSize, orientation); if (angle === undefined) { return undefined; } diff --git a/tests/unit/captureReceiptTest.ts b/tests/unit/captureReceiptTest.ts index 7e81ffc559dd..56e4a4d7f7fa 100644 --- a/tests/unit/captureReceiptTest.ts +++ b/tests/unit/captureReceiptTest.ts @@ -59,6 +59,23 @@ describe('captureReceipt', () => { }); }); + describe('blink timing', () => { + it('hands the capture callback to full photos so the blink matches the real shutter moment', () => { + // Given a flash capture, which meters the flash before the photo is actually taken + const onWillCapturePhoto = jest.fn(); + + // When the receipt is captured on iOS and on Android + captureReceiptIOS(camera, photoOutput, {...BASE_OPTIONS, flash: true, onWillCapturePhoto}); + const iosCallbacks = mockCapturePhotoToPath.mock.calls.at(-1)?.at(3); + captureReceiptAndroid(camera, photoOutput, {...BASE_OPTIONS, flash: true, onWillCapturePhoto}); + const androidCallbacks = mockCapturePhotoToPath.mock.calls.at(-1)?.at(3); + + // Then both photos report the moment they are taken, so the blink can wait for it instead of firing on the tap + expect(iosCallbacks).toEqual({onWillCapturePhoto}); + expect(androidCallbacks).toEqual({onWillCapturePhoto}); + }); + }); + describe('on Android', () => { it('keeps the preview snapshot for a plain portrait scan', () => { // Given a portrait scan without flash, where a full photo is slow on many Android cameras diff --git a/tests/unit/components/ScanCameraCancelledCaptureTest.tsx b/tests/unit/components/ScanCameraCancelledCaptureTest.tsx index 72a4fcb33410..c1d142a832a6 100644 --- a/tests/unit/components/ScanCameraCancelledCaptureTest.tsx +++ b/tests/unit/components/ScanCameraCancelledCaptureTest.tsx @@ -81,7 +81,7 @@ jest.mock('@libs/ReceiptStorage', () => ({ jest.mock('@pages/iou/request/step/IOURequestStepScan/hooks/useCameraInitTelemetry', () => ({ __esModule: true, - default: () => ({handleCameraInitialized: jest.fn()}), + default: () => ({handleCameraConfigured: jest.fn(), handleSessionConfigSelected: jest.fn()}), })); jest.mock('@hooks/useReceiptPhotoOutput', () => ({__esModule: true, default: () => ({photoOutput: {}, cameraAspectRatio: undefined, updatePhotoResolution: jest.fn()})})); diff --git a/tests/unit/isCameraClosedErrorTest.ts b/tests/unit/isCameraClosedErrorTest.ts index b7575ae48bb2..e7de52509359 100644 --- a/tests/unit/isCameraClosedErrorTest.ts +++ b/tests/unit/isCameraClosedErrorTest.ts @@ -3,7 +3,7 @@ import isCameraClosedError from '@pages/iou/request/step/IOURequestStepScan/isCa describe('isCameraClosedError', () => { it('recognizes the CameraX error for a capture cancelled by the camera closing', () => { // Given the error vision-camera passes through when CameraX closes the camera mid-capture - const error = {code: 'unknown/unknown', message: 'Camera is closed.'}; + const error = new Error('androidx.camera.core.ImageCaptureException: Camera is closed.'); // When it is classified const result = isCameraClosedError(error); @@ -12,15 +12,19 @@ describe('isCameraClosedError', () => { expect(result).toBe(true); }); - it('recognizes the vision-camera error for a session that is already gone', () => { - // Given the error vision-camera rejects with when the capture reaches native after the session closed - const error = {code: 'session/camera-not-ready', message: 'The Camera is not ready yet! Wait for the onInitialized() callback!'}; + it('recognizes the vision-camera errors for a session that is already gone', () => { + // Given the errors vision-camera v5 rejects with when the capture reaches native after the camera was torn down + const errors = [ + new Error('PhotoOutput is not yet connected to the CameraSession!'), + new Error('Photo Output is not yet attached to the CameraSession!'), + new Error("Cannot take snapshot - PreviewView isn't ready yet!"), + ]; - // When it is classified - const result = isCameraClosedError(error); + // When each is classified + const results = errors.map(isCameraClosedError); - // Then it counts as the camera closing too - expect(result).toBe(true); + // Then they count as the camera closing too, since v5 errors have no code to tell them apart + expect(results).toEqual([true, true, true]); }); it('does not treat other camera errors as the camera closing', () => { diff --git a/tests/unit/libs/cameraCaptureTest.ts b/tests/unit/libs/cameraCaptureTest.ts index 2385f045dc45..891f93d6ada3 100644 --- a/tests/unit/libs/cameraCaptureTest.ts +++ b/tests/unit/libs/cameraCaptureTest.ts @@ -66,7 +66,7 @@ describe('capturePhotoToPath', () => { // Then it is written to the plain path, sized as displayed, and its native buffer is released expect(saveToFileAsync).toHaveBeenCalledWith('/receipts/receipt.jpg'); - expect(result).toEqual({path: '/receipts/receipt.jpg', width: 3024, height: 4032}); + expect(result).toEqual({path: '/receipts/receipt.jpg', width: 3024, height: 4032, orientation: 'right'}); expect(dispose).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts index 2f7623d940ee..12650a7026ee 100644 --- a/tests/unit/rotatePhotoToUprightTest.ts +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -26,6 +26,19 @@ describe('getUprightRotation', () => { expect(getUprightRotation({width: 1440, height: 1920, rotation: 90})).toBe(90); }); + it('turns a sideways frame the way the capture was oriented', () => { + // Given landscape frames saved without a rotation tag, captured with the sensor turned either way + const size = {width: 1920, height: 1440, rotation: 0}; + + // When the upright rotation is derived from each capture's orientation + const leftTurn = getUprightRotation(size, 'left'); + const rightTurn = getUprightRotation(size, 'right'); + + // Then each turns its own way, so a photo taken in the opposite hold is not saved upside down + expect(leftTurn).toBe(90); + expect(rightTurn).toBe(270); + }); + it('leaves a square photo with no metadata alone', () => { expect(getUprightRotation({width: 1440, height: 1440, rotation: 0})).toBeUndefined(); }); From 30cb5c6bbbd59268928c5c27719a0bcc88b33add Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 22 Oct 2026 09:21:34 +0200 Subject: [PATCH 11/11] Patch VisionCamera to keep the iOS screen awake while the camera runs --- patches/react-native-vision-camera/details.md | 15 +++++ ...004+restore-v4-ios-keep-screen-awake.patch | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 patches/react-native-vision-camera/react-native-vision-camera+5.2.3+004+restore-v4-ios-keep-screen-awake.patch diff --git a/patches/react-native-vision-camera/details.md b/patches/react-native-vision-camera/details.md index 47940e03c885..12e05dcf4d22 100644 --- a/patches/react-native-vision-camera/details.md +++ b/patches/react-native-vision-camera/details.md @@ -52,3 +52,18 @@ - Upstream PR/issue: https://github.com/margelo/react-native-vision-camera/pull/4230. Drop this patch once a release with it is installed. - E/App issue: https://github.com/Expensify/App/issues/102514 - PR introducing patch: https://github.com/Expensify/App/pull/102898 + +### [react-native-vision-camera+5.2.3+004+restore-v4-ios-keep-screen-awake.patch](react-native-vision-camera+5.2.3+004+restore-v4-ios-keep-screen-awake.patch) + +- Reason: + + ``` + V4 kept the iPhone screen awake while a camera was active (`UIApplication.shared.isIdleTimerDisabled = isActive` + in `CameraView.swift`). V5 never sets the idle timer, so the screen dims and locks on Scan, the odometer camera and + Take photo, which matters most during multi-scan. The patch sets it in `start()` and clears it in `stop()` and on + deinit. Android is unaffected: V4 never kept the screen on there either. + ``` + +- Upstream PR/issue: https://github.com/margelo/react-native-vision-camera/pull/4233. Drop this patch once a release with it is installed. +- E/App issue: https://github.com/Expensify/App/issues/102514 +- PR introducing patch: https://github.com/Expensify/App/pull/102898 diff --git a/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+004+restore-v4-ios-keep-screen-awake.patch b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+004+restore-v4-ios-keep-screen-awake.patch new file mode 100644 index 000000000000..301bc054c8e9 --- /dev/null +++ b/patches/react-native-vision-camera/react-native-vision-camera+5.2.3+004+restore-v4-ios-keep-screen-awake.patch @@ -0,0 +1,62 @@ +diff --git a/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift b/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift +index 761841f..4763d1f 100644 +--- a/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift ++++ b/node_modules/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift +@@ -7,6 +7,7 @@ + import AVFoundation + import Foundation + import NitroModules ++import UIKit + + final class HybridCameraSession: HybridCameraSessionSpec { + let session: AVCaptureSession +@@ -21,6 +22,7 @@ final class HybridCameraSession: HybridCameraSessionSpec { + private var shouldBeRunning = false + private var lastRuntimeErrorRestart: Date? + private var runtimeErrorObserver: NSObjectProtocol? ++ private var isKeepingScreenAwake = false + + init(enableMultiCam: Bool) { + if enableMultiCam { +@@ -44,6 +46,11 @@ final class HybridCameraSession: HybridCameraSessionSpec { + if let runtimeErrorObserver { + NotificationCenter.default.removeObserver(runtimeErrorObserver) + } ++ if isKeepingScreenAwake { ++ DispatchQueue.main.async { ++ UIApplication.shared.isIdleTimerDisabled = false ++ } ++ } + } + + var isRunning: Bool { +@@ -231,6 +238,7 @@ final class HybridCameraSession: HybridCameraSessionSpec { + func start() -> Promise { + return Promise.parallel(Self.queue) { + self.shouldBeRunning = true ++ self.setKeepScreenAwake(true) + if self.session.isRunning { + return + } +@@ -241,6 +249,7 @@ final class HybridCameraSession: HybridCameraSessionSpec { + func stop() -> Promise { + return Promise.parallel(Self.queue) { + self.shouldBeRunning = false ++ self.setKeepScreenAwake(false) + if !self.session.isRunning { + return + } +@@ -248,6 +257,13 @@ final class HybridCameraSession: HybridCameraSessionSpec { + } + } + ++ private func setKeepScreenAwake(_ keepAwake: Bool) { ++ isKeepingScreenAwake = keepAwake ++ DispatchQueue.main.async { ++ UIApplication.shared.isIdleTimerDisabled = keepAwake ++ } ++ } ++ + private func restartAfterRuntimeError() { + Self.queue.async { [weak self] in + guard let self, self.shouldBeRunning, !self.session.isRunning else {