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 b7fc73b3a954..80dc8926932a 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): @@ -407,6 +412,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 @@ -506,6 +535,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -527,6 +557,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -545,6 +576,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -565,6 +597,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -584,6 +617,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -603,6 +637,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -622,6 +657,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -641,6 +677,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -660,6 +697,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -679,6 +717,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -698,6 +737,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -717,6 +757,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -736,6 +777,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -755,6 +797,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -3030,11 +3073,6 @@ PODS: - Turf - Yoga - RNNitroSQLite (9.8.3): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - hermes-engine - NitroModules - RCTRequired @@ -3427,12 +3465,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: @@ -3454,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`) @@ -3466,6 +3524,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`)" @@ -3670,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: @@ -3693,6 +3754,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: @@ -3948,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 @@ -3964,6 +4027,7 @@ SPEC CHECKSUMS: ExpoModulesCore: 9c02efc618119023146b9b8db9c0930e43ac977d ExpoModulesJSI: 4f849c58e99b57c2438b21ed6bc2fcb6f984a0d1 ExpoModulesWorklets: 5e70b46d570c5b1b702c18bc8ac8d1af0171450e + ExpoModulesWorkletsAdapter: 536e81cd66f1b69c9301c5df6099b4e172a3702d ExpoStoreReview: 2a0f6b8e04112aea2f6e3e1cb84109d78f4d041f ExpoTaskManager: f68326227852a30148700965c37c058c1473c6a2 ExpoVideo: b1d9a325ddcd4f3ed8ccb374c01b688e98bc6a04 @@ -3996,6 +4060,7 @@ SPEC CHECKSUMS: MapboxMaps: 22340ae0e0d47b2e4761019b1d6c569ab04e088d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NitroFetch: 42521ebe6c3f291b2be0e1d73c7fe95c4e4ea0b8 + NitroImage: 03c650b246b841609b53236df5c03549d156e9ad NitroModules: 6232dc95a28a7a221227e618733ce0e674e8753b NWWebSocket: 873eb4b18acd0317da70cdba388d792a3187ce00 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 @@ -4013,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 @@ -4066,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 @@ -4082,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 @@ -4098,7 +4163,7 @@ SPEC CHECKSUMS: ReactCodegen: cd296de5e1c422deebafff34134d1721b5c29704 ReactCommon: d5c1bb4427bf51c443de5926aac332c89ddd9363 ReactNativeBiometrics: 1fb6ad206cfd39900e952b1abcdcdba22bdd6fd9 - ReactNativeDependencies: fa0a54b3f5319ae0e3b9aff32bfee7a424b88e66 + ReactNativeDependencies: 4227659c64db3eab64c6ea87b5695fd329922798 ReactNativeHybridApp: a35e7f25b54a370ada94c01309ccb1ff0954b949 RNAppleAuthentication: 9027af8aa92b4719ef1b6030a8e954d37079473a RNCClipboard: 7a7d4557bfd3370b35c99dfecd92ae7b9fc4948a @@ -4112,7 +4177,7 @@ SPEC CHECKSUMS: RNLiveMarkdown: b6894bb864d517f51ea80c98facd26571f2cd0fe RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce - RNNitroSQLite: c986393b7cb0e5e4587d0c9f7108e56ee8db9627 + RNNitroSQLite: 56c1f2b950788d3923ca6163c7a8117b6c31cc0d RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73 RNReanimated: e75c988a6527ec651ea2957b93891ddb64d530d4 @@ -4131,7 +4196,7 @@ SPEC CHECKSUMS: Turf: c9eb11a65d96af58cac523460fd40fec5061b081 TweetNacl: 3abf4d1d2082b0114e7a67410e300892448951e6 UMAppLoader: 173cb90a3039aa9c1b84bace4487e1907a4a5a39 - VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe + VisionCamera: c0e2c308592613de270de5f6e722b53d04a26f0d Yoga: c30c859b7e9b75e547b600b486fe33165f60de00 PODFILE CHECKSUM: c937d1b7b774ce019c0c848e4ad1e850f08c0e90 diff --git a/package-lock.json b/package-lock.json index c1ddc3a94bc6..e71bd53edd78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,6 +124,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.117", @@ -144,7 +145,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.13.0", @@ -37083,6 +37084,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", @@ -37704,25 +37724,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 bde007963126..a922c5cb001d 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.117", @@ -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.13.0", diff --git a/patches/react-native-vision-camera/details.md b/patches/react-native-vision-camera/details.md index 2152b6fb0dc7..12e05dcf4d22 100644 --- a/patches/react-native-vision-camera/details.md +++ b/patches/react-native-vision-camera/details.md @@ -1,28 +1,69 @@ # `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) +### [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) -This patch carries two related iOS fixes to `CameraSession.configure`. The second addresses a crash that the first one exposed. +- Reason: -- 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. + ``` + 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: - - **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. + - 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. + ``` -- 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. +- 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 - - **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. +### [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) -- 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 +- 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 + +### [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+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/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) 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/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 { diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2600eb78d58e..9b39d6d37230 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -433,9 +433,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, }, @@ -2843,10 +2840,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/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index 75b3c9ce1522..14b17b238293 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -16,35 +16,37 @@ 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'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {capturePhotoToPath} from '@libs/cameraCapture'; +import {getZoomProp} 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 Log from '@libs/Log'; import ReceiptStorage from '@libs/ReceiptStorage'; 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, 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, useCameraFormat, Camera as VisionCamera} from 'react-native-vision-camera'; +import {useCameraDevice, useCameraDevices, Camera as VisionCamera} from 'react-native-vision-camera'; type CapturedPhoto = { uri: string; @@ -95,29 +97,17 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme const isCapturing = useRef(false); const isConfirmingPhoto = 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), - ]); + const {photoOutput, cameraAspectRatio, updatePhotoResolution} = useReceiptPhotoOutput('balanced', isLandscape); const hasFlash = !!device?.hasFlash; - const cameraAspectRatio = getCameraAspectRatio(format, isLandscape); - const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, !!device?.supportsFocus && !capturedPhoto); + const {tapGesture, cameraFocusIndicatorAnimatedStyle} = useTapToFocusGesture(cameraRef, !!device?.supportsFocusMetering && !capturedPhoto); // The shutter that held screen reader focus is replaced by the review controls, so tell the user what happened useAccessibilityAnnouncement(translate('attachmentPicker.photoTakenAnnouncement'), !!capturedPhoto, {shouldAnnounceOnNative: true}); @@ -181,13 +171,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) { discardPhoto(photo.path); return; @@ -215,12 +203,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 retakePhoto = () => { @@ -327,12 +315,11 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme diff --git a/src/hooks/useNativeCamera.ts b/src/hooks/useNativeCamera.ts index 284f0627273b..c8c3ff8625ac 100644 --- a/src/hooks/useNativeCamera.ts +++ b/src/hooks/useNativeCamera.ts @@ -1,13 +1,16 @@ 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'; 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 +18,7 @@ import {AppState} from 'react-native'; import {useTapGesture} 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 +57,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 +68,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( @@ -122,24 +125,29 @@ 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. */ -function focusCameraAtPoint(cameraRef: React.RefObject, point: Point) { +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 = getErrorMessage(error); + if (FOCUS_CANCELLED_MESSAGES.some((cancelled) => message.includes(cancelled))) { return; } - Log.warn('Error focusing camera', error); + Log.warn('Error focusing camera', {message: getFirstLine(message)}); }); } -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/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 51234e482575..f6007e6602b8 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -50,7 +50,7 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { const sourcePath = fileURIToPath(uriOrPath); - // vision-camera writes straight into the receipts folder through its `path` option. + // 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/libs/cameraCapture/getCameraAspectRatio.ts b/src/libs/cameraCapture/getCameraAspectRatio.ts new file mode 100644 index 000000000000..35a7d2b29788 --- /dev/null +++ b/src/libs/cameraCapture/getCameraAspectRatio.ts @@ -0,0 +1,14 @@ +import type {Size} from 'react-native-vision-camera'; + +function getCameraAspectRatio(photoSize: Size | undefined, isInLandscapeMode: boolean): number | undefined { + if (!photoSize) { + return undefined; + } + if (isInLandscapeMode) { + return photoSize.width / photoSize.height; + } + + return photoSize.height / photoSize.width; +} + +export default getCameraAspectRatio; 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/getWideLensZoom.ts b/src/libs/cameraCapture/getWideLensZoom.ts new file mode 100644 index 000000000000..f682938bbec3 --- /dev/null +++ b/src/libs/cameraCapture/getWideLensZoom.ts @@ -0,0 +1,25 @@ +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; +} + +/** + * Unset when it would be 1: VisionCamera re-applies `zoom` from JS after configuring, and on Android that call can land + * before the camera starts, so CameraX rejects it with "Camera is not active" through `onError`. + */ +function getZoomProp(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 new file mode 100644 index 000000000000..95736452762b --- /dev/null +++ b/src/libs/cameraCapture/index.ts @@ -0,0 +1,59 @@ +import fileURIToPath from '@libs/fileURIToPath'; + +import type {CameraOrientation, CameraPhotoOutput, CameraRef, CapturePhotoCallbacks, 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 its metadata. +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); +} + +/** 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); + }); +} + +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), orientation: photo.orientation})) + .finally(() => { + photo.dispose(); + }); + }); +} + +/** 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 callAsPromise(() => camera.takeSnapshot()).then((image) => + callAsPromise(() => 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/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/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..3f5e0e21c4e4 --- /dev/null +++ b/src/libs/cameraCapture/types.ts @@ -0,0 +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/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..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,12 +263,12 @@ 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, 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, + 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 eb9e52bf50e6..5a79a35e5607 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -9,16 +9,19 @@ 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'; 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 isCameraClosedError from '@pages/iou/request/step/IOURequestStepScan/isCameraClosedError'; import StepScreenWrapper from '@pages/iou/request/step/StepScreenWrapper'; import withFullTransactionOrNotFound from '@pages/iou/request/step/withFullTransactionOrNotFound'; @@ -43,14 +45,12 @@ 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'; type IOURequestStepOdometerImageProps = WithFullTransactionOrNotFoundProps; @@ -96,6 +96,8 @@ function IOURequestStepOdometerImage({ }); const {setIsLoaderVisible} = useFullScreenLoaderActions(); + 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'); const icon = imageType === CONST.IOU.ODOMETER_IMAGE_TYPE.START ? lazyIcons.OdometerStart : lazyIcons.OdometerEnd; @@ -206,13 +208,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}) => @@ -262,13 +266,6 @@ 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); - // Wait for camera permission status to render if (cameraPermissionStatus == null) { return null; @@ -305,7 +302,8 @@ function IOURequestStepOdometerImage({ { + 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, + {onWillCapturePhoto}, + ); +}; + +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 b68d342b1796..f798fc2f274e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/index.ts @@ -1,23 +1,34 @@ +import {capturePhotoToPath, captureSnapshotToPath} from '@libs/cameraCapture'; + import type {CaptureReceipt, CaptureReceiptOptions} from './types'; +import getReceiptFilePath from './getReceiptFilePath'; + /** - * Whether `captureReceipt` will use `takePhoto` (as opposed to `takeSnapshot`) for the given options. + * 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 (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, path, isInLandscapeMode, onWillCapturePhoto}) => { + const filePath = getReceiptFilePath(path); + + if (!shouldTakePhoto({flash, hasFlash, isInLandscapeMode})) { + return captureSnapshotToPath(camera, filePath); } - return camera.takeSnapshot({quality: 85, path}); + const isUsingFlash = flash && hasFlash; + + return capturePhotoToPath( + photoOutput, + { + flashMode: isUsingFlash ? 'on' : 'off', + enableShutterSound: !isPlatformMuted, + }, + filePath, + {onWillCapturePhoto}, + ); }; 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..8dd8029e307c 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/captureReceipt/types.ts @@ -1,4 +1,6 @@ -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; @@ -6,8 +8,9 @@ type CaptureReceiptOptions = { isPlatformMuted: boolean | undefined; path: string; isInLandscapeMode: boolean; + onWillCapturePhoto?: () => void; }; -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 8da685cea636..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 @@ -3,10 +3,13 @@ import ActivityIndicator from '@components/ActivityIndicator'; 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 useWindowDimensions from '@hooks/useWindowDimensions'; +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'; @@ -20,7 +23,6 @@ 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 usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; import isCameraClosedError from '@pages/iou/request/step/IOURequestStepScan/isCameraClosedError'; @@ -29,14 +31,11 @@ import startReceiptPrepareSpan from '@pages/iou/request/step/IOURequestStepScan/ import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {PhotoFile} from 'react-native-vision-camera'; - import {useNavigation} from '@react-navigation/native'; 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 {useCameraFormat} from 'react-native-vision-camera'; import type {CameraProps} from './types'; @@ -52,7 +51,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(); const navigation = useNavigation(); @@ -87,22 +85,8 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou cameraFocusIndicatorAnimatedStyle, } = useNativeCamera({onFocusStart, onFocusCleanup}); - // photoResolution picks the format; videoResolution only breaks ties. videoResolution is platform-specific: - // - iOS: `takeSnapshot` returns the raw video frame, so prefer a video size close to 6 MP. - // - 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}, - Platform.OS === 'ios' - ? {photoResolution: {width: CONST.RECEIPT_CAMERA.IOS_STILL_WIDTH, height: CONST.RECEIPT_CAMERA.IOS_STILL_HEIGHT}} - : {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; + const {photoOutput, cameraAspectRatio, updatePhotoResolution} = useReceiptPhotoOutput(cameraPlatformConfig.scanQualityPrioritization, isInLandscapeMode); + const outputs = [photoOutput]; // Blink animation for shutter feedback const blinkOpacity = useSharedValue(0); @@ -112,10 +96,23 @@ 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 {handleCameraInitialized} = useCameraInitTelemetry({cameraPermissionStatus, device, format}); + const {handleCameraConfigured, handleSessionConfigSelected} = useCameraInitTelemetry({cameraPermissionStatus, device}); + + // VisionCamera replaces the prepared settings on every reconfigure. + const finishCameraSetup = () => { + updatePhotoResolution(); + handleCameraConfigured(); + const {preparedPhotoSettings} = cameraPlatformConfig; + if (!preparedPhotoSettings) { + return; + } + photoOutput.prepareSettings(preparedPhotoSettings).catch((error: unknown) => { + Log.info('[Camera] could not prepare the photo settings', false, {error: getErrorMessage(error)}); + }); + }; + const {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto} = usePhotoUpgrade(); const maybeCancelShutterSpan = () => { @@ -158,32 +155,31 @@ 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(); - - const path = getReceiptsUploadFolderPath(); - - const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + 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(); + } - // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it - // reads the most recent video frame, which a photo capture can interrupt. - const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); + const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !isPhotoCapture; - receiptCapture - .then((photo: PhotoFile) => { + // 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(), onWillCapturePhoto: showBlink}) + .then((photo: CapturedPhoto) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, @@ -216,13 +212,13 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou .catch((error: unknown) => { isCapturingPhoto.current = false; maybeCancelShutterSpan(); - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = getErrorMessage(error); const isScreenFocused = navigation.isFocused(); // Leaving the screen closes the camera, which cancels a capture still in flight (e.g. a flash shot). if (!isScreenFocused && isCameraClosedError(error)) { Log.info('Photo capture cancelled by leaving the screen', false, {error: errorMessage}); } else { - Log.warn('Error taking photo', errorMessage); + Log.warn('Error taking photo', {message: errorMessage, stack: error instanceof Error ? error.stack : undefined}); } if (isScreenFocused) { showCameraAlert(); @@ -231,7 +227,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou }); if (shouldUpgradeToPhoto) { - startPhotoCapture(camera.current); + startPhotoCapture(photoOutput); } }; @@ -266,8 +262,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou )} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index 1002421625fc..e2a8a6c7d957 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -7,6 +7,12 @@ 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 {getErrorMessage} from '@libs/ErrorUtils'; +import Log from '@libs/Log'; + import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -16,9 +22,9 @@ 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 {Camera, CameraDevice, CameraDeviceFormat, CameraProps} from 'react-native-vision-camera'; +import type {CameraDevice, CameraOutput, CameraRef, CameraSessionConfig} from 'react-native-vision-camera'; -import React 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'; @@ -28,16 +34,13 @@ import NavigationAwareCamera from './NavigationAwareCamera/Camera'; type CameraViewportProps = { /** Ref to the underlying Camera instance */ - camera: RefObject; + camera: RefObject; /** The active camera device descriptor */ device: CameraDevice; - /** The selected camera format (resolution / FPS) */ - format: CameraDeviceFormat | undefined; - - /** Target frames-per-second for the camera preview */ - fps?: number; + /** Outputs the session streams to, such as the photo output captures are taken from */ + outputs: CameraOutput[]; /** Aspect ratio used to size the camera viewfinder */ cameraAspectRatio: number | undefined; @@ -66,8 +69,11 @@ 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 when the camera finishes initializing */ - onInitialized?: () => void; + /** Callback fired each time the session finishes configuring, so the outputs' negotiated sizes can be read */ + onConfigured?: () => void; + + /** Callback fired with the session config VisionCamera picked, before `onConfigured` */ + onSessionConfigSelected?: (config: CameraSessionConfig) => void; /** Callback fired when the camera preview is laid out */ onLayout?: (event: LayoutChangeEvent) => void; @@ -88,9 +94,6 @@ type CameraViewportProps = { setFlash: (updater: (prev: boolean) => boolean) => void; - /** Whether photos follow the device or the preview orientation, defaults to "device" */ - outputOrientation?: CameraProps['outputOrientation']; - /** Extra content rendered below the viewfinder, inside the camera view */ children?: ReactNode; }; @@ -98,8 +101,7 @@ type CameraViewportProps = { function CameraViewport({ camera, device, - format, - fps, + outputs, cameraAspectRatio, isInLandscapeMode, shouldFillPortraitViewport = true, @@ -109,7 +111,8 @@ function CameraViewport({ isAttachmentPickerActive, didCapturePhoto = false, hasPendingPhotoCapture = false, - onInitialized, + onConfigured, + onSessionConfigSelected, onLayout, shouldShowFlashButton, flashSentryLabel = CONST.SENTRY_LABEL.REQUEST_STEP.SCAN.FLASH, @@ -117,7 +120,6 @@ function CameraViewport({ flash, hasFlash, setFlash, - outputOrientation = 'device', children, }: CameraViewportProps) { const theme = useTheme(); @@ -125,6 +127,38 @@ 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 = 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 ( @@ -133,19 +167,23 @@ function CameraViewport({ Log.warn('[Camera] VisionCamera error', {summary: getFirstLine(error.message), 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 bfc9aae11dc6..3c596ca00238 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; shouldStayActiveWhenBlurred?: boolean; }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts deleted file mode 100644 index d7cffe111c24..000000000000 --- a/src/pages/iou/request/step/IOURequestStepScan/getCameraAspectRatio.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {CameraDeviceFormat} from 'react-native-vision-camera'; - -function getCameraAspectRatio(format: CameraDeviceFormat | undefined, isInLandscapeMode: boolean): number | undefined { - if (!format) { - return undefined; - } - if (isInLandscapeMode) { - return format.photoWidth / format.photoHeight; - } - - return format.photoHeight / format.photoWidth; -} - -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..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'; @@ -6,7 +7,7 @@ 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 ReactNativeBlobUtil from 'react-native-blob-util'; @@ -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(() => { @@ -88,7 +87,7 @@ function useCameraInitTelemetry({cameraPermissionStatus, device, format}: UseCam }; }, []); - const handleCameraInitialized = () => { + const handleCameraConfigured = () => { // Prevent duplicate span endings if callback fires multiple times if (cameraInitialized.current) { return; @@ -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,15 @@ function useCameraInitTelemetry({cameraPermissionStatus, device, format}: UseCam }); }; - return {handleCameraInitialized}; + // VisionCamera calls this during configure, before `onConfigured` ends the span. + const handleSessionConfigSelected = (config: CameraSessionConfig) => { + if (!cameraPlatformConfig.reportsAutoFocusSystem) { + return; + } + autoFocusSystem.current = config.autoFocusSystem; + }; + + return {handleCameraConfigured, handleSessionConfigSelected}; } export default useCameraInitTelemetry; diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts index a3a389751940..22a7d44aedce 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 = () => { 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/getCameraCapabilityAttributes.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts index 89cba1d51cea..4865b1648684 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/getCameraCapabilityAttributes.ts @@ -1,49 +1,28 @@ /** - * 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. + * 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'; 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 { +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)); - } - + // 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_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_SELECTED_FORMAT_AF_SYSTEM]: autoFocusSystem, + // 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/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 6af92dbb4aa8..57d84fc810da 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,12 +1,11 @@ /** - * 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. + * 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'; -import type {Orientation} from 'react-native-vision-camera'; +import type {CameraOrientation} from 'react-native-vision-camera'; import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; @@ -17,18 +16,20 @@ type PhotoSize = { rotation?: number; }; -function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): 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'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. 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. */ @@ -40,7 +41,7 @@ function releaseQuietly(releasable: {release: () => void}) { } } -function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { +function rotatePhotoToUpright(stillPath: string, orientation?: CameraOrientation): Promise { const sourceUri = getPhotoSource(stillPath); const startedAt = Date.now(); diff --git a/tests/ui/DistanceRequestStartPageTest.tsx b/tests/ui/DistanceRequestStartPageTest.tsx index 4932e78be3f6..a56b88d7911b 100644 --- a/tests/ui/DistanceRequestStartPageTest.tsx +++ b/tests/ui/DistanceRequestStartPageTest.tsx @@ -55,6 +55,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 96e1fd272e20..1a4d6b27c927 100644 --- a/tests/ui/IOURequestRedirectToStartPageTest.tsx +++ b/tests/ui/IOURequestRedirectToStartPageTest.tsx @@ -40,6 +40,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()})), })); // Stands in for the module-level navigation-ready promise so each test can decide whether the NavigationContainer diff --git a/tests/ui/IOURequestStartPageManualTabTest.tsx b/tests/ui/IOURequestStartPageManualTabTest.tsx index ae2fc96d238e..009c7cbeaf48 100644 --- a/tests/ui/IOURequestStartPageManualTabTest.tsx +++ b/tests/ui/IOURequestStartPageManualTabTest.tsx @@ -39,6 +39,8 @@ jest.mock('react-native-tab-view', () => ({TabView: () => null, TabBar: () => nu 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/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/ui/ScanSkipConfirmationTest.tsx b/tests/ui/ScanSkipConfirmationTest.tsx index 52925e6b7f42..47ab53db2bee 100644 --- a/tests/ui/ScanSkipConfirmationTest.tsx +++ b/tests/ui/ScanSkipConfirmationTest.tsx @@ -73,7 +73,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 014c9ae6d0e8..fae9356369e3 100644 --- a/tests/ui/components/AttachmentCameraTest.tsx +++ b/tests/ui/components/AttachmentCameraTest.tsx @@ -19,7 +19,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(path = '/tmp/photos/shot.jpg') { + return {width: 4032, height: 3024, orientation: 'right', saveToTemporaryFileAsync: jest.fn(() => Promise.resolve(path)), dispose: jest.fn()}; +} +const mockCapturePhoto = jest.fn(() => Promise.resolve(createMockPhoto())); let mockPermissionStatus = 'granted'; jest.mock('@libs/isInLandscapeMode'); @@ -57,31 +60,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); @@ -119,11 +107,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); @@ -161,7 +145,7 @@ describe('AttachmentCamera', () => { await waitForBatchedUpdatesWithAct(); // Then the photo waits for the user to choose "Retake" or "Use photo", so nothing is sent yet - expect(mockTakePhoto).toHaveBeenCalledTimes(1); + expect(mockCapturePhoto).toHaveBeenCalledTimes(1); expect(onCapture).not.toHaveBeenCalled(); expect(screen.getByLabelText(translateLocal('attachmentPicker.retake'))).toBeOnTheScreen(); expect(screen.getByLabelText(translateLocal('attachmentPicker.usePhoto'))).toBeOnTheScreen(); @@ -288,7 +272,7 @@ describe('AttachmentCamera', () => { expect(screen.queryByLabelText(translateLocal('attachmentPicker.capturedPhotoPreview'))).not.toBeOnTheScreen(); fireEvent.press(screen.getByLabelText(translateLocal('receipt.shutter'))); await waitForBatchedUpdatesWithAct(); - expect(mockTakePhoto).toHaveBeenCalledTimes(2); + expect(mockCapturePhoto).toHaveBeenCalledTimes(2); }); it('closes the camera when a close request arrives during review', async () => { @@ -327,11 +311,11 @@ describe('AttachmentCamera', () => { it('deletes a photo that finishes capturing after the camera was closed', async () => { // Given a capture that is still in flight - let resolveTakePhoto: (photo: {path: string; width: number; height: number}) => void = () => {}; - mockTakePhoto.mockImplementationOnce( + let resolveCapturePhoto: (photo: ReturnType) => void = () => {}; + mockCapturePhoto.mockImplementationOnce( () => new Promise((resolve) => { - resolveTakePhoto = resolve; + resolveCapturePhoto = resolve; }), ); const {onCapture} = renderCamera(); @@ -341,7 +325,7 @@ describe('AttachmentCamera', () => { // When the user closes the camera before the capture resolves fireEvent.press(screen.getByLabelText(translateLocal('common.close'))); await act(async () => { - resolveTakePhoto({path: '/tmp/photos/late.jpg', width: 3024, height: 4032}); + resolveCapturePhoto(createMockPhoto('/tmp/photos/late.jpg')); }); await waitForBatchedUpdatesWithAct(); @@ -359,11 +343,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(); @@ -409,7 +393,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/captureReceiptTest.ts b/tests/unit/captureReceiptTest.ts new file mode 100644 index 000000000000..56e4a4d7f7fa --- /dev/null +++ b/tests/unit/captureReceiptTest.ts @@ -0,0 +1,90 @@ +import type CapturedPhoto from '@libs/cameraCapture/types'; + +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'; + +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), +})); + +// 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 +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', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('on 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 + captureReceiptIOS(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 + captureReceiptIOS(camera, photoOutput, {...BASE_OPTIONS, flash: true}); + const flashSettings = getSettings(); + captureReceiptIOS(camera, photoOutput, {...BASE_OPTIONS, isInLandscapeMode: true}); + const landscapeSettings = getSettings(); + captureReceiptIOS(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('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 + // When the receipt is captured + captureReceiptAndroid(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/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/components/OdometerCameraCancelledCaptureTest.tsx b/tests/unit/components/OdometerCameraCancelledCaptureTest.tsx index 43059b8e784e..ca9c170d3c4f 100644 --- a/tests/unit/components/OdometerCameraCancelledCaptureTest.tsx +++ b/tests/unit/components/OdometerCameraCancelledCaptureTest.tsx @@ -15,15 +15,7 @@ let mockRejectCapture: (error: unknown) => void = () => {}; jest.mock('@hooks/useNativeCamera', () => ({ __esModule: true, default: () => ({ - camera: { - current: { - takePhoto: () => - new Promise((resolve, reject) => { - mockResolveCapture = resolve; - mockRejectCapture = reject; - }), - }, - }, + camera: {current: {}}, device: {id: 'back', hasFlash: true}, cameraPermissionStatus: 'granted', flash: true, @@ -73,7 +65,14 @@ jest.mock('@hooks/useLocalize', () => ({ default: () => ({translate: (key: string) => key}), })); jest.mock('@hooks/useIsInLandscapeMode', () => ({__esModule: true, default: () => false})); -jest.mock('react-native-vision-camera', () => ({useCameraFormat: () => undefined})); +jest.mock('@hooks/useReceiptPhotoOutput', () => ({__esModule: true, default: () => ({photoOutput: {}, cameraAspectRatio: undefined, updatePhotoResolution: jest.fn()})})); +jest.mock('@libs/cameraCapture', () => ({ + capturePhotoToPath: () => + new Promise((resolve, reject) => { + mockResolveCapture = resolve; + mockRejectCapture = reject; + }), +})); jest.mock('react-native-blob-util', () => ({ __esModule: true, default: {fs: {isDir: () => Promise.resolve(true), mkdir: () => Promise.resolve()}}, diff --git a/tests/unit/components/ScanCameraCancelledCaptureTest.tsx b/tests/unit/components/ScanCameraCancelledCaptureTest.tsx index ddab9112fe55..c1d142a832a6 100644 --- a/tests/unit/components/ScanCameraCancelledCaptureTest.tsx +++ b/tests/unit/components/ScanCameraCancelledCaptureTest.tsx @@ -81,10 +81,10 @@ 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('react-native-vision-camera', () => ({useCameraFormat: () => undefined})); +jest.mock('@hooks/useReceiptPhotoOutput', () => ({__esModule: true, default: () => ({photoOutput: {}, cameraAspectRatio: undefined, updatePhotoResolution: jest.fn()})})); jest.mock('@hooks/useLocalize', () => ({ __esModule: true, @@ -183,7 +183,7 @@ describe('Scan camera capture cancelled by leaving the screen', () => { // Then the user is told the camera failed, and the failure is logged as a warning, as before expect(alertSpy).toHaveBeenCalledWith('receipt.cameraErrorTitle', 'receipt.cameraErrorMessage'); - expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', CAMERA_CLOSED_ERROR.message); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', expect.objectContaining({message: CAMERA_CLOSED_ERROR.message})); }); it('still logs a warning, without an alert, when the camera fails for another reason after the user left the scan screen', async () => { @@ -198,7 +198,7 @@ describe('Scan camera capture cancelled by leaving the screen', () => { // Then no alert appears on the screen the user went to, but the failure stays a warning because it is a real camera error, not a cancellation expect(alertSpy).not.toHaveBeenCalled(); - expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', writeError.message); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', expect.objectContaining({message: writeError.message})); expect(mockLogInfo).not.toHaveBeenCalledWith('Photo capture cancelled by leaving the screen', expect.anything(), expect.anything()); }); @@ -215,7 +215,7 @@ describe('Scan camera capture cancelled by leaving the screen', () => { // Then no alert appears on the screen the user went to, but this is a real storage failure, so it stays a warning instead of being logged as a cancellation expect(alertSpy).not.toHaveBeenCalled(); - expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', saveError.message); + expect(mockLogWarn).toHaveBeenCalledWith('Error taking photo', expect.objectContaining({message: saveError.message})); expect(mockLogInfo).not.toHaveBeenCalledWith('Photo capture cancelled by leaving the screen', expect.anything(), expect.anything()); }); }); diff --git a/tests/unit/getCameraCapabilityAttributesTest.ts b/tests/unit/getCameraCapabilityAttributesTest.ts index 106b556317d6..85fb7fcdf8c4 100644 --- a/tests/unit/getCameraCapabilityAttributesTest.ts +++ b/tests/unit/getCameraCapabilityAttributesTest.ts @@ -2,116 +2,57 @@ import getCameraCapabilityAttributes from '@pages/iou/request/step/IOURequestSte import CONST from '@src/CONST'; -import type {CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; +import type {CameraDevice, DeviceType} 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, - }; +function createLens(type: DeviceType): CameraDevice { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return {type} as CameraDevice; } -function createDevice(overrides: DeviceOverrides = {}): Required { - return { - formats: [], - minFocusDistance: 12, - physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], - neutralZoom: 2, - ...overrides, - }; +function createDevice(lensTypes: DeviceType[], zoomLensSwitchFactors: number[]): Pick { + return {physicalDevices: lensTypes.map(createLens), zoomLensSwitchFactors}; } -const CONTRAST_FORMAT = createFormat(); -const INTERCHANGEABLE_PHASE_FORMAT = createFormat({autoFocusSystem: 'phase-detection'}); - 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); + 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]); - expect(attributes).toMatchObject({ - [CONST.TELEMETRY.ATTRIBUTE_MIN_FOCUS_DISTANCE]: 12, - [CONST.TELEMETRY.ATTRIBUTE_PHYSICAL_DEVICE_COUNT]: 2, - [CONST.TELEMETRY.ATTRIBUTE_NEUTRAL_ZOOM]: 2, - }); - }); + // When the attributes are derived + const attributes = getCameraCapabilityAttributes(device); - 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); - - 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('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})]}); - - expect(getCameraCapabilityAttributes(device, CONTRAST_FORMAT)).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]: false}); - }); + 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 frame rate range', () => { - const device = createDevice({formats: [CONTRAST_FORMAT, createFormat({autoFocusSystem: 'phase-detection', maxFps: 60})]}); + // 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 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}); - }); - - 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('does not flag anything when the selected format already focuses by phase detection', () => { - const device = createDevice({formats: [INTERCHANGEABLE_PHASE_FORMAT, CONTRAST_FORMAT]}); - - 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('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]); - 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); + // When the attributes are derived with that autofocus system + const attributes = getCameraCapabilityAttributes(device, 'phase-detection'); - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_SELECTED_FORMAT_AF_SYSTEM]).toBeUndefined(); - expect(attributes[CONST.TELEMETRY.ATTRIBUTE_HAS_INTERCHANGEABLE_PHASE_FORMAT]).toBeUndefined(); - expect(attributes).toMatchObject({[CONST.TELEMETRY.ATTRIBUTE_PHASE_DETECTION_FORMAT_COUNT]: 1}); + // 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'); }); }); diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts index 0e0335bc08b6..e98d5ccb3d96 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,23 @@ 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( +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. // 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 +105,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 +120,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 +132,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 +143,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 +159,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 +187,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 +207,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 +236,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 +262,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 +286,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 +306,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 +322,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 +341,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 +357,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 +379,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 +393,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 +406,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 +420,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 +433,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 +445,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 +470,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 +486,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 +501,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 +517,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 +537,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 +556,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 +570,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 +581,7 @@ describe('usePhotoUpgrade', () => { }); act(() => { - result.current.startPhotoCapture(camera); + result.current.startPhotoCapture(photoOutput); }); act(() => { result.current.upgradeReceiptWithPhoto(DURABLE_NAME); @@ -599,18 +600,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/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); + }); +}); 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/cameraCaptureDeviceTest.ts b/tests/unit/libs/cameraCaptureDeviceTest.ts new file mode 100644 index 000000000000..c108171e7c63 --- /dev/null +++ b/tests/unit/libs/cameraCaptureDeviceTest.ts @@ -0,0 +1,90 @@ +import getWideLensZoom, {getZoomProp} 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'); + }); +}); + +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..891f93d6ada3 --- /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, orientation: 'right'}); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts index 6b36f4beadce..12650a7026ee 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(); @@ -44,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(); });