diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index a1c9e4aa0c..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,12 +0,0 @@ -# These are supported funding model platforms - -github: mrousavy -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: mrousavy -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml deleted file mode 100644 index 34cd358a64..0000000000 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: 🐛 Bug Report -description: File a bug report -title: "🐛 " -labels: [🐛 bug] -body: - - type: textarea - attributes: - label: What's happening? - description: Explain what you are trying to do and what happened instead. Be as precise as possible, I can't help you if I don't understand your issue. - placeholder: I wanted to take a picture, but the method failed with this error "[capture/photo-not-enabled] Failed to take photo, photo is not enabled!" - validations: - required: true - - type: textarea - attributes: - label: Reproduceable Code - description: > - Share a small reproduceable code snippet here (or the entire file if necessary). - Most importantly, share how you use the `` component and what props you pass to it. - This will be automatically formatted into code, so no need for backticks. - render: tsx - placeholder: > - const device = useCameraDevices() - - // ... - - - validations: - required: true - - type: textarea - attributes: - label: Relevant log output - description: > - Paste any relevant **native log output** (Xcode Logs/Android Studio Logcat) here. - This will be automatically formatted into code, so no need for backticks. - - * For iOS, run the project through Xcode and copy the logs from the log window. - - * For Android, either open the project through Android Studio and paste the logs from the logcat window, or run `adb logcat` in terminal. - render: shell - placeholder: > - 09:03:46 I ReactNativeJS: Running "VisionCameraExample" with {"rootTag":11} - - 09:03:47 I ReactNativeJS: Re-rendering App. Camera: undefined | Microphone: undefined - - 09:03:47 I VisionCamera: Installing JSI bindings... - - 09:03:47 I VisionCamera: Finished installing JSI bindings! - ... - validations: - required: true - - type: textarea - attributes: - label: Camera Device - description: > - Paste the JSON Camera `device` that was used here. - Make sure to leave out the `formats` prop as that is too long for the issue. - - Run this code in your app to get the `device` as a JSON: - - ``` - - console.log(JSON.stringify(device, (k, v) => k === "formats" ? [] : v, 2)) - - ``` - - This will be automatically formatted into code, so no need for backticks. - render: json - placeholder: > - { - "id": "com.apple.avfoundation.avcapturedevice.built-in_video:6", - "devices": [ - "ultra-wide-angle-camera", - "wide-angle-camera" - ], - "formats": null, - "hardwareLevel": "full", - "hasFlash": true, - "hasTorch": true, - "isMultiCam": true, - "minZoom": 1, - "neutralZoom": 2, - "maxZoom": 123.75, - "name": "Back Dual Wide Camera", - "position": "back", - "supportsFocus": true, - "supportsLowLightBoost": false, - "supportsRawCapture": false - } - validations: - required: true - - type: input - attributes: - label: Device - description: > - Which device are you seeing this Problem on? - Mention the full name of the phone, as well as the operating system and version. - If you have tested this on multiple devices (ex. Android and iOS) then mention all of those devices (comma separated) - placeholder: ex. iPhone 11 Pro (iOS 14.3) - validations: - required: true - - type: input - attributes: - label: VisionCamera Version - description: Which version of react-native-vision-camera are you using? - placeholder: ex. 3.1.6 - validations: - required: true - - type: dropdown - attributes: - label: Can you reproduce this issue in the VisionCamera Example app? - description: > - Try to build the example app (`package/example/`) and see if the issue is reproduceable here. - **Note:** If you don't try this in the example app, I most likely won't help you with your issue. - options: - - I didn't try (⚠️ your issue might get ignored & closed if you don't try this) - - Yes, I can reproduce the same issue in the Example app here - - No, I cannot reproduce the issue in the Example app - default: 0 - validations: - required: true - - type: checkboxes - attributes: - label: Additional information - description: Please check all the boxes that apply - options: - - label: I am using Expo - - label: I have enabled Frame Processors (react-native-worklets-core) - - label: I have read the [Troubleshooting Guide](https://react-native-vision-camera.com/docs/guides/troubleshooting) - required: true - - label: I agree to follow this project's [Code of Conduct](https://github.com/mrousavy/react-native-vision-camera/blob/main/CODE_OF_CONDUCT.md) - required: true - - label: I searched for [similar issues in this repository](https://github.com/mrousavy/react-native-vision-camera/issues) and found none. - required: true diff --git a/.github/ISSUE_TEMPLATE/BUILD_ERROR.yml b/.github/ISSUE_TEMPLATE/BUILD_ERROR.yml deleted file mode 100644 index f97039fc52..0000000000 --- a/.github/ISSUE_TEMPLATE/BUILD_ERROR.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: 🔧 Build Error -description: File a build error bug report -title: "🔧 " -labels: [🔧 build error] -body: - - type: textarea - attributes: - label: How were you trying to build the app? - description: Explain how you tried to build the app, through Xcode, `yarn ios`, a CI, or other. Be as precise as possible, I can't help you if I don't understand your issue. - placeholder: I tried to build my app with react-native-vision-camera using the `yarn ios` command, and it failed. - validations: - required: true - - type: textarea - attributes: - label: Full build logs - description: Share the full build logs that appear in the console. Make sure you don't just paste the last few lines here, but rather everything from start to end. - render: tsx - placeholder: > - $ react-native run-android - - > Configure project :react-native-vision-camera - - [VisionCamera] react-native-worklets-core found, Frame Processors enabled! - - ... - validations: - required: true - - type: textarea - attributes: - label: Project dependencies - description: Share all of your project's dependencies including their versions from `package.json`. This is useful if there are any other conflicting libraries. - render: json - placeholder: > - "dependencies": { - "react-native": "^0.73.4", - "react-native-reanimated": "^3.9.0", - "react-native-vision-camera": "^4.0.0", - "react-native-worklets-core": "^1.0.0", - ... - }, - validations: - required: true - - type: input - attributes: - label: VisionCamera Version - description: Which version of react-native-vision-camera are you using? - placeholder: ex. 3.1.6 - validations: - required: true - - type: dropdown - attributes: - label: Target platforms - description: Select the platforms where the build error occurs. - multiple: true - options: - - iOS - - Android - validations: - required: true - - type: dropdown - attributes: - label: Operating system - description: Select your operating system that you are trying to build on. - multiple: true - options: - - MacOS - - Windows - - Linux - validations: - required: true - - type: dropdown - attributes: - label: Can you build the VisionCamera Example app? - description: > - Try to build the example app (`package/example/`) and see if the issue is reproduceable here. - **Note:** If you don't try to build the example app, I most likely won't help you with your issue. - options: - - I didn't try (⚠️ your issue might get ignored & closed if you don't try this) - - Yes, I can successfully build the Example app here - - No, I cannot build the Example app either - default: 0 - validations: - required: true - - type: checkboxes - attributes: - label: Additional information - description: Please check all the boxes that apply - options: - - label: I am using Expo - - label: I have enabled Frame Processors (react-native-worklets-core) - - label: I have read the [Troubleshooting Guide](https://react-native-vision-camera.com/docs/guides/troubleshooting) - required: true - - label: I agree to follow this project's [Code of Conduct](https://github.com/mrousavy/react-native-vision-camera/blob/main/CODE_OF_CONDUCT.md) - required: true - - label: I searched for [similar issues in this repository](https://github.com/mrousavy/react-native-vision-camera/issues) and found none. - required: true diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml deleted file mode 100644 index 6a704922cb..0000000000 --- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: ✨ Feature request -description: Suggest an idea for this project -title: "✨ " -labels: [✨ feature] -body: - - type: textarea - attributes: - label: What feature or enhancement are you suggesting? - description: Explain what the feature or enhancement you're suggesting is, how it might improve VisionCamera and what it's pros and cons are. - placeholder: I think it would be great to have support for rotation in VisionCamera. - validations: - required: true - - type: dropdown - attributes: - label: What Platforms whould this feature/enhancement affect? - description: Select the platforms where this feature/enhancement should work on. Features/Enhancements that work on all platforms are more likely to be picked up than platform specifics. - multiple: true - options: - - iOS - - Android - validations: - required: true - - type: textarea - attributes: - label: Alternatives/Workarounds - description: Explain if there are any alternatives/workarounds for the feature/enhancement. - placeholder: I am currently manually applying the rotation to the image once it has been taken. - validations: - required: true - - type: checkboxes - attributes: - label: Additional information - description: Please check all the boxes that apply - options: - - label: I agree to follow this project's [Code of Conduct](https://github.com/mrousavy/react-native-vision-camera/blob/main/CODE_OF_CONDUCT.md) - required: true - - label: I searched for [similar feature requests in this repository](https://github.com/mrousavy/react-native-vision-camera/issues?q=is%3Aopen+is%3Aissue+label%3A%22✨+enhancement%22) and found none. - required: true diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000..04a76ee21b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,206 @@ +name: "🐛 Bug report (runtime error / crash / unexpected behavior)" +description: "Something is broken at runtime — a crash, a frozen preview, wrong output, wrong camera selected, etc." +title: "🐛 " +labels: ["🐛 bug"] +body: + - type: markdown + attributes: + value: | + ## Before opening this issue + + Camera behavior depends on the device, OS version, format, lens, multi-cam support, permissions, thermal state, and dozens of other factors. **We cannot debug something we cannot reproduce.** Bug reports without a reproduction will be **closed without investigation**. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you've done each of these. Every box must be ticked. + options: + - label: I have read and followed every applicable step in the [Nitro Modules Troubleshooting guide](https://nitro.margelo.com/docs/guides/troubleshooting). + required: true + - label: I have read the [VisionCamera Troubleshooting guide](https://visioncamera.margelo.com/docs). + required: true + - label: I have searched [existing issues](https://github.com/mrousavy/react-native-vision-camera/issues?q=is%3Aissue) and found nothing matching. + required: true + - label: I am on the [latest version of react-native-vision-camera](https://github.com/mrousavy/react-native-vision-camera/releases), or have a specific reason I cannot upgrade. + required: true + + - type: input + id: repro + attributes: + label: Reproduction + description: | + **Required.** Either (strongly preferred) a link to a PR on this repo that adds a failing harness test reproducing the bug, **or** a link to a minimal public GitHub repository that reproduces the issue on a fresh clone. + + ### ✅ Preferred: open a PR with a failing harness test + + Add the smallest possible `it(...)` block under [`apps/simple-camera/__tests__/`](https://github.com/mrousavy/react-native-vision-camera/tree/main/apps/simple-camera/__tests__) that reproduces the bug. Follow [the harness-tests README](https://github.com/mrousavy/react-native-vision-camera/blob/main/apps/simple-camera/__tests__/README.md) for the conventions — it explains where to put the test, how to write it against the `VisionCamera` imperative API, and the hard-vs-soft requirement rules. + + This is the fastest path to a fix: your PR's CI run is the reproduction (no maintainer setup needed), the maintainer can push a fix to the same branch until CI goes green, and both the fix and the regression test land together so the same bug can never come back silently. + + Link the PR URL here. Example: `https://github.com/mrousavy/react-native-vision-camera/pull/1234` + + ### 🥈 Fallback: a reproduction repo + + If the bug genuinely cannot be expressed as a harness test (for example, something specific to a `` component with custom rendering that isn't covered by the imperative tests yet), fork this repo and modify the [`apps/simple-camera`](https://github.com/mrousavy/react-native-vision-camera/tree/main/apps/simple-camera) app, or start from a fresh `npx @react-native-community/cli init` and add only what's needed to show the bug. Link the repo URL here instead. + + Issues without either a PR link or a reproduction repo link are **invalid** and will be closed. Stripped-down snippets in the description do not count. + placeholder: "https://github.com/mrousavy/react-native-vision-camera/pull/ (or a repo URL as fallback)" + validations: + required: true + + - type: textarea + id: repro-steps + attributes: + label: Steps to reproduce + description: | + Exact, numbered steps someone can follow on a fresh clone of the repo above. Include: + - Which device + OS to run on (if specific) + - Which screen / button to interact with + - What to watch for + placeholder: | + 1. Clone https://github.com//vision-camera-bug-repro + 2. `bun install && cd ios && pod install` + 3. Run on iPhone 15 Pro (iOS 17.5) + 4. Tap "Start recording", wait 3 seconds + 5. App crashes with ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + placeholder: "Recording should continue until I tap Stop." + validations: + required: true + + - type: textarea + id: actual + attributes: + label: What actually happened? + description: Describe the actual behavior. If the app crashes, include the crash signal / exception type. + placeholder: "App crashes with EXC_BAD_ACCESS on the capture session queue." + validations: + required: true + + - type: dropdown + id: platforms + attributes: + label: Affected platforms + multiple: true + options: + - iOS (device) + - iOS (simulator) + - Android (device) + - Android (emulator) + validations: + required: true + + - type: input + id: device + attributes: + label: Device(s) affected + description: Exact model and OS version. Include every device you tested on. + placeholder: "iPhone 15 Pro (iOS 17.5.1), Pixel 8 (Android 14)" + validations: + required: true + + - type: input + id: version-exact + attributes: + label: VisionCamera version + description: | + The exact version from your `package.json` (e.g. `5.2.1`). + See [Releases](https://github.com/mrousavy/react-native-vision-camera/releases) for the full list. + placeholder: "5.2.1" + validations: + required: true + + - type: input + id: rn-version + attributes: + label: React Native version + placeholder: "0.85.2" + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: React Native architecture + options: + - New Architecture (Fabric / bridgeless) + - Old Architecture (Paper) + validations: + required: true + + - type: checkboxes + id: features + attributes: + label: Features being used + description: Which VisionCamera features are in use when the bug happens? Check all that apply. + options: + - label: Preview + - label: Photo capture + - label: Video capture + - label: Frame Processors (worklets) + - label: Skia Frame Processors + - label: Code/Barcode Scanner + - label: Location metadata + - label: Multi-cam + - label: Depth data + - label: HDR / custom dynamic range + - label: Custom format / FPS / resolution + + - type: textarea + id: logs + attributes: + label: Relevant logs / stack trace + description: | + Paste the full crash log, Xcode console output, or `adb logcat` output. Redact anything sensitive. + + **Do not paste screenshots of logs** — paste the text. It will automatically be rendered as a code block. + render: shell + placeholder: | + // iOS example: + Exception Type: EXC_CRASH (SIGABRT) + Termination Reason: SIGNAL 6 Abort trap: 6 + Crashed Thread: 8 com.margelo.camera.session + + Thread 8 Crashed: + 0 libsystem_kernel.dylib __pthread_kill + 8 + 1 libsystem_pthread.dylib pthread_kill + 268 + 2 libsystem_c.dylib abort + 136 + 3 AVFCapture -[AVCaptureOutput attachToFigCaptureSession:] + 108 + 4 AVFCapture -[AVCaptureSession _makeConfigurationLive:] + 344 + ... + + // Android example: + FATAL EXCEPTION: mrousavy/VisionCamera.CameraQueue + Process: com.example.app, PID: 12345 + java.lang.IllegalStateException: Camera not initialized + at com.mrousavy.camera.core.CameraSession.startRunning(CameraSession.kt:142) + at com.mrousavy.camera.react.CameraView.onAttachedToWindow(CameraView.kt:89) + ... + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Anything else that might be relevant — other libraries, custom native modules, Expo vs bare, etc. You can drag-and-drop screenshots or a short screen recording here too (especially useful for broken preview, wrong orientation, or glitched frames). + + - type: checkboxes + id: submission + attributes: + label: Submission + description: Please confirm each of the following about what you submitted above. Every box must be ticked. + options: + - label: The reproduction I linked is either (preferred) a PR against this repo that adds a failing harness test following [the harness-tests README](https://github.com/mrousavy/react-native-vision-camera/blob/main/apps/simple-camera/__tests__/README.md), or (fallback) a public repo that reproduces the bug on a fresh clone. I understand the issue will be closed without one. + required: true + - label: I pasted logs as text (not screenshots). + required: true + - label: I wrote this report in my own words. I did not paste AI-generated descriptions of the bug. + required: true diff --git a/.github/ISSUE_TEMPLATE/build-error-android.yml b/.github/ISSUE_TEMPLATE/build-error-android.yml new file mode 100644 index 0000000000..d77b967c70 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/build-error-android.yml @@ -0,0 +1,222 @@ +name: "🛠️ Build error (Android)" +description: "Your app fails to build / compile / link on Android when using react-native-vision-camera." +title: "🛠️ (Android) " +labels: ["🛠️ build-error", "platform:android"] +body: + - type: markdown + attributes: + value: | + ## Before opening this issue + + Most build errors are caused by environment or setup issues, not by VisionCamera itself. + + **A public reproduction repository is required.** Build errors depend on your toolchain, native dependencies, Gradle config, and dozens of other factors — we cannot debug something we cannot reproduce. Reports without a repro will be **closed without investigation**. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you've done each of these. Every box must be ticked. + options: + - label: I have read and followed every applicable step in the [Nitro Modules Troubleshooting guide](https://nitro.margelo.com/docs/guides/troubleshooting). + required: true + - label: I have read the [VisionCamera Troubleshooting guide](https://visioncamera.margelo.com/docs) and [Installation guide](https://visioncamera.margelo.com/docs). + required: true + - label: I did a clean build — deleted `node_modules`, `android/build`, `android/.gradle`, `android/app/build`, and `~/.gradle/caches`, then reinstalled. + required: true + - label: I searched [existing build-error issues](https://github.com/mrousavy/react-native-vision-camera/issues?q=is%3Aissue+label%3A%22%F0%9F%9B%A0%EF%B8%8F+build-error%22) and found nothing matching. + required: true + + - type: textarea + id: error + attributes: + label: Full build error output + description: | + The complete error as printed by Gradle — not just the last line. Run with `--stacktrace` if the failure is inside a task and include it. + + **Paste as text**, not as a screenshot. Use a code block. + render: shell + validations: + required: true + + - type: input + id: version-exact + attributes: + label: VisionCamera version + description: | + Exact version from `package.json` (e.g. `5.2.1`). + See [Releases](https://github.com/mrousavy/react-native-vision-camera/releases) for the full list. + placeholder: "5.2.1" + validations: + required: true + + - type: input + id: rn-version + attributes: + label: React Native version + placeholder: "0.85.2" + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: React Native architecture + options: + - New Architecture (Fabric / bridgeless) + - Old Architecture (Paper) + validations: + required: true + + - type: dropdown + id: expo + attributes: + label: Expo? + options: + - Bare React Native (no Expo) + - Expo (managed, EAS Build) + - Expo (bare workflow with config plugins) + validations: + required: true + + - type: input + id: host-os + attributes: + label: Host OS + version + description: What OS are you building on? Include architecture if relevant. + placeholder: "macOS 14.5 (Apple Silicon) / Ubuntu 22.04 / Windows 11" + validations: + required: true + + - type: input + id: android-studio-version + attributes: + label: Android Studio version + description: From Android Studio → About. Even if you build from the CLI, please fill this out. + placeholder: "Android Studio Koala 2024.1.1" + validations: + required: true + + - type: input + id: gradle-version + attributes: + label: Gradle version + description: From `android/gradle/wrapper/gradle-wrapper.properties`. + placeholder: "8.7" + validations: + required: true + + - type: input + id: agp-version + attributes: + label: Android Gradle Plugin (AGP) version + description: From `android/build.gradle`. + placeholder: "8.3.2" + validations: + required: true + + - type: input + id: kotlin-version + attributes: + label: Kotlin version + description: From `android/build.gradle` `kotlinVersion`. + placeholder: "1.9.24" + validations: + required: true + + - type: input + id: jdk-version + attributes: + label: JDK version + description: From `java --version`. + placeholder: "OpenJDK 17.0.10" + validations: + required: true + + - type: input + id: ndk-version + attributes: + label: NDK version + description: From `android/build.gradle` `ndkVersion`. Leave blank if not set. + placeholder: "26.1.10909125" + + - type: input + id: android-min-sdk + attributes: + label: Android minSdkVersion + placeholder: "24" + validations: + required: true + + - type: input + id: android-compile-sdk + attributes: + label: Android compileSdkVersion + placeholder: "34" + validations: + required: true + + - type: input + id: android-target-sdk + attributes: + label: Android targetSdkVersion + placeholder: "34" + validations: + required: true + + - type: dropdown + id: target + attributes: + label: Build target + multiple: true + options: + - Physical device + - Emulator + validations: + required: true + + - type: dropdown + id: package-manager + attributes: + label: Package manager + options: + - npm + - yarn (classic) + - yarn (berry / pnp) + - pnpm + - bun + validations: + required: true + + - type: input + id: repro + attributes: + label: Reproduction repository + description: | + **Required.** A link to a minimal public GitHub repository that reproduces the build error on a fresh clone. + + Start from a fresh `npx @react-native-community/cli init` and add only what's needed to trigger the error. + + Issues without a reproduction repo are **invalid** and will be closed. Stripped-down snippets in the description do not count. + placeholder: "https://github.com//vision-camera-build-repro" + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Any other relevant info — other native libraries, custom Gradle/Kotlin changes, monorepo setup, EAS Build config, R8/ProGuard rules, Hermes vs JSC, etc. + + - type: checkboxes + id: submission + attributes: + label: Submission + description: Please confirm each of the following about what you submitted above. Every box must be ticked. + options: + - label: I pasted the full error output as text (not a screenshot). + required: true + - label: I wrote this report in my own words. I did not paste AI-generated summaries of the error. + required: true + - label: The reproduction repository I linked is public and reproduces the build error on a fresh clone. I understand the issue will be closed without one. + required: true diff --git a/.github/ISSUE_TEMPLATE/build-error-ios.yml b/.github/ISSUE_TEMPLATE/build-error-ios.yml new file mode 100644 index 0000000000..63127baedb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/build-error-ios.yml @@ -0,0 +1,182 @@ +name: "🛠️ Build error (iOS)" +description: "Your app fails to build / compile / link on iOS when using react-native-vision-camera." +title: "🛠️ (iOS) " +labels: ["🛠️ build-error", "platform:ios"] +body: + - type: markdown + attributes: + value: | + ## Before opening this issue + + Most build errors are caused by environment or setup issues, not by VisionCamera itself. + + **A public reproduction repository is required.** Build errors depend on your toolchain, native dependencies, Podfile config, and dozens of other factors — we cannot debug something we cannot reproduce. Reports without a repro will be **closed without investigation**. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you've done each of these. Every box must be ticked. + options: + - label: I have read and followed every applicable step in the [Nitro Modules Troubleshooting guide](https://nitro.margelo.com/docs/guides/troubleshooting). + required: true + - label: I have read the [VisionCamera Troubleshooting guide](https://visioncamera.margelo.com/docs) and [Installation guide](https://visioncamera.margelo.com/docs). + required: true + - label: I did a clean build — deleted `node_modules`, `ios/Pods`, `ios/build`, and Xcode's DerivedData, then reinstalled. + required: true + - label: I searched [existing build-error issues](https://github.com/mrousavy/react-native-vision-camera/issues?q=is%3Aissue+label%3A%22%F0%9F%9B%A0%EF%B8%8F+build-error%22) and found nothing matching. + required: true + + - type: textarea + id: error + attributes: + label: Full build error output + description: | + The complete error as printed by `xcodebuild` / `pod install` — not just the last line. + + **Paste as text**, not as a screenshot. Use a code block. + render: shell + validations: + required: true + + - type: input + id: version-exact + attributes: + label: VisionCamera version + description: | + Exact version from `package.json` (e.g. `5.2.1`). + See [Releases](https://github.com/mrousavy/react-native-vision-camera/releases) for the full list. + placeholder: "5.2.1" + validations: + required: true + + - type: input + id: rn-version + attributes: + label: React Native version + placeholder: "0.85.2" + validations: + required: true + + - type: dropdown + id: architecture + attributes: + label: React Native architecture + options: + - New Architecture (Fabric / bridgeless) + - Old Architecture (Paper) + validations: + required: true + + - type: dropdown + id: expo + attributes: + label: Expo? + options: + - Bare React Native (no Expo) + - Expo (managed, EAS Build) + - Expo (bare workflow with config plugins) + validations: + required: true + + - type: input + id: host-os + attributes: + label: Host macOS version + description: From `sw_vers`. Include chip (Apple Silicon / Intel). + placeholder: "macOS 14.5 (Sonoma) on Apple Silicon (M2)" + validations: + required: true + + - type: input + id: xcode-version + attributes: + label: Xcode version + description: From `xcodebuild -version`. + placeholder: "Xcode 15.4 (15F31d)" + validations: + required: true + + - type: input + id: ios-deployment-target + attributes: + label: iOS Deployment Target + description: From your `Podfile` / target settings. + placeholder: "15.1" + validations: + required: true + + - type: input + id: cocoapods-version + attributes: + label: CocoaPods version + description: From `pod --version`. + placeholder: "1.15.2" + validations: + required: true + + - type: input + id: ruby-version + attributes: + label: Ruby version + description: From `ruby --version` (matters for CocoaPods). + placeholder: "3.2.2" + validations: + required: true + + - type: dropdown + id: target + attributes: + label: Build target + multiple: true + options: + - Physical device + - Simulator + validations: + required: true + + - type: dropdown + id: package-manager + attributes: + label: Package manager + options: + - npm + - yarn (classic) + - yarn (berry / pnp) + - pnpm + - bun + validations: + required: true + + - type: input + id: repro + attributes: + label: Reproduction repository + description: | + **Required.** A link to a minimal public GitHub repository that reproduces the build error on a fresh clone. + + Start from a fresh `npx @react-native-community/cli init` and add only what's needed to trigger the error. + + Issues without a reproduction repo are **invalid** and will be closed. Stripped-down snippets in the description do not count. + placeholder: "https://github.com//vision-camera-build-repro" + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Any other relevant info — other native libraries, custom Podfile changes, monorepo setup, EAS Build config, use_frameworks!, static/dynamic linking, etc. + + - type: checkboxes + id: submission + attributes: + label: Submission + description: Please confirm each of the following about what you submitted above. Every box must be ticked. + options: + - label: I pasted the full error output as text (not a screenshot). + required: true + - label: I wrote this report in my own words. I did not paste AI-generated summaries of the error. + required: true + - label: The reproduction repository I linked is public and reproduces the build error on a fresh clone. I understand the issue will be closed without one. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5ef62f86fe..69ed05af27 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: + - name: Documentation + url: https://visioncamera.margelo.com/docs + about: Check the docs before opening an issue — most questions are answered there. - name: Troubleshooting Guide - url: https://react-native-vision-camera.com/docs/guides/troubleshooting - about: Please read the Troubleshooting Guide before opening an issue. - - name: Margelo Community Discord - url: https://discord.gg/6CSHz2qAvA - about: Discuss and chat about react-native-vision-camera or other Margelo libraries with our team or other community members. Remember to read the rules! - - name: CameraX Issue Tracker - url: https://issuetracker.google.com/issues?q=componentid:618491%20status:open# - about: If you experience an issue on Android, browsing through CameraX issues often leads to a solution that we can implement in VisionCamera as well. + url: https://visioncamera.margelo.com/docs + about: Common build and runtime issues with known fixes. + - name: Discord + url: https://margelo.com/discord + about: Chat with the community for general questions and help. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000000..25f20db5de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,86 @@ +name: "✨ Feature request" +description: "Suggest a new feature, API, or capability for react-native-vision-camera." +title: "✨ " +labels: ["✨ feature"] +body: + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm you've done each of these. Every box must be ticked. + options: + - label: I have searched [existing issues](https://github.com/mrousavy/react-native-vision-camera/issues?q=is%3Aissue) for a similar request. + required: true + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the real-world use case. Focus on the problem first, not the solution. + placeholder: | + I'm building a document scanner and I need to detect when the camera is focused on a flat surface before capturing, so the user doesn't get blurry scans. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed API / solution + description: | + What would the new feature look like? Ideally include a code sketch of how you'd use it. + If it's platform-specific (e.g. iOS-only), call that out. + placeholder: | + ```ts + const focus = useCameraFocusState(camera) + if (focus.isStable && focus.subjectArea === 'document') { ... } + ``` + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What workarounds have you tried? Why are they not good enough? + placeholder: | + I tried polling `camera.getIsFocused()` every 100ms but it's noisy and doesn't tell me when the scene has stabilized. + + - type: dropdown + id: platforms + attributes: + label: Platforms this should target + multiple: true + options: + - iOS + - Android + - Both + validations: + required: true + + - type: dropdown + id: contribute + attributes: + label: Would you be willing to contribute this? + options: + - "Yes — I'd like to open a PR." + - "Maybe, with guidance." + - "No, I'm requesting only." + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Links to related AVFoundation / CameraX APIs, papers, other libraries that do this, etc. + + - type: checkboxes + id: submission + attributes: + label: Submission + description: Please confirm each of the following about what you submitted above. Every box must be ticked. + options: + - label: I described the problem, not just the solution. + required: true + - label: I wrote this request in my own words. I did not paste AI-generated proposals. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 28922496d4..0000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,44 +0,0 @@ - - -## What - - - -## Changes - - - -## Tested on - - - -## Related issues - - diff --git a/.github/actions/collect-devicefarm-results/action.yml b/.github/actions/collect-devicefarm-results/action.yml new file mode 100644 index 0000000000..7701b3e71e --- /dev/null +++ b/.github/actions/collect-devicefarm-results/action.yml @@ -0,0 +1,88 @@ +name: Collect Device Farm Results +description: Find, print, and publish Harness Device Farm test results + +inputs: + artifact-folder: + description: Device Farm artifact folder produced by the test action + required: true + platform: + description: Platform label used in filenames and log output + required: true + test-step-outcome: + description: Outcome of the upstream Device Farm execution step + required: false + +outputs: + junit-file: + description: Copied JUnit file path + value: ${{ steps.find-harness-junit.outputs.junit_file }} + log-file: + description: Copied harness log file path + value: ${{ steps.find-harness-log.outputs.log_file }} + +runs: + using: composite + steps: + - name: Find Harness JUnit result + id: find-harness-junit + shell: bash + run: | + set -euo pipefail + ARTIFACT_DIR="${{ inputs.artifact-folder }}" + PLATFORM="${{ inputs.platform }}" + WORKSPACE_JUNIT=".github/test-results/harness-results-${PLATFORM}.junit.xml" + bash "$GITHUB_ACTION_PATH/../../scripts/find-devicefarm-artifact.sh" \ + "$ARTIFACT_DIR" \ + 'harness-results.junit.xml' \ + "$WORKSPACE_JUNIT" + echo "junit_file=$WORKSPACE_JUNIT" >> "$GITHUB_OUTPUT" + + - name: Find Harness output log + id: find-harness-log + shell: bash + run: | + set -euo pipefail + ARTIFACT_DIR="${{ inputs.artifact-folder }}" + PLATFORM="${{ inputs.platform }}" + WORKSPACE_LOG=".github/test-results/harness-output-${PLATFORM}.log" + bash "$GITHUB_ACTION_PATH/../../scripts/find-devicefarm-artifact.sh" \ + "$ARTIFACT_DIR" \ + 'harness-output.log' \ + "$WORKSPACE_LOG" + echo "log_file=$WORKSPACE_LOG" >> "$GITHUB_OUTPUT" + + - name: Publish Harness test results + id: publish-harness-results + if: steps.find-harness-junit.outputs.junit_file != '' + continue-on-error: true + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: ${{ steps.find-harness-junit.outputs.junit_file }} + comment_mode: off + job_summary: true + check_run: false + action_fail: true + action_fail_on_inconclusive: true + fail_on: test failures + + - name: Print Harness output log + shell: bash + run: | + set -euo pipefail + LOG_FILE="${{ steps.find-harness-log.outputs.log_file }}" + PLATFORM="${{ inputs.platform }}" + PUBLISH_OUTCOME="${{ steps.publish-harness-results.outcome }}" + TEST_STEP_OUTCOME="${{ inputs.test-step-outcome }}" + echo "===== Harness ${PLATFORM} output log =====" + cat "$LOG_FILE" + echo "===== End Harness ${PLATFORM} output log =====" + + if [[ "$PUBLISH_OUTCOME" == "failure" ]]; then + echo "Harness test result publication reported failures." >&2 + exit 1 + fi + + if [[ "$TEST_STEP_OUTCOME" == "failure" ]]; then + echo "Device Farm test step reported failure." >&2 + exit 1 + fi diff --git a/.github/actions/upload-devicefarm-artifact/action.yml b/.github/actions/upload-devicefarm-artifact/action.yml new file mode 100644 index 0000000000..c4c9e964a5 --- /dev/null +++ b/.github/actions/upload-devicefarm-artifact/action.yml @@ -0,0 +1,169 @@ +name: Upload File to AWS Device Farm +description: Create and upload a file to AWS Device Farm and wait until processing finishes. + +inputs: + project-arn: + description: Device Farm project ARN. + required: true + file-path: + description: Path to the file to upload. + required: true + upload-type: + description: Device Farm upload type (for example ANDROID_APP, APPIUM_NODE_TEST_SPEC). + required: false + default: ANDROID_APP + upload-name: + description: Optional upload name shown in Device Farm. Defaults to filename. + required: false + default: '' + content-type: + description: MIME type for the upload. + required: false + default: application/vnd.android.package-archive + aws-region: + description: AWS region where the Device Farm project exists. + required: false + default: us-west-2 + poll-interval-seconds: + description: Poll interval in seconds while waiting for processing to complete. + required: false + default: '5' + max-wait-seconds: + description: Maximum wait time in seconds before timing out. + required: false + default: '600' + +outputs: + file-arn: + description: ARN for the uploaded file. + value: ${{ steps.upload.outputs.upload_arn }} + upload-arn: + description: Device Farm upload ARN. + value: ${{ steps.upload.outputs.upload_arn }} + upload-name: + description: Effective upload name in Device Farm. + value: ${{ steps.upload.outputs.upload_name }} + upload-status: + description: Final upload processing status. + value: ${{ steps.upload.outputs.upload_status }} + +runs: + using: composite + steps: + - id: upload + shell: bash + run: | + set -euo pipefail + + PROJECT_ARN="${{ inputs.project-arn }}" + FILE_PATH="${{ inputs.file-path }}" + UPLOAD_TYPE="${{ inputs.upload-type }}" + UPLOAD_NAME_INPUT="${{ inputs.upload-name }}" + CONTENT_TYPE="${{ inputs.content-type }}" + AWS_REGION="${{ inputs.aws-region }}" + POLL_INTERVAL="${{ inputs.poll-interval-seconds }}" + MAX_WAIT="${{ inputs.max-wait-seconds }}" + + if [[ -z "$FILE_PATH" ]]; then + echo "No input file provided. Set file-path." >&2 + exit 1 + fi + + if [[ ! -f "$FILE_PATH" ]]; then + echo "Upload file not found: $FILE_PATH" >&2 + exit 1 + fi + + REQUIRE_ZIP="false" + if [[ "$UPLOAD_TYPE" == "ANDROID_APP" || "$UPLOAD_TYPE" == "IOS_APP" || "$UPLOAD_TYPE" == *_TEST_PACKAGE ]]; then + REQUIRE_ZIP="true" + fi + + if [[ "$REQUIRE_ZIP" == "true" ]] && ! unzip -tq "$FILE_PATH" >/dev/null 2>&1; then + echo "File is not a valid ZIP archive (required for upload type $UPLOAD_TYPE): $FILE_PATH" >&2 + exit 1 + fi + + if ! [[ "$POLL_INTERVAL" =~ ^[0-9]+$ ]] || ! [[ "$MAX_WAIT" =~ ^[0-9]+$ ]]; then + echo "poll-interval-seconds and max-wait-seconds must be positive integers." >&2 + exit 1 + fi + if (( POLL_INTERVAL <= 0 || MAX_WAIT <= 0 )); then + echo "poll-interval-seconds and max-wait-seconds must be greater than 0." >&2 + exit 1 + fi + + if [[ -n "$UPLOAD_NAME_INPUT" ]]; then + UPLOAD_NAME="$UPLOAD_NAME_INPUT" + else + UPLOAD_NAME="$(basename "$FILE_PATH")" + fi + + IFS=$'\t' read -r UPLOAD_ARN UPLOAD_URL CREATED_UPLOAD_NAME INITIAL_STATUS < <( + aws devicefarm create-upload \ + --region "$AWS_REGION" \ + --project-arn "$PROJECT_ARN" \ + --name "$UPLOAD_NAME" \ + --type "$UPLOAD_TYPE" \ + --content-type "$CONTENT_TYPE" \ + --query 'upload.[arn,url,name,status]' \ + --output text + ) + + if [[ -z "$UPLOAD_ARN" || -z "$UPLOAD_URL" ]]; then + echo "Failed to create Device Farm upload." >&2 + exit 1 + fi + + SIGNED_HEADERS="$(sed -n 's/.*[?&]X-Amz-SignedHeaders=\([^&]*\).*/\1/p' <<< "$UPLOAD_URL")" + echo "Created upload '$CREATED_UPLOAD_NAME' with initial status '$INITIAL_STATUS'." + echo "Signed headers for upload URL: ${SIGNED_HEADERS:-unknown}" + + curl --fail --silent --show-error \ + --request PUT \ + --header "content-type: $CONTENT_TYPE" \ + --upload-file "$FILE_PATH" \ + "$UPLOAD_URL" + + elapsed=0 + STATUS="PROCESSING" + MESSAGE="" + while true; do + IFS=$'\t' read -r STATUS MESSAGE < <( + aws devicefarm get-upload \ + --region "$AWS_REGION" \ + --arn "$UPLOAD_ARN" \ + --query 'upload.[status,message]' \ + --output text + ) + + case "$STATUS" in + SUCCEEDED) + break + ;; + FAILED | ERRORED) + echo "Device Farm upload failed: ${MESSAGE:-}" >&2 + aws devicefarm get-upload \ + --region "$AWS_REGION" \ + --arn "$UPLOAD_ARN" \ + --query 'upload.{status:status,message:message,metadata:metadata,name:name,type:type,contentType:contentType}' \ + --output json >&2 || true + exit 1 + ;; + *) + ;; + esac + + if (( elapsed >= MAX_WAIT )); then + echo "Timed out waiting for upload to finish. Last status: $STATUS" >&2 + exit 1 + fi + + sleep "$POLL_INTERVAL" + elapsed=$((elapsed + POLL_INTERVAL)) + done + + echo "file_arn=$UPLOAD_ARN" >> "$GITHUB_OUTPUT" + echo "upload_arn=$UPLOAD_ARN" >> "$GITHUB_OUTPUT" + echo "upload_name=$CREATED_UPLOAD_NAME" >> "$GITHUB_OUTPUT" + echo "upload_status=$STATUS" >> "$GITHUB_OUTPUT" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 249e397712..00da1cb69d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,39 +1,31 @@ version: 2 updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - labels: - - "🛠 dependencies" - package-ecosystem: "gradle" - directory: "/package/android/" + directories: + - "/apps/simple-camera/android/" + - "/packages/react-native-vision-camera/android/" + - "/packages/react-native-vision-camera-barcode-scanner/android/" + - "/packages/react-native-vision-camera-location/android/" + - "/packages/react-native-vision-camera-resizer/android/" schedule: interval: "daily" - labels: - - "🛠 dependencies" - - "🤖 android" - # - package-ecosystem: "npm" - # directory: "/package/" - # schedule: - # interval: "monthly" - # labels: - # - "🛠 dependencies" - # - "☕️ js" - # - package-ecosystem: "npm" - # directory: "/package/example/" - # schedule: - # interval: "monthly" - # labels: - # - "🛠 dependencies" - # - "🛸 example" - # - "☕️ js" - # - package-ecosystem: "npm" - # directory: "/docs/" - # schedule: - # interval: "monthly" - # labels: - # - "🛠 dependencies" - # - "📚 documentation" - # - "☕️ js" + groups: + camera-libs: + patterns: + - "androidx.camera:*" + gms-location: + patterns: + - "com.google.android.gms:play-services-location" + mlkit: + patterns: + - "com.google.mlkit:*" + - "com.google.android.gms:play-services-mlkit-*" + gradle-build-and-plugins: + patterns: + - "*" + exclude-patterns: + - "androidx.camera:*" + - "com.google.android.gms:play-services-location" + - "com.google.mlkit:*" + - "com.google.android.gms:play-services-mlkit-*" diff --git a/.github/funding-octocat.svg b/.github/funding-octocat.svg deleted file mode 100644 index 3b7609f8a2..0000000000 --- a/.github/funding-octocat.svg +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- This library helped you?
Consider sponsoring!
-
-
-
diff --git a/.github/scripts/find-devicefarm-artifact.sh b/.github/scripts/find-devicefarm-artifact.sh new file mode 100644 index 0000000000..2221ea4055 --- /dev/null +++ b/.github/scripts/find-devicefarm-artifact.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +# From AWS devicefarm we receive a zip file with all the artifacts. This script helps retrieving +# a specific artifact file from the zip and copying it to a destination path in the workspace. + +set -euo pipefail + +ARTIFACT_DIR="${1:-}" +TARGET_NAME="${2:-}" +DESTINATION_PATH="${3:-}" + +if [[ -z "$ARTIFACT_DIR" || ! -d "$ARTIFACT_DIR" ]]; then + echo "Device Farm artifact folder missing: '$ARTIFACT_DIR'" >&2 + exit 1 +fi + +if [[ -z "$TARGET_NAME" ]]; then + echo "Expected target artifact filename as the second argument." >&2 + exit 1 +fi + +if [[ -z "$DESTINATION_PATH" ]]; then + echo "Expected destination path as the third argument." >&2 + exit 1 +fi + +FOUND_FILE="$(find "$ARTIFACT_DIR" -type f -name "$TARGET_NAME" | head -n 1 || true)" +if [[ -z "$FOUND_FILE" ]]; then + EXTRACT_DIR="$(mktemp -d)" + + while IFS= read -r ZIP_FILE; do + unzip -o -qq "$ZIP_FILE" -d "$EXTRACT_DIR" || true + done < <(find "$ARTIFACT_DIR" -type f -name '*.zip') + + FOUND_FILE="$(find "$EXTRACT_DIR" -type f -name "$TARGET_NAME" | head -n 1 || true)" +fi + +if [[ -z "$FOUND_FILE" ]]; then + echo "Could not find $TARGET_NAME in Device Farm artifacts." >&2 + exit 1 +fi + +mkdir -p "$(dirname "$DESTINATION_PATH")" +cp "$FOUND_FILE" "$DESTINATION_PATH" + +echo "Found artifact file: $FOUND_FILE" +echo "Copied artifact file to workspace: $DESTINATION_PATH" +printf '%s\n' "$DESTINATION_PATH" diff --git a/.github/workflows/build-android-release.yml b/.github/workflows/build-android-release.yml new file mode 100644 index 0000000000..d00179f79c --- /dev/null +++ b/.github/workflows/build-android-release.yml @@ -0,0 +1,52 @@ +name: Build Android (Release) + +on: + release: + types: [published] + pull_request: + paths: + - '.github/workflows/build-android-release.yml' + +permissions: + contents: write + +jobs: + build_release: + name: Build Android Example App (release, new architecture) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + + - name: Install npm dependencies (bun) + run: bun install + + - name: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 17 + java-package: jdk + + - name: Run Gradle Build for apps/simple-camera/android/ + working-directory: apps/simple-camera/android + run: | + ./gradlew clean :app:assembleRelease --no-daemon --no-build-cache + + - name: Upload APK artifact + if: ${{ github.event_name == 'pull_request' }} + uses: actions/upload-artifact@v7 + with: + name: SimpleCamera-Release-Android + path: apps/simple-camera/android/app/build/outputs/apk/release/app-release.apk + retention-days: 7 + + - name: Upload APK to Release + if: ${{ github.event_name == 'release' }} + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" "apps/simple-camera/android/app/build/outputs/apk/release/app-release.apk#SimpleCamera-${GITHUB_REF_NAME}.apk" --clobber + + - name: Stop Gradle Daemon + working-directory: apps/simple-camera/android + run: ./gradlew --stop diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml deleted file mode 100644 index f7b1f4a31f..0000000000 --- a/.github/workflows/build-android.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Build Android - -on: - push: - branches: - - main - paths: - - '.github/workflows/build-android.yml' - - 'package/android/**' - - 'package/example/android/**' - - 'package/yarn.lock' - - 'package/example/yarn.lock' - pull_request: - paths: - - '.github/workflows/build-android.yml' - - 'package/android/**' - - 'package/example/android/**' - - 'package/yarn.lock' - - 'package/example/yarn.lock' - -jobs: - build: - name: Build Android Example App - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./package - steps: - - uses: actions/checkout@v4 - - - name: Setup JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 17 - java-package: jdk - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - name: Restore node_modules from cache - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-with-fps-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-with-fps-yarn- - - name: Install node_modules - run: yarn install --frozen-lockfile - - name: Install node_modules for example/ - run: yarn install --frozen-lockfile --cwd example - - - name: Restore Gradle cache - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - key: ${{ runner.os }}-with-fps-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-with-fps-gradle- - - name: Run Gradle Build for example/android/ - run: cd example/android && ./gradlew assembleDebug --no-daemon --build-cache && cd ../.. - - # Gradle cache doesn't like daemons - - name: Stop Gradle Daemon - run: cd android && ./gradlew --stop - - build-no-frame-processors: - name: Build Android Example App (without Frame Processors) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./package - steps: - - uses: actions/checkout@v4 - - - name: Setup JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 17 - java-package: jdk - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - name: Restore node_modules from cache - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-without-fps-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-without-fps-yarn- - - name: Install node_modules - run: yarn install --frozen-lockfile - - name: Install node_modules for example/ - run: yarn install --frozen-lockfile --cwd example - - name: Remove worklets, skia and reanimated - run: yarn remove react-native-worklets-core @shopify/react-native-skia react-native-reanimated --cwd example - - - name: Restore Gradle cache - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - key: ${{ runner.os }}-without-fps-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-without-fps-gradle- - - name: Run Gradle Build for example/android/ - run: cd example/android && ./gradlew assembleDebug --no-daemon --build-cache && cd ../.. - - # Gradle cache doesn't like daemons - - name: Stop Gradle Daemon - run: cd android && ./gradlew --stop diff --git a/.github/workflows/build-ios-release.yml b/.github/workflows/build-ios-release.yml new file mode 100644 index 0000000000..f462a88da4 --- /dev/null +++ b/.github/workflows/build-ios-release.yml @@ -0,0 +1,71 @@ +name: Build iOS (Release) + +on: + release: + types: [published] + pull_request: + paths: + - '.github/workflows/build-ios-release.yml' + +permissions: + contents: write + +jobs: + build_release: + name: Build iOS Example App (release, new architecture) + runs-on: macos-26 + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + + - name: Install npm dependencies (bun) + run: bun install + + - name: Setup Ruby (bundle) + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.9 + bundler-cache: true + working-directory: apps/simple-camera + + - name: Select Xcode 26.2 + run: sudo xcode-select -s "/Applications/Xcode_26.2.app/Contents/Developer" + - name: Install Pods + working-directory: apps/simple-camera + run: bun pods + + - name: Build App (Release, Simulator) + working-directory: apps/simple-camera/ios + run: | + set -o pipefail + xcodebuild \ + CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ + -derivedDataPath build -UseModernBuildSystem=YES \ + -workspace SimpleCamera.xcworkspace \ + -scheme SimpleCamera \ + -sdk iphonesimulator \ + -configuration Release \ + -destination 'generic/platform=iOS Simulator' \ + -showBuildTimingSummary \ + clean \ + build \ + CODE_SIGNING_ALLOWED=NO | xcbeautify --renderer github-actions + + - name: Package .app for Simulator + run: | + cd apps/simple-camera/ios/build/Build/Products/Release-iphonesimulator + zip -r ../../../../../../../SimpleCamera-Release-Simulator.zip SimpleCamera.app + + - name: Upload .app artifact + if: ${{ github.event_name == 'pull_request' }} + uses: actions/upload-artifact@v7 + with: + name: SimpleCamera-Release-Simulator + path: SimpleCamera-Release-Simulator.zip + retention-days: 7 + + - name: Upload .app to Release + if: ${{ github.event_name == 'release' }} + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" "SimpleCamera-Release-Simulator.zip#SimpleCamera-Simulator-${GITHUB_REF_NAME}.app.zip" --clobber diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml deleted file mode 100644 index a979d50b5e..0000000000 --- a/.github/workflows/build-ios.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Build iOS - -on: - push: - branches: - - main - paths: - - '.github/workflows/build-ios.yml' - - 'package/ios/**' - - 'package/*.podspec' - - 'package/example/ios/**' - pull_request: - paths: - - '.github/workflows/build-ios.yml' - - 'package/ios/**' - - 'package/*.podspec' - - 'package/example/ios/**' - -jobs: - build: - name: Build iOS Example App - runs-on: macOS-latest - defaults: - run: - working-directory: package/example/ios - steps: - - uses: actions/checkout@v4 - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - name: Restore node_modules from cache - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-with-fps-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-with-fps-yarn- - - name: Install node_modules for example/ - run: yarn install --frozen-lockfile --cwd .. - - - name: Restore buildcache - uses: mikehardy/buildcache-action@v2 - continue-on-error: true - - - name: Setup Ruby (bundle) - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.7.2 - bundler-cache: true - working-directory: package/example/ios - - - name: Restore Pods cache - uses: actions/cache@v4 - with: - path: package/example/ios/Pods - key: ${{ runner.os }}-with-fps-pods-${{ hashFiles('**/Podfile.lock', '**/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-with-fps-pods- - - name: Install Pods - run: pod install - - name: Install xcpretty - run: gem install xcpretty - - name: Build App - run: "set -o pipefail && xcodebuild \ - CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ - -derivedDataPath build -UseModernBuildSystem=YES \ - -workspace VisionCameraExample.xcworkspace \ - -scheme VisionCameraExample \ - -sdk iphonesimulator \ - -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ - build \ - CODE_SIGNING_ALLOWED=NO | xcpretty" - - build-no-frame-processors: - name: Build iOS Example App without Frame Processors - runs-on: macOS-latest - defaults: - run: - working-directory: package/example/ios - steps: - - uses: actions/checkout@v4 - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - name: Restore node_modules from cache - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-without-fps-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-without-fps-yarn- - - name: Install node_modules for example/ - run: yarn install --frozen-lockfile --cwd .. - - name: Remove worklets, skia and reanimated - run: yarn remove react-native-worklets-core @shopify/react-native-skia react-native-reanimated --cwd .. - - - name: Restore buildcache - uses: mikehardy/buildcache-action@v2 - continue-on-error: true - - - name: Setup Ruby (bundle) - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.7.2 - bundler-cache: true - working-directory: package/example/ios - - - name: Restore Pods cache - uses: actions/cache@v4 - with: - path: package/example/ios/Pods - key: ${{ runner.os }}-without-fps-pods-${{ hashFiles('**/Podfile.lock', '**/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-without-fps-pods- - - name: Install Pods - run: pod install - - name: Install xcpretty - run: gem install xcpretty - - name: Build App - run: "set -o pipefail && xcodebuild \ - CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ - -derivedDataPath build -UseModernBuildSystem=YES \ - -workspace VisionCameraExample.xcworkspace \ - -scheme VisionCameraExample \ - -sdk iphonesimulator \ - -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ - build \ - CODE_SIGNING_ALLOWED=NO | xcpretty" diff --git a/.github/workflows/compress-images.yml b/.github/workflows/compress-images.yml deleted file mode 100644 index c12d854870..0000000000 --- a/.github/workflows/compress-images.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Compress Images (docs) -on: - pull_request: - # Run Image Actions when JPG, JPEG, PNG or WebP files are added or changed. - # See https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#onpushpull_requestpaths for reference. - paths: - - ".github/workflows/compress-images.yml" - - "**.jpg" - - "**.jpeg" - - "**.png" - - "**.webp" - -jobs: - compress-images: - # Only run on Pull Requests within the same repository, and not from forks. - if: github.event.pull_request.head.repo.full_name == github.repository - name: 🗂 Compress images - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - - name: Compress Images - uses: calibreapp/image-actions@main - with: - # The `GITHUB_TOKEN` is automatically generated by GitHub and scoped only to the repository that is currently running the action. By default, the action can’t update Pull Requests initiated from forked repositories. - # See https://docs.github.com/en/actions/reference/authentication-in-a-workflow and https://help.github.com/en/articles/virtual-environments-for-github-actions#token-permissions - githubToken: ${{ secrets.GITHUB_TOKEN }} - ignorePaths: "e2e/**" - jpegQuality: "80" - jpegProgressive: false - pngQuality: "80" - webpQuality: "80" diff --git a/.github/workflows/harness-android-emulator.yml b/.github/workflows/harness-android-emulator.yml new file mode 100644 index 0000000000..bf1662509d --- /dev/null +++ b/.github/workflows/harness-android-emulator.yml @@ -0,0 +1,165 @@ +name: Harness Android Emulator + +# In PRs, this has the same head_ref and cancels in-progress runs. +# On main, this has no head_ref, so it uses run_id - which is unique +# per run, making cancel-in-progress useless and allowing parallel runs +# for the main branch. +concurrency: + group: harness-android-emulator-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +on: + workflow_dispatch: + inputs: + api_level: + description: 'Android API level for the emulator' + required: false + default: '35' + type: string + device_arch: + description: 'Device architecture (x86_64, arm64-v8a)' + required: false + default: 'x86_64' + type: choice + options: + - x86_64 + - arm64-v8a + device_profile: + description: 'Android emulator profile' + required: false + default: 'pixel' + type: string + emulator_name: + description: 'Android emulator (AVD) name from rn-harness config' + required: false + default: 'Pixel_API_35' + type: string + push: + branches: + - main + paths: + - '.github/workflows/harness-android-emulator.yml' + - 'apps/simple-camera/**' + - 'packages/react-native-vision-camera/**' + - 'packages/react-native-vision-camera-barcode-scanner/**' + - 'packages/react-native-vision-camera-location/**' + - 'packages/react-native-vision-camera-resizer/**' + - 'packages/react-native-vision-camera-skia/**' + - 'bun.lock' + - 'package.json' + pull_request: + paths: + - '.github/workflows/harness-android-emulator.yml' + - 'apps/simple-camera/**' + - 'packages/react-native-vision-camera/**' + - 'packages/react-native-vision-camera-barcode-scanner/**' + - 'packages/react-native-vision-camera-location/**' + - 'packages/react-native-vision-camera-resizer/**' + - 'packages/react-native-vision-camera-skia/**' + - 'bun.lock' + - 'package.json' + +env: + HARNESS_PROJECT_ROOT: apps/simple-camera + HARNESS_ANDROID_APP_BUILD_OUTPUT: apps/simple-camera/android/app/build/outputs/apk/debug/app-debug.apk + HARNESS_ANDROID_BUNDLE_ID: com.margelo.nitro.camera.example.simple + HARNESS_ANDROID_DEVICE_MODE: emulator + HARNESS_ANDROID_API_LEVEL: ${{ github.event.inputs.api_level || '35' }} + HARNESS_ANDROID_DEVICE_ARCH: ${{ github.event.inputs.device_arch || 'x86_64' }} + HARNESS_ANDROID_DEVICE_PROFILE: ${{ github.event.inputs.device_profile || 'pixel' }} + HARNESS_ANDROID_DISK_SIZE: 1G + HARNESS_ANDROID_HEAP_SIZE: 1G + HARNESS_ANDROID_EMULATOR: ${{ github.event.inputs.emulator_name || 'Pixel_API_35' }} + HARNESS_ANDROID_EMULATOR_OPTIONS: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back emulated -camera-front emulated + HARNESS_ANDROID_EMULATOR_BOOT_TIMEOUT_SECONDS: 240 + HARNESS_ANDROID_STARTUP_TIMEOUT_SECONDS: 60 + HARNESS_ANDROID_TEST_TIMEOUT_SECONDS: 600 + +jobs: + test-android: + name: Test Android + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: '17' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Restore Gradle/CMake cache + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + apps/simple-camera/android/.gradle + apps/simple-camera/android/app/.cxx + key: ${{ runner.os }}-gradle-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}-${{ hashFiles('apps/simple-camera/android/**/*.gradle*', 'apps/simple-camera/android/**/gradle-wrapper.properties', 'packages/**/CMakeLists.txt', 'packages/**/*.cmake', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-gradle-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}- + ${{ runner.os }}-gradle- + + - name: Restore Android app from cache + id: cache-apk-restore + uses: actions/cache/restore@v4 + with: + path: ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + key: harness-android-apk-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}-${{ hashFiles('apps/simple-camera/android/**/*.gradle*', 'apps/simple-camera/android/**/gradle-wrapper.properties', 'apps/simple-camera/android/app/src/**', 'packages/**/android/**', 'packages/**/cpp/**', 'packages/**/nitrogen/generated/**', 'apps/simple-camera/package.json', 'bun.lock') }} + + - name: Build Android app + if: steps.cache-apk-restore.outputs.cache-hit != 'true' + working-directory: apps/simple-camera/android + run: ./gradlew assembleDebug -PreactNativeArchitectures=${{ env.HARNESS_ANDROID_DEVICE_ARCH }} --no-daemon --build-cache --console=plain + + - name: Save Android app to cache + if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + key: harness-android-apk-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}-${{ hashFiles('apps/simple-camera/android/**/*.gradle*', 'apps/simple-camera/android/**/gradle-wrapper.properties', 'apps/simple-camera/android/app/src/**', 'packages/**/android/**', 'packages/**/cpp/**', 'packages/**/nitrogen/generated/**', 'apps/simple-camera/package.json', 'bun.lock') }} + + - name: Verify Android app artifact + run: test -f ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls /dev/kvm + + - name: Run Harness tests on Android + uses: reactivecircus/android-emulator-runner@v2 + with: + working-directory: ${{ env.HARNESS_PROJECT_ROOT }} + api-level: ${{ env.HARNESS_ANDROID_API_LEVEL }} + arch: ${{ env.HARNESS_ANDROID_DEVICE_ARCH }} + profile: ${{ env.HARNESS_ANDROID_DEVICE_PROFILE }} + disk-size: ${{ env.HARNESS_ANDROID_DISK_SIZE }} + heap-size: ${{ env.HARNESS_ANDROID_HEAP_SIZE }} + force-avd-creation: true + avd-name: ${{ env.HARNESS_ANDROID_EMULATOR }} + emulator-boot-timeout: ${{ env.HARNESS_ANDROID_EMULATOR_BOOT_TIMEOUT_SECONDS }} + disable-animations: true + emulator-options: ${{ env.HARNESS_ANDROID_EMULATOR_OPTIONS }} + script: bash ./scripts/run-harness-android-ci.sh + + - name: Stop Gradle Daemon + if: always() + working-directory: apps/simple-camera/android + run: ./gradlew --stop diff --git a/.github/workflows/harness-aws-device.yml b/.github/workflows/harness-aws-device.yml new file mode 100644 index 0000000000..31126c5691 --- /dev/null +++ b/.github/workflows/harness-aws-device.yml @@ -0,0 +1,600 @@ +name: Harness AWS Device + +### About +# +# This workflow performs Android and iOS e2e tests with React Native Harness +# on AWS Device Farm real devices. + +# This is needed for the AWS action to be able to upload properly to AWS +permissions: + id-token: write + contents: read + pull-requests: read + +# In PRs, this has the same head_ref and cancels in-progress runs. +# On main, this has no head_ref, so it uses run_id - which is unique +# per run, making cancel-in-progress useless and allowing parallel runs +# for the main branch. +concurrency: + group: harness-aws-device-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +on: + workflow_dispatch: + inputs: + device_arch: + description: "Device architecture (x86_64, arm64-v8a)" + required: false + default: "arm64-v8a" + type: choice + options: + - x86_64 + - arm64-v8a + push: + branches: + - main + paths: + - ".github/workflows/harness-aws-device.yml" + - ".github/actions/upload-devicefarm-artifact/**" + - "apps/simple-camera/**" + - "packages/react-native-vision-camera/**" + - "packages/react-native-vision-camera-barcode-scanner/**" + - "packages/react-native-vision-camera-location/**" + - "packages/react-native-vision-camera-resizer/**" + - "packages/react-native-vision-camera-skia/**" + - "bun.lock" + - "package.json" + - "patches/**" + pull_request: + paths: + - ".github/workflows/harness-aws-device.yml" + - ".github/actions/upload-devicefarm-artifact/**" + - "apps/simple-camera/**" + - "packages/react-native-vision-camera/**" + - "packages/react-native-vision-camera-barcode-scanner/**" + - "packages/react-native-vision-camera-location/**" + - "packages/react-native-vision-camera-resizer/**" + - "packages/react-native-vision-camera-skia/**" + - "bun.lock" + - "package.json" + - "patches/**" + +env: + USE_CCACHE: 1 + HARNESS_AWS_REGION: us-west-2 + HARNESS_DEVICE_FARM_PROJECT_ARN: arn:aws:devicefarm:us-west-2:633665345122:project:210b1942-012b-4653-9673-f3ff91c5e649 + HARNESS_XCODE_VERSION: "26.2" + HARNESS_PROJECT_ROOT: apps/simple-camera + HARNESS_ANDROID_APP_BUILD_OUTPUT: apps/simple-camera/android/app/build/outputs/apk/debug/app-debug.apk + HARNESS_IOS_APP_BUILD_OUTPUT: apps/simple-camera/ios/build/devicefarm/SimpleCamera.ipa + HARNESS_IOS_DERIVED_DATA_OUTPUT: apps/simple-camera/ios/build/devicefarm/DerivedData + HARNESS_ANDROID_APK_ARTIFACT_NAME: harness-android-apk + HARNESS_IOS_IPA_ARTIFACT_NAME: harness-ios-ipa + HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_ARTIFACT_NAME: harness-xctest-agent-ipa + HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_PATH: apps/simple-camera/ios/build/devicefarm/HarnessXCTestAgentUITests.ipa + HARNESS_IOS_XCTEST_UI_TEST_PACKAGE_OUTPUT: apps/simple-camera/ios/build/devicefarm/HarnessXCTestUITestPackage.zip + HARNESS_DEVICE_FARM_REPO_ARCHIVE_NAME: react-native-vision-camera.zip + HARNESS_ANDROID_DEVICE_FARM_TEST_PACKAGE_OUTPUT: apps/simple-camera/android/build/devicefarm/AndroidTestPackage.zip + HARNESS_ANDROID_BUNDLE_ID: com.margelo.nitro.camera.example.simple + HARNESS_ANDROID_DEVICE_ARCH: ${{ github.event.inputs.device_arch || 'arm64-v8a' }} + # Name of the device pools on AWS to pick devices from. + HARNESS_DEVICE_FARM_ANDROID_DEVICE_POOL_ARN: LatestFlagshipsDynamic + HARNESS_DEVICE_FARM_IOS_DEVICE_POOL_ARN: iPhonePool + +jobs: + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + run_android: ${{ steps.set_dispatch.outputs.run_android || steps.set_filter.outputs.run_android }} + run_ios: ${{ steps.set_dispatch.outputs.run_ios || steps.set_filter.outputs.run_ios }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - id: filter + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: dorny/paths-filter@v4 + with: + filters: | + android: + - 'apps/simple-camera/android/**' + - 'apps/simple-camera/device-farm-tests/AwsTestSpec.yml' + - 'packages/**/*.kt' + - 'packages/**/android/**/*.@(cpp|hpp)' + ios: + - 'apps/simple-camera/ios/**' + - 'apps/simple-camera/device-farm-tests/AwsTestSpecIOS.yml' + - 'packages/**/*.@(swift|mm)' + shared: + - 'apps/simple-camera/__tests__/**' + - 'apps/simple-camera/src/**' + - 'apps/simple-camera/rn-harness.config.mjs' + - 'apps/simple-camera/package.json' + - 'packages/**' + - '!packages/**/*.kt' + - '!packages/**/android/**/*.@(cpp|hpp)' + - '!packages/**/*.@(swift|mm)' + - 'patches/**' + - '.github/workflows/harness-aws-device.yml' + - '.github/actions/upload-devicefarm-artifact/**' + - 'bun.lock' + - 'package.json' + + - id: set_dispatch + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + echo "run_android=true" >> "$GITHUB_OUTPUT" + echo "run_ios=true" >> "$GITHUB_OUTPUT" + + - id: set_filter + if: ${{ github.event_name != 'workflow_dispatch' }} + run: | + echo "run_android=${{ steps.filter.outputs.android == 'true' || steps.filter.outputs.shared == 'true' }}" >> "$GITHUB_OUTPUT" + echo "run_ios=${{ steps.filter.outputs.ios == 'true' || steps.filter.outputs.shared == 'true' }}" >> "$GITHUB_OUTPUT" + + build-android: + name: Build Android + runs-on: ubuntu-latest + timeout-minutes: 45 + needs: + - detect-changes + if: ${{ needs.detect-changes.outputs.run_android == 'true' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: "17" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Restore Gradle/CMake cache + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + apps/simple-camera/android/.gradle + apps/simple-camera/android/app/.cxx + key: ${{ runner.os }}-gradle-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}-${{ hashFiles('apps/simple-camera/android/**/*.gradle*', 'apps/simple-camera/android/**/gradle-wrapper.properties', 'packages/**/CMakeLists.txt', 'packages/**/*.cmake', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-gradle-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}- + ${{ runner.os }}-gradle- + + - name: Restore Android app from cache + id: cache-apk-restore + uses: actions/cache/restore@v4 + with: + path: ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + key: harness-android-apk-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}-${{ hashFiles('apps/simple-camera/android/**/*.gradle*', 'apps/simple-camera/android/**/gradle-wrapper.properties', 'apps/simple-camera/android/app/src/**', 'packages/**/android/**', 'packages/**/cpp/**', 'packages/**/nitrogen/generated/**', 'apps/simple-camera/package.json', 'bun.lock') }} + + - name: Build Android app + if: steps.cache-apk-restore.outputs.cache-hit != 'true' + working-directory: apps/simple-camera/android + run: ./gradlew assembleDebug -Pandroid.injected.testOnly=false -PreactNativeArchitectures=${{ env.HARNESS_ANDROID_DEVICE_ARCH }} --no-daemon --build-cache --console=plain + + - name: Save Android app to cache + if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + key: harness-android-apk-${{ env.HARNESS_ANDROID_DEVICE_ARCH }}-${{ hashFiles('apps/simple-camera/android/**/*.gradle*', 'apps/simple-camera/android/**/gradle-wrapper.properties', 'apps/simple-camera/android/app/src/**', 'packages/**/android/**', 'packages/**/cpp/**', 'packages/**/nitrogen/generated/**', 'apps/simple-camera/package.json', 'bun.lock') }} + + - name: Verify Android app artifact + run: test -f ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + + - name: Upload Android app artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ env.HARNESS_ANDROID_APK_ARTIFACT_NAME }} + path: ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + if-no-files-found: error + retention-days: 7 + + test-android: + name: Test Android + runs-on: ubuntu-latest + timeout-minutes: 45 + needs: + - detect-changes + - build-android + if: ${{ needs.detect-changes.outputs.run_android == 'true' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Download Android app artifact + uses: actions/download-artifact@v8 + with: + name: ${{ env.HARNESS_ANDROID_APK_ARTIFACT_NAME }} + path: apps/simple-camera/android/app/build/outputs/apk/debug + + - name: Verify Android app artifact + run: test -f ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + + - name: Create Android Device Farm test package + run: | + set -euo pipefail + + PACKAGE_PATH="${{ env.HARNESS_ANDROID_DEVICE_FARM_TEST_PACKAGE_OUTPUT }}" + PACKAGE_DIR="$(dirname "$PACKAGE_PATH")" + + rm -f "$PACKAGE_PATH" + mkdir -p "$PACKAGE_DIR" + + git archive \ + --format=zip \ + --output "$PACKAGE_PATH" \ + HEAD + + unzip -tq "$PACKAGE_PATH" + + - name: Verify Android Device Farm test package + run: test -f ${{ env.HARNESS_ANDROID_DEVICE_FARM_TEST_PACKAGE_OUTPUT }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::633665345122:role/GitHubDeviceFarmRole + aws-region: ${{ env.HARNESS_AWS_REGION }} + + - name: Upload Android test package to AWS Device Farm + id: upload-android-test-package + uses: ./.github/actions/upload-devicefarm-artifact + with: + project-arn: ${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }} + aws-region: ${{ env.HARNESS_AWS_REGION }} + file-path: ${{ env.HARNESS_ANDROID_DEVICE_FARM_TEST_PACKAGE_OUTPUT }} + upload-type: APPIUM_NODE_TEST_PACKAGE + content-type: application/zip + upload-name: AndroidTestPackage-${{ github.run_id }}-${{ github.run_attempt }}.zip + + - name: Upload Android test spec to AWS Device Farm + id: upload-android-test-spec + uses: ./.github/actions/upload-devicefarm-artifact + with: + project-arn: ${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }} + aws-region: ${{ env.HARNESS_AWS_REGION }} + file-path: apps/simple-camera/device-farm-tests/AwsTestSpec.yml + upload-type: APPIUM_NODE_TEST_SPEC + content-type: application/octet-stream + upload-name: AwsTestSpecAndroid-${{ github.run_id }}-${{ github.run_attempt }}.yml + + - name: Upload APK to AWS Device Farm + id: upload-apk + uses: ./.github/actions/upload-devicefarm-artifact + with: + project-arn: ${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }} + aws-region: ${{ env.HARNESS_AWS_REGION }} + file-path: ${{ env.HARNESS_ANDROID_APP_BUILD_OUTPUT }} + upload-name: app-debug-${{ github.run_id }}-${{ github.run_attempt }}.apk + + - name: Schedule Device Farm Android Automated Test + id: run-test + uses: aws-actions/aws-devicefarm-mobile-device-testing@v3 + continue-on-error: true + with: + run-settings-json: | + { + "name": "GitHubAction-${{ github.workflow }}-android_${{ github.run_id }}_${{ github.run_attempt }}", + "projectArn": "${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }}", + "appArn": "${{ steps.upload-apk.outputs['upload-arn'] }}", + "devicePoolArn": "${{ env.HARNESS_DEVICE_FARM_ANDROID_DEVICE_POOL_ARN }}", + "test": { + "type": "APPIUM_NODE", + "testPackageArn": "${{ steps.upload-android-test-package.outputs['upload-arn'] }}", + "testSpecArn": "${{ steps.upload-android-test-spec.outputs['upload-arn'] }}" + } + } + artifact-types: ALL + + - name: Collect Android Device Farm results + if: always() + uses: ./.github/actions/collect-devicefarm-results + with: + artifact-folder: ${{ steps.run-test.outputs.artifact-folder }} + platform: android + test-step-outcome: ${{ steps.run-test.outcome }} + + build-ios: + name: Build iOS + runs-on: macos-latest + timeout-minutes: 45 + needs: + - detect-changes + if: ${{ needs.detect-changes.outputs.run_ios == 'true' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Ccache + uses: hendrikmuhs/ccache-action@v1.2.23 + with: + max-size: 1.5G + key: ${{ runner.os }}-${{ runner.arch }}-xcode${{ env.HARNESS_XCODE_VERSION }}-ccache-harness-ios + create-symlink: true + + - name: Setup ccache behavior + run: | + echo "CCACHE_SLOPPINESS=clang_index_store,file_stat_matches,include_file_ctime,include_file_mtime,ivfsoverlay,pch_defines,modules,system_headers,time_macros" >> $GITHUB_ENV + echo "CCACHE_FILECLONE=true" >> $GITHUB_ENV + echo "CCACHE_DEPEND=true" >> $GITHUB_ENV + echo "CCACHE_INODECACHE=true" >> $GITHUB_ENV + + - name: Setup Ruby (bundle) + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4.9 + bundler-cache: true + working-directory: apps/simple-camera/ + + - name: Select Xcode ${{ env.HARNESS_XCODE_VERSION }} + run: sudo xcode-select -s "/Applications/Xcode_${{ env.HARNESS_XCODE_VERSION }}.app/Contents/Developer" + + - name: Restore CocoaPods cache + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/CocoaPods + apps/simple-camera/ios/Pods + key: ${{ runner.os }}-pods-${{ hashFiles('apps/simple-camera/ios/Podfile.lock', 'apps/simple-camera/package.json', 'bun.lock') }} + restore-keys: | + ${{ runner.os }}-pods- + + - name: Install Pods + working-directory: apps/simple-camera/ios + env: + RCT_USE_PREBUILT_RNCORE: "0" + run: bun pods + + - name: Restore DerivedData cache + id: cache-deriveddata-restore + uses: actions/cache/restore@v4 + with: + path: ${{ env.HARNESS_IOS_DERIVED_DATA_OUTPUT }} + key: ${{ runner.os }}-${{ runner.arch }}-xcode${{ env.HARNESS_XCODE_VERSION }}-dd-${{ hashFiles('bun.lock', 'apps/simple-camera/Gemfile.lock', 'apps/simple-camera/ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-xcode${{ env.HARNESS_XCODE_VERSION }}-dd- + + - name: Build iOS app + run: | + set -euo pipefail + DERIVED_DATA="${{ env.HARNESS_IOS_DERIVED_DATA_OUTPUT }}" + IPA_PATH="${{ env.HARNESS_IOS_APP_BUILD_OUTPUT }}" + APP_PATH="${DERIVED_DATA}/Build/Products/Debug-iphoneos/SimpleCamera.app" + PAYLOAD_ROOT="$(mktemp -d)" + PAYLOAD_DIR="$PAYLOAD_ROOT/Payload" + + rm -f "$IPA_PATH" + mkdir -p "$DERIVED_DATA" "$(dirname "$IPA_PATH")" "$PAYLOAD_DIR" + + xcodebuild \ + CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ + -workspace apps/simple-camera/ios/SimpleCamera.xcworkspace \ + -scheme SimpleCamera \ + -configuration Debug \ + -sdk iphoneos \ + -destination generic/platform=iOS \ + -derivedDataPath "$DERIVED_DATA" \ + -showBuildTimingSummary \ + ONLY_ACTIVE_ARCH=YES \ + build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY= \ + COMPILER_INDEX_STORE_ENABLE=NO | xcbeautify --renderer github-actions + + test -d "$APP_PATH" + cp -R "$APP_PATH" "$PAYLOAD_DIR/" + + ( + cd "$PAYLOAD_ROOT" + zip -qry "$GITHUB_WORKSPACE/$IPA_PATH" Payload + ) + + - name: Build Harness XCTest UI runner IPA + run: | + set -euo pipefail + + PROJECT_ROOT="apps/simple-camera" + DERIVED_DATA="$PROJECT_ROOT/.harness/xctest-agent/device" + IPA_PATH="apps/simple-camera/ios/build/devicefarm/HarnessXCTestAgentUITests.ipa" + PAYLOAD_ROOT="$(mktemp -d)" + PAYLOAD_DIR="$PAYLOAD_ROOT/Payload" + + rm -rf "$DERIVED_DATA" "$IPA_PATH" + mkdir -p "$(dirname "$IPA_PATH")" "$PAYLOAD_DIR" + + ( + cd "$PROJECT_ROOT" + node ../../node_modules/react-native-harness/bin.js xctest build --destination device + ) + + RUNNER_APP="$(find "$DERIVED_DATA/Build/Products/Debug-iphoneos" -maxdepth 1 -type d -name '*UITests-Runner.app' -print -quit)" + test -n "$RUNNER_APP" + test -d "$RUNNER_APP" + + cp -R "$RUNNER_APP" "$PAYLOAD_DIR/" + + ( + cd "$PAYLOAD_ROOT" + zip -qry "$GITHUB_WORKSPACE/$IPA_PATH" Payload + ) + + test -f "$IPA_PATH" + + - name: Save DerivedData cache + if: steps.cache-deriveddata-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: ${{ env.HARNESS_IOS_DERIVED_DATA_OUTPUT }} + key: ${{ steps.cache-deriveddata-restore.outputs.cache-primary-key }} + + - name: Verify iOS app artifact + run: test -f ${{ env.HARNESS_IOS_APP_BUILD_OUTPUT }} + + - name: Upload iOS app artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ env.HARNESS_IOS_IPA_ARTIFACT_NAME }} + path: ${{ env.HARNESS_IOS_APP_BUILD_OUTPUT }} + if-no-files-found: error + retention-days: 7 + + - name: Upload Harness XCTest UI runner IPA + uses: actions/upload-artifact@v7 + with: + name: ${{ env.HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_ARTIFACT_NAME }} + path: ${{ env.HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_PATH }} + if-no-files-found: error + retention-days: 7 + + test-ios: + name: Test iOS + runs-on: ubuntu-latest + timeout-minutes: 45 + needs: + - detect-changes + - build-ios + if: ${{ needs.detect-changes.outputs.run_ios == 'true' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Download iOS app artifact + uses: actions/download-artifact@v8 + with: + name: ${{ env.HARNESS_IOS_IPA_ARTIFACT_NAME }} + path: ${{ env.HARNESS_PROJECT_ROOT }}/ios/build/devicefarm + + - name: Download Harness XCTest UI runner IPA artifact + uses: actions/download-artifact@v8 + with: + name: ${{ env.HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_ARTIFACT_NAME }} + path: ${{ env.HARNESS_PROJECT_ROOT }}/ios/build/devicefarm + + - name: Verify iOS app artifact + run: test -f ${{ env.HARNESS_IOS_APP_BUILD_OUTPUT }} + + - name: Verify Harness XCTest UI runner IPA artifact + run: test -f ${{ env.HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_PATH }} + + - name: Create XCTest UI test package + run: | + set -euo pipefail + + PACKAGE_PATH="${{ env.HARNESS_IOS_XCTEST_UI_TEST_PACKAGE_OUTPUT }}" + PACKAGE_ROOT="$(mktemp -d)" + REPO_ARCHIVE="$PACKAGE_ROOT/${{ env.HARNESS_DEVICE_FARM_REPO_ARCHIVE_NAME }}" + RUNNER_IPA="$PACKAGE_ROOT/$(basename "${{ env.HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_PATH }}")" + + rm -f "$PACKAGE_PATH" + mkdir -p "$(dirname "$PACKAGE_PATH")" + + git archive \ + --format=zip \ + --output "$REPO_ARCHIVE" \ + HEAD + + cp "${{ env.HARNESS_IOS_HARNESS_XCTEST_RUNNER_IPA_PATH }}" "$RUNNER_IPA" + + ( + cd "$PACKAGE_ROOT" + zip -qry "$GITHUB_WORKSPACE/$PACKAGE_PATH" \ + "$(basename "$RUNNER_IPA")" \ + "${{ env.HARNESS_DEVICE_FARM_REPO_ARCHIVE_NAME }}" + ) + + unzip -tq "$PACKAGE_PATH" + unzip -l "$PACKAGE_PATH" + + - name: Verify XCTest UI test package + run: test -f ${{ env.HARNESS_IOS_XCTEST_UI_TEST_PACKAGE_OUTPUT }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: arn:aws:iam::633665345122:role/GitHubDeviceFarmRole + aws-region: ${{ env.HARNESS_AWS_REGION }} + + - name: Upload IPA to AWS Device Farm + id: upload-ipa + uses: ./.github/actions/upload-devicefarm-artifact + with: + project-arn: ${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }} + aws-region: ${{ env.HARNESS_AWS_REGION }} + file-path: ${{ env.HARNESS_IOS_APP_BUILD_OUTPUT }} + upload-type: IOS_APP + content-type: application/octet-stream + upload-name: simple-camera-${{ github.run_id }}-${{ github.run_attempt }}.ipa + + - name: Upload XCTest UI test package to AWS Device Farm + id: upload-xctest-ui-package + uses: ./.github/actions/upload-devicefarm-artifact + with: + project-arn: ${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }} + aws-region: ${{ env.HARNESS_AWS_REGION }} + file-path: ${{ env.HARNESS_IOS_XCTEST_UI_TEST_PACKAGE_OUTPUT }} + upload-type: XCTEST_UI_TEST_PACKAGE + content-type: application/zip + upload-name: harness-xctest-ui-package-${{ github.run_id }}-${{ github.run_attempt }}.zip + + - name: Upload iOS test spec to AWS Device Farm + id: upload-ios-test-spec + uses: ./.github/actions/upload-devicefarm-artifact + with: + project-arn: ${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }} + aws-region: ${{ env.HARNESS_AWS_REGION }} + file-path: apps/simple-camera/device-farm-tests/AwsTestSpecIOS.yml + upload-type: XCTEST_UI_TEST_SPEC + content-type: application/octet-stream + upload-name: AwsTestSpecIOS-${{ github.run_id }}-${{ github.run_attempt }}.yml + + - name: Schedule Device Farm iOS Automated Test + id: run-test + uses: aws-actions/aws-devicefarm-mobile-device-testing@v3 + continue-on-error: true + with: + run-settings-json: | + { + "name": "GitHubAction-${{ github.workflow }}-ios_${{ github.run_id }}_${{ github.run_attempt }}", + "projectArn": "${{ env.HARNESS_DEVICE_FARM_PROJECT_ARN }}", + "appArn": "${{ steps.upload-ipa.outputs['upload-arn'] }}", + "devicePoolArn": "${{ env.HARNESS_DEVICE_FARM_IOS_DEVICE_POOL_ARN }}", + "test": { + "type": "XCTEST_UI", + "testPackageArn": "${{ steps.upload-xctest-ui-package.outputs['upload-arn'] }}", + "testSpecArn": "${{ steps.upload-ios-test-spec.outputs['upload-arn'] }}" + } + } + artifact-types: ALL + + - name: Collect iOS Device Farm results + if: always() + uses: ./.github/actions/collect-devicefarm-results + with: + artifact-folder: ${{ steps.run-test.outputs.artifact-folder }} + platform: ios + test-step-outcome: ${{ steps.run-test.outcome }} diff --git a/.github/workflows/lint-cpp.yml b/.github/workflows/lint-cpp.yml new file mode 100644 index 0000000000..76cfdc5abe --- /dev/null +++ b/.github/workflows/lint-cpp.yml @@ -0,0 +1,36 @@ +name: Lint C++ + +on: + push: + branches: + - main + paths: + - '.github/workflows/lint-cpp.yml' + - '**/*.h' + - '**/*.hpp' + - '**/*.cpp' + - '**/*.c' + - '**/*.mm' + pull_request: + paths: + - '.github/workflows/lint-cpp.yml' + - '**/*.h' + - '**/*.hpp' + - '**/*.cpp' + - '**/*.c' + - '**/*.mm' + +jobs: + lint: + name: Format C++ + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + + - name: Run clang-format + run: bun run lint-cpp + + - name: Verify no files have changed after format + run: git diff --exit-code HEAD -- . ':(exclude)bun.lock' + diff --git a/.github/workflows/lint-kotlin.yml b/.github/workflows/lint-kotlin.yml new file mode 100644 index 0000000000..1b668d5960 --- /dev/null +++ b/.github/workflows/lint-kotlin.yml @@ -0,0 +1,34 @@ +name: Lint Kotlin + +on: + push: + branches: + - main + paths: + - '.github/workflows/lint-kotlin.yml' + - '**/*.kt' + pull_request: + paths: + - '.github/workflows/lint-kotlin.yml' + - '**/*.kt' + +jobs: + lint: + name: Format Kotlin + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + + - name: Install ktlint + run: | + curl -sSLO https://github.com/pinterest/ktlint/releases/latest/download/ktlint + chmod a+x ktlint + sudo mv ktlint /usr/local/bin/ + + - name: Run ktlint + run: bun run lint-kotlin + + - name: Verify no files have changed after format + run: git diff --exit-code HEAD -- . ':(exclude)bun.lock' + diff --git a/.github/workflows/lint-swift.yml b/.github/workflows/lint-swift.yml new file mode 100644 index 0000000000..c62538866d --- /dev/null +++ b/.github/workflows/lint-swift.yml @@ -0,0 +1,28 @@ +name: Lint Swift + +on: + push: + branches: + - main + paths: + - '.github/workflows/lint-swift.yml' + - '**/*.swift' + pull_request: + paths: + - '.github/workflows/lint-swift.yml' + - '**/*.swift' + +jobs: + lint: + name: Format Swift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + + - name: Run swift format + run: bun run lint-swift + + - name: Verify no files have changed after format + run: git diff --exit-code HEAD -- . ':(exclude)bun.lock' + diff --git a/.github/workflows/validate-android.yml b/.github/workflows/validate-android.yml deleted file mode 100644 index 4b6fc8a311..0000000000 --- a/.github/workflows/validate-android.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Validate Android - -on: - push: - branches: - - main - paths: - - '.github/workflows/validate-android.yml' - - 'package/android/**' - - 'package/android/.editorconfig' - pull_request: - paths: - - '.github/workflows/validate-android.yml' - - 'package/android/**' - - 'package/android/.editorconfig' - -jobs: - KTLint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Download ktlint - working-directory: ./package/android/ - run: | - curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.1.1/ktlint - chmod a+x ktlint - - name: Run ktlint - working-directory: ./package/android/ - run: | - ./ktlint --reporter=checkstyle,output=build/ktlint-report.xml --relative --editorconfig=./.editorconfig - continue-on-error: true - - uses: yutailang0119/action-ktlint@v4 - with: - report-path: ./package/android/build/*.xml - continue-on-error: false - - uses: actions/upload-artifact@v4 - with: - name: ktlint-report - path: ./package/android/build/*.xml diff --git a/.github/workflows/validate-cpp.yml b/.github/workflows/validate-cpp.yml deleted file mode 100644 index b73832a024..0000000000 --- a/.github/workflows/validate-cpp.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Validate C++ - -on: - push: - branches: - - main - paths: - - '.github/workflows/validate-cpp.yml' - - 'package/android/src/main/cpp/**' - - 'package/ios/**' - pull_request: - paths: - - '.github/workflows/validate-cpp.yml' - - 'package/android/src/main/cpp/**' - - 'package/ios/**' - -jobs: - lint: - name: Check clang-format - runs-on: ubuntu-latest - strategy: - matrix: - path: - - 'package/android/src/main/cpp' - - 'package/ios' - steps: - - uses: actions/checkout@v4 - - name: Run clang-format style check - uses: mrousavy/clang-format-action@v1 - with: - clang-format-version: '16' - check-path: ${{ matrix.path }} - clang-format-style-path: package/.clang-format - diff --git a/.github/workflows/validate-ios.yml b/.github/workflows/validate-ios.yml deleted file mode 100644 index e4870c7bd0..0000000000 --- a/.github/workflows/validate-ios.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Validate iOS - -on: - push: - branches: - - main - paths: - - '.github/workflows/validate-ios.yml' - - 'package/ios/**' - pull_request: - paths: - - '.github/workflows/validate-ios.yml' - - 'package/ios/**' - -jobs: - SwiftLint: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./package - steps: - - uses: actions/checkout@v4 - - name: Run SwiftLint GitHub Action (--strict) - uses: norio-nomura/action-swiftlint@master - with: - args: --strict - env: - WORKING_DIRECTORY: ios - SwiftFormat: - runs-on: macOS-latest - defaults: - run: - working-directory: ./package/ios - steps: - - uses: actions/checkout@v4 - - - name: Install SwiftFormat - run: brew install swiftformat - - - name: Format Swift code - run: swiftformat --verbose . - - - name: Verify formatted code is unchanged - run: git diff --exit-code HEAD diff --git a/.github/workflows/validate-js.yml b/.github/workflows/validate-js.yml deleted file mode 100644 index fbd637c68a..0000000000 --- a/.github/workflows/validate-js.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Validate JS - -on: - push: - branches: - - main - paths: - - '.github/workflows/validate-js.yml' - - 'package/src/**' - - 'package/*.json' - - 'package/*.js' - - 'package/*.lock' - - 'package/example/src/**' - - 'package/example/*.json' - - 'package/example/*.js' - - 'package/example/*.lock' - - 'package/example/*.tsx' - pull_request: - paths: - - '.github/workflows/validate-js.yml' - - 'package/src/**' - - 'package/*.json' - - 'package/*.js' - - 'package/*.lock' - - 'package/example/src/**' - - 'package/example/*.json' - - 'package/example/*.js' - - 'package/example/*.lock' - - 'package/example/*.tsx' - -jobs: - compile: - name: Compile JS (tsc) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./package - steps: - - uses: actions/checkout@v4 - - - name: Install reviewdog - uses: reviewdog/action-setup@v1 - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - name: Restore node_modules from cache - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install node_modules - run: yarn install --frozen-lockfile - - name: Install node_modules (example/) - run: yarn install --frozen-lockfile --cwd example - - - name: Run TypeScript # Reviewdog tsc errorformat: %f:%l:%c - error TS%n: %m - run: | - yarn typescript | reviewdog -name="tsc" -efm="%f(%l,%c): error TS%n: %m" -reporter="github-pr-review" -filter-mode="nofilter" -fail-on-error -tee - env: - REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Run TypeScript in example/ # Reviewdog tsc errorformat: %f:%l:%c - error TS%n: %m - run: | - cd example && yarn typescript | reviewdog -name="tsc" -efm="%f(%l,%c): error TS%n: %m" -reporter="github-pr-review" -filter-mode="nofilter" -fail-on-error -tee && cd .. - env: - REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - lint: - name: Lint JS (eslint, prettier) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./package - steps: - - uses: actions/checkout@v4 - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - - name: Restore node_modules from cache - uses: actions/cache@v4 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install node_modules - run: yarn install --frozen-lockfile - - name: Install node_modules (example/) - run: yarn install --frozen-lockfile --cwd example - - - name: Run ESLint - run: yarn lint -f @jamesacarr/github-actions - - - name: Run ESLint with auto-fix - run: yarn lint --fix - - - name: Run ESLint in example/ - run: cd example && yarn lint -f @jamesacarr/github-actions && cd .. - - - name: Run ESLint in example/ with auto-fix - run: cd example && yarn lint --fix && cd .. - - - name: Verify no files have changed after auto-fix - run: git diff --exit-code HEAD diff --git a/.gitignore b/.gitignore index 8bbf73d843..263c2f24cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,36 @@ -.DS_Store -**/node_modules/ - -# no yarn/npm in the root repo! -/package-lock.json -/yarn.lock - -# when switching from v2 -> v3 branches -/example -/ios -/android -/lib +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +**/.DS_Store + +# Claude Code local settings +.claude/settings.local.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..0b89c715ac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, + "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" } +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 96b64bce96..0000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,76 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by creating an issue on the GitHub repository. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 186de9f4fa..0000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,95 +0,0 @@ -# Contributing - -## Guidelines - -1. Don't be an asshole. -2. Don't waste anyone's time. - -## Get started - -1. Fork & clone the repository -2. Install dependencies - ``` - cd react-native-vision-camera - cd package - yarn bootstrap - ``` - -Read the READMEs in [`android/`](android/README.md) and [`ios/`](ios/README.md) for a quick overview of the native development workflow. - -> You can also open VisionCamera in [a quick online editor (github1s)](https://github1s.com/mrousavy/react-native-vision-camera) - -### JS/TS - -1. Open the entire folder in Visual Studio Code -2. Start the metro bundler in the `example/` directory using `yarn start` -3. Run either the iOS or Android project to test changes - -> Run `yarn check-js` to validate codestyle - -### iOS - -1. Open the `example/ios/VisionCameraExample.xcworkspace` file with Xcode -2. Change signing configuration to your developer account -3. Select your device in the devices drop-down -4. Hit run - -> Run `yarn check-ios` to validate codestyle - -### Android - -1. Open the `example/android/` folder with Android Studio -2. Start the metro bundler in the `example/` directory using `yarn start` -3. Select your device in the devices drop-down -4. Once your device is connected, make sure it can find the metro bundler's port: - ``` - adb reverse tcp:8081 tcp:8081 - ``` -6. Hit run - -> Run `yarn check-android` to validate codestyle - -### Docs - -1. Edit the relevant file, it may be easiest to search for what you're editing to find the right file -2. Install all dependencies by running `yarn` inside the `docs` folder - -> Run `yarn start` to generate the docs, you can then view them in your browser to confirm your changes - -## Committing - -### Codestyle - -Great code produces great products. That's why we love to keep our codebases clean, and to achieve that, we use linters and formatters which output errors when something isn't formatted the way we like it to be. - -Before pushing your changes, you can verify that everything is still correctly formatted by running all linters: - -``` -yarn check-all -``` - -This validates Swift, Kotlin, C++ and JS/TS code: - -```bash -$ yarn check-all - yarn run v1.22.10 - Formatting Swift code.. - Linting Swift code.. - Linting Kotlin code.. - Linting C++ code.. - Linting JS/TS code.. - All done! - ✨ Done in 8.05s. -``` - -### PR messages - -When creating a pull-request, make sure to use the [conventional changelog](https://github.com/conventional-changelog/conventional-changelog) format for it's title: - -* ✨ For new features or enhancements, use `feat`. Example: `feat: Support ultra-wide-angle cameras` -* 🐛 For bugfixes or build improvements, use `fix`. Example: `fix: Fix iOS 13 crash when switching cameras` -* 💨 For performance improvements, use `perf`. Example: `perf: Improve takePhoto() performance by re-using file-session` -* 🛠️ When upgrading dependencies, use `chore(deps)`. Example: `chore(deps): Upgrade react-native to 0.70` -* 📚 When changing anything in the documentation, use `docs`. Example: `docs: Fix typo in installation instructions` - -Pull-requests will be squash-committed, so no need to prefix your individual commits with the conventional changelog format as long as the commit messages are descriptive. diff --git a/LICENSE b/LICENSE index 90c2790293..b160bffffb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,20 @@ -Copyright 2021 Marc Rousavy +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2025 Marc Rousavy +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4df2faaa25..bdc9990aea 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ - - - VisionCamera + + + VisionCamera +

- +
-### Features - VisionCamera is a powerful, high-performance Camera library for React Native. It features: * 📸 Photo and Video capture @@ -21,12 +20,12 @@ VisionCamera is a powerful, high-performance Camera library for React Native. It * 📱 Customizable devices and multi-cameras ("fish-eye" zoom) * 🎞️ Customizable resolutions and aspect-ratios (4k/8k images) * ⏱️ Customizable FPS (30..240 FPS) -* 🧩 [Frame Processors](https://react-native-vision-camera.com/docs/guides/frame-processors) (JS worklets to run facial recognition, AI object detection, realtime video chats, ...) +* 🧩 [Frame Processors](https://visioncamera.margelo.com/docs/frame-output) (JS worklets to run facial recognition, AI object detection, realtime video chats, ...) * 🎨 Drawing shapes, text, filters or shaders onto the Camera * 🔍 Smooth zooming (Reanimated) * ⏯️ Fast pause and resume * 🌓 HDR & Night modes -* ⚡ Custom C++/GPU accelerated video pipeline (OpenGL) +* ⚡ Custom C++/GPU accelerated resizer (Metal/Vulkan) Install VisionCamera from npm: @@ -35,14 +34,20 @@ npm i react-native-vision-camera cd ios && pod install ``` -..and get started by [setting up permissions](https://react-native-vision-camera.com/docs/guides)! +..and get started by [setting up permissions](https://visioncamera.margelo.com/docs)! + +### Links + +- [Documentation Website](https://visioncamera.margelo.com) +- [Documentation LLMs.txt](https://visioncamera.margelo.com/llms.txt) +- [Community Discord](https://margelo.com/discord) +- [Example App](./apps/simple-camera/) +- [AWS Device Farm Tests](https://us-west-2.console.aws.amazon.com/devicefarm/home#/mobile/projects/210b1942-012b-4653-9673-f3ff91c5e649/runs) (sign in via the [AWS Access Portal](https://d-9267d6576e.awsapps.com/start) first) -### Documentation +### VisionCamera V4 -* [Guides](https://react-native-vision-camera.com/docs/guides) -* [API](https://react-native-vision-camera.com/docs/api) -* [Example](./package/example/) -* [Frame Processor Plugins](https://react-native-vision-camera.com/docs/guides/frame-processor-plugins-community) +As VisionCamera V5 is released, VisionCamera V4 is no longer actively maintained. +The VisionCamera V4 code has been archived under [margelo/react-native-vision-camera-v4-snapshot](https://github.com/margelo/react-native-vision-camera-v4-snapshot), and the old documentation page is deployed at [visioncamera4.margelo.com](https://visioncamera4.margelo.com). ### ShadowLens @@ -50,10 +55,10 @@ To see VisionCamera in action, check out [ShadowLens](https://mrousavy.com/proje @@ -61,35 +66,25 @@ To see VisionCamera in action, check out [ShadowLens](https://mrousavy.com/proje ```tsx function App() { - const device = useCameraDevice('back') - - if (device == null) return return ( ) } ``` -> See the [example](./package/example/) app +> See the [example](./apps/simple-camera/) app ### Adopting at scale - - This library helped you? Consider sponsoring! - - -VisionCamera is provided _as is_, I work on it in my free time. - -If you're integrating VisionCamera in a production app, consider [funding this project](https://github.com/sponsors/mrousavy) and contact me to receive premium enterprise support, help with issues, prioritize bugfixes, request features, help at integrating VisionCamera and/or Frame Processors, and more. +VisionCamera is built by [Margelo](https://margelo.com). +We make apps better and faster. ### Socials * 🐦 [**Follow me on Twitter**](https://twitter.com/mrousavy) for updates * 📝 [**Check out my blog**](https://mrousavy.com/blog) for examples and experiments -* 💬 [**Join the Margelo Community Discord**](https://discord.gg/6CSHz2qAvA) for chatting about VisionCamera -* 💖 [**Sponsor me on GitHub**](https://github.com/sponsors/mrousavy) to support my work -* 🍪 [**Buy me a Ko-Fi**](https://ko-fi.com/mrousavy) to support my work +* 💬 [**Join the Margelo Community Discord**](https://margelo.com/discord) for chatting about VisionCamera diff --git a/apps/simple-camera/.bundle/config b/apps/simple-camera/.bundle/config new file mode 100644 index 0000000000..848943bb52 --- /dev/null +++ b/apps/simple-camera/.bundle/config @@ -0,0 +1,2 @@ +BUNDLE_PATH: "vendor/bundle" +BUNDLE_FORCE_RUBY_PLATFORM: 1 diff --git a/apps/simple-camera/.gitignore b/apps/simple-camera/.gitignore new file mode 100644 index 0000000000..e59bd3f3a1 --- /dev/null +++ b/apps/simple-camera/.gitignore @@ -0,0 +1,78 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# react-native-harness (auto-generated manifest + crash dumps) +.harness/ diff --git a/apps/simple-camera/.watchmanconfig b/apps/simple-camera/.watchmanconfig new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/apps/simple-camera/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/apps/simple-camera/Gemfile b/apps/simple-camera/Gemfile new file mode 100644 index 0000000000..879ece55de --- /dev/null +++ b/apps/simple-camera/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' + +# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version +ruby ">= 3.4.9" + +# Exclude problematic versions of cocoapods and activesupport that cause build failures. +gem 'cocoapods', '>= 1.16.2', '< 1.17' +gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem 'xcodeproj', '>= 1.27.0', '< 2.0' +gem 'concurrent-ruby', '>= 1.3.6', '< 2.0' + +# Ruby 3.4.0 has removed some libraries from the standard library. +gem 'bigdecimal' +gem 'logger' +gem 'benchmark' +gem 'mutex_m' +gem 'nkf' diff --git a/apps/simple-camera/Gemfile.lock b/apps/simple-camera/Gemfile.lock new file mode 100644 index 0000000000..3b3d816781 --- /dev/null +++ b/apps/simple-camera/Gemfile.lock @@ -0,0 +1,123 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + drb (2.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + ffi (1.17.4) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.7) + logger (1.7.0) + minitest (5.27.0) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + netrc (0.11.0) + nkf (0.2.0) + public_suffix (4.0.7) + rexml (3.4.4) + ruby-macho (2.5.1) + securerandom (0.4.1) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + +PLATFORMS + ruby + +DEPENDENCIES + activesupport (>= 6.1.7.5, != 7.1.0) + benchmark + bigdecimal + cocoapods (>= 1.16.2, < 1.17) + concurrent-ruby (>= 1.3.6, < 2.0) + logger + mutex_m + nkf + xcodeproj (>= 1.27.0, < 2.0) + +RUBY VERSION + ruby 3.4.9 + +BUNDLED WITH + 4.0.13 diff --git a/apps/simple-camera/README.md b/apps/simple-camera/README.md new file mode 100644 index 0000000000..e2e5c9406a --- /dev/null +++ b/apps/simple-camera/README.md @@ -0,0 +1,97 @@ +This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). + +# Getting Started + +> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. + +## Step 1: Start Metro + +First, you will need to run **Metro**, the JavaScript build tool for React Native. + +To start the Metro dev server, run the following command from the root of your React Native project: + +```sh +# Using npm +npm start + +# OR using Yarn +yarn start +``` + +## Step 2: Build and run your app + +With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: + +### Android + +```sh +# Using npm +npm run android + +# OR using Yarn +yarn android +``` + +### iOS + +For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). + +The first time you create a new project, run the Ruby bundler to install CocoaPods itself: + +```sh +bundle install +``` + +Then, and every time you update your native dependencies, run: + +```sh +bundle exec pod install +``` + +For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). + +```sh +# Using npm +npm run ios + +# OR using Yarn +yarn ios +``` + +If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. + +This is one way to run your app — you can also build it directly from Android Studio or Xcode. + +## Step 3: Modify your app + +Now that you have successfully run the app, let's make changes! + +Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). + +When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: + +- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). +- **iOS**: Press R in iOS Simulator. + +## Congratulations! :tada: + +You've successfully run and modified your React Native App. :partying_face: + +### Now what? + +- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). +- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + +# Learn More + +To learn more about React Native, take a look at the following resources: + +- [React Native Website](https://reactnative.dev) - learn more about React Native. +- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how to set up your environment. +- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. +- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. +- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. diff --git a/apps/simple-camera/__tests__/README.md b/apps/simple-camera/__tests__/README.md new file mode 100644 index 0000000000..454a8bdb8f --- /dev/null +++ b/apps/simple-camera/__tests__/README.md @@ -0,0 +1,227 @@ +# VisionCamera Harness Tests + +This folder contains the on-device test suite for the VisionCamera imperative API. Tests run on a real phone (local `adb` device or an AWS Device Farm device) through [react-native-harness](https://www.react-native-harness.dev), which embeds a Jest-compatible runner in the `simple-camera` app and talks to it over a Metro-driven bridge. + +For LLMs, ensure you understand [Harness' llms.txt file](https://www.react-native-harness.dev/llms-full.txt) before hallucinating APIs. + +## Why these tests exist + +Two goals, in order: + +1. **Regressions in the public API surface fail CI automatically.** Every feature of `VisionCamera` that this library supports on real hardware has a test here. If a refactor breaks `capturePhoto`, the CI run turns red on the next PR. +2. **Bug reports become executable.** Anyone who finds a bug is expected to open a PR here that adds a single failing test reproducing the issue — **not** a separate reproduction repo. The maintainer fixes the bug on the same branch until the CI goes green, and the test is merged along with the fix. That way the same bug can never regress silently again. + +**If you are reporting a bug:** open a PR that adds the smallest possible `it(...)` block somewhere under this folder, aligned with the rules below. Then open the issue referencing the PR — the CI run on the PR is the reproduction. You do **not** need to create a separate repo. + +## Layout + +Tests are split by domain. Each file tests one slice of the imperative `VisionCamera` API: + +| File | Covers | +|------|--------| +| [visioncamera.devices.harness.ts](visioncamera.devices.harness.ts) | `VisionCamera.createDeviceFactory`, device enumeration, per-device capabilities, `getCameraForId`, `addOnCameraDevicesChangedListener`, `getSupportedExtensions`, `userPreferredCamera` | +| [visioncamera.session.harness.ts](visioncamera.session.harness.ts) | `createCameraSession`, `configure`, `start`, `stop`, `addOnStartedListener` / `addOnStoppedListener` / `addOnErrorListener` / interruption listeners, reconfigure-while-running, multi-cam | +| [visioncamera.photo.harness.ts](visioncamera.photo.harness.ts) | `createPhotoOutput`, `capturePhoto` / `capturePhotoToFile`, container formats (JPEG, HEIC, DNG), flash / mirror / quality / resolution options, capture lifecycle callbacks, preview images | +| [visioncamera.video.harness.ts](visioncamera.video.harness.ts) | `createVideoOutput`, `Recorder` lifecycle, audio, `maxDuration` / `maxFileSize` stops, pause / resume / cancel, persistent recorder, higher-resolution codecs | +| [visioncamera.frame.harness.ts](visioncamera.frame.harness.ts) | `createFrameOutput`, worklet install via `react-native-vision-camera-worklets`, YUV / RGB / native pixel formats, `scheduleOnRN`, `createSynchronizable`, `setOnFrameDroppedCallback`, `enablePreviewSizedOutputBuffers` | +| [visioncamera.multi-output.harness.ts](visioncamera.multi-output.harness.ts) | Multi-output sessions that combine photo, video, and frame outputs, output replacement while other outputs stay attached, persistent recording across session restarts | +| [visioncamera.constraints.harness.ts](visioncamera.constraints.harness.ts) | `VisionCamera.resolveConstraints` + `onSessionConfigSelected`, FPS / HDR / stabilization / binned / pixelFormat / resolutionBias constraints | +| [visioncamera.controller.harness.ts](visioncamera.controller.harness.ts) | `CameraController` — zoom, torch, exposure bias, focus metering, low-light boost, subject area listener | +| [visioncamera.coordinates.harness.ts](visioncamera.coordinates.harness.ts) | `Frame.convertFramePointToCameraPoint` / `convertCameraPointToFramePoint`, `PreviewView.convertViewPointToCameraPoint` / `convertCameraPointToViewPoint`, `PreviewView.createMeteringPoint`, `convertScannedObjectCoordinatesToViewCoordinates`, end-to-end Frame → Camera → View round-trip | +| [visioncamera.nativepreviewview.harness.tsx](visioncamera.nativepreviewview.harness.tsx) | Bare `NativePreviewView` lifecycle, layout-sensitive preview regression coverage, `resizeMode`, Android `implementationMode`, gesture controllers, multi-preview mounting, `PreviewView` ref methods, Android `takeSnapshot()` dimensions | +| [visioncamera.camera-view.harness.tsx](visioncamera.camera-view.harness.tsx) | High-level `` preview lifecycle, photo output integration, controller props, native gestures, `CameraRef` methods, `isActive`, mount / unmount / replacement behavior | + +Pick the file that best matches what you're testing. If you're reproducing a bug that spans multiple outputs, put it in the file most central to the failure. If nothing fits, open a new `visioncamera..harness.ts` — Jest picks up anything matching `__tests__/**/*.harness.{ts,tsx}`. + +## How a test is written + +The contract is deliberately strict so that the tests read exactly like VisionCamera user code — contributors and LLMs should be able to drop in a reproduction without having to learn framework-specific helpers. + +### 1. Use the `VisionCamera` API as-is. **No helpers.** + +Every test builds up its session inline, end-to-end, from `VisionCamera` up. Do **not** extract helpers like `createSession()` or `configureAndStart()` — the API should read in tests exactly as users would write it in their app. + +```ts +it('captures a JPEG Photo in-memory', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.FHD_4_3, + containerFormat: 'jpeg', + quality: 0.9, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.containerFormat).toBe('jpeg') + photo.dispose() + + await session.stop() +}) +``` + +**`beforeAll` may cache trivial API results** (e.g. the `CameraDeviceFactory` and the default back / front `CameraDevice`). It must not wrap any camera session setup — every `it` block gets its own `session`, `photoOutput`, etc. to run as atomically as possible. +Each atomic test must tear down any non-trivial objects properly to avoid leaking hardware state between tests - most importantly, you must always `stop()` (or even `dispose()`) a `CameraSession`. + +### 2. Hard vs. soft requirements + +Cameras differ. A failing hard requirement is a real bug; a missing soft feature is a device limitation and should not fail the test. + +- **Hard requirement** — checked with `expect(...)`, makes the test fail. Examples: a back camera exists; a photo output produces a photo with `width > 0`; `session.configure` returns one controller per connection, or an API contract holds up to it's promise. +- **Soft requirement** — gated by the matching capability flag and `context.skip(': ')` when not supported. Harness reports this as a real skipped test with the reason in the run summary and JUnit output, so do not use `console.log(...)` plus `return` for runtime camera capability gaps. + +```ts +it('resolves photoHDR: true when the device supports photo HDR', async (context) => { + if (!backDevice.supportsPhotoHDR) { + return context.skip('photoHDR: not supported on this device') + } + // hard-assert HDR behavior from here on +}) +``` + +Use the guard form above instead of `context.skip(condition, reason)` when a nullable value needs to be narrowed afterwards. Keep the `return` so TypeScript understands the rest of the test only runs when the capability exists. If only one optional case inside a matrix is unsupported, split that case into its own `it(...)` so the always-supported cases still run and the optional case is reported as skipped. + +Only call `context.skip(...)` from the `it(...)` body or code that intentionally skips that whole test. Do not hide it inside a shared helper for optional sub-assertions, because it aborts the entire `it(...)`. If a cross-platform test has an Android-only or iOS-only assertion, split that behavior into a dedicated `it(...)` with its own platform skip. + +Capability flags live on +- `CameraDevice` (`hasFlash`, `hasTorch`, `supportsFocusMetering`, `supportsExposureBias`, `supportsPhotoHDR`, `supportsFPS(n)`, `supportsVideoStabilizationMode('cinematic')`, etc.), +- `CameraController` (`minISO`, `maxISO`, `minExposureDuration`, etc.) and on +- `VisionCamera` (`supportsMultiCamSessions`) + +Use them. Do **not** introduce ad-hoc try/catch wrappers around an operation just to silently skip it — if there is no way to query support upfront, flag that as a missing API (see "Known API gaps" below) and `it.skip` the test with a TODO explaining what would let you turn it into a hard requirement. + +### 3. Test behavior, not types + +Nitrogen and TypeScript enforce types at compile time and Nitro Modules enforce them at the bridge. Type-shape assertions like `typeof x === 'number'` or `Array.isArray(devices)` are pure noise — if a number came back as a string, the bridge would have already thrown. + +Assert things that require the camera to actually do work: + +- **The operation completes without throwing** on the happy path — `await session.configure(...)`, `await photoOutput.capturePhoto(...)`, `await recorder.stop()` returning at all is a meaningful assertion. +- **The operation throws when it should** on the false path — e.g. calling `session.start()` before `configure()`, capturing from a disposed output, requesting an unsupported `targetResolution`. Use `await expect(...).rejects.toThrow()`. +- **The result has the right semantic value**, not the right type — a captured `Photo` has `width > 0` and `height > 0`, a video file's size on disk is `> 0`, the returned controller list has `length === connections.length`. +- **API contracts between fields hold** — e.g. if `device.hasFlash` is false then `capturePhoto({ flashMode: 'on' })` must reject; if a connection has `mirrorMode: 'auto'`, the resulting `Photo.isMirrored` reflects the device's front/back position. These cross-field invariants are exactly what types can't catch and what real bugs ship as. +- **Approximate numeric values use matcher tolerances** — for coordinates, dimensions, timestamps, or similar floating-point values, prefer `expect(actual).toBeCloseTo(expected, digits)` over manual `Math.abs(actual - expected)` assertions. The matcher is shorter, keeps the expected value visible in failures, and matches the style used throughout the coordinate tests. +- **Lifecycle and listeners fire in the right order** — `addOnStartedListener` resolves after `start()`, `addOnStoppedListener` after `stop()`, recording callbacks after `recorder.stop()`. Wait on the listener, don't poll `isRunning`. + +If your test would still pass with the implementation stubbed to `throw new Error('TODO')`, you're testing the type system, not the camera. + +### 4. Prefer callbacks over polled state + +`session.isRunning` updates asynchronously on Android. Wait for `session.addOnStartedListener(...)` and `addOnStoppedListener(...)` using `waitUntil(() => started, { timeout: 10_000 })` instead of polling `isRunning` in a sleep loop. + +### 5. Don't silently swallow errors + +No `.catch(() => undefined)` or `try {} catch {}` around otherwise-expected-to-succeed calls. If `session.stop()` can throw, the test should fail — that's a regression. +If something 100% throws for now, it's a missing feature/regression and we should still add a test for it - either `it(...)` or `it.skip(...)` depending on context. This is like a TODO list then, so we make the test green at some point in the near future. + +### 6. Dispose only when it matters + +`Photo`, `Frame`, and `Image` hold large native buffers — call `.dispose()` as soon as you're done with them. You do **not** need to dispose `CameraDevice`, `CameraController`, or outputs in tests; the JS runtime GC _usually_ frees them between tests. +Disposing HybridObjects in JS makes the object no longer usable - any subsequent call to it _will throw_. So only dispose if absolutely necessary or if holding on to large native memory (e.g. `Photo`, `Frame`, `Image`, ...). + +### 7. No artificial `setTimeout` delays + +Tests must only wait on events they actually depend on (`session.addOnStartedListener`, `onRecordingFinished`, a frame counter, a `CompletableDeferred`). Sleeping a random number of milliseconds "so the camera settles" introduces flakiness and masks real regressions. If you catch yourself writing `await sleep(500)` to "make it work", treat it as a bug to fix, not a patch to keep. + +The exception is when elapsed wall-clock time is part of the behavior under test. Video recording tests may sleep briefly after `startRecording()` because they intentionally need the recorder to produce a non-empty clip, collect stats, exercise pause/resume over time, or observe that `cancelRecording()` does not later emit `onRecordingFinished`. Keep those sleeps short, local to the recording phase, and make the reason obvious from the surrounding test. Do not use sleeps to wait for session, preview, frame, or listener lifecycle state. + +### 8. Platform guards + +Pure iOS-only features (`CameraObjectOutput`, `continuity camera`, `getSupportedVideoCodecs`, etc.) or Android-only features (`CameraExtension`, etc.) should start with a `return context.skip('...: iOS only')` / `return context.skip('...: Android only')` platform guard. Do not branch on `Platform.OS` to mask behavioral differences that should be identical across platforms — flag those as bugs. +If a behavior should be supported on both platforms, write one shared test. If that makes CI red on one platform, keep the failure visible until the platform discrepancy is fixed. +Harness tests should cover the broadest behavior the public API promises. A bug report coming from one native stack (for example an AVFoundation assertion or a CameraX exception) is not a reason to make the regression test platform-specific if the user-facing behavior should work everywhere. Do not add platform guards just to make a test narrower, faster, or closer to the original report. There is no upside: it only hides regressions on the other platform. When in doubt, run the shared behavior on all Harness platforms and let CI show any real platform discrepancy. +Do not guard features that expose runtime availability checks behind such flags - e.g. `setFocusLocked(...)` can be probed with `device.supportsManualFocus` - even if this natively is always `false` on Android, this allows us to automatically run the test in the future once we add focus locking support to Android too. +Also do not guard features that are technically possible to implement on the other platform, but not yet implemented due to a TODO behind such feature flags. `setFocusLocked` would be such a case. In these situations, it's expected to have the test red on the missing platform until it's implemented - kinda like a task list for the maintainers. +Platform guards only apply to statically for sure known platform-specific behaviour, like the `CameraObjectOutput` on iOS or `CameraExtension` on Android. + +### 9. Keep assertions compact and diagnostic + +Tests should read like a small executable spec for one behavior. A few patterns keep them simpler without making them weaker: + +- **Name the invariant, not the implementation detail.** Prefer local names like `expectedBounds`, `reportedBounds`, `roundTripped`, or `capturedPhoto` over names that describe temporary mechanics. +- **Use matcher assertions instead of boolean arithmetic.** Prefer `toBeCloseTo`, `toHaveLength`, `toContain`, `toEqual`, and `rejects.toThrow` over manually computing booleans inline and asserting on those - this makes it easier to read tests, especially when they fail in CI outputs. This is especially important for Harness/Vitest logs from AWS Device Farm: rich matchers preserve the received and expected values, while aggregate checks such as max/min deltas usually only show the derived number. +- **Loop over repeated dimensions or cases.** For edges, axes, formats, or corner points, a tiny inline array plus one expectation is clearer than four copy-pasted assertions that can drift. +- **Keep local math behind local names.** Small functions inside an `it` block are fine when they name a one-off transform or assertion, such as `getBounds(...)`. Do not put arithmetic, boolean expressions, `map(...)`, or other transformations directly inside `expect(...)`; assign them to descriptive local names first. Do not collapse multiple facts into one computed assertion like `expect(a + b).toBeGreaterThan(0)` — assert `a` and `b` separately so CI failures identify the broken value. Do not extract shared setup helpers; the camera session still needs to be built inline. +- **Log the facts needed to debug a CI failure.** One final `console.log` with orientation, resolution, or compared values is useful. Per-frame or per-step logs usually make Harness output harder to read. + +## Running the tests + +```sh +# Build the debug APK once +cd apps/simple-camera && bun run build:android + +# Install + grant camera / microphone / location permissions +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +BUNDLE_ID=com.margelo.nitro.camera.example.simple +adb shell pm grant $BUNDLE_ID android.permission.CAMERA +adb shell pm grant $BUNDLE_ID android.permission.RECORD_AUDIO +adb shell pm grant $BUNDLE_ID android.permission.ACCESS_FINE_LOCATION +adb shell pm grant $BUNDLE_ID android.permission.ACCESS_COARSE_LOCATION + +# Run the full harness suite against the connected device +HARNESS_ANDROID_DEVICE_MANUFACTURER= \ +HARNESS_ANDROID_DEVICE_MODEL= \ +bun run test:harness:android + +# Or just one file +HARNESS_ANDROID_DEVICE_MANUFACTURER= \ +HARNESS_ANDROID_DEVICE_MODEL= \ +bun run test:harness:android -- --testPathPatterns=photo +``` + +`HARNESS_ANDROID_DEVICE_MANUFACTURER` / `HARNESS_ANDROID_DEVICE_MODEL` come from `adb shell getprop ro.product.manufacturer` / `ro.product.model`. On AWS Device Farm they're set automatically by the workflow. + +Permissions are granted once per install. If you reinstall the APK with `adb install -r`, re-run the `pm grant` lines before the next test run — otherwise the first test's `expect(cameraPermissionStatus).toBe('authorized')` will fail. + +The `.harness/` folder is auto-generated by the harness bundler and is gitignored. You can safely delete it. + +## Known API gaps / currently-skipped tests + +A few tests are authored but `it.skip`'d because the VisionCamera API doesn't yet expose the precondition they need. Each skip has a `TODO` in the file pointing at what needs to land first. Today: + +- **Photo container format support** — HEIC and DNG capture work on some devices and fail on others, but there is no `CameraDevice.supportedPhotoContainerFormats` today. These tests are `it.skip` with a TODO until the API lands. Once it exists the tests become soft-requirements gated on the flag. +- **`initialZoom` / `initialExposureBias` on Android** — `applyInitialConfig` runs at `configure()` time, before CameraX's LifecycleOwner reaches STARTED. `CameraControl.setExposureCompensationIndex` silently fails in that state. The corresponding tests are `it.skip` until the initial config application happens at a point where CameraX accepts it. +- **`enablePreviewSizedOutputBuffers` on Android** — the flag is not honored by `HybridFrameOutput.kt` today (`TODO: enablePreviewSizedOutputBuffers is not taken into account here.`). +- **`onFrameDropped` on Android** — `HybridFrameOutput.setOnFrameDroppedCallback` is a no-op (`TODO: CameraX does not have a way to figure out if a Frame has been dropped or not.`). + +If you hit another case where you can't write a test because an API is missing, add the test with `it.skip` and a TODO explaining the precondition — that way when the API lands we already know which tests to flip back on. + +## CI + +Harness tests run on every push and PR that touches this folder, the VisionCamera library, or the harness workflow config — see [.github/workflows/harness-aws-device.yml](../../../.github/workflows/harness-aws-device.yml) and [.github/workflows/harness-android-emulator.yml](../../../.github/workflows/harness-android-emulator.yml). + +The AWS Device Farm run is the source of truth: it's a real phone, a real SoC, a real camera pipeline. The emulator run is best-effort and may skip hardware-dependent tests. + +If your PR fails CI, the fastest way to debug is: + +1. Download the `harness output log` artifact from the failed workflow run. It contains the full JS console output per test. +2. Inspect the Harness/JUnit skipped-test summary to see which soft requirements were skipped — the skip reason tells you what your test device lacks. +3. Grep for `FAIL` to see which `it` blocks failed and their stack traces. +4. Open the JUnit XML artifact in your IDE's JUnit viewer for a structured summary. + +Ideally, run the Harness tests on a real phone and stream native logs (`adb logcat` on Android) to understand certain failures, or possibly native crashes. + +## High-level components / hooks (``, `useCamera()`, etc.) + +High-level component tests live alongside the imperative suites when the API surface is React rendering, layout, or component convenience behavior. Keep them focused: render the smallest component tree that reproduces the behavior, wait on real lifecycle events, and assert through the public ref or callbacks. + +Detailed preview rendering, layout, snapshot, resize-mode, and implementation-mode coverage belongs in `visioncamera.nativepreviewview.harness.tsx`. `visioncamera.camera-view.harness.tsx` should stay focused on the high-level `` wrapper: `isActive`, lifecycle callbacks, ref exposure, output wiring, high-level gesture props, and React mount/unmount behavior. + +The goal is to have the imperative API tests cover everything, and the high-level components tests should just cover their abstractions or base features (which under the hood use the same imperative API) - but not repeat the whole tests from the imperative API again. +For example; we don't need to test if a `CameraVideoOutput` properly stops recording once `maxFileSize` is reached both the imperative API tests, as well as in the ``/`useCamera()` high-level APIs. Instead, only do such specific tests in the imperative API tests (as this is much more narrow and is easier to debug in CI), and keep high-level tests _high-level_ - e.g. ensuring the Camera can start, ensuring React lifecycle/unmounting/remounting works, ensuring `` renders properly, ensuring it tears the session down and starts again when rendering a new one, ensuring `isActive` works, ensuring it attaches outputs when the `outputs={[...]}` array updates, testing the `ref` methods, etc etc. + +For layout regressions, prefer geometry and native ref assertions over golden screenshots. Device Farm camera feeds are not stable visual fixtures. +Camera sensors in AWS Device Farm are often covered with tape, so they do not show bright visual content, but it's not fully black either - it's kinda greyish with noise, or sometimes also looks red-ish as if a thumb covers the camera. If you do visual tests, ensure it's not full black or full white or any other full color - Camera preview should be some noise between white and black. This helps separate actual Camera stream from just black or white background views in React, or `resizeMode="contain"` fill/padding. + +Mounting native Hybrid views to exercise their ref methods is still allowed. Some imperative APIs (e.g. `PreviewView.convertViewPointToCameraPoint`, `PreviewView.createMeteringPoint`) can only be reached through a mounted, laid-out view. Those tests may `render()` from `react-native-harness`, capture the ref via `hybridRef`, and call the methods directly — see [visioncamera.nativepreviewview.harness.tsx](visioncamera.nativepreviewview.harness.tsx) and [visioncamera.coordinates.harness.ts](visioncamera.coordinates.harness.ts) for the imperative pattern. diff --git a/apps/simple-camera/__tests__/test-utils.ts b/apps/simple-camera/__tests__/test-utils.ts new file mode 100644 index 0000000000..706d6664cc --- /dev/null +++ b/apps/simple-camera/__tests__/test-utils.ts @@ -0,0 +1,38 @@ +/** + * A Promise paired with externally-callable resolve/reject. Useful when an + * event source (a callback pair, a listener) needs to feed into a Promise + * the test can `await`. The error path becomes a Promise rejection, so a + * native error fails the test with its own message instead of a timeout. + */ +export function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** + * Race a Promise against a timeout. Rejects with a labeled error if the + * Promise hasn't settled within `ms` milliseconds. + */ +export async function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timed out after ${ms}ms: ${label}`)), + ms, + ) + }) + try { + return await Promise.race([promise, timeout]) + } finally { + if (timer != null) clearTimeout(timer) + } +} diff --git a/apps/simple-camera/__tests__/visioncamera.barcode-scanner.harness.ts b/apps/simple-camera/__tests__/visioncamera.barcode-scanner.harness.ts new file mode 100644 index 0000000000..b3df779465 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.barcode-scanner.harness.ts @@ -0,0 +1,81 @@ +import { Image as RNImage } from 'react-native' +import { describe, expect, it } from 'react-native-harness' +import type { Image as NitroImage } from 'react-native-nitro-image' +import { loadImage } from 'react-native-nitro-image' +import { + type Barcode, + createBarcodeScanner, + type TargetBarcodeFormat, +} from 'react-native-vision-camera-barcode-scanner' +import { withTimeout } from './test-utils' + +const qrCodeAsset = require('../src/assets/qr-code-margelo.png') +const code128Asset = require('../src/assets/code-128-mrousavy.png') + +describe('VisionCamera - Barcode Scanner', () => { + it('scans a QR code from a Nitro Image', async () => { + const barcodes = await scanCodesInAssetImage(qrCodeAsset, ['qr-code']) + + expect(barcodes).toHaveLength(1) + expect(barcodes[0]?.format).toBe('qr-code') + expect(barcodes[0]?.rawValue).toBe('https://margelo.com') + }) + + it('scans a Code 128 barcode from a Nitro Image', async () => { + const code128Barcodes = await scanCodesInAssetImage(code128Asset, [ + 'code-128', + ]) + + expect(code128Barcodes).toHaveLength(1) + expect(code128Barcodes[0]?.format).toBe('code-128') + expect(code128Barcodes[0]?.rawValue).toBe('https://mrousavy.com') + + const allFormatBarcodes = await scanCodesInAssetImage(code128Asset, [ + 'all-formats', + ]) + + expect(allFormatBarcodes).toHaveLength(1) + expect(allFormatBarcodes[0]?.format).toBe('code-128') + expect(allFormatBarcodes[0]?.rawValue).toBe('https://mrousavy.com') + }) +}) + +async function scanCodesInAssetImage( + source: number, + barcodeFormats: TargetBarcodeFormat[], +): Promise { + const image = await loadNitroImageFromAsset(source) + return await scanLoadedImage(image, barcodeFormats) +} + +async function loadNitroImageFromAsset(source: number): Promise { + const resolvedSource = RNImage.resolveAssetSource(source) + const response = await fetch(resolvedSource.uri) + const buffer = await response.arrayBuffer() + + return await loadImage({ + encodedImageData: { + buffer, + width: resolvedSource.width, + height: resolvedSource.height, + imageFormat: 'png', + }, + }) +} + +async function scanLoadedImage( + image: NitroImage, + barcodeFormats: TargetBarcodeFormat[], +): Promise { + const scanner = createBarcodeScanner({ barcodeFormats }) + try { + return await withTimeout( + scanner.scanCodesInImageAsync(image), + 15_000, + `scan ${barcodeFormats.join(', ')} from image`, + ) + } finally { + scanner.dispose() + image.dispose() + } +} diff --git a/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx b/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx new file mode 100644 index 0000000000..6c238adfcf --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx @@ -0,0 +1,494 @@ +import { createRef } from 'react' +import { type LayoutChangeEvent, StyleSheet } from 'react-native' +import { + afterEach, + beforeAll, + cleanup, + describe, + expect, + it, + render, +} from 'react-native-harness' +import type { CameraDevice, CameraRef, Point } from 'react-native-vision-camera' +import { + Camera, + CommonResolutions, + VisionCamera, +} from 'react-native-vision-camera' +import { deferred, withTimeout } from './test-utils' + +interface Layout { + x: number + y: number + width: number + height: number +} + +function toLayout(event: LayoutChangeEvent): Layout { + const layout = event.nativeEvent.layout + return { + x: layout.x, + y: layout.y, + width: layout.width, + height: layout.height, + } +} + +function expectPreviewGeometry(camera: CameraRef, layout: Layout) { + expect(layout.width).toBeGreaterThan(0) + expect(layout.height).toBeGreaterThan(0) + expect(camera.preview).toBeDefined() + expect(camera.controller).toBeDefined() + + const viewCenter: Point = { x: layout.width / 2, y: layout.height / 2 } + const cameraPoint = camera.convertViewPointToCameraPoint(viewCenter) + const roundTripped = camera.convertCameraPointToViewPoint(cameraPoint) + expect(roundTripped.x).toBeCloseTo(viewCenter.x, 0) + expect(roundTripped.y).toBeCloseTo(viewCenter.y, 0) + + const meteringPoint = camera.createMeteringPoint(viewCenter.x, viewCenter.y) + expect(meteringPoint.relativeX).toBeCloseTo(viewCenter.x, 0) + expect(meteringPoint.relativeY).toBeCloseTo(viewCenter.y, 0) + expect(meteringPoint.normalizedX).toBeGreaterThanOrEqual(0) + expect(meteringPoint.normalizedX).toBeLessThanOrEqual(1) + expect(meteringPoint.normalizedY).toBeGreaterThanOrEqual(0) + expect(meteringPoint.normalizedY).toBeLessThanOrEqual(1) +} + +describe('VisionCamera - Camera View', () => { + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + const factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + afterEach(() => { + cleanup() + }) + + it('starts the high-level Camera preview and exposes preview/controller ref methods', async () => { + const cameraRef = createRef() + const layout = deferred() + const started = deferred() + const stopped = deferred() + const previewStarted = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + layout.reject(error) + started.reject(error) + stopped.reject(error) + previewStarted.reject(error) + } + + const { rerender } = await render( + { + layout.resolve(toLayout(event)) + }} + onStarted={started.resolve} + onStopped={stopped.resolve} + onPreviewStarted={previewStarted.resolve} + onError={onError} + />, + ) + + const cameraLayout = await withTimeout( + layout.promise, + 10_000, + 'Camera onLayout', + ) + await withTimeout(started.promise, 15_000, 'Camera onStarted') + await withTimeout(previewStarted.promise, 15_000, 'Camera onPreviewStarted') + expect(sessionError).toBe(undefined) + + const camera = cameraRef.current + if (camera == null) throw new Error('no Camera ref') + expectPreviewGeometry(camera, cameraLayout) + + await rerender( + , + ) + await withTimeout(stopped.promise, 10_000, 'Camera onStopped') + }) + + it('runs preview with a photo output attached and captures a JPEG', async () => { + const cameraRef = createRef() + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const layout = deferred() + const started = deferred() + const stopped = deferred() + const previewStarted = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + layout.reject(error) + started.reject(error) + stopped.reject(error) + previewStarted.reject(error) + } + + const { rerender } = await render( + { + layout.resolve(toLayout(event)) + }} + onStarted={started.resolve} + onStopped={stopped.resolve} + onPreviewStarted={previewStarted.resolve} + onError={onError} + />, + ) + + const cameraLayout = await withTimeout( + layout.promise, + 10_000, + 'photo Camera onLayout', + ) + await withTimeout(started.promise, 15_000, 'photo Camera onStarted') + await withTimeout( + previewStarted.promise, + 15_000, + 'photo Camera onPreviewStarted', + ) + expect(sessionError).toBe(undefined) + + const camera = cameraRef.current + if (camera == null) throw new Error('no Camera ref') + expectPreviewGeometry(camera, cameraLayout) + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + try { + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + expect(photo.containerFormat).toBe('jpeg') + expect(photo.isRawPhoto).toBe(false) + } finally { + photo.dispose() + } + + await rerender( + , + ) + await withTimeout(stopped.promise, 10_000, 'photo Camera onStopped') + }) + + it('mounts with native tap-to-focus and zoom gestures enabled', async () => { + const cameraRef = createRef() + const layout = deferred() + const started = deferred() + const stopped = deferred() + const previewStarted = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + layout.reject(error) + started.reject(error) + stopped.reject(error) + previewStarted.reject(error) + } + + const { rerender } = await render( + { + layout.resolve(toLayout(event)) + }} + onStarted={started.resolve} + onStopped={stopped.resolve} + onPreviewStarted={previewStarted.resolve} + onError={onError} + />, + ) + + const cameraLayout = await withTimeout( + layout.promise, + 10_000, + 'gesture Camera onLayout', + ) + await withTimeout(started.promise, 15_000, 'gesture Camera onStarted') + await withTimeout( + previewStarted.promise, + 15_000, + 'gesture Camera onPreviewStarted', + ) + expect(sessionError).toBe(undefined) + + const camera = cameraRef.current + if (camera == null) throw new Error('no Camera ref') + expectPreviewGeometry(camera, cameraLayout) + + await rerender( + , + ) + await withTimeout(stopped.promise, 10_000, 'gesture Camera onStopped') + }) + + it('starts a new Camera view after the previous one unmounts', async () => { + for (let attempt = 1; attempt <= 2; attempt++) { + const cameraRef = createRef() + const layout = deferred() + const started = deferred() + const previewStarted = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + layout.reject(error) + started.reject(error) + previewStarted.reject(error) + } + + await render( + { + layout.resolve(toLayout(event)) + }} + onStarted={started.resolve} + onPreviewStarted={previewStarted.resolve} + onError={onError} + />, + ) + + const cameraLayout = await withTimeout( + layout.promise, + 10_000, + `remounted Camera onLayout attempt ${attempt}`, + ) + await withTimeout( + started.promise, + 15_000, + `remounted Camera onStarted attempt ${attempt}`, + ) + await withTimeout( + previewStarted.promise, + 15_000, + `remounted Camera onPreviewStarted attempt ${attempt}`, + ) + expect(sessionError).toBe(undefined) + + const camera = cameraRef.current + if (camera == null) throw new Error('no Camera ref') + expectPreviewGeometry(camera, cameraLayout) + + cleanup() + } + }) + + it('replaces an active Camera view with another active Camera view in one update', async () => { + const firstRef = createRef() + const secondRef = createRef() + const firstLayout = deferred() + const secondLayout = deferred() + const firstStarted = deferred() + const firstPreviewStarted = deferred() + const secondStarted = deferred() + const secondPreviewStarted = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + firstLayout.reject(error) + secondLayout.reject(error) + firstStarted.reject(error) + firstPreviewStarted.reject(error) + secondStarted.reject(error) + secondPreviewStarted.reject(error) + } + + const { rerender } = await render( + { + firstLayout.resolve(toLayout(event)) + }} + onStarted={firstStarted.resolve} + onPreviewStarted={firstPreviewStarted.resolve} + onError={onError} + />, + ) + + const firstCameraLayout = await withTimeout( + firstLayout.promise, + 10_000, + 'first replaced Camera onLayout', + ) + await withTimeout( + firstStarted.promise, + 15_000, + 'first replaced Camera onStarted', + ) + await withTimeout( + firstPreviewStarted.promise, + 15_000, + 'first replaced Camera onPreviewStarted', + ) + expect(sessionError).toBe(undefined) + + const firstCamera = firstRef.current + if (firstCamera == null) throw new Error('no first Camera ref') + expectPreviewGeometry(firstCamera, firstCameraLayout) + + await rerender( + { + secondLayout.resolve(toLayout(event)) + }} + onStarted={secondStarted.resolve} + onPreviewStarted={secondPreviewStarted.resolve} + onError={onError} + />, + ) + + const secondCameraLayout = await withTimeout( + secondLayout.promise, + 10_000, + 'second replaced Camera onLayout', + ) + await withTimeout( + secondStarted.promise, + 15_000, + 'second replaced Camera onStarted', + ) + await withTimeout( + secondPreviewStarted.promise, + 15_000, + 'second replaced Camera onPreviewStarted', + ) + expect(sessionError).toBe(undefined) + + const secondCamera = secondRef.current + if (secondCamera == null) throw new Error('no second Camera ref') + expectPreviewGeometry(secondCamera, secondCameraLayout) + }) + + it('fires stop callbacks when isActive is rerendered from true to false', async () => { + const cameraRef = createRef() + const layout = deferred() + const started = deferred() + const stopped = deferred() + const previewStarted = deferred() + const previewStopped = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + layout.reject(error) + started.reject(error) + stopped.reject(error) + previewStarted.reject(error) + previewStopped.reject(error) + } + + const { rerender } = await render( + { + layout.resolve(toLayout(event)) + }} + onStarted={started.resolve} + onStopped={stopped.resolve} + onPreviewStarted={previewStarted.resolve} + onPreviewStopped={previewStopped.resolve} + onError={onError} + />, + ) + + await withTimeout(layout.promise, 10_000, 'active Camera onLayout') + await withTimeout(started.promise, 15_000, 'active Camera onStarted') + await withTimeout( + previewStarted.promise, + 15_000, + 'active Camera onPreviewStarted', + ) + + await rerender( + , + ) + + await withTimeout(stopped.promise, 10_000, 'inactive Camera onStopped') + await withTimeout( + previewStopped.promise, + 10_000, + 'inactive Camera onPreviewStopped', + ) + expect(sessionError).toBe(undefined) + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts new file mode 100644 index 0000000000..ddb6a82684 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.constraints.harness.ts @@ -0,0 +1,498 @@ +import { + beforeAll, + describe, + expect, + it, + waitUntil, +} from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, + CameraSessionConfig, + Constraint, +} from 'react-native-vision-camera' +import { + CommonDynamicRanges, + CommonResolutions, + VisionCamera, +} from 'react-native-vision-camera' + +describe('VisionCamera - Constraints', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + it('resolves a baseline config with no constraints', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + + const config = received + if (config == null) throw new Error('no config') + expect(backDevice.supportedPixelFormats).toContain(config.nativePixelFormat) + console.log(`baseline config: ${config.toString()}`) + await session.stop() + }) + + it('resolves an explicit fps: 30 constraint', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 30 }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.selectedFPS).toBe(30) + await session.stop() + }) + + it('resolves a fps: 60 constraint if the device supports it', async (context) => { + if (!backDevice.supportsFPS(60)) { + return context.skip('fps: 60 not supported on this device') + } + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 60 }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.selectedFPS).toBe(60) + await session.stop() + }) + + it('resolves photoHDR: true when the device supports photo HDR', async (context) => { + if (!backDevice.supportsPhotoHDR) { + return context.skip('photoHDR: not supported on this device') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: photoOutput }, { photoHDR: true }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.isPhotoHDREnabled).toBe(true) + await session.stop() + }) + + it('resolves a HDR video dynamic range when the device supports it', async (context) => { + const hasHdr = backDevice.supportedVideoDynamicRanges.some( + (d) => d.bitDepth === 'hdr-10-bit', + ) + if (!hasHdr) { + return context.skip('video HDR: no HDR dynamic range on this device') + } + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [ + { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, + { resolutionBias: videoOutput }, + ], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') + await session.stop() + }) + + it('resolves a video stabilization constraint when supported', async (context) => { + // The resolver downgrades stabilization modes (cinematic → standard → off) + // when the requested one isn't supported, so picking the most demanding + // mode the device exposes gives the test the most coverage. + const stabDevice = factory.cameraDevices.find((d) => + d.supportsVideoStabilizationMode('cinematic'), + ) + if (stabDevice == null) { + return context.skip( + 'videoStabilizationMode: no device on this system supports "cinematic"', + ) + } + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: stabDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ videoStabilizationMode: 'cinematic' }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.selectedVideoStabilizationMode).toBe('cinematic') + await session.stop() + }) + + it('resolves a preview stabilization constraint when supported', async (context) => { + const stabDevice = factory.cameraDevices.find((d) => + d.supportsPreviewStabilizationMode('preview-optimized'), + ) + if (stabDevice == null) { + return context.skip( + 'previewStabilizationMode: no device on this system supports "preview-optimized"', + ) + } + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: stabDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [{ previewStabilizationMode: 'preview-optimized' }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + expect(received?.selectedPreviewStabilizationMode).toBe('preview-optimized') + await session.stop() + }) + + it('resolves a binned: true constraint when supported', async (context) => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [{ binned: true }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + if (received?.isBinned !== true) { + await session.stop() + return context.skip( + `binned: true: device resolved to isBinned=${received?.isBinned}`, + ) + } + expect(received.isBinned).toBe(true) + await session.stop() + }) + + it('honors a pixelFormat constraint matching a supported device format', async (context) => { + const candidate = backDevice.supportedPixelFormats.find( + (f) => f !== 'unknown', + ) + if (candidate == null) { + return context.skip( + 'pixelFormat constraint: device has no non-unknown formats', + ) + } + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + let received: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [{ pixelFormat: candidate }], + onSessionConfigSelected: (config) => { + received = config + }, + }, + ]) + await waitUntil(() => received != null, { timeout: 5_000 }) + console.log( + `requested pixelFormat=${candidate} resolved=${received?.nativePixelFormat}`, + ) + expect(received?.nativePixelFormat).toBe(candidate) + await session.stop() + }) + + it('resolves the same config via VisionCamera.resolveConstraints and session.configure', async () => { + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const outputConfig = { + output: photoOutput, + mirrorMode: 'auto' as const, + } + const constraints: Constraint[] = [{ fps: 30 }] + + const standalone = await VisionCamera.resolveConstraints( + backDevice, + [outputConfig], + constraints, + ) + + const session = await VisionCamera.createCameraSession(false) + let sessionConfig: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [outputConfig], + constraints, + onSessionConfigSelected: (config) => { + sessionConfig = config + }, + }, + ]) + await waitUntil(() => sessionConfig != null, { timeout: 5_000 }) + expect(sessionConfig?.selectedFPS).toBe(standalone.selectedFPS) + expect(sessionConfig?.nativePixelFormat).toBe(standalone.nativePixelFormat) + expect(sessionConfig?.isPhotoHDREnabled).toBe(standalone.isPhotoHDREnabled) + expect(sessionConfig?.isBinned).toBe(standalone.isBinned) + + await session.stop() + }) + + it('applies constraint priority ordering for resolutionBias', async () => { + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HIGHEST_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.VGA_16_9, + enableAudio: false, + }) + + const photoFirst = await VisionCamera.resolveConstraints( + backDevice, + [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + [{ resolutionBias: photoOutput }, { resolutionBias: videoOutput }], + ) + const videoFirst = await VisionCamera.resolveConstraints( + backDevice, + [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + [{ resolutionBias: videoOutput }, { resolutionBias: photoOutput }], + ) + console.log( + `resolutionBias photo-first: nativePixelFormat=${photoFirst.nativePixelFormat} binned=${photoFirst.isBinned}`, + ) + console.log( + `resolutionBias video-first: nativePixelFormat=${videoFirst.nativePixelFormat} binned=${videoFirst.isBinned}`, + ) + }) + + // Verifies the resolver's priority mechanism by running the same pair of + // constraints in both orderings and asserting that the first-listed (= highest + // priority) one wins in each direction. The lower-priority constraint may or + // may not survive depending on device combination support — that's a hardware + // capability question, not a priority-ordering question, so we only log it. + // + // This catches regressions like "resolver always drops the first constraint + // instead of the last" or "priority order is silently reversed", without + // depending on which feature combinations the AWS Device Farm device happens + // to support together. + it('honors constraint priority ordering between stabilization and HDR', async (context) => { + let chosenStabilizationMode: 'cinematic' | 'standard' | undefined + for (const mode of ['cinematic', 'standard'] as const) { + if (backDevice.supportsVideoStabilizationMode(mode)) { + chosenStabilizationMode = mode + break + } + } + const hasHdr = backDevice.supportedVideoDynamicRanges.some( + (d) => d.bitDepth === 'hdr-10-bit', + ) + + if (chosenStabilizationMode == null || !hasHdr) { + return context.skip( + 'priority ordering: device lacks stabilization and/or HDR support', + ) + } + + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const outputs = [{ output: videoOutput, mirrorMode: 'auto' as const }] + + // [stab, HDR] — stab has higher priority and must be honored. HDR may + // legitimately fall back to SDR if the device can't combine them. + const stabFirst = await VisionCamera.resolveConstraints( + backDevice, + outputs, + [ + { videoStabilizationMode: chosenStabilizationMode }, + { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, + ], + ) + expect(stabFirst.selectedVideoStabilizationMode).toBe( + chosenStabilizationMode, + ) + console.log( + `priority [stab, HDR]: stab=${stabFirst.selectedVideoStabilizationMode} ` + + `hdr=${stabFirst.selectedVideoDynamicRange?.bitDepth}`, + ) + + // [HDR, stab] — HDR has higher priority and must be honored. Stabilization + // may legitimately fall back to off/auto if the device can't combine them. + const hdrFirst = await VisionCamera.resolveConstraints( + backDevice, + outputs, + [ + { videoDynamicRange: CommonDynamicRanges.ANY_HDR }, + { videoStabilizationMode: chosenStabilizationMode }, + ], + ) + expect(hdrFirst.selectedVideoDynamicRange?.bitDepth).toBe('hdr-10-bit') + console.log( + `priority [HDR, stab]: stab=${hdrFirst.selectedVideoStabilizationMode} ` + + `hdr=${hdrFirst.selectedVideoDynamicRange?.bitDepth}`, + ) + }) + + it('reconfigures the running session with a different constraint set', async (context) => { + if (!backDevice.supportsFPS(60)) { + return context.skip( + 'reconfigure with new constraints: fps: 60 not supported', + ) + } + + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + let firstConfig: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 30 }], + onSessionConfigSelected: (config) => { + firstConfig = config + }, + }, + ]) + await waitUntil(() => firstConfig != null, { timeout: 5_000 }) + expect(firstConfig?.selectedFPS).toBe(30) + + await session.start() + + try { + let secondConfig: CameraSessionConfig | undefined + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 60 }], + onSessionConfigSelected: (config) => { + secondConfig = config + }, + }, + ]) + await waitUntil(() => secondConfig != null, { timeout: 5_000 }) + expect(secondConfig?.selectedFPS).toBe(60) + + expect(sessionError).toBe(undefined) + } finally { + errorSub.remove() + await session.stop() + } + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.controller.harness.ts b/apps/simple-camera/__tests__/visioncamera.controller.harness.ts new file mode 100644 index 0000000000..00b80283d6 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.controller.harness.ts @@ -0,0 +1,698 @@ +import { beforeAll, describe, expect, it } from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, +} from 'react-native-vision-camera' +import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' + +describe('VisionCamera - Controller', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + it('sets zoom to min, max, and mid values', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + await controller.setZoom(controller.minZoom) + expect(controller.zoom).toBe(controller.minZoom) + + await controller.setZoom(controller.maxZoom) + expect(controller.zoom).toBe(controller.maxZoom) + + const mid = (controller.minZoom + controller.maxZoom) / 2 + await controller.setZoom(mid) + expect(controller.zoom).toBeGreaterThanOrEqual(controller.minZoom) + expect(controller.zoom).toBeLessThanOrEqual(controller.maxZoom) + } finally { + await session.stop() + } + }) + + it('rejects setZoom outside the device zoom range', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + const tooHighZoom = controller.maxZoom + 1 + const tooLowZoom = controller.minZoom - 1 + await expect(controller.setZoom(tooHighZoom)).rejects.toThrow() + await expect(controller.setZoom(tooLowZoom)).rejects.toThrow() + } finally { + await session.stop() + } + }) + + it('runs a zoom animation', async (context) => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + if (controller.minZoom === controller.maxZoom) { + return context.skip('zoom animation: device exposes no zoom range') + } + + await controller.setZoom(controller.minZoom) + const targetZoom = Math.min(controller.maxZoom, controller.minZoom + 0.1) + await controller.startZoomAnimation(targetZoom, 100) + expect(controller.zoom).toBeCloseTo(targetZoom, 1) + await controller.cancelZoomAnimation() + } finally { + await session.stop() + } + }) + + // TODO(Android): Re-enable once initial CameraX control config is applied after the camera is active. + it.skip('honors initialZoom passed to configure', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const desiredZoom = Math.min( + Math.max(backDevice.minZoom, 1.5), + backDevice.maxZoom, + ) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + initialZoom: desiredZoom, + }, + ]) + await session.start() + try { + expect(controller?.zoom).toBe(desiredZoom) + } finally { + await session.stop() + } + }) + + it('sets torchMode on/off when the device has a torch', async (context) => { + if (!backDevice.hasTorch) { + return context.skip('torch: device has no torch') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + await controller.setTorchMode('on') + expect(controller.torchMode).toBe('on') + + await controller.setTorchMode('off') + expect(controller.torchMode).toBe('off') + } finally { + await session.stop() + } + }) + + it('rejects setTorchMode on a device without a torch', async (context) => { + const noTorchDevice = factory.cameraDevices.find((d) => !d.hasTorch) + if (noTorchDevice == null) { + return context.skip( + 'no-torch path: no device on this system lacks a torch', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: noTorchDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + await expect(controller.setTorchMode('on')).rejects.toThrow() + } finally { + await session.stop() + } + }) + + it('enables torch at min/max strength when the device supports it', async (context) => { + if (!backDevice.hasTorch) { + return context.skip('torch strength: device has no torch') + } + if (!backDevice.supportsTorchStrength) { + return context.skip( + 'torch strength: device does not support custom strength', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + // Min strength (dimmest level the device supports) + await controller.enableTorchWithStrength(backDevice.minTorchStrength) + expect(controller.torchMode).toBe('on') + + // Max strength — the case that caught the off-by-one in the original + // CameraX strength mapping (a naive `1 + strength * maxLevel` + // overshoots to `maxLevel + 1` and `setTorchStrengthLevel` throws + // IllegalArgumentException). Must round-trip without throwing. + // + // torchStrength is asserted within range rather than equal to the + // requested value because iOS's `AVCaptureDevice.torchLevel` reflects + // actual hardware brightness, which the system silently caps under + // thermal pressure (typical on CI device farms running back-to-back). + // CameraX's `torchStrengthLevel` echoes the requested value, but the + // shared assertion has to tolerate iOS's hardware-state semantics. + await controller.enableTorchWithStrength(backDevice.maxTorchStrength) + expect(controller.torchMode).toBe('on') + expect(controller.torchStrength).toBeGreaterThanOrEqual( + backDevice.minTorchStrength, + ) + expect(controller.torchStrength).toBeLessThanOrEqual( + backDevice.maxTorchStrength, + ) + + await controller.setTorchMode('off') + expect(controller.torchMode).toBe('off') + // torchStrength reports the last-configured level even when the torch is + // off — that's what both AVCaptureDevice.torchLevel and CameraX's + // torchStrengthLevel return. + expect(controller.torchStrength).toBeGreaterThanOrEqual( + backDevice.minTorchStrength, + ) + expect(controller.torchStrength).toBeLessThanOrEqual( + backDevice.maxTorchStrength, + ) + } finally { + await session.stop() + } + }) + + it('rejects enableTorchWithStrength outside the device range', async (context) => { + if (!backDevice.hasTorch || !backDevice.supportsTorchStrength) { + return context.skip( + 'torch strength out-of-range: device does not support custom strength', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + // Above max — the case that catches the off-by-one in any future + // [0, 1] -> [1, maxLevel] mapping on Android. + const tooHighTorchStrength = backDevice.maxTorchStrength + 1 + await expect( + controller.enableTorchWithStrength(tooHighTorchStrength), + ).rejects.toThrow() + // Below min — `0` on iOS triggers Apple's NSException without the + // internal floor, and `0` on Android is outside CameraX's [1, max]. + const tooLowTorchStrength = backDevice.minTorchStrength - 1 + await expect( + controller.enableTorchWithStrength(tooLowTorchStrength), + ).rejects.toThrow() + } finally { + await session.stop() + } + }) + + it('rejects enableTorchWithStrength on a device without torch strength support', async (context) => { + const noStrengthDevice = factory.cameraDevices.find( + (d) => !d.supportsTorchStrength, + ) + if (noStrengthDevice == null) { + return context.skip( + 'no-torch-strength path: every device on this system supports custom torch strength', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: noStrengthDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + await expect(controller.enableTorchWithStrength(0.5)).rejects.toThrow() + } finally { + await session.stop() + } + }) + + it('sets exposure bias to min/max when the device supports it', async (context) => { + if (!backDevice.supportsExposureBias) { + return context.skip('exposureBias: not supported on this device') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + // AVCaptureDevice quantizes the requested bias to a discrete rational + // step, so the readback may differ from the request by a tiny epsilon. + await controller.setExposureBias(backDevice.maxExposureBias) + expect(controller.exposureBias).toBeCloseTo(backDevice.maxExposureBias, 4) + + await controller.setExposureBias(backDevice.minExposureBias) + expect(controller.exposureBias).toBeCloseTo(backDevice.minExposureBias, 4) + + await controller.setExposureBias(0) + expect(controller.exposureBias).toBeCloseTo(0, 4) + } finally { + await session.stop() + } + }) + + it('rejects setExposureBias outside the device range', async (context) => { + if (!backDevice.supportsExposureBias) { + return context.skip( + 'exposureBias out-of-range: not supported on this device', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + const tooHighExposureBias = backDevice.maxExposureBias + 1 + const tooLowExposureBias = backDevice.minExposureBias - 1 + await expect( + controller.setExposureBias(tooHighExposureBias), + ).rejects.toThrow() + await expect( + controller.setExposureBias(tooLowExposureBias), + ).rejects.toThrow() + } finally { + await session.stop() + } + }) + + // TODO(Android): Re-enable once initial CameraX control config is applied after the camera is active. + it.skip('honors initialExposureBias passed to configure', async (context) => { + if (!backDevice.supportsExposureBias) { + return context.skip( + 'initialExposureBias: device does not support exposure bias', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const initial = backDevice.maxExposureBias + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + initialExposureBias: initial, + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + expect(controller.exposureBias).toBeCloseTo(initial, 4) + } finally { + await session.stop() + } + }) + + it('runs focusTo and resetFocus when the device supports focus metering', async (context) => { + if (!backDevice.supportsFocusMetering) { + return context.skip('focusTo: device does not support focus metering') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + const point = VisionCamera.createNormalizedMeteringPoint(0.5, 0.5) + await controller.focusTo(point, { + modes: ['AF'], + responsiveness: 'snappy', + }) + await controller.resetFocus() + } finally { + await session.stop() + } + }) + + it('enables low-light boost via CameraController.configure when supported', async (context) => { + const lowLightDevice = factory.cameraDevices.find( + (d) => d.supportsLowLightBoost, + ) + if (lowLightDevice == null) { + return context.skip( + 'low-light boost: no device on this system supports it', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: lowLightDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + await controller.configure({ enableLowLightBoost: true }) + expect(controller.isLowLightBoostEnabled).toBe(true) + await controller.configure({ enableLowLightBoost: false }) + expect(controller.isLowLightBoostEnabled).toBe(false) + } finally { + await session.stop() + } + }) + + it('exposes sane controller state while running', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + expect(controller.device.id).toBe(backDevice.id) + expect(controller.minZoom).toBeLessThanOrEqual(controller.maxZoom) + expect(controller.zoom).toBeGreaterThanOrEqual(controller.minZoom) + expect(controller.zoom).toBeLessThanOrEqual(controller.maxZoom) + console.log( + `controller: zoom=${controller.zoom} displayable=${controller.displayableZoomFactor} ` + + `focusMode=${controller.focusMode} exposureMode=${controller.exposureMode} wbMode=${controller.whiteBalanceMode} ` + + `isConnected=${controller.isConnected}`, + ) + } finally { + await session.stop() + } + }) + + it('registers a subject area changed listener without throwing', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + const subscription = controller.addSubjectAreaChangedListener(() => {}) + subscription.remove() + } finally { + await session.stop() + } + }) + + it('locks focus to a specific lens position when the device supports it', async (context) => { + if (!backDevice.supportsFocusLocking) { + return context.skip('focus locking: not supported on this device') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + // Lock focus at a specific lens position. AVCaptureDevice quantizes + // lensPosition to discrete steps, so use a loose tolerance on readback. + await controller.setFocusLocked(0.5) + expect(controller.focusMode).toBe('locked') + expect(controller.lensPosition).toBeCloseTo(0.5, 1) + + // Lock at whatever the current value is. + await controller.lockCurrentFocus() + expect(controller.focusMode).toBe('locked') + } finally { + await session.stop() + } + }) + + it('locks exposure to a specific duration/ISO when the device supports it', async (context) => { + if (!backDevice.supportsExposureLocking) { + return context.skip('exposure locking: not supported on this device') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + // Pick a mid-range duration/ISO the device must accept. Hardware + // quantizes both, so only assert mode here. + const duration = + (controller.minExposureDuration + controller.maxExposureDuration) / 2 + const iso = (controller.minISO + controller.maxISO) / 2 + + // iOS reports 'custom' for manual duration/ISO; Android may report + // 'locked'. Accept either so the test stays stable across platforms. + await controller.setExposureLocked(duration, iso) + expect(controller.exposureMode).toBeOneOf(['custom', 'locked']) + + await controller.lockCurrentExposure() + expect(controller.exposureMode).toBeOneOf(['custom', 'locked']) + } finally { + await session.stop() + } + }) + + it('locks white-balance to specific gains when the device supports it', async (context) => { + if (!backDevice.supportsWhiteBalanceLocking) { + return context.skip('white-balance locking: not supported on this device') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + await session.start() + + try { + // Use neutral 1.0 gains — guaranteed to be inside the [1, maxGain] + // range every supported device exposes. + await controller.setWhiteBalanceLocked({ + redGain: 1, + greenGain: 1, + blueGain: 1, + }) + expect(controller.whiteBalanceMode).toBe('locked') + + await controller.lockCurrentWhiteBalance() + expect(controller.whiteBalanceMode).toBe('locked') + } finally { + await session.stop() + } + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.coordinates.harness.tsx b/apps/simple-camera/__tests__/visioncamera.coordinates.harness.tsx new file mode 100644 index 0000000000..db8a16c0de --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.coordinates.harness.tsx @@ -0,0 +1,680 @@ +import { type LayoutChangeEvent, Platform, StyleSheet } from 'react-native' +import { + afterEach, + beforeAll, + cleanup, + describe, + expect, + it, + render, + waitUntil, +} from 'react-native-harness' +import { callback } from 'react-native-nitro-modules' +import type { + CameraDevice, + CameraDeviceFactory, + Point, + PreviewView, +} from 'react-native-vision-camera' +import { + CommonResolutions, + NativePreviewView, + VisionCamera, +} from 'react-native-vision-camera' +import { provider as workletsProvider } from 'react-native-vision-camera-worklets' +import { scheduleOnRN } from 'react-native-worklets' +import { deferred, withTimeout } from './test-utils' + +describe('VisionCamera - Coordinates', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + // Every test that renders a view must unmount it so the next test starts + // from a clean overlay. (cleanup() is a no-op for the worklet-only tests + // that never called render().) + afterEach(() => { + cleanup() + }) + + // --------------------------------------------------------------------------- + // Frame coordinate conversions (worklet thread) + // --------------------------------------------------------------------------- + + // Round-trips through the affine sensor<->buffer matrix. If a regression + // makes the matrix non-invertible or stops accounting for one of the + // axes, this catches it without depending on the camera's opaque sensor + // coordinate system. + it('round-trips Frame -> Camera -> Frame coordinates for the frame center and corners', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + type Report = { + width: number + height: number + points: { input: Point; roundTripped: Point }[] + } + let report: Report | undefined + const onReport = (r: Report) => { + report = r + } + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + const w = frame.width + const h = frame.height + const inputs: Point[] = [ + { x: w / 2, y: h / 2 }, + { x: 0, y: 0 }, + { x: w, y: 0 }, + { x: 0, y: h }, + { x: w, y: h }, + ] + const result = inputs.map((input) => { + const cameraPoint = frame.convertFramePointToCameraPoint(input) + const roundTripped = frame.convertCameraPointToFramePoint(cameraPoint) + return { input, roundTripped } + }) + scheduleOnRN(onReport, { width: w, height: h, points: result }) + frame.dispose() + }) + + await session.start() + try { + await waitUntil(() => report != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + const r = report + if (r == null) throw new Error('no report') + for (const { input, roundTripped } of r.points) { + // toBeCloseTo(expected, 0) tolerates |received - expected| < 0.5 — + // tight enough to catch a regression, loose enough to ride out + // sub-pixel ULPs from the 3x3 affine matrix round-trip. + expect(roundTripped.x).toBeCloseTo(input.x, 0) + expect(roundTripped.y).toBeCloseTo(input.y, 0) + } + console.log( + `frame ${r.width}x${r.height} round-trip points: ${JSON.stringify(r.points)}`, + ) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + // The frame center maps to *some* point in opaque camera space, and that + // point must be stable across consecutive frames as long as the device + // isn't moving. If a regression makes the conversion read out an + // uninitialized matrix or accidentally use frame timestamps, this + // catches a value that jitters frame-to-frame. + it('produces a stable camera point for the frame center across consecutive frames', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const samples: Point[] = [] + const onSample = (p: Point) => { + samples.push(p) + } + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + const center = { x: frame.width / 2, y: frame.height / 2 } + const cameraPoint = frame.convertFramePointToCameraPoint(center) + scheduleOnRN(onSample, cameraPoint) + frame.dispose() + }) + + await session.start() + try { + await waitUntil(() => samples.length >= 5 || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(samples.length).toBeGreaterThanOrEqual(5) + const first = samples[0] + if (first == null) throw new Error('no samples') + for (const s of samples) { + // Camera coordinates are opaque between platforms (iOS normalizes + // to [0,1], Android stays in sensor-pixel space). All we assert is + // that for a stationary device, the result doesn't move. + expect(s.x).toBeCloseTo(first.x, 0) + expect(s.y).toBeCloseTo(first.y, 0) + } + console.log( + `frame center camera point samples: ${JSON.stringify(samples)}`, + ) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + // --------------------------------------------------------------------------- + // Preview coordinate conversions (mounted NativePreviewView) + // --------------------------------------------------------------------------- + + // Helper inlined per test so each `it` reads end-to-end. This block is the + // setup pattern reused below: configure -> render the view -> start -> + // wait for the ref + layout + onPreviewStarted. Tests don't share state. + + it('round-trips View -> Camera -> View coordinates for the view center and corners', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let previewRef: PreviewView | undefined + const previewStarted = deferred() + const layout = deferred<{ width: number; height: number }>() + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + previewStarted.reject(error) + layout.reject(error) + }) + + await render( + { + previewRef = r + })} + onPreviewStarted={callback(() => { + previewStarted.resolve() + })} + onLayout={(e: LayoutChangeEvent) => { + layout.resolve({ + width: e.nativeEvent.layout.width, + height: e.nativeEvent.layout.height, + }) + }} + />, + ) + + await session.start() + try { + await withTimeout(layout.promise, 10_000, 'preview view onLayout') + await withTimeout(previewStarted.promise, 15_000, 'preview started') + expect(sessionError).toBe(undefined) + if (previewRef == null) throw new Error('no preview ref') + + const { width: w, height: h } = await layout.promise + const inputs: Point[] = [ + { x: w / 2, y: h / 2 }, + { x: 0, y: 0 }, + { x: w, y: 0 }, + { x: 0, y: h }, + { x: w, y: h }, + ] + for (const input of inputs) { + const cameraPoint = previewRef.convertViewPointToCameraPoint(input) + const roundTripped = + previewRef.convertCameraPointToViewPoint(cameraPoint) + expect(roundTripped.x).toBeCloseTo(input.x, 0) + expect(roundTripped.y).toBeCloseTo(input.y, 0) + } + console.log(`preview round-trip ok on ${w}x${h}`) + } finally { + errorSub.remove() + await session.stop() + } + }) + + // The view center has to map back to the view center through both halves + // of the API independently. This is what the docs promise — and the + // example in coordinate-systems.mdx shows `{ x: 196, y: 379.5 }` round- + // tripping through `convertCameraPointToViewPoint(convertViewPointTo + // CameraPoint(...))` for the view center. + it('maps the view center through Camera coordinates back onto the view center', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let previewRef: PreviewView | undefined + const previewStarted = deferred() + const layout = deferred<{ width: number; height: number }>() + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + previewStarted.reject(error) + layout.reject(error) + }) + + await render( + { + previewRef = r + })} + onPreviewStarted={callback(() => { + previewStarted.resolve() + })} + onLayout={(e: LayoutChangeEvent) => { + layout.resolve({ + width: e.nativeEvent.layout.width, + height: e.nativeEvent.layout.height, + }) + }} + />, + ) + + await session.start() + try { + await withTimeout(layout.promise, 10_000, 'preview view onLayout') + await withTimeout(previewStarted.promise, 15_000, 'preview started') + expect(sessionError).toBe(undefined) + if (previewRef == null) throw new Error('no preview ref') + + const { width: w, height: h } = await layout.promise + const viewCenter: Point = { x: w / 2, y: h / 2 } + const cameraPoint = previewRef.convertViewPointToCameraPoint(viewCenter) + const back = previewRef.convertCameraPointToViewPoint(cameraPoint) + expect(back.x).toBeCloseTo(viewCenter.x, 0) + expect(back.y).toBeCloseTo(viewCenter.y, 0) + console.log( + `preview center round-trip: ${JSON.stringify(viewCenter)} -> ${JSON.stringify(cameraPoint)} -> ${JSON.stringify(back)}`, + ) + } finally { + errorSub.remove() + await session.stop() + } + }) + + // createMeteringPoint should echo the input as `relativeX/Y` per the docs. + // The normalized side has to land inside [0, 1] by definition (it's + // "normalized after orientation, cropping, and scaling"). Anything else + // means the conversion produced a point outside the camera's logical + // coordinate system. + it('createMeteringPoint echoes view coords as relative and normalizes camera coords to [0, 1]', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let previewRef: PreviewView | undefined + const previewStarted = deferred() + const layout = deferred<{ width: number; height: number }>() + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + previewStarted.reject(error) + layout.reject(error) + }) + + await render( + { + previewRef = r + })} + onPreviewStarted={callback(() => { + previewStarted.resolve() + })} + onLayout={(e: LayoutChangeEvent) => { + layout.resolve({ + width: e.nativeEvent.layout.width, + height: e.nativeEvent.layout.height, + }) + }} + />, + ) + + await session.start() + try { + await withTimeout(layout.promise, 10_000, 'preview view onLayout') + await withTimeout(previewStarted.promise, 15_000, 'preview started') + expect(sessionError).toBe(undefined) + if (previewRef == null) throw new Error('no preview ref') + + const { width: w, height: h } = await layout.promise + const viewCenter: Point = { x: w / 2, y: h / 2 } + const mp = previewRef.createMeteringPoint(viewCenter.x, viewCenter.y) + + // relativeX/Y is documented to echo the view coordinate the user + // tapped. + expect(mp.relativeX).toBeCloseTo(viewCenter.x, 0) + expect(mp.relativeY).toBeCloseTo(viewCenter.y, 0) + + // normalizedX/Y are the camera-space coords after orientation / + // cropping / scaling, and per MeteringPoint.nitro.ts must be in [0, 1]. + expect(mp.normalizedX).toBeGreaterThanOrEqual(0) + expect(mp.normalizedX).toBeLessThanOrEqual(1) + expect(mp.normalizedY).toBeGreaterThanOrEqual(0) + expect(mp.normalizedY).toBeLessThanOrEqual(1) + + // The center of the view should map to (approximately) the center of + // the camera's normalized coord system, since cover/contain crops are + // symmetric around the center. numDigits=1 tolerates |x - 0.5| < 0.05. + expect(mp.normalizedX).toBeCloseTo(0.5, 1) + expect(mp.normalizedY).toBeCloseTo(0.5, 1) + console.log( + `metering point at view center: relative=(${mp.relativeX}, ${mp.relativeY}) normalized=(${mp.normalizedX}, ${mp.normalizedY})`, + ) + } finally { + errorSub.remove() + await session.stop() + } + }) + + // Repro for https://github.com/mrousavy/react-native-vision-camera/issues/3871 + it('round-trips Frame center -> Camera -> View center end-to-end', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [ + { output: previewOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + let previewRef: PreviewView | undefined + const previewStarted = deferred() + const layout = deferred<{ width: number; height: number }>() + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + previewStarted.reject(error) + layout.reject(error) + }) + + await render( + { + previewRef = r + })} + onPreviewStarted={callback(() => { + previewStarted.resolve() + })} + onLayout={(e: LayoutChangeEvent) => { + layout.resolve({ + width: e.nativeEvent.layout.width, + height: e.nativeEvent.layout.height, + }) + }} + />, + ) + + let frameCenterCamera: Point | undefined + let observedOrientation: string | undefined + const onSample = (cameraPoint: Point, orientation: string) => { + frameCenterCamera = cameraPoint + observedOrientation = orientation + } + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + const center = { x: frame.width / 2, y: frame.height / 2 } + const cameraPoint = frame.convertFramePointToCameraPoint(center) + scheduleOnRN(onSample, cameraPoint, frame.orientation) + frame.dispose() + }) + + await session.start() + try { + await withTimeout(layout.promise, 10_000, 'preview view onLayout') + await withTimeout(previewStarted.promise, 15_000, 'preview started') + await waitUntil(() => frameCenterCamera != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + if (previewRef == null) throw new Error('no preview ref') + if (frameCenterCamera == null) throw new Error('no frame center sample') + + const { width: w, height: h } = await layout.promise + const projected = + previewRef.convertCameraPointToViewPoint(frameCenterCamera) + const viewCenter: Point = { x: w / 2, y: h / 2 } + + // toBeCloseTo(_, -2) tolerates |projected - center| < 50 (dp), which + // is loose enough to absorb sub-pixel round-trip drift from two + // affine transforms and the `resizeMode='cover'` aspect-ratio crop, + // and tight enough to catch a regression where one platform ignores + // orientation/mirroring metadata — which produces drift of half the + // view dimension or more. + expect(projected.x).toBeCloseTo(viewCenter.x, -2) + expect(projected.y).toBeCloseTo(viewCenter.y, -2) + console.log( + `frame.orientation=${observedOrientation} frame-center camera=${JSON.stringify(frameCenterCamera)} -> view=${JSON.stringify(projected)} (view center ${JSON.stringify(viewCenter)})`, + ) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + // Analyzer coordinates may be reported in the Frame's intended/oriented + // image space, while Frame.convertFramePointToCameraPoint consumes raw + // buffer-space points. The center-only test above cannot catch an + // off-center rectangle drifting after orientation is applied. + // See https://github.com/mrousavy/react-native-vision-camera/pull/3878. + it('maps oriented Frame rectangles into the same Camera bounds', async (context) => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'yuv', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + type Bounds = { + left: number + top: number + right: number + bottom: number + } + type ProjectionReport = { + orientation: string + expected: Bounds + reported: Bounds + } + let report: ProjectionReport | undefined + const onReport = (r: ProjectionReport) => { + report = r + } + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + const w = frame.width + const h = frame.height + + const orientedWidth = + frame.orientation === 'left' || frame.orientation === 'right' ? h : w + const orientedHeight = + frame.orientation === 'left' || frame.orientation === 'right' ? w : h + const box = { + left: orientedWidth * 0.34, + top: orientedHeight * 0.29, + right: orientedWidth * 0.62, + bottom: orientedHeight * 0.57, + } + const orientedCorners: Point[] = [ + { x: box.left, y: box.top }, + { x: box.right, y: box.top }, + { x: box.right, y: box.bottom }, + { x: box.left, y: box.bottom }, + ] + + const orientedPointToFramePoint = (point: Point): Point => { + switch (frame.orientation) { + case 'right': + return { x: w - point.y, y: point.x } + case 'left': + return { x: point.y, y: h - point.x } + case 'down': + return { x: w - point.x, y: h - point.y } + default: + return point + } + } + const getCameraBounds = (points: Point[]): Bounds => { + const cameraPoints = points.map((point) => + frame.convertFramePointToCameraPoint(point), + ) + const xs = cameraPoints.map((point) => point.x) + const ys = cameraPoints.map((point) => point.y) + return { + left: Math.min(...xs), + top: Math.min(...ys), + right: Math.max(...xs), + bottom: Math.max(...ys), + } + } + + scheduleOnRN(onReport, { + orientation: frame.orientation, + expected: getCameraBounds( + orientedCorners.map(orientedPointToFramePoint), + ), + reported: getCameraBounds(orientedCorners), + }) + frame.dispose() + }) + + await session.start() + try { + await waitUntil(() => report != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + const r = report + if (r == null) throw new Error('no rectangle projection report') + + if (r.orientation === 'up') { + return context.skip( + 'oriented rectangle projection: frame orientation is up', + ) + } + + for (const edge of ['left', 'top', 'right', 'bottom'] as const) { + expect(r.reported[edge]).toBeCloseTo(r.expected[edge], 0) + } + + console.log( + `oriented rectangle projection orientation=${r.orientation} expected=${JSON.stringify(r.expected)} reported=${JSON.stringify(r.reported)}`, + ) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + // TODO: Re-enable once we have a way to produce a ScannedObject without a + // real on-device scan (e.g. a `createMockScannedObject` factory or + // a CI-friendly QR fixture). On iOS, the only way to obtain a + // ScannedObject today is via `CameraObjectOutput`, which depends + // on a real QR code being visible to the rear camera — not + // reliable on AWS Device Farm or a closed test rig. + it.skip("converts a ScannedObject's bounding box into view coordinates (iOS only)", async (context) => { + if (Platform.OS !== 'ios') { + return context.skip( + 'convertScannedObjectCoordinatesToViewCoordinates: iOS only', + ) + } + // Pending API: a way to mint a ScannedObject without a live scan. + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.devices.harness.ts b/apps/simple-camera/__tests__/visioncamera.devices.harness.ts new file mode 100644 index 0000000000..e08fc95b8a --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.devices.harness.ts @@ -0,0 +1,191 @@ +import { beforeAll, describe, expect, it } from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, +} from 'react-native-vision-camera' +import { VisionCamera } from 'react-native-vision-camera' + +describe('VisionCamera - Devices', () => { + let factory: CameraDeviceFactory + + beforeAll(async () => { + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + }) + + it('enumerates at least one back and one front camera', () => { + const back = factory.getDefaultCamera('back') + const front = factory.getDefaultCamera('front') + expect(back).toBeDefined() + expect(front).toBeDefined() + + const hasBack = factory.cameraDevices.some((d) => d.position === 'back') + const hasFront = factory.cameraDevices.some((d) => d.position === 'front') + expect(hasBack).toBe(true) + expect(hasFront).toBe(true) + }) + + it('logs external cameras when present (optional)', (context) => { + const external = factory.cameraDevices.filter( + (d) => d.position === 'external', + ) + if (external.length === 0) { + return context.skip('external cameras: none available on this device') + } + for (const device of external) { + console.log( + `external camera: id=${device.id} name=${device.localizedName}`, + ) + } + }) + + it('returns the same device when calling getCameraForId with a known id', () => { + const first = factory.cameraDevices[0] + expect(first).toBeDefined() + if (first == null) throw new Error('no cameras') + + const looked = factory.getCameraForId(first.id) + expect(looked).toBeDefined() + expect(looked?.id).toBe(first.id) + }) + + it('returns undefined when calling getCameraForId with an unknown id', () => { + const looked = factory.getCameraForId( + `definitely-not-a-real-camera-id-${Date.now()}`, + ) + expect(looked).toBe(undefined) + }) + + it('returns an array from getSupportedExtensions for the default back camera', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + const extensions = await factory.getSupportedExtensions(device) + console.log( + `back camera extensions: ${extensions.map((e) => e.type).join(', ') || '(none)'}`, + ) + }) + + it('subscribes and unsubscribes a devices-changed listener', () => { + const subscription = factory.addOnCameraDevicesChangedListener(() => {}) + subscription.remove() + subscription.remove() + }) + + it('reports sane capability invariants for each device', () => { + for (const device of factory.cameraDevices) { + const label = `${device.position}:${device.id}` + + expect(device.minZoom).toBeLessThanOrEqual(device.maxZoom) + + if (device.supportsExposureBias) { + expect(device.minExposureBias).toBeLessThanOrEqual( + device.maxExposureBias, + ) + } + + if (device.mediaTypes.includes('video')) { + expect(device.supportedPixelFormats.length).toBeGreaterThan(0) + expect(device.supportedFPSRanges.length).toBeGreaterThan(0) + for (const range of device.supportedFPSRanges) { + expect(range.min).toBeLessThanOrEqual(range.max) + } + } + + console.log( + `device ${label}: type=${device.type} virtual=${device.isVirtualDevice} ` + + `zoom=${device.minZoom}-${device.maxZoom} fpsRanges=${device.supportedFPSRanges + .map((r) => `${r.min}-${r.max}`) + .join(',')}`, + ) + } + }) + + it('logs optional hardware capabilities per device', () => { + const capabilities: Array<{ + device: CameraDevice + caps: Record + }> = factory.cameraDevices.map((device) => ({ + device, + caps: { + hasFlash: device.hasFlash, + hasTorch: device.hasTorch, + supportsPhotoHDR: device.supportsPhotoHDR, + supportsLowLightBoost: device.supportsLowLightBoost, + supportsFocusMetering: device.supportsFocusMetering, + supportsExposureBias: device.supportsExposureBias, + supports60fps: device.supportsFPS(60), + supports120fps: device.supportsFPS(120), + supportsCinematicStab: + device.supportsVideoStabilizationMode('cinematic'), + supportsPreviewImage: device.supportsPreviewImage, + hdrRanges: device.supportedVideoDynamicRanges.length, + }, + })) + + for (const { device, caps } of capabilities) { + console.log( + `caps ${device.position}:${device.id}: ${JSON.stringify(caps)}`, + ) + } + }) + + it('returns non-empty getSupportedResolutions for photo/video streams on a back device', () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + const photoResolutions = device.getSupportedResolutions('photo') + const videoResolutions = device.getSupportedResolutions('video') + expect(photoResolutions.length).toBeGreaterThan(0) + expect(videoResolutions.length).toBeGreaterThan(0) + }) + + it('gets and sets userPreferredCamera', () => { + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + const previous = factory.userPreferredCamera + factory.userPreferredCamera = back + expect(factory.userPreferredCamera?.id).toBe(back.id) + factory.userPreferredCamera = previous + }) + + it('exposes supportedMultiCamDeviceCombinations consistently with supportsMultiCamSessions', () => { + if (VisionCamera.supportsMultiCamSessions) { + expect( + factory.supportedMultiCamDeviceCombinations.length, + ).toBeGreaterThanOrEqual(1) + } else { + expect(factory.supportedMultiCamDeviceCombinations).toHaveLength(0) + } + }) + + it('every device in a supportedMultiCamDeviceCombinations combination is also present in cameraDevices', (context) => { + const combinations = factory.supportedMultiCamDeviceCombinations + if (combinations.length === 0) { + return context.skip( + 'supportedMultiCamDeviceCombinations device lookup: no combinations on this platform', + ) + } + const knownIds = factory.cameraDevices.map((d) => d.id) + for (const combination of combinations) { + expect(combination.length).toBeGreaterThan(0) + for (const device of combination) { + expect(knownIds).toContain(device.id) + } + } + }) + + it('logs every supported multi-cam device combination', () => { + const combinations = factory.supportedMultiCamDeviceCombinations + console.log( + `supportedMultiCamDeviceCombinations: ${combinations.length} combinations`, + ) + for (const [index, combination] of combinations.entries()) { + const description = combination + .map((d) => `${d.position}:${d.id}`) + .join(', ') + console.log(` [${index}] ${description}`) + } + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.frame.harness.ts b/apps/simple-camera/__tests__/visioncamera.frame.harness.ts new file mode 100644 index 0000000000..e678514d5f --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.frame.harness.ts @@ -0,0 +1,712 @@ +import { + beforeAll, + describe, + expect, + it, + waitUntil, +} from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, + FrameDroppedReason, +} from 'react-native-vision-camera' +import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' +import { provider as workletsProvider } from 'react-native-vision-camera-worklets' +import { createSynchronizable, scheduleOnRN } from 'react-native-worklets' +import { deferred, withTimeout } from './test-utils' + +describe('VisionCamera - Frame', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + it('delivers frames to a worklet and posts back via scheduleOnRN', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const receivedFrames = deferred() + let framesReceived = 0 + const onFrameReceived = () => { + framesReceived++ + if (framesReceived >= 3) { + receivedFrames.resolve() + } + } + const errorSub = session.addOnErrorListener(receivedFrames.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }) + + await session.start() + try { + await withTimeout(receivedFrames.promise, 15_000, 'receive frames') + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + expect(framesReceived).toBeGreaterThanOrEqual(3) + }) + + it('reports native buffers and conditionally reads pixel buffers', async (context) => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + type NativeFrameBufferReport = + | { state: 'skip'; reason: string } + | { state: 'error'; errorMessage: string } + | { state: 'success' } + + type NativeFrameBufferResult = + | { state: 'skip'; reason: string } + | { state: 'success'; frames: number } + + const receivedBuffers = deferred() + let buffersReceived = 0 + const report = (frameBufferReport: NativeFrameBufferReport) => { + switch (frameBufferReport.state) { + case 'skip': + receivedBuffers.resolve(frameBufferReport) + break + case 'error': + receivedBuffers.reject(new Error(frameBufferReport.errorMessage)) + break + case 'success': + buffersReceived++ + if (buffersReceived >= 3) { + receivedBuffers.resolve({ + state: 'success', + frames: buffersReceived, + }) + } + break + } + } + const errorSub = session.addOnErrorListener(receivedBuffers.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + try { + if (!frame.hasNativeBuffer) { + scheduleOnRN(report, { + state: 'skip', + reason: + 'native frame buffers: device does not expose native buffers', + }) + return + } + + const nativeBuffer = frame.getNativeBuffer() + try { + if (nativeBuffer.pointer === 0n) { + scheduleOnRN(report, { + state: 'error', + errorMessage: 'Frame native buffer pointer was 0.', + }) + return + } + } finally { + nativeBuffer.release() + } + + if (frame.hasPixelBuffer) { + const pixelBufferBytes = frame.getPixelBuffer().byteLength + if (pixelBufferBytes <= 0) { + scheduleOnRN(report, { + state: 'error', + errorMessage: 'Frame pixel buffer was empty.', + }) + return + } + } + + scheduleOnRN(report, { + state: 'success', + }) + } catch (e) { + scheduleOnRN(report, { + state: 'error', + errorMessage: String(e), + }) + } finally { + frame.dispose() + } + }) + + await session.start() + try { + const result = await withTimeout( + receivedBuffers.promise, + 15_000, + 'receive native frame buffer reports', + ) + if (result.state === 'skip') { + return context.skip(result.reason) + } + expect(result.frames).toBeGreaterThanOrEqual(3) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('keeps YUV plane buffers readable across repeated reads', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'yuv', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + type BufferReport = { + frameBytes: number + firstPlaneBytes: number[] + secondPlaneBytes: number[] + } + const receivedBufferReports = deferred() + const bufferReports: BufferReport[] = [] + const report = ( + frameBytes: number, + firstPlaneBytes: number[], + secondPlaneBytes: number[], + ) => { + if (bufferReports.length < 3) { + bufferReports.push({ frameBytes, firstPlaneBytes, secondPlaneBytes }) + if (bufferReports.length >= 3) { + receivedBufferReports.resolve(bufferReports) + } + } + } + const reportError = (errorMessage: string) => { + receivedBufferReports.reject(new Error(errorMessage)) + } + const errorSub = session.addOnErrorListener(receivedBufferReports.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + try { + const frameBytes = frame.getPixelBuffer().byteLength + const planes = frame.getPlanes() + const firstPlaneBytes = planes.map( + (plane) => plane.getPixelBuffer().byteLength, + ) + const secondPlaneBytes = planes.map( + (plane) => plane.getPixelBuffer().byteLength, + ) + scheduleOnRN(report, frameBytes, firstPlaneBytes, secondPlaneBytes) + } catch (e) { + scheduleOnRN(reportError, String(e)) + } finally { + frame.dispose() + } + }) + + await session.start() + let reports: BufferReport[] = [] + try { + reports = await withTimeout( + receivedBufferReports.promise, + 15_000, + 'read YUV frame pixel buffers', + ) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + + for (const bufferReport of reports) { + expect(bufferReport.frameBytes).toBeGreaterThan(0) + expect(bufferReport.firstPlaneBytes.length).toBeGreaterThan(0) + expect(bufferReport.firstPlaneBytes).toEqual( + bufferReport.secondPlaneBytes, + ) + for (const planeBytes of bufferReport.firstPlaneBytes) { + expect(planeBytes).toBeGreaterThan(0) + } + } + }) + + it('delivers YUV frames with planar access when streaming in yuv', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'yuv', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let reportedWidth = 0 + let reportedHeight = 0 + let reportedPlanes = -1 + const reportedFrame = deferred() + const report = (w: number, h: number, planes: number) => { + reportedWidth = w + reportedHeight = h + reportedPlanes = planes + if (reportedWidth > 0 && reportedHeight > 0) { + reportedFrame.resolve() + } + } + const errorSub = session.addOnErrorListener(reportedFrame.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + const w = frame.width + const h = frame.height + let planeCount = 0 + if (frame.isPlanar) { + const planes = frame.getPlanes() + planeCount = planes.length + } + scheduleOnRN(report, w, h, planeCount) + frame.dispose() + }) + + await session.start() + try { + await withTimeout(reportedFrame.promise, 15_000, 'receive YUV frame') + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + console.log( + `yuv frame reported ${reportedWidth}x${reportedHeight} planes=${reportedPlanes}`, + ) + expect(reportedWidth).toBeGreaterThan(0) + expect(reportedHeight).toBeGreaterThan(0) + expect(reportedPlanes).toBeGreaterThanOrEqual(1) + }) + + it('delivers readable pixel buffers when streaming in rgb', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.VGA_16_9, + pixelFormat: 'rgb', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const receivedFrame = deferred() + const onFrame = (pixelBufferBytes: number) => { + if (pixelBufferBytes > 0) { + receivedFrame.resolve() + } else { + receivedFrame.reject(new Error('RGB frame pixel buffer was empty.')) + } + } + const onError = (errorMessage: string) => { + receivedFrame.reject(new Error(errorMessage)) + } + const errorSub = session.addOnErrorListener(receivedFrame.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + try { + const pixelBufferBytes = frame.getPixelBuffer().byteLength + scheduleOnRN(onFrame, pixelBufferBytes) + } catch (e) { + scheduleOnRN(onError, String(e)) + } finally { + frame.dispose() + } + }) + + await session.start() + try { + await withTimeout(receivedFrame.promise, 15_000, 'receive RGB frame') + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('synchronizes state from the worklet using createSynchronizable', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.VGA_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const counter = createSynchronizable(0) + const sessionFailed = deferred() + const errorSub = session.addOnErrorListener(sessionFailed.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + counter.setBlocking((prev) => prev + 1) + frame.dispose() + }) + + await session.start() + try { + await Promise.race([ + waitUntil(() => counter.getBlocking() >= 3, { timeout: 15_000 }), + sessionFailed.promise, + ]) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + expect(counter.getBlocking()).toBeGreaterThanOrEqual(3) + }) + + // Verifies that `targetResolution` actually drives the frame pipeline — the + // other frame tests only assert width/height > 0, so a regression that + // snaps every request to a default resolution would slip through. + it("streams frames at the device's maximum supported frame resolution", async () => { + const supported = backDevice.getSupportedResolutions('stream') + expect(supported.length).toBeGreaterThan(0) + const max = supported.reduce((a, b) => + a.width * a.height > b.width * b.height ? a : b, + ) + + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: max, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: frameOutput }], + }, + ]) + + let receivedWidth = 0 + let receivedHeight = 0 + const receivedFrame = deferred() + const report = (w: number, h: number) => { + receivedWidth = w + receivedHeight = h + if (receivedWidth > 0 && receivedHeight > 0) { + receivedFrame.resolve() + } + } + const errorSub = session.addOnErrorListener(receivedFrame.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(report, frame.width, frame.height) + frame.dispose() + }) + + await session.start() + try { + await withTimeout( + receivedFrame.promise, + 15_000, + 'receive maximum resolution frame', + ) + + const requestedShortEdge = Math.min(max.width, max.height) + const requestedLongEdge = Math.max(max.width, max.height) + const streamedShortEdge = Math.min(receivedWidth, receivedHeight) + const streamedLongEdge = Math.max(receivedWidth, receivedHeight) + + // currentResolution should match what's actually being streamed. + const reported = frameOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported frame resolution') + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + expect(reportedShortEdge).toBe(streamedShortEdge) + expect(reportedLongEdge).toBe(streamedLongEdge) + + console.log( + `max device stream res=${max.width}x${max.height} reported=${reported.width}x${reported.height} streamed=${receivedWidth}x${receivedHeight}`, + ) + expect(streamedShortEdge).toBe(requestedShortEdge) + expect(streamedLongEdge).toBe(requestedLongEdge) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it("streams frames at the device's minimum supported frame resolution", async () => { + const supported = backDevice.getSupportedResolutions('stream') + expect(supported.length).toBeGreaterThan(0) + const min = supported.reduce((a, b) => + a.width * a.height < b.width * b.height ? a : b, + ) + + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: min, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: frameOutput }], + }, + ]) + + let receivedWidth = 0 + let receivedHeight = 0 + const receivedFrame = deferred() + const report = (w: number, h: number) => { + receivedWidth = w + receivedHeight = h + if (receivedWidth > 0 && receivedHeight > 0) { + receivedFrame.resolve() + } + } + const errorSub = session.addOnErrorListener(receivedFrame.reject) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(report, frame.width, frame.height) + frame.dispose() + }) + + await session.start() + try { + await withTimeout( + receivedFrame.promise, + 15_000, + 'receive minimum resolution frame', + ) + + const requestedShortEdge = Math.min(min.width, min.height) + const requestedLongEdge = Math.max(min.width, min.height) + const streamedShortEdge = Math.min(receivedWidth, receivedHeight) + const streamedLongEdge = Math.max(receivedWidth, receivedHeight) + + const reported = frameOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported frame resolution') + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + expect(reportedShortEdge).toBe(streamedShortEdge) + expect(reportedLongEdge).toBe(streamedLongEdge) + + console.log( + `min device stream res=${min.width}x${min.height} reported=${reported.width}x${reported.height} streamed=${receivedWidth}x${receivedHeight}`, + ) + expect(streamedShortEdge).toBe(requestedShortEdge) + expect(streamedLongEdge).toBe(requestedLongEdge) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + // TODO: Re-enable this test once the Android CameraX ImageAnalysis pipeline surfaces + // dropped-frame notifications. Today HybridFrameOutput.setOnFrameDroppedCallback + // is a no-op on Android (see the `TODO: CameraX does not have a way to figure + // out if a Frame has been dropped` comment in HybridFrameOutput.kt). + it.skip('invokes the onFrameDropped callback when the worklet stalls', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [{ fps: 30 }], + }, + ]) + + let droppedReason: FrameDroppedReason | undefined + frameOutput.setOnFrameDroppedCallback((reason) => { + droppedReason = reason + }) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + const start = Date.now() + // Deliberately stall so subsequent frames are dropped. + while (Date.now() - start < 150) { + // busy wait + } + frame.dispose() + }) + + await session.start() + try { + await waitUntil(() => droppedReason != null, { timeout: 15_000 }) + console.log(`frame dropped reason: ${droppedReason}`) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + frameOutput.setOnFrameDroppedCallback(undefined) + await session.stop() + } + }) + + // TODO: Re-enable once the Android frame output honors `enablePreviewSizedOutputBuffers` + // (today HybridFrameOutput.kt / HybridDepthFrameOutput.kt both have a + // `TODO: enablePreviewSizedOutputBuffers is not taken into account here.`). + // Actually, maybe we should remoev `enablePreviewSizedOutputBuffers` in favor of + // the simple, yet more flexible `targetResolution: ...` prop anyways. + it.skip('delivers smaller buffers when enablePreviewSizedOutputBuffers is true', async () => { + const session = await VisionCamera.createCameraSession(false) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.UHD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: true, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: frameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let reportedWidth = 0 + let reportedHeight = 0 + const report = (w: number, h: number) => { + reportedWidth = w + reportedHeight = h + } + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(report, frame.width, frame.height) + frame.dispose() + }) + + await session.start() + try { + await waitUntil(() => reportedWidth > 0 && reportedHeight > 0, { + timeout: 15_000, + }) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + await session.stop() + } + console.log( + `preview-sized frame: ${reportedWidth}x${reportedHeight} (requested target ${CommonResolutions.UHD_16_9.width}x${CommonResolutions.UHD_16_9.height})`, + ) + const requestedPixels = + CommonResolutions.UHD_16_9.width * CommonResolutions.UHD_16_9.height + const actualPixels = reportedWidth * reportedHeight + expect(actualPixels).toBeLessThan(requestedPixels) + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts b/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts new file mode 100644 index 0000000000..93912a6431 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.multi-output.harness.ts @@ -0,0 +1,565 @@ +import { + beforeAll, + describe, + expect, + it, + waitUntil, +} from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, +} from 'react-native-vision-camera' +import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' +import { provider as workletsProvider } from 'react-native-vision-camera-worklets' +import { scheduleOnRN } from 'react-native-worklets' +import { deferred, withTimeout } from './test-utils' + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +describe('VisionCamera - Multi-Output', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + await VisionCamera.requestMicrophonePermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + expect(VisionCamera.microphonePermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + it('configures photo + video + frame outputs in a single session', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() + try { + expect(sessionError).toBe(undefined) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('streams frames, captures a photo, and records video simultaneously', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + let framesReceived = 0 + let sessionError: Error | undefined + const onFrameReceived = () => { + framesReceived++ + } + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }) + + await session.start() + try { + // Wait for the frame pipeline to start. + await waitUntil(() => framesReceived >= 1 || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + + // Start recording while frames are still streaming. + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) + + // Capture a photo while video is being recorded and frames are still streaming. + const framesAtPhoto = framesReceived + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // Frames must keep arriving after the photo capture completes — + // photo capture must not permanently stall the frame pipeline. + await waitUntil( + () => framesReceived > framesAtPhoto + 2 || sessionError != null, + { timeout: 15_000 }, + ) + expect(sessionError).toBe(undefined) + expect(framesReceived).toBeGreaterThan(framesAtPhoto) + + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + expect(sessionError).toBe(undefined) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('keeps a persistent recording running across a session restart with photo + frame outputs attached', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + enablePersistentRecorder: true, + }) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + + await session.stop() + await session.start() + await sleep(500) + + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + } finally { + await session.stop() + } + }) + + it('replaces the photo output while a video + frame output are also attached', async () => { + const session = await VisionCamera.createCameraSession(false) + const firstPhotoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let framesReceived = 0 + let sessionError: Error | undefined + const onFrameReceived = () => { + framesReceived++ + } + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: firstPhotoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }) + + await session.start() + + try { + const secondPhotoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.FHD_4_3, + containerFormat: 'jpeg', + quality: 0.5, + qualityPrioritization: 'quality', + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: secondPhotoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + // The replacement photo output captures. + const photo = await secondPhotoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // The untouched video output still records. + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + + // The untouched frame output still streams. + const framesAtCheck = framesReceived + await waitUntil( + () => framesReceived > framesAtCheck + 2 || sessionError != null, + { timeout: 15_000 }, + ) + expect(framesReceived).toBeGreaterThan(framesAtCheck) + + expect(sessionError).toBe(undefined) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('replaces the video output while a photo + frame output are also attached', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const firstVideoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const frameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'native', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let framesReceived = 0 + let sessionError: Error | undefined + const onFrameReceived = () => { + framesReceived++ + } + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: firstVideoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread) + runtime.setOnFrameCallback(frameOutput, (frame) => { + 'worklet' + scheduleOnRN(onFrameReceived) + frame.dispose() + }) + + await session.start() + + try { + const secondVideoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.FHD_16_9, + enableAudio: false, + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: secondVideoOutput, mirrorMode: 'auto' }, + { output: frameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + // The replacement video output records. + const recorder = await secondVideoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + + // The untouched photo output still captures. + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // The untouched frame output still streams. + const framesAtCheck = framesReceived + await waitUntil( + () => framesReceived > framesAtCheck + 2 || sessionError != null, + { timeout: 15_000 }, + ) + expect(framesReceived).toBeGreaterThan(framesAtCheck) + + expect(sessionError).toBe(undefined) + } finally { + runtime.setOnFrameCallback(frameOutput, undefined) + errorSub.remove() + await session.stop() + } + }) + + it('replaces the frame output with a different pixel format while a photo + video output are also attached', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + const yuvFrameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'yuv', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: yuvFrameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + let yuvIsPlanar: boolean | undefined + const reportYuv = (planar: boolean) => { + yuvIsPlanar = planar + } + const yuvRuntime = workletsProvider.createRuntimeForThread( + yuvFrameOutput.thread, + ) + yuvRuntime.setOnFrameCallback(yuvFrameOutput, (frame) => { + 'worklet' + scheduleOnRN(reportYuv, frame.isPlanar) + frame.dispose() + }) + + await session.start() + + try { + await waitUntil(() => yuvIsPlanar != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(yuvIsPlanar).toBe(true) + + yuvRuntime.setOnFrameCallback(yuvFrameOutput, undefined) + + const rgbFrameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.VGA_16_9, + pixelFormat: 'rgb', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + await session.configure([ + { + input: backDevice, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + { output: rgbFrameOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + + let rgbIsPlanar: boolean | undefined + const reportRgb = (planar: boolean) => { + rgbIsPlanar = planar + } + const rgbRuntime = workletsProvider.createRuntimeForThread( + rgbFrameOutput.thread, + ) + rgbRuntime.setOnFrameCallback(rgbFrameOutput, (frame) => { + 'worklet' + scheduleOnRN(reportRgb, frame.isPlanar) + frame.dispose() + }) + + try { + await waitUntil(() => rgbIsPlanar != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(rgbIsPlanar).toBe(false) + + // The untouched photo output still captures. + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + + // The untouched video output still records. + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + } finally { + rgbRuntime.setOnFrameCallback(rgbFrameOutput, undefined) + } + } finally { + errorSub.remove() + await session.stop() + } + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx b/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx new file mode 100644 index 0000000000..7ab896f8a9 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.nativepreviewview.harness.tsx @@ -0,0 +1,1191 @@ +import { + type LayoutChangeEvent, + PixelRatio, + Platform, + StyleSheet, + View, +} from 'react-native' +import { + afterEach, + beforeAll, + cleanup, + describe, + expect, + it, + render, +} from 'react-native-harness' +import { callback } from 'react-native-nitro-modules' +import type { + CameraDevice, + CameraDeviceFactory, + Point, + PreviewImplementationMode, + PreviewResizeMode, + PreviewView, +} from 'react-native-vision-camera' +import { NativePreviewView, VisionCamera } from 'react-native-vision-camera' +import { deferred, withTimeout } from './test-utils' + +interface Layout { + x: number + y: number + width: number + height: number +} + +function toLayout(event: LayoutChangeEvent): Layout { + const layout = event.nativeEvent.layout + return { + x: layout.x, + y: layout.y, + width: layout.width, + height: layout.height, + } +} + +function expectPreviewGeometry(preview: PreviewView, layout: Layout) { + expect(layout.width).toBeGreaterThan(0) + expect(layout.height).toBeGreaterThan(0) + + const viewCenter: Point = { x: layout.width / 2, y: layout.height / 2 } + const cameraPoint = preview.convertViewPointToCameraPoint(viewCenter) + const roundTripped = preview.convertCameraPointToViewPoint(cameraPoint) + expect(roundTripped.x).toBeCloseTo(viewCenter.x, 0) + expect(roundTripped.y).toBeCloseTo(viewCenter.y, 0) + + const meteringPoint = preview.createMeteringPoint(viewCenter.x, viewCenter.y) + expect(meteringPoint.relativeX).toBeCloseTo(viewCenter.x, 0) + expect(meteringPoint.relativeY).toBeCloseTo(viewCenter.y, 0) + expect(meteringPoint.normalizedX).toBeGreaterThanOrEqual(0) + expect(meteringPoint.normalizedX).toBeLessThanOrEqual(1) + expect(meteringPoint.normalizedY).toBeGreaterThanOrEqual(0) + expect(meteringPoint.normalizedY).toBeLessThanOrEqual(1) +} + +describe('VisionCamera - NativePreviewView', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + afterEach(() => { + cleanup() + }) + + it('starts a bare NativePreviewView and exposes ref methods', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + />, + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + 'NativePreviewView hybridRef', + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + 'NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'NativePreviewView onPreviewStarted', + ) + + expectPreviewGeometry(preview, previewLayout) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('matches takeSnapshot dimensions to the NativePreviewView layout on Android', async (context) => { + if (Platform.OS !== 'android') { + return context.skip('takeSnapshot dimensions: Android only') + } + + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + />, + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + 'snapshot NativePreviewView hybridRef', + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + 'snapshot NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'snapshot NativePreviewView onPreviewStarted', + ) + + const snapshot = await preview.takeSnapshot() + try { + expect(snapshot.width).toBeGreaterThan(0) + expect(snapshot.height).toBeGreaterThan(0) + + const expectedWidth = PixelRatio.getPixelSizeForLayoutSize( + previewLayout.width, + ) + const expectedHeight = PixelRatio.getPixelSizeForLayoutSize( + previewLayout.height, + ) + expect(snapshot.width).toBeCloseTo(expectedWidth, 0) + expect(snapshot.height).toBeCloseTo(expectedHeight, 0) + } finally { + snapshot.dispose() + } + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('keeps a flex preview laid out inside a padded overflow-hidden parent', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + /> + , + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + 'padded NativePreviewView hybridRef', + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + 'padded NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'padded NativePreviewView onPreviewStarted', + ) + + expect(previewLayout.x).toBeCloseTo(0, 0) + expect(previewLayout.y).toBeCloseTo(PADDING_TOP, 0) + expect(previewLayout.height).toBeGreaterThan(0) + expectPreviewGeometry(preview, previewLayout) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('survives repeated conditional placeholder-to-preview mounts in a padded parent', async () => { + for (let attempt = 1; attempt <= 5; attempt++) { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + const { rerender } = await render( + + + , + ) + + await rerender( + + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + /> + , + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + `conditional NativePreviewView hybridRef attempt ${attempt}`, + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + `conditional NativePreviewView onLayout attempt ${attempt}`, + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + `conditional NativePreviewView onPreviewStarted attempt ${attempt}`, + ) + + expect(previewLayout.x).toBeCloseTo(0, 0) + expect(previewLayout.y).toBeCloseTo(PADDING_TOP, 0) + expectPreviewGeometry(preview, previewLayout) + } finally { + errorSub.remove() + await session.stop() + cleanup() + } + } + }) + + it('keeps a fixed 150x300 preview centered on first mount', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const rootLayout = deferred() + const wrapperLayout = deferred() + const previewLayout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + rootLayout.reject(error) + wrapperLayout.reject(error) + previewLayout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + { + rootLayout.resolve(toLayout(event)) + }} + > + { + wrapperLayout.resolve(toLayout(event)) + }} + > + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + previewLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + /> + + , + ) + + const root = await withTimeout( + rootLayout.promise, + 10_000, + 'fixed preview root onLayout', + ) + const wrapper = await withTimeout( + wrapperLayout.promise, + 10_000, + 'fixed preview wrapper onLayout', + ) + const preview = await withTimeout( + previewRef.promise, + 10_000, + 'fixed NativePreviewView hybridRef', + ) + const layout = await withTimeout( + previewLayout.promise, + 10_000, + 'fixed NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'fixed NativePreviewView onPreviewStarted', + ) + + const expectedWrapperX = (root.width - FIXED_PREVIEW_WIDTH) / 2 + const expectedWrapperY = (root.height - FIXED_PREVIEW_HEIGHT) / 2 + expect(wrapper.width).toBeCloseTo(FIXED_PREVIEW_WIDTH, 0) + expect(wrapper.height).toBeCloseTo(FIXED_PREVIEW_HEIGHT, 0) + expect(wrapper.x).toBeCloseTo(expectedWrapperX, 0) + expect(wrapper.y).toBeCloseTo(expectedWrapperY, 0) + expect(layout.x).toBeCloseTo(0, 0) + expect(layout.y).toBeCloseTo(0, 0) + expect(layout.width).toBeCloseTo(FIXED_PREVIEW_WIDTH, 0) + expect(layout.height).toBeCloseTo(FIXED_PREVIEW_HEIGHT, 0) + expectPreviewGeometry(preview, layout) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('updates geometry when the preview layout changes while running', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const firstLayout = deferred() + const secondLayout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + firstLayout.reject(error) + secondLayout.reject(error) + previewStarted.reject(error) + }) + + try { + const { rerender } = await render( + + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + firstLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + /> + , + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + 'resizable NativePreviewView hybridRef', + ) + const first = await withTimeout( + firstLayout.promise, + 10_000, + 'resizable NativePreviewView first onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'resizable NativePreviewView onPreviewStarted', + ) + + expect(first.width).toBeCloseTo(FIXED_PREVIEW_WIDTH, 0) + expect(first.height).toBeCloseTo(FIXED_PREVIEW_HEIGHT, 0) + expectPreviewGeometry(preview, first) + + await rerender( + + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + secondLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + /> + , + ) + + const second = await withTimeout( + secondLayout.promise, + 10_000, + 'resizable NativePreviewView second onLayout', + ) + expect(second.width).toBeCloseTo(WIDE_PREVIEW_WIDTH, 0) + expect(second.height).toBeCloseTo(WIDE_PREVIEW_HEIGHT, 0) + expectPreviewGeometry(preview, second) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('starts a new NativePreviewView after the previous one unmounts', async () => { + for (let attempt = 1; attempt <= 2; attempt++) { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + />, + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + `remounted NativePreviewView hybridRef attempt ${attempt}`, + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + `remounted NativePreviewView onLayout attempt ${attempt}`, + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + `remounted NativePreviewView onPreviewStarted attempt ${attempt}`, + ) + + expectPreviewGeometry(preview, previewLayout) + cleanup() + } finally { + errorSub.remove() + await session.stop() + cleanup() + } + } + }) + + it('switches a running session between two mounted NativePreviewViews', async () => { + const session = await VisionCamera.createCameraSession(false) + const firstPreviewOutput = VisionCamera.createPreviewOutput() + const secondPreviewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: firstPreviewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const firstRef = deferred() + const secondRef = deferred() + const firstLayout = deferred() + const secondLayout = deferred() + const firstPreviewStarted = deferred() + const secondPreviewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + firstRef.reject(error) + secondRef.reject(error) + firstLayout.reject(error) + secondLayout.reject(error) + firstPreviewStarted.reject(error) + secondPreviewStarted.reject(error) + }) + + try { + await render( + + { + firstRef.resolve(preview) + })} + onLayout={(event) => { + firstLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(firstPreviewStarted.resolve)} + /> + { + secondRef.resolve(preview) + })} + onLayout={(event) => { + secondLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(secondPreviewStarted.resolve)} + /> + , + ) + + const firstPreview = await withTimeout( + firstRef.promise, + 10_000, + 'first NativePreviewView hybridRef', + ) + const secondPreview = await withTimeout( + secondRef.promise, + 10_000, + 'second NativePreviewView hybridRef', + ) + const firstPreviewLayout = await withTimeout( + firstLayout.promise, + 10_000, + 'first NativePreviewView onLayout', + ) + const secondPreviewLayout = await withTimeout( + secondLayout.promise, + 10_000, + 'second NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + firstPreviewStarted.promise, + 15_000, + 'first NativePreviewView onPreviewStarted', + ) + expectPreviewGeometry(firstPreview, firstPreviewLayout) + + await session.configure([ + { + input: backDevice, + outputs: [{ output: secondPreviewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await withTimeout( + secondPreviewStarted.promise, + 15_000, + 'second NativePreviewView onPreviewStarted', + ) + expectPreviewGeometry(secondPreview, secondPreviewLayout) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('mounts two NativePreviewViews from two preview outputs in one multi-cam session', async (context) => { + if (VisionCamera.supportsMultiCamSessions === false) { + return context.skip('two NativePreviewViews: multi-cam not supported') + } + const combination = factory.supportedMultiCamDeviceCombinations.find( + (devices) => devices.length >= 2, + ) + if (combination == null) { + return context.skip( + 'two NativePreviewViews: no multi-cam combination with two devices', + ) + } + + const firstDevice = combination[0] + const secondDevice = combination[1] + if (firstDevice == null) throw new Error('missing first multi-cam device') + if (secondDevice == null) throw new Error('missing second multi-cam device') + + const session = await VisionCamera.createCameraSession(true) + const firstPreviewOutput = VisionCamera.createPreviewOutput() + const secondPreviewOutput = VisionCamera.createPreviewOutput() + const controllers = await session.configure([ + { + input: firstDevice, + outputs: [{ output: firstPreviewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + { + input: secondDevice, + outputs: [{ output: secondPreviewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + const controllerDeviceIds = controllers.map( + (controller) => controller.device.id, + ) + const expectedDeviceIds = [firstDevice.id, secondDevice.id] + expect(controllerDeviceIds).toEqual(expectedDeviceIds) + + const firstRef = deferred() + const secondRef = deferred() + const firstLayout = deferred() + const secondLayout = deferred() + const firstPreviewStarted = deferred() + const secondPreviewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + firstRef.reject(error) + secondRef.reject(error) + firstLayout.reject(error) + secondLayout.reject(error) + firstPreviewStarted.reject(error) + secondPreviewStarted.reject(error) + }) + + try { + await render( + + { + firstRef.resolve(preview) + })} + onLayout={(event) => { + firstLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(firstPreviewStarted.resolve)} + /> + { + secondRef.resolve(preview) + })} + onLayout={(event) => { + secondLayout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(secondPreviewStarted.resolve)} + /> + , + ) + + const firstPreview = await withTimeout( + firstRef.promise, + 10_000, + 'first multi-cam NativePreviewView hybridRef', + ) + const secondPreview = await withTimeout( + secondRef.promise, + 10_000, + 'second multi-cam NativePreviewView hybridRef', + ) + const firstPreviewLayout = await withTimeout( + firstLayout.promise, + 10_000, + 'first multi-cam NativePreviewView onLayout', + ) + const secondPreviewLayout = await withTimeout( + secondLayout.promise, + 10_000, + 'second multi-cam NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + firstPreviewStarted.promise, + 15_000, + 'first multi-cam NativePreviewView onPreviewStarted', + ) + await withTimeout( + secondPreviewStarted.promise, + 15_000, + 'second multi-cam NativePreviewView onPreviewStarted', + ) + + expectPreviewGeometry(firstPreview, firstPreviewLayout) + expectPreviewGeometry(secondPreview, secondPreviewLayout) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('attaches native gesture controllers to a NativePreviewView', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + const [controller] = await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + if (controller == null) throw new Error('no controller') + + const tapGesture = VisionCamera.createTapToFocusGestureController() + const zoomGesture = VisionCamera.createZoomGestureController() + tapGesture.controller = controller + zoomGesture.controller = controller + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + />, + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + 'gesture NativePreviewView hybridRef', + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + 'gesture NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'gesture NativePreviewView onPreviewStarted', + ) + + expectPreviewGeometry(preview, previewLayout) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('supports cover and contain resizeMode previews', async () => { + const resizeModes: PreviewResizeMode[] = ['cover', 'contain'] + let coverTopLeft: Point | undefined + let containTopLeft: Point | undefined + + for (const resizeMode of resizeModes) { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + + + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + /> + + , + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + `${resizeMode} NativePreviewView hybridRef`, + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + `${resizeMode} NativePreviewView onLayout`, + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + `${resizeMode} NativePreviewView onPreviewStarted`, + ) + + expectPreviewGeometry(preview, previewLayout) + const topLeft = preview.convertViewPointToCameraPoint({ x: 0, y: 0 }) + if (resizeMode === 'cover') coverTopLeft = topLeft + else containTopLeft = topLeft + } finally { + errorSub.remove() + await session.stop() + cleanup() + } + } + + if (coverTopLeft == null) throw new Error('missing cover top-left point') + if (containTopLeft == null) + throw new Error('missing contain top-left point') + const roundedCoverTopLeft = { + x: Number(coverTopLeft.x.toFixed(3)), + y: Number(coverTopLeft.y.toFixed(3)), + } + const roundedContainTopLeft = { + x: Number(containTopLeft.x.toFixed(3)), + y: Number(containTopLeft.y.toFixed(3)), + } + expect(roundedCoverTopLeft).not.toEqual(roundedContainTopLeft) + }) + + it('supports both Android preview implementation modes', async (context) => { + if (Platform.OS !== 'android') { + return context.skip('implementationMode: Android only') + } + + const implementationModes: PreviewImplementationMode[] = [ + 'performance', + 'compatible', + ] + + for (const implementationMode of implementationModes) { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + }) + + try { + await render( + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + />, + ) + + const preview = await withTimeout( + previewRef.promise, + 10_000, + `${implementationMode} NativePreviewView hybridRef`, + ) + const previewLayout = await withTimeout( + layout.promise, + 10_000, + `${implementationMode} NativePreviewView onLayout`, + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + `${implementationMode} NativePreviewView onPreviewStarted`, + ) + + expectPreviewGeometry(preview, previewLayout) + } finally { + errorSub.remove() + await session.stop() + cleanup() + } + } + }) + + it('fires preview callbacks when the session starts and stops', async () => { + const session = await VisionCamera.createCameraSession(false) + const previewOutput = VisionCamera.createPreviewOutput() + await session.configure([ + { + input: backDevice, + outputs: [{ output: previewOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const previewRef = deferred() + const layout = deferred() + const previewStarted = deferred() + const previewStopped = deferred() + const errorSub = session.addOnErrorListener((error) => { + previewRef.reject(error) + layout.reject(error) + previewStarted.reject(error) + previewStopped.reject(error) + }) + + try { + await render( + { + previewRef.resolve(preview) + })} + onLayout={(event) => { + layout.resolve(toLayout(event)) + }} + onPreviewStarted={callback(previewStarted.resolve)} + onPreviewStopped={callback(previewStopped.resolve)} + />, + ) + + await withTimeout( + previewRef.promise, + 10_000, + 'callback NativePreviewView hybridRef', + ) + await withTimeout( + layout.promise, + 10_000, + 'callback NativePreviewView onLayout', + ) + + await session.start() + await withTimeout( + previewStarted.promise, + 15_000, + 'callback NativePreviewView onPreviewStarted', + ) + + await session.stop() + await withTimeout( + previewStopped.promise, + 10_000, + 'callback NativePreviewView onPreviewStopped', + ) + } finally { + errorSub.remove() + await session.stop() + } + }) +}) + +const PADDING_TOP = 82 +const FIXED_PREVIEW_WIDTH = 150 +const FIXED_PREVIEW_HEIGHT = 300 +const WIDE_PREVIEW_WIDTH = 260 +const WIDE_PREVIEW_HEIGHT = 180 + +const styles = StyleSheet.create({ + centeredRoot: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'black', + }, + fixedPreviewWrapper: { + width: FIXED_PREVIEW_WIDTH, + height: FIXED_PREVIEW_HEIGHT, + }, + fixedPreview: { + width: FIXED_PREVIEW_WIDTH, + height: FIXED_PREVIEW_HEIGHT, + backgroundColor: 'black', + }, + resizablePreview: { + backgroundColor: 'black', + }, + tallPreview: { + width: FIXED_PREVIEW_WIDTH, + height: FIXED_PREVIEW_HEIGHT, + }, + widePreview: { + width: WIDE_PREVIEW_WIDTH, + height: WIDE_PREVIEW_HEIGHT, + }, + issuePaddedContainer: { + flex: 1, + backgroundColor: 'black', + paddingTop: PADDING_TOP, + overflow: 'hidden', + }, + issueFlexPreview: { + flex: 1, + backgroundColor: 'black', + }, + placeholder: { + flex: 1, + backgroundColor: 'black', + }, + switchRoot: { + flex: 1, + backgroundColor: 'black', + }, + switchPreview: { + flex: 1, + backgroundColor: 'black', + }, +}) diff --git a/apps/simple-camera/__tests__/visioncamera.photo.harness.ts b/apps/simple-camera/__tests__/visioncamera.photo.harness.ts new file mode 100644 index 0000000000..db9b24b773 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.photo.harness.ts @@ -0,0 +1,866 @@ +import { + beforeAll, + describe, + expect, + it, + waitUntil, +} from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, + FlashMode, + MirrorMode, + QualityPrioritization, + Size, +} from 'react-native-vision-camera' +import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +describe('VisionCamera - Photo', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + it('captures a JPEG Photo in-memory', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.FHD_4_3, + containerFormat: 'jpeg', + quality: 0.9, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + expect(photo.containerFormat).toBe('jpeg') + expect(photo.isRawPhoto).toBe(false) + + const image = await photo.toImageAsync() + expect(image.width).toBeGreaterThan(0) + expect(image.height).toBeGreaterThan(0) + image.dispose() + photo.dispose() + + await session.stop() + }) + + it('checks and reads a native Photo pixel buffer in-memory', async (context) => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'native', + quality: 1, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + try { + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + try { + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + if (!photo.hasPixelBuffer) { + return context.skip( + 'Photo pixel buffer: captured native photo has no pixel buffer', + ) + } + + const pixelBuffer = photo.getPixelBuffer() + expect(pixelBuffer.byteLength).toBeGreaterThan(0) + const view = new Uint8Array(pixelBuffer) + expect(view[0]).toBeGreaterThanOrEqual(0) + } finally { + photo.dispose() + } + } finally { + await session.stop() + } + }) + + it('saves a JPEG Photo to a temporary file after converting it to an Image', async () => { + // Regression: on Android, `toImageAsync()` goes through CameraX's + // `jpegImageToJpegByteArray`, which advances the JPEG plane's `ByteBuffer` + // position to `capacity`. The plane buffer is shared across reads (Android's + // `ImageReader` caches the same `ByteBuffer` instance), so a subsequent + // `saveToTemporaryFileAsync()` that reads `buffer.remaining()` would write a + // 0-byte file, and `ExifInterface.saveAttributes()` would then throw + // "ExifInterface only supports saving attributes for JPEG, PNG, and WebP + // formats" because it cannot sniff the MIME type of an empty file. + // See `HybridPhoto.kt#saveToFile`. + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.9, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + + const image = await photo.toImageAsync() + expect(image.width).toBeGreaterThan(0) + expect(image.height).toBeGreaterThan(0) + image.dispose() + + const path = await photo.saveToTemporaryFileAsync() + expect(path.length).toBeGreaterThan(0) + // File paths must start with "/" and end with ".jpeg" or ".jpg". + expect(path).toMatch(/^\/.*\.(jpeg|jpg)$/) + photo.dispose() + + await session.stop() + }) + + // TODO: Re-enable once VisionCamera exposes a way to query supported photo + // container formats upfront (see the TODO in CameraPhotoOutput.nitro.ts + // near `TargetPhotoContainerFormat`). Without that API there is no + // precondition to gate on, and the HEIC path throws at configure time + // on devices that do not support the format. + it.skip('captures a HEIC Photo', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.FHD_4_3, + containerFormat: 'heic', + quality: 0.9, + qualityPrioritization: 'balanced', + }) + try { + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + } finally { + await session.stop() + } + }) + + // TODO: Re-enable once VisionCamera exposes a way to query RAW / DNG support + // upfront. Today the CameraX DngCreator path also crashes natively on + // some devices with a buffer-size assertion + // (java.lang.AssertionError: Height and width of image buffer did not + // match height and width of either the preCorrectionActiveArraySize or + // the pixelArraySize.) — see androidx.camera.core.imagecapture.DngImage2Disk. + it.skip('captures a RAW DNG Photo to a file', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.FHD_4_3, + containerFormat: 'dng', + quality: 1.0, + qualityPrioritization: 'quality', + }) + try { + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'off' }], + constraints: [], + }, + ]) + await session.start() + const file = await photoOutput.capturePhotoToFile( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(file.filePath.length).toBeGreaterThan(0) + } finally { + await session.stop() + } + }) + + it('captures with each qualityPrioritization the device supports', async () => { + const priorities: QualityPrioritization[] = ['quality', 'balanced'] + + for (const qualityPrioritization of priorities) { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + await session.stop() + } + }) + + it('captures with speed qualityPrioritization when supported', async (context) => { + if (!backDevice.supportsSpeedQualityPrioritization) { + return context.skip( + 'qualityPrioritization: speed not supported on device', + ) + } + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'speed', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + await session.stop() + }) + + it('captures at several target resolutions', async () => { + const targets: Size[] = [ + CommonResolutions.HD_4_3, + CommonResolutions.FHD_4_3, + CommonResolutions.HIGHEST_4_3, + ] + for (const targetResolution of targets) { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + console.log( + `target=${targetResolution.width}x${targetResolution.height} => resolved=${photo.width}x${photo.height}`, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + await session.stop() + } + }) + + // Verifies that `targetResolution` actually drives the output — without these, + // a regression that snaps every request to a default smaller format would + // pass all the other photo tests (they only assert width/height > 0). + it("captures at the device's maximum supported photo resolution", async () => { + const supportedPhotoResolutions = + backDevice.getSupportedResolutions('photo') + expect(supportedPhotoResolutions.length).toBeGreaterThan(0) + const maxPhotoResolution = supportedPhotoResolutions.reduce((a, b) => + a.width * a.height > b.width * b.height ? a : b, + ) + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: maxPhotoResolution, + containerFormat: 'native', + quality: 1, + qualityPrioritization: 'quality', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: photoOutput }], + initialZoom: backDevice.minZoom, + initialExposureBias: 0, + }, + ]) + await session.start() + try { + const requestedShortEdge = Math.min( + maxPhotoResolution.width, + maxPhotoResolution.height, + ) + const requestedLongEdge = Math.max( + maxPhotoResolution.width, + maxPhotoResolution.height, + ) + + // currentResolution must reflect the resolved output size before we + // even take the picture. + const reported = photoOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported photo resolution') + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + expect(reportedShortEdge).toBe(requestedShortEdge) + expect(reportedLongEdge).toBe(requestedLongEdge) + + // TODO: Figure out why we need prepareSettings + 1s sleep to capture max res???? + // Prepare default settings on the Photo Output before capturing, + // and add an artificial 1 second timeout. + // This is for some reason required for max res capture on iOS as + // otherwise the pipeline is not ready for 48MP+ capture (possibly a + // race condition inside AVFoundation?) and would give us binned (eg 24MP) + // photos instead - maybe because it tries to give a photo quickly while + // 48MP is still being warmed up? No idea. Bad DX imo. + await photoOutput.prepareSettings([{}]) + await sleep(1000) + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + const capturedShortEdge = Math.min(photo.width, photo.height) + const capturedLongEdge = Math.max(photo.width, photo.height) + console.log( + `max device res=${maxPhotoResolution.width}x${maxPhotoResolution.height} reported=${reported.width}x${reported.height} captured=${photo.width}x${photo.height}`, + ) + expect(capturedShortEdge).toBe(requestedShortEdge) + expect(capturedLongEdge).toBe(requestedLongEdge) + photo.dispose() + } finally { + await session.stop() + } + }) + + it("captures at the device's minimum supported photo resolution", async () => { + const supportedPhotoResolutions = + backDevice.getSupportedResolutions('photo') + expect(supportedPhotoResolutions.length).toBeGreaterThan(0) + const minPhotoResolution = supportedPhotoResolutions.reduce((a, b) => + a.width * a.height < b.width * b.height ? a : b, + ) + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: minPhotoResolution, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: photoOutput }], + }, + ]) + await session.start() + try { + const requestedShortEdge = Math.min( + minPhotoResolution.width, + minPhotoResolution.height, + ) + const requestedLongEdge = Math.max( + minPhotoResolution.width, + minPhotoResolution.height, + ) + + const reported = photoOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported photo resolution') + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + expect(reportedShortEdge).toBe(requestedShortEdge) + expect(reportedLongEdge).toBe(requestedLongEdge) + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + const capturedShortEdge = Math.min(photo.width, photo.height) + const capturedLongEdge = Math.max(photo.width, photo.height) + console.log( + `min device res=${minPhotoResolution.width}x${minPhotoResolution.height} reported=${reported.width}x${reported.height} captured=${photo.width}x${photo.height}`, + ) + expect(capturedShortEdge).toBe(requestedShortEdge) + expect(capturedLongEdge).toBe(requestedLongEdge) + photo.dispose() + } finally { + await session.stop() + } + }) + + it('invokes all capture lifecycle callbacks', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + let willBegin = 0 + let willCapture = 0 + let didCapture = 0 + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + try { + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + { + onWillBeginCapture: () => { + willBegin++ + }, + onWillCapturePhoto: () => { + willCapture++ + }, + onDidCapturePhoto: () => { + didCapture++ + }, + }, + ) + // Wait for the callbacks to drain BEFORE we stop the session, otherwise + // pending callback invocations can be dropped. + await waitUntil( + () => + (willBegin >= 1 && willCapture >= 1 && didCapture >= 1) || + sessionError != null, + { timeout: 5_000 }, + ) + expect(sessionError).toBe(undefined) + photo.dispose() + } finally { + errorSub.remove() + await session.stop() + } + + expect(willBegin).toBe(1) + expect(willCapture).toBe(1) + expect(didCapture).toBe(1) + }) + + it('delivers a preview image when previewImageTargetSize is set and the device supports it', async (context) => { + if (!backDevice.supportsPreviewImage) { + return context.skip( + 'onPreviewImageAvailable: device has no preview image support', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + previewImageTargetSize: { width: 256, height: 192 }, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + let previewImageFired = false + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + { + onPreviewImageAvailable: (image) => { + previewImageFired = true + image.dispose() + }, + }, + ) + photo.dispose() + await session.stop() + + await waitUntil(() => previewImageFired, { timeout: 5_000 }) + }) + + it('captures with each flashMode the device supports', async () => { + const modes: FlashMode[] = ['off', 'auto'] + + for (const flashMode of modes) { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode, enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + await session.stop() + } + }) + + it('captures with flashMode on when the device has flash', async (context) => { + if (!backDevice.hasFlash) { + return context.skip('flashMode on: device has no flash') + } + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'on', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + await session.stop() + }) + + it('prepares flash variants before capturing a photo', async () => { + const preparedFlashModes: FlashMode[] = ['off', 'auto'] + if (backDevice.hasFlash) { + preparedFlashModes.push('on') + } else { + console.log('[SKIP] prepareSettings flashMode on: device has no flash') + } + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.UHD_4_3, + containerFormat: 'native', + quality: 0.95, + qualityPrioritization: 'quality', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: photoOutput }], + }, + ]) + await session.start() + + try { + await photoOutput.prepareSettings( + preparedFlashModes.map((flashMode) => ({ + flashMode, + enableShutterSound: false, + })), + ) + + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + expect(photo.height).toBeGreaterThan(0) + photo.dispose() + } finally { + await session.stop() + } + }) + + it('toggles enableShutterSound and enableRedEyeReduction without error', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + for (const enableShutterSound of [true, false]) { + for (const enableRedEyeReduction of [true, false]) { + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound, enableRedEyeReduction }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + } + } + + await session.stop() + }) + + it('applies enableDistortionCorrection when the device supports it', async (context) => { + if (!backDevice.supportsDistortionCorrection) { + return context.skip('enableDistortionCorrection: not supported on device') + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'off' }], + constraints: [], + }, + ]) + await session.start() + + const photo = await photoOutput.capturePhoto( + { + flashMode: 'off', + enableShutterSound: false, + enableDistortionCorrection: true, + }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + await session.stop() + }) + + it('honors the mirrorMode on each output configuration', async () => { + const modes: MirrorMode[] = ['off', 'on', 'auto'] + for (const mirrorMode of modes) { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + console.log( + `mirrorMode=${mirrorMode} => photo.isMirrored=${photo.isMirrored}`, + ) + switch (mirrorMode) { + case 'off': + expect(photo.isMirrored).toBe(false) + break + case 'on': + expect(photo.isMirrored).toBe(true) + break + case 'auto': { + const expectedMirrored = backDevice.position === 'front' + expect(photo.isMirrored).toBe(expectedMirrored) + break + } + } + photo.dispose() + await session.stop() + } + }) + + it('captures a Photo from the default front camera', async () => { + const front = factory.getDefaultCamera('front') + expect(front).toBeDefined() + if (front == null) throw new Error('no front camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: front, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const photo = await photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(photo.width).toBeGreaterThan(0) + photo.dispose() + await session.stop() + }) + + it('writes different file paths for subsequent capturePhotoToFile calls', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const file1 = await photoOutput.capturePhotoToFile( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + const file2 = await photoOutput.capturePhotoToFile( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(file1.filePath.length).toBeGreaterThan(0) + expect(file2.filePath.length).toBeGreaterThan(0) + // File paths must start with "/" and end with ".jpeg" or ".jpg". + expect(file1.filePath).toMatch(/^\/.*\.(jpeg|jpg)$/) + expect(file2.filePath).toMatch(/^\/.*\.(jpeg|jpg)$/) + expect(file1.filePath).not.toBe(file2.filePath) + + await session.stop() + }) + + it('reports supportsDepthDataDelivery on a depth-capable device', async (context) => { + // `supportsDepthDataDelivery` is a per-output property that flips to `true` + // once the photo output is bound to a device that can produce depth data. + // The default back wide-angle on most phones does not — depth-capable + // devices are typically TrueDepth (front) or LiDAR/Dual virtual cameras. + // Pick whichever device on the system happens to support depth. + const depthDevice = factory.cameraDevices.find( + (d) => + d.type === 'true-depth' || + d.type === 'lidar-depth' || + d.type === 'dual', + ) + if (depthDevice == null) { + return context.skip( + 'supportsDepthDataDelivery: no depth-capable device on this system', + ) + } + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + await session.configure([ + { + input: depthDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + try { + console.log( + `photoOutput support flags: depthData=${photoOutput.supportsDepthDataDelivery} calibrationData=${photoOutput.supportsCameraCalibrationDataDelivery}`, + ) + expect(photoOutput.supportsDepthDataDelivery).toBe(true) + } finally { + await session.stop() + } + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.session.harness.ts b/apps/simple-camera/__tests__/visioncamera.session.harness.ts new file mode 100644 index 0000000000..65e32ded8b --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.session.harness.ts @@ -0,0 +1,580 @@ +import { + beforeAll, + describe, + expect, + it, + waitUntil, +} from 'react-native-harness' +import type { CameraDeviceFactory } from 'react-native-vision-camera' +import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' +import { provider as workletsProvider } from 'react-native-vision-camera-worklets' +import { scheduleOnRN } from 'react-native-worklets' +import { deferred, withTimeout } from './test-utils' + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +describe('VisionCamera - Session', () => { + let factory: CameraDeviceFactory + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + }) + + it('configures, starts and stops a session for every listed device', async () => { + const devices = factory.cameraDevices + expect(devices.length).toBeGreaterThan(0) + + for (const device of devices) { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + + const started = deferred() + const stopped = deferred() + const startSub = session.addOnStartedListener(started.resolve) + const stopSub = session.addOnStoppedListener(stopped.resolve) + const errorSub = session.addOnErrorListener((error) => { + started.reject(error) + stopped.reject(error) + }) + + try { + const controllers = await session.configure([ + { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + expect(controllers.length).toBe(1) + expect(controllers[0]?.device.id).toBe(device.id) + + await session.start() + await withTimeout(started.promise, 10_000, 'session start') + await session.stop() + await withTimeout(stopped.promise, 10_000, 'session stop') + } finally { + startSub.remove() + stopSub.remove() + errorSub.remove() + } + console.log( + `session ok: ${device.position}:${device.id} (${device.localizedName})`, + ) + } + }) + + it('fires onStarted/onStopped exactly once per lifecycle', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + + let startedCount = 0 + let stoppedCount = 0 + const started = deferred() + const stopped = deferred() + const startSub = session.addOnStartedListener(() => { + startedCount++ + started.resolve() + }) + const stopSub = session.addOnStoppedListener(() => { + stoppedCount++ + stopped.resolve() + }) + const errorSub = session.addOnErrorListener((error) => { + started.reject(error) + stopped.reject(error) + }) + + try { + await session.configure([ + { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + await session.start() + await withTimeout(started.promise, 10_000, 'session start') + + await session.stop() + await withTimeout(stopped.promise, 10_000, 'session stop') + + expect(startedCount).toBe(1) + expect(stoppedCount).toBe(1) + } finally { + startSub.remove() + stopSub.remove() + errorSub.remove() + } + }) + + it('stops delivering events after a listener subscription is removed', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + + await session.configure([ + { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let startedAfterRemove = 0 + const startSub = session.addOnStartedListener(() => { + startedAfterRemove++ + }) + startSub.remove() + + // Second listener we keep attached so we can observe that the session + // actually started before requesting a stop. + const started = deferred() + const stopped = deferred() + const secondStartSub = session.addOnStartedListener(started.resolve) + const stopSub = session.addOnStoppedListener(stopped.resolve) + const errorSub = session.addOnErrorListener((error) => { + started.reject(error) + stopped.reject(error) + }) + + try { + await session.start() + await withTimeout(started.promise, 10_000, 'session start') + await session.stop() + await withTimeout(stopped.promise, 10_000, 'session stop') + + expect(startedAfterRemove).toBe(0) + } finally { + secondStartSub.remove() + stopSub.remove() + errorSub.remove() + } + }) + + it('registers an onError listener without throwing', async () => { + const session = await VisionCamera.createCameraSession(false) + const subscription = session.addOnErrorListener(() => {}) + subscription.remove() + await session.stop() + }) + + it('registers interruption listeners without throwing', async () => { + const session = await VisionCamera.createCameraSession(false) + const a = session.addOnInterruptionStartedListener(() => {}) + const b = session.addOnInterruptionEndedListener(() => {}) + a.remove() + b.remove() + await session.stop() + }) + + it('reconfigures a running session with a new output set', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + + await session.configure([ + { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + + const controllers = await session.configure([ + { + input: device, + outputs: [ + { output: photoOutput, mirrorMode: 'auto' }, + { output: videoOutput, mirrorMode: 'auto' }, + ], + constraints: [], + }, + ]) + expect(controllers).toHaveLength(1) + + await session.stop() + }) + + it('replaces the photo output with one of a different config while running', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const firstPhotoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: device, + outputs: [{ output: firstPhotoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + try { + const firstPhoto = await firstPhotoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(firstPhoto.width).toBeGreaterThan(0) + expect(firstPhoto.height).toBeGreaterThan(0) + firstPhoto.dispose() + + const secondPhotoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.FHD_4_3, + containerFormat: 'jpeg', + quality: 0.5, + qualityPrioritization: 'quality', + }) + + await session.configure([ + { + input: device, + outputs: [{ output: secondPhotoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const secondPhoto = await secondPhotoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ) + expect(secondPhoto.width).toBeGreaterThan(0) + expect(secondPhoto.height).toBeGreaterThan(0) + secondPhoto.dispose() + + expect(sessionError).toBe(undefined) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('replaces the video output with one of a different config while running', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const firstVideoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: device, + outputs: [{ output: firstVideoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + try { + const firstRecorder = await firstVideoOutput.createRecorder({}) + const firstFinished = deferred() + await firstRecorder.startRecording( + () => firstFinished.resolve(), + firstFinished.reject, + ) + await sleep(500) + await firstRecorder.stopRecording() + await withTimeout(firstFinished.promise, 15_000, 'first recording finish') + + const secondVideoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.FHD_16_9, + enableAudio: false, + }) + + await session.configure([ + { + input: device, + outputs: [{ output: secondVideoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + const secondRecorder = await secondVideoOutput.createRecorder({}) + const secondFinished = deferred() + await secondRecorder.startRecording( + () => secondFinished.resolve(), + secondFinished.reject, + ) + await sleep(500) + await secondRecorder.stopRecording() + await withTimeout( + secondFinished.promise, + 15_000, + 'second recording finish', + ) + + expect(sessionError).toBe(undefined) + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('replaces the frame output with one of a different pixel format while running', async () => { + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const yuvFrameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.HD_16_9, + pixelFormat: 'yuv', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + let sessionError: Error | undefined + const errorSub = session.addOnErrorListener((error) => { + sessionError = error + }) + + await session.configure([ + { + input: device, + outputs: [{ output: yuvFrameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + // YUV is planar; RGB is not. We use frame.isPlanar to verify the swap + // actually swung the pipeline over to the new pixel format. + let yuvIsPlanar: boolean | undefined + const reportYuv = (planar: boolean) => { + yuvIsPlanar = planar + } + const yuvRuntime = workletsProvider.createRuntimeForThread( + yuvFrameOutput.thread, + ) + yuvRuntime.setOnFrameCallback(yuvFrameOutput, (frame) => { + 'worklet' + scheduleOnRN(reportYuv, frame.isPlanar) + frame.dispose() + }) + + await session.start() + + try { + await waitUntil(() => yuvIsPlanar != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(yuvIsPlanar).toBe(true) + + yuvRuntime.setOnFrameCallback(yuvFrameOutput, undefined) + + const rgbFrameOutput = VisionCamera.createFrameOutput({ + targetResolution: CommonResolutions.VGA_16_9, + pixelFormat: 'rgb', + enablePreviewSizedOutputBuffers: false, + enablePhysicalBufferRotation: false, + enableCameraMatrixDelivery: false, + allowDeferredStart: false, + dropFramesWhileBusy: true, + }) + + await session.configure([ + { + input: device, + outputs: [{ output: rgbFrameOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + + let rgbIsPlanar: boolean | undefined + const reportRgb = (planar: boolean) => { + rgbIsPlanar = planar + } + const rgbRuntime = workletsProvider.createRuntimeForThread( + rgbFrameOutput.thread, + ) + rgbRuntime.setOnFrameCallback(rgbFrameOutput, (frame) => { + 'worklet' + scheduleOnRN(reportRgb, frame.isPlanar) + frame.dispose() + }) + + try { + await waitUntil(() => rgbIsPlanar != null || sessionError != null, { + timeout: 15_000, + }) + expect(sessionError).toBe(undefined) + expect(rgbIsPlanar).toBe(false) + } finally { + rgbRuntime.setOnFrameCallback(rgbFrameOutput, undefined) + } + } finally { + errorSub.remove() + await session.stop() + } + }) + + it('supports a multi-cam session when the platform allows it', async (context) => { + if (!VisionCamera.supportsMultiCamSessions) { + return context.skip('multi-cam session: not supported on this platform') + } + const combination = factory.supportedMultiCamDeviceCombinations.find( + (devices) => devices.length >= 2, + ) + if (combination == null) { + return context.skip( + 'multi-cam session: no multi-device combination reported on this device', + ) + } + + const session = await VisionCamera.createCameraSession(true) + const connections = combination.map((device) => ({ + input: device, + outputs: [ + { + output: VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg' as const, + quality: 0.8, + qualityPrioritization: 'balanced' as const, + }), + mirrorMode: 'auto' as const, + }, + ], + constraints: [], + })) + + const controllers = await session.configure(connections) + expect(controllers).toHaveLength(combination.length) + const controllerDeviceIds = controllers.map( + (controller) => controller.device.id, + ) + const expectedDeviceIds = combination.map((device) => device.id) + expect(controllerDeviceIds).toEqual(expectedDeviceIds) + + const started = deferred() + const sub = session.addOnStartedListener(started.resolve) + const errorSub = session.addOnErrorListener(started.reject) + try { + await session.start() + await withTimeout(started.promise, 15_000, 'session start') + await session.stop() + } finally { + sub.remove() + errorSub.remove() + } + }) + + it('configures a multi-cam session for every supported device combination', async (context) => { + if (!VisionCamera.supportsMultiCamSessions) { + return context.skip( + 'multi-cam combinations: not supported on this platform', + ) + } + const combinations = factory.supportedMultiCamDeviceCombinations + if (combinations.length === 0) { + return context.skip( + 'multi-cam combinations: no combinations reported on this device', + ) + } + + const session = await VisionCamera.createCameraSession(true) + try { + // Starting every reported combination is expensive on physical devices. + // The previous test covers actual lifecycle; this one covers the full + // configure-time compatibility surface. + for (const combination of combinations) { + // iOS can report a singleton virtual camera (for example a Dual- or + // Triple-Camera) as a supported multi-cam device set. + expect(combination.length).toBeGreaterThan(0) + const connections = combination.map((device) => ({ + input: device, + outputs: [ + { + output: VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg' as const, + quality: 0.8, + qualityPrioritization: 'balanced' as const, + }), + mirrorMode: 'auto' as const, + }, + ], + constraints: [], + })) + const controllers = await session.configure(connections) + const controllerDeviceIds = controllers.map( + (controller) => controller.device.id, + ) + const expectedDeviceIds = combination.map((device) => device.id) + expect(controllerDeviceIds).toEqual(expectedDeviceIds) + + const description = combination + .map((device) => `${device.position}:${device.id}`) + .join(', ') + console.log(`multi-cam combination configured: [${description}]`) + } + } finally { + await session.stop() + await session.configure([]) + } + }) +}) diff --git a/apps/simple-camera/__tests__/visioncamera.video.harness.ts b/apps/simple-camera/__tests__/visioncamera.video.harness.ts new file mode 100644 index 0000000000..bfbe141d56 --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.video.harness.ts @@ -0,0 +1,686 @@ +import { Platform } from 'react-native' +import { + beforeAll, + describe, + expect, + it, + waitUntil, +} from 'react-native-harness' +import type { + CameraDevice, + CameraDeviceFactory, + Recorder, + RecordingFinishedReason, +} from 'react-native-vision-camera' +import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' +import { deferred, withTimeout } from './test-utils' + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) + +describe('VisionCamera - Video', () => { + let factory: CameraDeviceFactory + let backDevice: CameraDevice + + beforeAll(async () => { + await VisionCamera.requestCameraPermission() + await VisionCamera.requestMicrophonePermission() + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + expect(VisionCamera.microphonePermissionStatus).toBe('authorized') + factory = await VisionCamera.createDeviceFactory() + const back = factory.getDefaultCamera('back') + expect(back).toBeDefined() + if (back == null) throw new Error('no back camera') + backDevice = back + }) + + it('records a short clip and finishes with reason "stopped"', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + // File paths must start with "/" and end with ".mov" or ".mp4". + expect(recorder.filePath).toMatch(/^\/.*\.(mov|mp4)$/) + const finished = deferred<{ + path: string + reason: RecordingFinishedReason + }>() + + try { + await recorder.startRecording( + (path, reason) => finished.resolve({ path, reason }), + finished.reject, + ) + await sleep(1_000) + await recorder.stopRecording() + const result = await withTimeout(finished.promise, 10_000, 'finish') + + expect(result.reason).toBe('stopped') + expect(result.path.length).toBeGreaterThan(0) + // File paths must start with "/" and end with ".mov" or ".mp4". + expect(result.path).toMatch(/^\/.*\.(mov|mp4)$/) + } finally { + await session.stop() + } + }) + + it('records with audio enabled', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(1500) + await recorder.stopRecording() + await withTimeout(finished.promise, 10_000, 'finish') + } finally { + await session.stop() + } + }) + + it('applies a custom targetBitRate', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + targetBitRate: 2_000_000, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(1500) + await recorder.stopRecording() + await withTimeout(finished.promise, 10_000, 'finish') + } finally { + await session.stop() + } + }) + + it('stops automatically when maxDuration is reached', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({ maxDuration: 1 }) + const finished = deferred() + try { + await recorder.startRecording( + (_path, reason) => finished.resolve(reason), + finished.reject, + ) + const reason = await withTimeout(finished.promise, 15_000, 'maxDuration') + expect(reason).toBe('max-duration-reached') + } finally { + await session.stop() + } + }) + + it('stops automatically when maxFileSize is reached', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + targetBitRate: 8_000_000, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + // Device Farm camera feeds are often near-static, and Android encoders can + // undershoot `targetBitRate` heavily for that content. Keep the cap low + // enough to be reached in CI while still leaving room for a valid first GOP. + const recorder = await videoOutput.createRecorder({ maxFileSize: 128_000 }) + const finished = deferred() + try { + await recorder.startRecording( + (_path, reason) => finished.resolve(reason), + finished.reject, + ) + const reason = await withTimeout(finished.promise, 30_000, 'maxFileSize') + console.log( + `maxFileSize duration=${recorder.recordedDuration}s, size=${recorder.recordedFileSize}B`, + ) + expect(reason).toBe('max-file-size-reached') + } finally { + await session.stop() + } + }) + + it('pauses and resumes a recording', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + const paused = deferred() + const resumed = deferred() + const finished = deferred() + try { + await recorder.startRecording( + () => finished.resolve(), + (error) => { + // Any recording error must abort whichever wait is active. + paused.reject(error) + resumed.reject(error) + finished.reject(error) + }, + () => paused.resolve(), + () => resumed.resolve(), + ) + await sleep(300) + await recorder.pauseRecording() + await withTimeout(paused.promise, 5_000, 'pause') + + await recorder.resumeRecording() + await withTimeout(resumed.promise, 5_000, 'resume') + await sleep(300) + + await recorder.stopRecording() + await withTimeout(finished.promise, 10_000, 'finish') + } finally { + await session.stop() + } + }) + + it('cancels a recording and does not fire onRecordingFinished', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + let finishedCount = 0 + let errorCount = 0 + try { + await recorder.startRecording( + () => { + finishedCount++ + }, + () => { + errorCount++ + }, + ) + await sleep(500) + await recorder.cancelRecording() + await sleep(500) + expect(finishedCount).toBe(0) + expect(errorCount).toBe(0) + } finally { + await session.stop() + } + }) + + it('reports growing recordedDuration and recordedFileSize while recording', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + await recorder.startRecording(() => finished.resolve(), finished.reject) + expect(recorder.filePath.length).toBeGreaterThan(0) + await waitUntil( + () => recorder.recordedDuration > 0 && recorder.recordedFileSize > 0, + { timeout: 10_000 }, + ) + const midDuration = recorder.recordedDuration + const midSize = recorder.recordedFileSize + await recorder.stopRecording() + await withTimeout(finished.promise, 10_000, 'finish') + console.log( + `recorded mid duration=${midDuration}s mid size=${midSize}B, final size=${recorder.recordedFileSize}B`, + ) + expect(midDuration).toBeGreaterThan(0) + expect(midSize).toBeGreaterThan(0) + } finally { + await session.stop() + } + }) + + it('records with a persistent recorder across a session stop/start cycle', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + enablePersistentRecorder: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + + await session.stop() + await session.start() + await sleep(500) + + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + } finally { + await session.stop() + } + }) + + it('keeps a persistent recording running while switching the input device', async (context) => { + const frontDevice = factory.getDefaultCamera('front') + if (frontDevice == null) { + return context.skip( + 'persistent recorder device switch: no front camera available', + ) + } + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + enablePersistentRecorder: true, + }) + + // 1. Configure with the back camera + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + // 2. Start recording on the back camera + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(500) + + // 3. Reconfigure the running session with the front camera — the + // persistent recorder must keep running across the input switch. + await session.configure([ + { + input: frontDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await sleep(500) + + // 4. Stop the recording — the file should contain footage from both cameras. + await recorder.stopRecording() + await withTimeout(finished.promise, 15_000, 'finish') + } finally { + await session.stop() + } + }) + + it('records with enableHigherResolutionCodecs on Android', async (context) => { + if (Platform.OS !== 'android') { + return context.skip('enableHigherResolutionCodecs: Android only') + } + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.FHD_16_9, + enableAudio: false, + enableHigherResolutionCodecs: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + const recorder = await videoOutput.createRecorder({}) + const finished = deferred() + try { + await recorder.startRecording(() => finished.resolve(), finished.reject) + await sleep(1500) + await recorder.stopRecording() + await withTimeout(finished.promise, 10_000, 'finish') + } finally { + await session.stop() + } + }) + + it('records to a custom file path', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + // Discover the platform-specific app-writable temp directory by + // creating a default recorder and reading its chosen file path. + // We can't hard-code `/tmp/...` because iOS app sandboxes and Android + // app contexts both use platform-specific dynamic paths. + const probe = await videoOutput.createRecorder({}) + const probePath = probe.filePath + const tempDir = probePath.substring(0, probePath.lastIndexOf('/')) + const ext = Platform.OS === 'ios' ? 'mov' : 'mp4' + const customPath = `${tempDir}/visioncamera-custom-${Date.now()}.${ext}` + + const recorder = await videoOutput.createRecorder({ filePath: customPath }) + expect(recorder.filePath).toContain(customPath) + + const finished = deferred() + try { + await recorder.startRecording( + (filePath) => finished.resolve(filePath), + finished.reject, + ) + await sleep(500) + await recorder.stopRecording() + const path = await withTimeout(finished.promise, 10_000, 'finish') + + expect(path).toContain(customPath) + } finally { + await session.stop() + } + }) + + it('auto-creates parent directories for a nested custom file path', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const probe = await videoOutput.createRecorder({}) + const probePath = probe.filePath + const tempDir = probePath.substring(0, probePath.lastIndexOf('/')) + const ext = Platform.OS === 'ios' ? 'mov' : 'mp4' + // Use multiple non-existent nested folders so the test fails if the + // implementation doesn't recursively create parent dirs. + const customPath = `${tempDir}/visioncamera-nested-${Date.now()}/sub/dir/recording.${ext}` + + const recorder = await videoOutput.createRecorder({ filePath: customPath }) + expect(recorder.filePath).toContain(customPath) + + const finished = deferred() + try { + await recorder.startRecording( + (filePath) => finished.resolve(filePath), + finished.reject, + ) + await sleep(500) + await recorder.stopRecording() + // If the recording finishes without error, the nested directories + // had to be created on the fly - otherwise the encoder couldn't + // have written any bytes. + const path = await withTimeout(finished.promise, 10_000, 'finish') + expect(path).toContain(customPath) + } finally { + await session.stop() + } + }) + + it('fails to record when given an unwritable file path', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + // The filesystem root is read-only inside both iOS and Android app + // sandboxes, so writing to `/.mp4` must fail somehow. + const invalidPath = `/visioncamera-cannot-write-${Date.now()}.mp4` + let createError: Error | undefined + let startError: Error | undefined + let recordingError: Error | undefined + + try { + let recorder: Recorder | undefined + try { + recorder = await videoOutput.createRecorder({ filePath: invalidPath }) + } catch (e) { + createError = e as Error + } + if (recorder != null) { + try { + await recorder.startRecording( + () => {}, + (error) => { + recordingError = error + }, + ) + } catch (e) { + startError = e as Error + } + // The error may surface synchronously (startRecording rejection) + // or asynchronously (onRecordingError callback). + await waitUntil(() => startError != null || recordingError != null, { + timeout: 5_000, + }) + } + } finally { + await session.stop() + } + + const recordingFailure = createError ?? startError ?? recordingError + expect(recordingFailure).toBeDefined() + }) + + // Verifies that `targetResolution` actually drives the video pipeline. + // We can't introspect the captured MP4's dimensions from JS today, so the + // best signal is `videoOutput.currentResolution` once the output has been + // bound to the configured session. + it("records at the device's maximum supported video resolution", async () => { + const supported = backDevice.getSupportedResolutions('video') + expect(supported.length).toBeGreaterThan(0) + const max = supported.reduce((a, b) => + a.width * a.height > b.width * b.height ? a : b, + ) + + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: max, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: videoOutput }], + }, + ]) + await session.start() + try { + // iOS only populates the connection's format description once the + // session is actually streaming, so wait briefly. + await waitUntil(() => videoOutput.currentResolution != null, { + timeout: 10_000, + }) + + const reported = videoOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported video resolution') + + const requestedShortEdge = Math.min(max.width, max.height) + const requestedLongEdge = Math.max(max.width, max.height) + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + console.log( + `max device video res=${max.width}x${max.height} reported=${reported.width}x${reported.height}`, + ) + expect(reportedShortEdge).toBe(requestedShortEdge) + expect(reportedLongEdge).toBe(requestedLongEdge) + } finally { + await session.stop() + } + }) + + it("records at the device's minimum supported video resolution", async () => { + const supported = backDevice.getSupportedResolutions('video') + expect(supported.length).toBeGreaterThan(0) + const min = supported.reduce((a, b) => + a.width * a.height < b.width * b.height ? a : b, + ) + + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: min, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [{ resolutionBias: videoOutput }], + }, + ]) + await session.start() + try { + await waitUntil(() => videoOutput.currentResolution != null, { + timeout: 10_000, + }) + + const reported = videoOutput.currentResolution + expect(reported).toBeDefined() + if (reported == null) throw new Error('no reported video resolution') + + const requestedShortEdge = Math.min(min.width, min.height) + const requestedLongEdge = Math.max(min.width, min.height) + const reportedShortEdge = Math.min(reported.width, reported.height) + const reportedLongEdge = Math.max(reported.width, reported.height) + console.log( + `min device video res=${min.width}x${min.height} reported=${reported.width}x${reported.height}`, + ) + expect(reportedShortEdge).toBe(requestedShortEdge) + expect(reportedLongEdge).toBe(requestedLongEdge) + } finally { + await session.stop() + } + }) + + it('returns supported video codecs on iOS after the output is attached', async (context) => { + if (Platform.OS !== 'ios') { + return context.skip('getSupportedVideoCodecs: iOS only') + } + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + const codecs = videoOutput.getSupportedVideoCodecs() + expect(codecs.length).toBeGreaterThan(0) + console.log(`supported video codecs: ${codecs.join(', ')}`) + await session.stop() + }) +}) diff --git a/apps/simple-camera/android/app/build.gradle b/apps/simple-camera/android/app/build.gradle new file mode 100644 index 0000000000..4b26c0ab74 --- /dev/null +++ b/apps/simple-camera/android/app/build.gradle @@ -0,0 +1,119 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '..' + // root = file("../") + // The folder where the react-native NPM package is. Default is ../node_modules/react-native + reactNativeDir = file("../../../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen + codegenDir = file("../../../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js + cliFile = file("../../../../node_modules/react-native/cli.js") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. By default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + hermesCommand = "$rootDir/../../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "com.margelo.nitro.camera.example.simple" + defaultConfig { + applicationId "com.margelo.nitro.camera.example.simple" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/package/example/android/app/debug.keystore b/apps/simple-camera/android/app/debug.keystore similarity index 100% rename from package/example/android/app/debug.keystore rename to apps/simple-camera/android/app/debug.keystore diff --git a/package/example/android/app/proguard-rules.pro b/apps/simple-camera/android/app/proguard-rules.pro similarity index 100% rename from package/example/android/app/proguard-rules.pro rename to apps/simple-camera/android/app/proguard-rules.pro diff --git a/apps/simple-camera/android/app/src/main/AndroidManifest.xml b/apps/simple-camera/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..15d3bcbea1 --- /dev/null +++ b/apps/simple-camera/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package/example/android/app/src/main/java/com/mrousavy/camera/example/MainActivity.kt b/apps/simple-camera/android/app/src/main/java/com/margelo/nitro/camera/example/simple/MainActivity.kt similarity index 84% rename from package/example/android/app/src/main/java/com/mrousavy/camera/example/MainActivity.kt rename to apps/simple-camera/android/app/src/main/java/com/margelo/nitro/camera/example/simple/MainActivity.kt index cbe550b244..1737acd2e2 100644 --- a/package/example/android/app/src/main/java/com/mrousavy/camera/example/MainActivity.kt +++ b/apps/simple-camera/android/app/src/main/java/com/margelo/nitro/camera/example/simple/MainActivity.kt @@ -1,4 +1,4 @@ -package com.mrousavy.camera.example +package com.margelo.nitro.camera.example.simple import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate @@ -11,11 +11,11 @@ class MainActivity : ReactActivity() { * Returns the name of the main component registered from JavaScript. This is used to schedule * rendering of the component. */ - override fun getMainComponentName(): String = "VisionCameraExample" + override fun getMainComponentName(): String = "SimpleCamera" /** * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] - * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + * which allows you to enable New Architecture with a single boolean flag [fabricEnabled] */ override fun createReactActivityDelegate(): ReactActivityDelegate = DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) diff --git a/apps/simple-camera/android/app/src/main/java/com/margelo/nitro/camera/example/simple/MainApplication.kt b/apps/simple-camera/android/app/src/main/java/com/margelo/nitro/camera/example/simple/MainApplication.kt new file mode 100644 index 0000000000..1cc5a7d8e7 --- /dev/null +++ b/apps/simple-camera/android/app/src/main/java/com/margelo/nitro/camera/example/simple/MainApplication.kt @@ -0,0 +1,27 @@ +package com.margelo.nitro.camera.example.simple + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml b/apps/simple-camera/android/app/src/main/res/drawable/rn_edit_text_material.xml similarity index 99% rename from package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml rename to apps/simple-camera/android/app/src/main/res/drawable/rn_edit_text_material.xml index 73b37e4d99..5c25e728ea 100644 --- a/package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +++ b/apps/simple-camera/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -17,7 +17,8 @@ android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material" android:insetRight="@dimen/abc_edit_text_inset_horizontal_material" android:insetTop="@dimen/abc_edit_text_inset_top_material" - android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"> + android:insetBottom="@dimen/abc_edit_text_inset_bottom_material" + > + + + diff --git a/apps/simple-camera/android/build.gradle b/apps/simple-camera/android/build.gradle new file mode 100644 index 0000000000..88163784ac --- /dev/null +++ b/apps/simple-camera/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + ext { + buildToolsVersion = "36.1.0" + minSdkVersion = 26 + compileSdkVersion = 36 + targetSdkVersion = 36 + ndkVersion = "29.0.14206865" + kotlinVersion = "2.2.21" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +apply plugin: "com.facebook.react.rootproject" diff --git a/apps/simple-camera/android/gradle.properties b/apps/simple-camera/android/gradle.properties new file mode 100644 index 0000000000..9afe61598f --- /dev/null +++ b/apps/simple-camera/android/gradle.properties @@ -0,0 +1,44 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=false diff --git a/apps/simple-camera/android/gradle/wrapper/gradle-wrapper.jar b/apps/simple-camera/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..61285a659d Binary files /dev/null and b/apps/simple-camera/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/simple-camera/android/gradle/wrapper/gradle-wrapper.properties b/apps/simple-camera/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..37f78a6af8 --- /dev/null +++ b/apps/simple-camera/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/simple-camera/android/gradlew b/apps/simple-camera/android/gradlew new file mode 100755 index 0000000000..adff685a03 --- /dev/null +++ b/apps/simple-camera/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/apps/simple-camera/android/gradlew.bat b/apps/simple-camera/android/gradlew.bat new file mode 100644 index 0000000000..e509b2dd8f --- /dev/null +++ b/apps/simple-camera/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/apps/simple-camera/android/settings.gradle b/apps/simple-camera/android/settings.gradle new file mode 100644 index 0000000000..0d5c8d950e --- /dev/null +++ b/apps/simple-camera/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'SimpleCamera' +include ':app' +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/apps/simple-camera/app.json b/apps/simple-camera/app.json new file mode 100644 index 0000000000..841a2fd83b --- /dev/null +++ b/apps/simple-camera/app.json @@ -0,0 +1,4 @@ +{ + "name": "SimpleCamera", + "displayName": "SimpleCamera" +} diff --git a/apps/simple-camera/babel.config.js b/apps/simple-camera/babel.config.js new file mode 100644 index 0000000000..bfd0b8d143 --- /dev/null +++ b/apps/simple-camera/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], +} diff --git a/apps/simple-camera/device-farm-tests/AwsTestSpec.yml b/apps/simple-camera/device-farm-tests/AwsTestSpec.yml new file mode 100644 index 0000000000..4efde13281 --- /dev/null +++ b/apps/simple-camera/device-farm-tests/AwsTestSpec.yml @@ -0,0 +1,41 @@ +version: 0.1 + +android_test_host: amazon_linux_2 + +phases: + install: + commands: + - devicefarm-cli use node 20 + - node -v + - curl -fsSL https://bun.com/install | bash + - ~/.bun/bin/bun install + + - adb wait-for-device + - adb devices -l + + test: + commands: + - MANUFACTURER="$(adb shell getprop ro.product.manufacturer | tr -d '\r')" + - MODEL="$(adb shell getprop ro.product.model | tr -d '\r')" + - 'echo "Resolved Device Farm device for harness: ${MANUFACTURER} ${MODEL}"' + - HARNESS_ARTIFACTS="$WORKING_DIRECTORY/harness-artifacts" + - HARNESS_JUNIT="$HARNESS_ARTIFACTS/harness-results.junit.xml" + - HARNESS_LOG="$HARNESS_ARTIFACTS/harness-output.log" + - mkdir -p "$HARNESS_ARTIFACTS" + - | + set -euo pipefail + + cd apps/simple-camera + + HARNESS_ANDROID_DEVICE_MANUFACTURER="$MANUFACTURER" \ + HARNESS_ANDROID_DEVICE_MODEL="$MODEL" \ + CI=true \ + FORCE_COLOR=1 \ + JEST_JUNIT_OUTPUT_FILE="$HARNESS_JUNIT" \ + ~/.bun/bin/bun run test:harness:android -- \ + --reporters=default \ + --reporters=jest-junit \ + --verbose 2>&1 | tee "$HARNESS_LOG" + +artifacts: + - $WORKING_DIRECTORY diff --git a/apps/simple-camera/device-farm-tests/AwsTestSpecIOS.yml b/apps/simple-camera/device-farm-tests/AwsTestSpecIOS.yml new file mode 100644 index 0000000000..f7cae21bfe --- /dev/null +++ b/apps/simple-camera/device-farm-tests/AwsTestSpecIOS.yml @@ -0,0 +1,82 @@ +version: 0.1 + +ios_test_host: macos_sequoia + +phases: + install: + commands: + - devicefarm-cli use node 20 + - node -v + - xcodebuild -version + - curl -fsSL https://bun.com/install | bash + - xcrun devicectl list devices + + # The XCTEST_UI package contains the Harness XCTest runner IPA and a + # repo archive. Unpack the repo so the Node/Bun Harness tests can run. + - | + set -euo pipefail + + REPO_ARCHIVE="$DEVICEFARM_XCTEST_BUILD_DIRECTORY/react-native-vision-camera.zip" + HARNESS_REPO_ROOT="$WORKING_DIRECTORY/react-native-vision-camera" + + test -f "$REPO_ARCHIVE" + rm -rf "$HARNESS_REPO_ROOT" + mkdir -p "$HARNESS_REPO_ROOT" + unzip -q "$REPO_ARCHIVE" -d "$HARNESS_REPO_ROOT" + + cd "$HARNESS_REPO_ROOT" + ~/.bun/bin/bun install --frozen-lockfile + + test: + commands: + - HARNESS_ARTIFACTS="$WORKING_DIRECTORY/harness-artifacts" + - HARNESS_JUNIT="$HARNESS_ARTIFACTS/harness-results.junit.xml" + - HARNESS_LOG="$HARNESS_ARTIFACTS/harness-output.log" + - HARNESS_METRO_HOST_FILE="$HARNESS_ARTIFACTS/metro-host-ip.txt" + - mkdir -p "$HARNESS_ARTIFACTS" "$WORKING_DIRECTORY/react-native-vision-camera/apps/simple-camera/.harness/crash-reports" + + # Resolve host IPv6 for Metro. On macOS runners IPv4 won't work, as + # confirmed by AWS support. AWS recommends utun interfaces for this. + - | + set -euo pipefail + + METRO_HOST_IP="$(ifconfig utun1 2>/dev/null | awk '/inet6 / {ip=$2} END {print ip}' || true)" + if [ -z "$METRO_HOST_IP" ]; then + METRO_HOST_IP="$(ifconfig utun0 2>/dev/null | awk '/inet6 / {ip=$2} END {print ip}' || true)" + fi + + METRO_HOST_IP="${METRO_HOST_IP%%%*}" + if [ -z "$METRO_HOST_IP" ]; then + echo "Failed to resolve Metro host IPv6." >&2 + exit 1 + fi + + echo "Using Metro host IPv6 for iOS harness: ${METRO_HOST_IP}" + echo "$METRO_HOST_IP" > "$HARNESS_METRO_HOST_FILE" + + # Actually run the JS Harness tests. Harness owns the XCTest agent + # lifecycle, but uses AWS's generated .xctestrun instead of building one. + - | + set -euo pipefail + + : "${DEVICEFARM_DEVICE_UDID:?Missing DEVICEFARM_DEVICE_UDID. Device Farm must provide the physical iOS device UDID.}" + + cd "$WORKING_DIRECTORY/react-native-vision-camera/apps/simple-camera" + + HARNESS_IOS_XCTESTRUN_FILE="$DEVICEFARM_XCUITESTRUN_FILE" \ + HARNESS_IOS_XCTEST_DERIVED_DATA_PATH="$DEVICEFARM_DERIVED_DATA_PATH" \ + HARNESS_METRO_BIND_HOST="::" \ + HARNESS_IOS_METRO_HOST="$(cat "$HARNESS_METRO_HOST_FILE")" \ + HARNESS_IOS_DEVICE_ID="$DEVICEFARM_DEVICE_UDID" \ + CI=true \ + FORCE_COLOR=1 \ + JEST_JUNIT_OUTPUT_FILE="$HARNESS_JUNIT" \ + ~/.bun/bin/bun run test:harness -- \ + --harnessRunner ios \ + --reporters=default \ + --reporters=jest-junit \ + --verbose 2>&1 | tee "$HARNESS_LOG" + +artifacts: + - $WORKING_DIRECTORY/harness-artifacts + - $WORKING_DIRECTORY/react-native-vision-camera/apps/simple-camera/.harness/crash-reports diff --git a/apps/simple-camera/index.js b/apps/simple-camera/index.js new file mode 100644 index 0000000000..c6f88c403b --- /dev/null +++ b/apps/simple-camera/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native' +import { name as appName } from './app.json' +import App from './src/App' + +AppRegistry.registerComponent(appName, () => App) diff --git a/apps/simple-camera/ios/.xcode.env b/apps/simple-camera/ios/.xcode.env new file mode 100644 index 0000000000..3d5782c715 --- /dev/null +++ b/apps/simple-camera/ios/.xcode.env @@ -0,0 +1,11 @@ +# This `.xcode.env` file is versioned and is used to source the environment +# used when running script phases inside Xcode. +# To customize your local environment, you can create an `.xcode.env.local` +# file that is not versioned. + +# NODE_BINARY variable contains the PATH to the node executable. +# +# Customize the NODE_BINARY variable here. +# For example, to use nvm with brew, add the following line +# . "$(brew --prefix nvm)/nvm.sh" --no-use +export NODE_BINARY=$(command -v node) diff --git a/apps/simple-camera/ios/Podfile b/apps/simple-camera/ios/Podfile new file mode 100644 index 0000000000..e956a9b149 --- /dev/null +++ b/apps/simple-camera/ios/Podfile @@ -0,0 +1,34 @@ +# Resolve react_native_pods.rb with node to allow for hoisting +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip + +platform :ios, '15.5' +prepare_react_native_project! + +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green + use_frameworks! :linkage => linkage.to_sym +end + +target 'SimpleCamera' do + config = use_native_modules! + + use_react_native!( + :path => config[:reactNativePath], + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/.." + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + # :ccache_enabled => true + ) + end +end diff --git a/apps/simple-camera/ios/Podfile.lock b/apps/simple-camera/ios/Podfile.lock new file mode 100644 index 0000000000..727225291f --- /dev/null +++ b/apps/simple-camera/ios/Podfile.lock @@ -0,0 +1,2858 @@ +PODS: + - FBLazyVector (0.85.3) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (9.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 8.0.0) + - GoogleMLKit/MLKitCore (9.0.0): + - MLKitCommon (~> 14.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - hermes-engine (250829098.0.10): + - hermes-engine/Pre-built (= 250829098.0.10) + - hermes-engine/Pre-built (250829098.0.10) + - MLImage (1.0.0-beta8) + - MLKitBarcodeScanning (8.0.0): + - MLKitCommon (~> 14.0) + - MLKitVision (~> 10.0) + - MLKitCommon (14.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (10.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta8) + - MLKitCommon (~> 14.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - NitroImage (0.15.0): + - 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.35.9): + - hermes-engine + - 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 + - PromisesObjC (2.4.0) + - RCTDeprecation (0.85.3) + - RCTRequired (0.85.3) + - RCTSwiftUI (0.85.3) + - RCTSwiftUIWrapper (0.85.3): + - RCTSwiftUI + - RCTTypeSafety (0.85.3): + - FBLazyVector (= 0.85.3) + - RCTRequired (= 0.85.3) + - React-Core (= 0.85.3) + - React (0.85.3): + - React-Core (= 0.85.3) + - React-Core/DevSupport (= 0.85.3) + - React-Core/RCTWebSocket (= 0.85.3) + - React-RCTActionSheet (= 0.85.3) + - React-RCTAnimation (= 0.85.3) + - React-RCTBlob (= 0.85.3) + - React-RCTImage (= 0.85.3) + - React-RCTLinking (= 0.85.3) + - React-RCTNetwork (= 0.85.3) + - React-RCTSettings (= 0.85.3) + - React-RCTText (= 0.85.3) + - React-RCTVibration (= 0.85.3) + - React-callinvoker (0.85.3) + - React-Core (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.85.3) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core-prebuilt (0.85.3): + - ReactNativeDependencies + - React-Core/CoreModulesHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/Default (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/DevSupport (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.85.3) + - React-Core/RCTWebSocket (= 0.85.3) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTActionSheetHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTAnimationHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTBlobHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTImageHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTLinkingHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTNetworkHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTSettingsHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTTextHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTVibrationHeaders (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTWebSocket (0.85.3): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.85.3) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-CoreModules (0.85.3): + - RCTTypeSafety (= 0.85.3) + - React-Core-prebuilt + - React-Core/CoreModulesHeaders (= 0.85.3) + - React-debug + - React-featureflags + - React-jsi (= 0.85.3) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-NativeModulesApple + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.85.3) + - React-runtimeexecutor + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-cxxreact (0.85.3): + - hermes-engine + - React-callinvoker (= 0.85.3) + - React-Core-prebuilt + - React-debug (= 0.85.3) + - React-jsi (= 0.85.3) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-logger (= 0.85.3) + - React-perflogger (= 0.85.3) + - React-runtimeexecutor + - React-timing (= 0.85.3) + - React-utils + - ReactNativeDependencies + - React-debug (0.85.3): + - React-debug/redbox (= 0.85.3) + - React-debug/redbox (0.85.3) + - React-defaultsnativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-domnativemodule + - React-Fabric/animated + - React-featureflags + - React-featureflagsnativemodule + - React-idlecallbacksnativemodule + - React-intersectionobservernativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-mutationobservernativemodule + - React-RCTFBReactNativeSpec + - React-webperformancenativemodule + - ReactNativeDependencies + - Yoga + - React-domnativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-FabricComponents + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animated (= 0.85.3) + - React-Fabric/animationbackend (= 0.85.3) + - React-Fabric/animations (= 0.85.3) + - React-Fabric/attributedstring (= 0.85.3) + - React-Fabric/bridging (= 0.85.3) + - React-Fabric/componentregistry (= 0.85.3) + - React-Fabric/componentregistrynative (= 0.85.3) + - React-Fabric/components (= 0.85.3) + - React-Fabric/consistency (= 0.85.3) + - React-Fabric/core (= 0.85.3) + - React-Fabric/dom (= 0.85.3) + - React-Fabric/imagemanager (= 0.85.3) + - React-Fabric/leakchecker (= 0.85.3) + - React-Fabric/mounting (= 0.85.3) + - React-Fabric/observers (= 0.85.3) + - React-Fabric/scheduler (= 0.85.3) + - React-Fabric/telemetry (= 0.85.3) + - React-Fabric/uimanager (= 0.85.3) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animated (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animationbackend + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animationbackend (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animations (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/attributedstring (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/bridging (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistry (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistrynative (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.85.3) + - React-Fabric/components/root (= 0.85.3) + - React-Fabric/components/scrollview (= 0.85.3) + - React-Fabric/components/view (= 0.85.3) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/legacyviewmanagerinterop (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/root (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/scrollview (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/view (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric/consistency (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/core (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/dom (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/imagemanager (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/leakchecker (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/mounting (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events (= 0.85.3) + - React-Fabric/observers/intersection (= 0.85.3) + - React-Fabric/observers/mutation (= 0.85.3) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/events (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/intersection (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/mutation (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/scheduler (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animationbackend + - React-Fabric/observers/events + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancecdpmetrics + - React-performancetimeline + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/telemetry (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/uimanager/consistency (= 0.85.3) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager/consistency (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-FabricComponents (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.85.3) + - React-FabricComponents/textlayoutmanager (= 0.85.3) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.85.3) + - React-FabricComponents/components/iostextinput (= 0.85.3) + - React-FabricComponents/components/modal (= 0.85.3) + - React-FabricComponents/components/rncore (= 0.85.3) + - React-FabricComponents/components/safeareaview (= 0.85.3) + - React-FabricComponents/components/scrollview (= 0.85.3) + - React-FabricComponents/components/switch (= 0.85.3) + - React-FabricComponents/components/text (= 0.85.3) + - React-FabricComponents/components/textinput (= 0.85.3) + - React-FabricComponents/components/unimplementedview (= 0.85.3) + - React-FabricComponents/components/virtualview (= 0.85.3) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/inputaccessory (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/iostextinput (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/modal (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/rncore (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/safeareaview (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/scrollview (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/switch (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/text (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/textinput (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/unimplementedview (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/virtualview (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/textlayoutmanager (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricImage (0.85.3): + - hermes-engine + - RCTRequired (= 0.85.3) + - RCTTypeSafety (= 0.85.3) + - React-Core-prebuilt + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.85.3) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-featureflags (0.85.3): + - React-Core-prebuilt + - ReactNativeDependencies + - React-featureflagsnativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-graphics (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-utils + - ReactNativeDependencies + - React-hermes (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.85.3) + - React-jsi + - React-jsiexecutor (= 0.85.3) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-oscompat + - React-perflogger (= 0.85.3) + - React-runtimeexecutor + - ReactNativeDependencies + - React-idlecallbacksnativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-ImageManager (0.85.3): + - React-Core-prebuilt + - React-Core/Default + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-intersectionobservernativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-Fabric/bridging + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-jserrorhandler (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - ReactCommon/turbomodule/bridging + - ReactNativeDependencies + - React-jsi (0.85.3): + - hermes-engine + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsiexecutor (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-jserrorhandler + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspector (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-oscompat + - React-perflogger (= 0.85.3) + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspectorcdp (0.85.3): + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsinspectornetwork (0.85.3): + - React-Core-prebuilt + - React-jsinspectorcdp + - ReactNativeDependencies + - React-jsinspectortracing (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsinspectornetwork + - React-oscompat + - React-timing + - React-utils + - ReactNativeDependencies + - React-jsitooling (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.85.3) + - React-debug + - React-jsi (= 0.85.3) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsitracing (0.85.3): + - React-jsi + - React-logger (0.85.3): + - React-Core-prebuilt + - ReactNativeDependencies + - React-Mapbuffer (0.85.3): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-microtasksnativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-mutationobservernativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-Fabric/bridging + - React-Fabric/observers/mutation + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-blur (4.4.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - 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 + - react-native-menu (2.0.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - 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 + - react-native-safe-area-context (5.8.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.8.0) + - react-native-safe-area-context/fabric (= 5.8.0) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.8.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - 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 + - react-native-safe-area-context/fabric (5.8.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-skia (2.6.4): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React + - 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 + - react-native-vector-icons-ionicons (13.1.2) + - react-native-video (6.19.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-video/Video (= 6.19.2) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-video/Fabric (6.19.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - 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 + - react-native-video/Video (6.19.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-video/Fabric + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.85.3): + - hermes-engine + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-runtimeexecutor + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-networking (0.85.3): + - React-Core-prebuilt + - React-jsinspectornetwork + - React-jsinspectortracing + - React-performancetimeline + - React-timing + - ReactNativeDependencies + - React-oscompat (0.85.3) + - React-perflogger (0.85.3): + - React-Core-prebuilt + - ReactNativeDependencies + - React-performancecdpmetrics (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-performancetimeline + - React-runtimeexecutor + - React-timing + - ReactNativeDependencies + - React-performancetimeline (0.85.3): + - React-Core-prebuilt + - React-featureflags + - React-jsinspector + - React-jsinspectortracing + - React-perflogger + - React-timing + - ReactNativeDependencies + - React-RCTActionSheet (0.85.3): + - React-Core/RCTActionSheetHeaders (= 0.85.3) + - React-RCTAnimation (0.85.3): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders + - React-debug + - React-featureflags + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTAppDelegate (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-RCTBlob (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTFabric (0.85.3): + - hermes-engine + - RCTSwiftUIWrapper + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricComponents + - React-FabricImage + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-networking + - React-performancecdpmetrics + - React-performancetimeline + - React-RCTAnimation + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTText + - React-rendererconsistency + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-RCTFBReactNativeSpec (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec/components (= 0.85.3) + - ReactCommon + - ReactNativeDependencies + - React-RCTFBReactNativeSpec/components (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-NativeModulesApple + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTImage (0.85.3): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTLinking (0.85.3): + - React-Core/RCTLinkingHeaders (= 0.85.3) + - React-jsi (= 0.85.3) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.85.3) + - React-RCTNetwork (0.85.3): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-NativeModulesApple + - React-networking + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTRuntime (0.85.3): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-debug + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-utils + - ReactNativeDependencies + - React-RCTSettings (0.85.3): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTText (0.85.3): + - React-Core/RCTTextHeaders (= 0.85.3) + - Yoga + - React-RCTVibration (0.85.3): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-rendererconsistency (0.85.3) + - React-renderercss (0.85.3): + - React-debug + - React-utils + - React-rendererdebug (0.85.3): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-RuntimeApple (0.85.3): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-RuntimeCore (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-runtimeexecutor (0.85.3): + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi (= 0.85.3) + - React-utils + - ReactNativeDependencies + - React-RuntimeHermes (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-runtimescheduler (0.85.3): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - ReactNativeDependencies + - React-timing (0.85.3): + - React-debug + - React-utils (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-debug + - React-jsi (= 0.85.3) + - ReactNativeDependencies + - React-webperformancenativemodule (0.85.3): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-jsi + - React-jsiexecutor + - React-performancetimeline + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactAppDependencyProvider (0.85.3): + - ReactCodegen + - ReactCodegen (0.85.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactCommon (0.85.3): + - React-Core-prebuilt + - ReactCommon/turbomodule (= 0.85.3) + - ReactNativeDependencies + - ReactCommon/turbomodule (0.85.3): + - hermes-engine + - React-callinvoker (= 0.85.3) + - React-Core-prebuilt + - React-cxxreact (= 0.85.3) + - React-jsi (= 0.85.3) + - React-logger (= 0.85.3) + - React-perflogger (= 0.85.3) + - ReactCommon/turbomodule/bridging (= 0.85.3) + - ReactCommon/turbomodule/core (= 0.85.3) + - ReactNativeDependencies + - ReactCommon/turbomodule/bridging (0.85.3): + - hermes-engine + - React-callinvoker (= 0.85.3) + - React-Core-prebuilt + - React-cxxreact (= 0.85.3) + - React-jsi (= 0.85.3) + - React-logger (= 0.85.3) + - React-perflogger (= 0.85.3) + - ReactNativeDependencies + - ReactCommon/turbomodule/core (0.85.3): + - hermes-engine + - React-callinvoker (= 0.85.3) + - React-Core-prebuilt + - React-cxxreact (= 0.85.3) + - React-debug (= 0.85.3) + - React-featureflags (= 0.85.3) + - React-jsi (= 0.85.3) + - React-logger (= 0.85.3) + - React-perflogger (= 0.85.3) + - React-utils (= 0.85.3) + - ReactNativeDependencies + - ReactNativeDependencies (0.85.3) + - RNGestureHandler (3.0.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - 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 + - RNReanimated (4.4.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/apple (= 4.4.0) + - RNReanimated/common (= 4.4.0) + - RNWorklets + - Yoga + - RNReanimated/apple (4.4.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNReanimated/common (4.4.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNScreens (4.25.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNScreens/common (= 4.25.2) + - Yoga + - RNScreens/common (4.25.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNVectorIcons (10.3.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - 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 + - RNWorklets (0.9.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/apple (= 0.9.1) + - RNWorklets/common (= 0.9.1) + - Yoga + - RNWorklets/apple (0.9.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets/common (0.9.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - VisionCamera (5.0.11): + - 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 + - VisionCameraBarcodeScanner (5.0.11): + - GoogleMLKit/BarcodeScanning (= 9.0.0) + - 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 + - VisionCamera + - Yoga + - VisionCameraLocation (5.0.11): + - 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 + - VisionCamera + - Yoga + - VisionCameraResizer (5.0.11): + - 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 + - VisionCamera + - Yoga + - VisionCameraWorklets (5.0.11): + - 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 + - RNWorklets + - VisionCamera + - Yoga + - Yoga (0.0.0) + +DEPENDENCIES: + - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) + - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroImage (from `../../../node_modules/react-native-nitro-image`) + - NitroModules (from `../../../node_modules/react-native-nitro-modules`) + - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) + - RCTSwiftUI (from `../../../node_modules/react-native/ReactApple/RCTSwiftUI`) + - RCTSwiftUIWrapper (from `../../../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) + - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../../../node_modules/react-native/`) + - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../../../node_modules/react-native/`) + - React-Core-prebuilt (from `../../../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) + - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-intersectionobservernativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) + - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../../../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-mutationobservernativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) + - "react-native-blur (from `../../../node_modules/@react-native-community/blur`)" + - "react-native-menu (from `../../../node_modules/@react-native-menu/menu`)" + - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) + - "react-native-skia (from `../../../node_modules/@shopify/react-native-skia`)" + - "react-native-vector-icons-ionicons (from `../../../node_modules/@react-native-vector-icons/ionicons`)" + - react-native-video (from `../../../node_modules/react-native-video`) + - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-networking (from `../../../node_modules/react-native/ReactCommon/react/networking`) + - React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../../../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) + - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../../../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../../../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) + - React-webperformancenativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) + - ReactCodegen (from `build/generated/ios/ReactCodegen`) + - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) + - RNReanimated (from `../../../node_modules/react-native-reanimated`) + - RNScreens (from `../../../node_modules/react-native-screens`) + - RNVectorIcons (from `../../../node_modules/react-native-vector-icons`) + - RNWorklets (from `../../../node_modules/react-native-worklets`) + - VisionCamera (from `../../../node_modules/react-native-vision-camera`) + - VisionCameraBarcodeScanner (from `../../../node_modules/react-native-vision-camera-barcode-scanner`) + - VisionCameraLocation (from `../../../node_modules/react-native-vision-camera-location`) + - VisionCameraResizer (from `../../../node_modules/react-native-vision-camera-resizer`) + - VisionCameraWorklets (from `../../../node_modules/react-native-vision-camera-worklets`) + - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + FBLazyVector: + :path: "../../../node_modules/react-native/Libraries/FBLazyVector" + hermes-engine: + :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-v250829098.0.10 + NitroImage: + :path: "../../../node_modules/react-native-nitro-image" + NitroModules: + :path: "../../../node_modules/react-native-nitro-modules" + RCTDeprecation: + :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + RCTRequired: + :path: "../../../node_modules/react-native/Libraries/Required" + RCTSwiftUI: + :path: "../../../node_modules/react-native/ReactApple/RCTSwiftUI" + RCTSwiftUIWrapper: + :path: "../../../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" + RCTTypeSafety: + :path: "../../../node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../../../node_modules/react-native/" + React-callinvoker: + :path: "../../../node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: "../../../node_modules/react-native/" + React-Core-prebuilt: + :podspec: "../../../node_modules/react-native/React-Core-prebuilt.podspec" + React-CoreModules: + :path: "../../../node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../../../node_modules/react-native/ReactCommon/cxxreact" + React-debug: + :path: "../../../node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../../../node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../../../node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../../../node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../../../node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-intersectionobservernativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" + React-jserrorhandler: + :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../../../node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectorcdp: + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + React-jsinspectornetwork: + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/network" + React-jsinspectortracing: + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../../../node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../../../node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../../../node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + React-mutationobservernativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver" + react-native-blur: + :path: "../../../node_modules/@react-native-community/blur" + react-native-menu: + :path: "../../../node_modules/@react-native-menu/menu" + react-native-safe-area-context: + :path: "../../../node_modules/react-native-safe-area-context" + react-native-skia: + :path: "../../../node_modules/@shopify/react-native-skia" + react-native-vector-icons-ionicons: + :path: "../../../node_modules/@react-native-vector-icons/ionicons" + react-native-video: + :path: "../../../node_modules/react-native-video" + React-NativeModulesApple: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-networking: + :path: "../../../node_modules/react-native/ReactCommon/react/networking" + React-oscompat: + :path: "../../../node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" + React-performancecdpmetrics: + :path: "../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + React-performancetimeline: + :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimation: + :path: "../../../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../../../node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../../../node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: "../../../node_modules/react-native/React" + React-RCTFBReactNativeSpec: + :path: "../../../node_modules/react-native/React" + React-RCTImage: + :path: "../../../node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../../../node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../../../node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: "../../../node_modules/react-native/React/Runtime" + React-RCTSettings: + :path: "../../../node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../../../node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../../../node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" + React-RuntimeApple: + :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../../../node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../../../node_modules/react-native/ReactCommon/react/utils" + React-webperformancenativemodule: + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + ReactAppDependencyProvider: + :path: build/generated/ios/ReactAppDependencyProvider + ReactCodegen: + :path: build/generated/ios/ReactCodegen + ReactCommon: + :path: "../../../node_modules/react-native/ReactCommon" + ReactNativeDependencies: + :podspec: "../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNGestureHandler: + :path: "../../../node_modules/react-native-gesture-handler" + RNReanimated: + :path: "../../../node_modules/react-native-reanimated" + RNScreens: + :path: "../../../node_modules/react-native-screens" + RNVectorIcons: + :path: "../../../node_modules/react-native-vector-icons" + RNWorklets: + :path: "../../../node_modules/react-native-worklets" + VisionCamera: + :path: "../../../node_modules/react-native-vision-camera" + VisionCameraBarcodeScanner: + :path: "../../../node_modules/react-native-vision-camera-barcode-scanner" + VisionCameraLocation: + :path: "../../../node_modules/react-native-vision-camera-location" + VisionCameraResizer: + :path: "../../../node_modules/react-native-vision-camera-resizer" + VisionCameraWorklets: + :path: "../../../node_modules/react-native-vision-camera-worklets" + Yoga: + :path: "../../../node_modules/react-native/ReactCommon/yoga" + +SPEC CHECKSUMS: + FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + hermes-engine: 691752261227b9de03faf08561f47f2e2b5b52e9 + MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 + MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343 + MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 + MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + NitroImage: 33c4698f86b081bd3d0245223f34ea561b0934ea + NitroModules: 16bc17a076b12304d608f7c915b9d321f56dfc19 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 + RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc + RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 + RCTSwiftUIWrapper: 966ca7f5f22ac0b2b2255fb09cffc381f5440b03 + RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2 + React: e2dc35338068bbd299c66f043ae0d7f25de8499e + React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 + React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0 + React-Core-prebuilt: 0e292a9b21c7ff7a2d50d7db3141213b15222a2a + React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146 + React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716 + React-debug: 92944dc4d89f56d640e75498266cbde557a48189 + React-defaultsnativemodule: cd64bc09d7ca24112bbaf1b91edbbcf3d81ea7dc + React-domnativemodule: dacf5bc055ae041039574f38b73a20b91e368774 + React-Fabric: bb0baa33d91839631d315800eb23e9aaa4338a44 + React-FabricComponents: c504d0b0e2f3054b2ba19af839f175cb361153c0 + React-FabricImage: 91eaea1cc58d25ae2596a9277bcfe028f92374c3 + React-featureflags: 5ac0455da0af12ca79b40402e2f42c5c7556b638 + React-featureflagsnativemodule: df7da181b064f10f5959a7cd529b5aab3686ecb2 + React-graphics: d25b1195baf24c7918543f4aff9be89cc080906f + React-hermes: 663286153a8ad6bf752b742654c766ff5e8991e7 + React-idlecallbacksnativemodule: 0bd5392cb67f1ab25df736814b7c05213b1d3c68 + React-ImageManager: a03eed3e3d4222130dc0ad503a1a5f3aa89de746 + React-intersectionobservernativemodule: 5d0f1c3c7b30031b0f6f730ee52dd9d607e2c966 + React-jserrorhandler: d5d6e7e20c5a2d6e8607e18d31d8712d7de676f6 + React-jsi: eb7cc4cffcf24796cc302d5b2bca0e92544139a9 + React-jsiexecutor: 65006f60e64c72c6b82f62ef6bd17c84846e73f8 + React-jsinspector: 01e32e2247b2486117fbb143db7f7717ef462c6d + React-jsinspectorcdp: f1cbb34ca41d188ba22efd9b663cf258a911f6cd + React-jsinspectornetwork: f61acc94c881c41451f508abfe6efa748b956c21 + React-jsinspectortracing: 9395894d9bd4d17931b9afcf230c0e7f4cb3d674 + React-jsitooling: 03ead841daa12a93b18479f5e400ceab3732d36d + React-jsitracing: 4ae61c79e14360d1c6ab566031c62c490da78439 + React-logger: bf149dea4343a9037b74bade36cced8b63f03f46 + React-Mapbuffer: fec3e025f0ffba6b32cd2a1d7bbdee3e269aae90 + React-microtasksnativemodule: ab33a818d339f5a1da308893c11b487be66121a8 + React-mutationobservernativemodule: a42d1626651ccd7d0dc02a56e69d4ec77c248893 + react-native-blur: ea6c78ddeaa7aa0fce36ea29d1f81dd23063032a + react-native-menu: cef8fb263b5fbc4a748de65494fe768b7527c135 + react-native-safe-area-context: fb5c8ee9f6dd62ef710611b3d370c501f42a4ac0 + react-native-skia: 1c97500b89ca2f1e067048918fa11325795686f6 + react-native-vector-icons-ionicons: 6831196cf1486bfd26b715d749c5c9a7d67bc0eb + react-native-video: db3a70efc9fa0c00f5499d7798283bf0585cbd37 + React-NativeModulesApple: deba264b03bd79c6bd61014fa30e40321b5e443a + React-networking: 35e6070b084f435429f85c5db40b4d5b38652fe9 + React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e + React-perflogger: ca04287f205086a1edb5c95882be7b6068458889 + React-performancecdpmetrics: cf1d0a3178ccd59353cedcacbda421f40100a889 + React-performancetimeline: c9771212e7a43032d6f8d5edfb58280d46a7ce1f + React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884 + React-RCTAnimation: 343147a9cd68c93d0ca280799fedfc7102d76ce4 + React-RCTAppDelegate: 5054754e92aaa9f8bfabe0f1022b84e46f3dcb57 + React-RCTBlob: 8c7ae3422ca4e72bc64b7a0142fd730efc5d4dfb + React-RCTFabric: be458db054b206c4d8e4f20f666e75d5f2c2d420 + React-RCTFBReactNativeSpec: 06db2e8d0f352d9fa23321aed1dd2cde25a3e83c + React-RCTImage: 481457bca63e039eb997f7d16c7560472f49657e + React-RCTLinking: 76cbb871240cec2dc5e7aa26c60f59e0ebbcf5a2 + React-RCTNetwork: e23a778225b7672e38545d3c5c24e1f4aad6d15f + React-RCTRuntime: 93c830b3ab3f7b494bbe7ae7289784f8b07b3947 + React-RCTSettings: 96196b535bef147381f96cc60ce9bda85d8be848 + React-RCTText: 749ebbd1a999fd84d80f37002ea3bf597fcea6df + React-RCTVibration: 5b41a7f274757c2928845981d970916ef9e4ca14 + React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f + React-renderercss: 80eb778756fed511d6128fc005188ac9008b0baf + React-rendererdebug: b46f338fb9d3f0bea6cf0621016c6c5a7a18e72e + React-RuntimeApple: c494b2089fad4a0c553cf63c2bb265f7eca2285d + React-RuntimeCore: b0bb151c3e2b26c6309d45d05c54aa65a6e0c094 + React-runtimeexecutor: 00b18635b6216a1708f6eb35dbadfd993ec91c7b + React-RuntimeHermes: bb44c4c574ce1b9507cad2e6be015344d18b94a9 + React-runtimescheduler: e1631e57209cb94b3efc29002b6a049cac3f6599 + React-timing: 356b88317ca60d373b0d94b6e7a71b0a572899f5 + React-utils: ccc01da318979af773259c4f6cdb1876f6f86f1a + React-webperformancenativemodule: f8b97c2cb6cfa94e92a503c09ad6d491c50a1390 + ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2 + ReactCodegen: dfe41c3f92bdf782bcf9b2c9d7c12cb9cfd82cb7 + ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f + ReactNativeDependencies: 2eb4b828d36504e50dff4d7a9c3808068bbd4713 + RNGestureHandler: ed28fea435eee1ea629ed8372c2e98138a73a472 + RNReanimated: 0f1a1b11eddb9a66ebf1c1a70d0cdd230f4bb10f + RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9 + RNVectorIcons: 97f26211c07d69e45b189f9dd0dbbe5e2d2b0b92 + RNWorklets: 04a35c45bd72d24914cbaf24bdfa4e30e1eab2df + VisionCamera: 204d37d39a6a59b31bc48c777094be3096594810 + VisionCameraBarcodeScanner: 27f6ed59ad96425067a0ff582aacb0d89484115f + VisionCameraLocation: a0289fd95ebd3317f60f52416319c5ac182bba21 + VisionCameraResizer: a35ea21e06bfb8f1e652dc5d1b7b20cb0fddc7a4 + VisionCameraWorklets: 38e14b319413d2caa6d9fa85062a003a821cf417 + Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b + +PODFILE CHECKSUM: 225e4ff5bb6719c47fa66724251c4ad2098c76da + +COCOAPODS: 1.16.2 diff --git a/apps/simple-camera/ios/SimpleCamera.xcodeproj/project.pbxproj b/apps/simple-camera/ios/SimpleCamera.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..15c47d954a --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera.xcodeproj/project.pbxproj @@ -0,0 +1,502 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + 7942A3BA92A2F4C28D623278 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + CD5E53A548EFC1AEAC11C5AC /* libPods-SimpleCamera.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BECD76ABC304D3BDE643076A /* libPods-SimpleCamera.a */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0BC3913005B45C281440831B /* Pods-SimpleCamera.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SimpleCamera.release.xcconfig"; path = "Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera.release.xcconfig"; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* SimpleCamera.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleCamera.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = SimpleCamera/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = SimpleCamera/Info.plist; sourceTree = ""; }; + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = SimpleCamera/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SimpleCamera/AppDelegate.swift; sourceTree = ""; }; + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SimpleCamera/LaunchScreen.storyboard; sourceTree = ""; }; + BECD76ABC304D3BDE643076A /* libPods-SimpleCamera.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SimpleCamera.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + DCAEECC560FB2F25AE78B25A /* Pods-SimpleCamera.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SimpleCamera.debug.xcconfig"; path = "Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera.debug.xcconfig"; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CD5E53A548EFC1AEAC11C5AC /* libPods-SimpleCamera.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* SimpleCamera */ = { + isa = PBXGroup; + children = ( + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 761780EC2CA45674006654EE /* AppDelegate.swift */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + ); + name = SimpleCamera; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + BECD76ABC304D3BDE643076A /* libPods-SimpleCamera.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* SimpleCamera */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + BBD78D7AC51CEA395F1C20DB /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* SimpleCamera.app */, + ); + name = Products; + sourceTree = ""; + }; + BBD78D7AC51CEA395F1C20DB /* Pods */ = { + isa = PBXGroup; + children = ( + DCAEECC560FB2F25AE78B25A /* Pods-SimpleCamera.debug.xcconfig */, + 0BC3913005B45C281440831B /* Pods-SimpleCamera.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* SimpleCamera */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SimpleCamera" */; + buildPhases = ( + FF8919CD419A2DDBF5FF89CE /* [CP] Check Pods Manifest.lock */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + ED316CA97AC37062F84C6F55 /* [CP] Embed Pods Frameworks */, + E66C89007D8B24BBB7F89A64 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SimpleCamera; + productName = SimpleCamera; + productReference = 13B07F961A680F5B00A75B9A /* SimpleCamera.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1120; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "SimpleCamera" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* SimpleCamera */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 7942A3BA92A2F4C28D623278 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n"; + }; + E66C89007D8B24BBB7F89A64 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + ED316CA97AC37062F84C6F55 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SimpleCamera/Pods-SimpleCamera-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + FF8919CD419A2DDBF5FF89CE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SimpleCamera-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DCAEECC560FB2F25AE78B25A /* Pods-SimpleCamera.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = CJW62Q77E7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = SimpleCamera/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.margelo.nitro.camera.example.simple; + PRODUCT_NAME = SimpleCamera; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0BC3913005B45C281440831B /* Pods-SimpleCamera.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = CJW62Q77E7; + INFOPLIST_FILE = SimpleCamera/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.margelo.nitro.camera.example.simple; + PRODUCT_NAME = SimpleCamera; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SimpleCamera" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "SimpleCamera" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/apps/simple-camera/ios/SimpleCamera.xcodeproj/xcshareddata/xcschemes/SimpleCamera.xcscheme b/apps/simple-camera/ios/SimpleCamera.xcodeproj/xcshareddata/xcschemes/SimpleCamera.xcscheme new file mode 100644 index 0000000000..0dd76f9426 --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera.xcodeproj/xcshareddata/xcschemes/SimpleCamera.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package/example/ios/VisionCameraExample.xcworkspace/contents.xcworkspacedata b/apps/simple-camera/ios/SimpleCamera.xcworkspace/contents.xcworkspacedata similarity index 76% rename from package/example/ios/VisionCameraExample.xcworkspace/contents.xcworkspacedata rename to apps/simple-camera/ios/SimpleCamera.xcworkspace/contents.xcworkspacedata index 880ce6a125..9bf6a00d2b 100644 --- a/package/example/ios/VisionCameraExample.xcworkspace/contents.xcworkspacedata +++ b/apps/simple-camera/ios/SimpleCamera.xcworkspace/contents.xcworkspacedata @@ -2,7 +2,7 @@ + location = "group:SimpleCamera.xcodeproj"> diff --git a/apps/simple-camera/ios/SimpleCamera/AppDelegate.swift b/apps/simple-camera/ios/SimpleCamera/AppDelegate.swift new file mode 100644 index 0000000000..66adff8335 --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera/AppDelegate.swift @@ -0,0 +1,78 @@ +import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + private func valueForLaunchArgument(_ name: String) -> String? { + let args = ProcessInfo.processInfo.arguments + guard let index = args.firstIndex(of: name), index + 1 < args.count else { + return nil + } + return args[index + 1] + } + + private func configureMetroFromLaunchContext() { + let defaults = UserDefaults.standard + let launchArgJsLocation = valueForLaunchArgument("-RCT_jsLocation") + let launchArgPackagerScheme = valueForLaunchArgument("-RCT_packager_scheme") + + // React Native reads these UserDefaults when constructing the debug Metro URL. + // This is only for Harness runs on AWS Device Farm, where the physical iOS + // device needs a custom IPv6 Metro host passed through launch arguments from + // apps/simple-camera/rn-harness.config.mjs. + // Release builds still use the prebundled JS bundle in bundleURL(), so these + // values do not affect release app startup. + if let jsLocation = launchArgJsLocation { + defaults.set(jsLocation, forKey: "RCT_jsLocation") + } + + if let packagerScheme = launchArgPackagerScheme { + defaults.set(packagerScheme, forKey: "RCT_packager_scheme") + } + } + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + configureMetroFromLaunchContext() + + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + window = UIWindow(frame: UIScreen.main.bounds) + + factory.startReactNative( + withModuleName: "SimpleCamera", + in: window, + launchOptions: launchOptions + ) + + return true + } +} + +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + self.bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/apps/simple-camera/ios/SimpleCamera/Images.xcassets/AppIcon.appiconset/Contents.json b/apps/simple-camera/ios/SimpleCamera/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..ddd7fca89e --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,53 @@ +{ + "images": [ + { + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/simple-camera/ios/SimpleCamera/Images.xcassets/Contents.json b/apps/simple-camera/ios/SimpleCamera/Images.xcassets/Contents.json new file mode 100644 index 0000000000..97a8662ebd --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/apps/simple-camera/ios/SimpleCamera/Info.plist b/apps/simple-camera/ios/SimpleCamera/Info.plist new file mode 100644 index 0000000000..781787d4a7 --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + SimpleCamera + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSCameraUsageDescription + VisionCamera needs access to your Camera for very obvious reasons. + NSMicrophoneUsageDescription + VisionCamera needs access to your Microphone to record audio for video recordings. + NSLocationWhenInUseUsageDescription + VisionCamera needs access to your Location to add GPS tags to captured photos. + RCTNewArchEnabled + + UIAppFonts + + Ionicons.ttf + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/apps/simple-camera/ios/SimpleCamera/LaunchScreen.storyboard b/apps/simple-camera/ios/SimpleCamera/LaunchScreen.storyboard new file mode 100644 index 0000000000..689b7f7c60 --- /dev/null +++ b/apps/simple-camera/ios/SimpleCamera/LaunchScreen.storyboard @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package/example/ios/VisionCameraExample/PrivacyInfo.xcprivacy b/apps/simple-camera/ios/SimpleCamera/PrivacyInfo.xcprivacy similarity index 94% rename from package/example/ios/VisionCameraExample/PrivacyInfo.xcprivacy rename to apps/simple-camera/ios/SimpleCamera/PrivacyInfo.xcprivacy index 5b037f0c27..9029c2d13e 100644 --- a/package/example/ios/VisionCameraExample/PrivacyInfo.xcprivacy +++ b/apps/simple-camera/ios/SimpleCamera/PrivacyInfo.xcprivacy @@ -6,19 +6,20 @@ NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons - C617.1 - 3B52.1 + CA92.1 + C56D.1 + 1C8F.1 NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPICategoryFileTimestamp NSPrivacyAccessedAPITypeReasons - CA92.1 + C617.1 diff --git a/apps/simple-camera/jest.harness.config.mjs b/apps/simple-camera/jest.harness.config.mjs new file mode 100644 index 0000000000..da1398f600 --- /dev/null +++ b/apps/simple-camera/jest.harness.config.mjs @@ -0,0 +1,6 @@ +const config = { + preset: 'react-native-harness', + testMatch: ['/__tests__/**/*.harness.{js,jsx,ts,tsx}'], +} + +export default config diff --git a/apps/simple-camera/metro.config.js b/apps/simple-camera/metro.config.js new file mode 100644 index 0000000000..5480b7790d --- /dev/null +++ b/apps/simple-camera/metro.config.js @@ -0,0 +1,16 @@ +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config') +const path = require('node:path') + +const root = path.resolve(__dirname, '..', '..') + +/** + * Metro configuration + * https://facebook.github.io/metro/docs/configuration + * + * @type {import('@react-native/metro-config').MetroConfig} + */ +const config = { + watchFolders: [root], +} + +module.exports = mergeConfig(getDefaultConfig(__dirname), config) diff --git a/apps/simple-camera/package.json b/apps/simple-camera/package.json new file mode 100644 index 0000000000..34ef1f08ec --- /dev/null +++ b/apps/simple-camera/package.json @@ -0,0 +1,67 @@ +{ + "name": "simple-camera", + "private": true, + "description": "Example app for developing and testing react-native-vision-camera.", + "author": "Marc Rousavy (https://github.com/mrousavy)", + "scripts": { + "android": "react-native run-android", + "ios": "react-native run-ios", + "bundle-install": "bundle install", + "pods": "cd ios && bundle exec pod install", + "start": "react-native start --client-logs", + "build:android": "cd android && ./gradlew assembleDebug --no-daemon --console=plain", + "test:harness": "react-native-harness", + "test:harness:android": "react-native-harness --harnessRunner android", + "test:harness:ios": "HARNESS_METRO_BIND_HOST=:: react-native-harness --harnessRunner ios" + }, + "dependencies": { + "@react-native-community/blur": "^4.4.1", + "@react-native-menu/menu": "^2.0.0", + "@react-native-vector-icons/ionicons": "13.1.2", + "@react-navigation/native": "^7.2.5", + "@react-navigation/native-stack": "^7.16.0", + "@shopify/react-native-skia": "2.6.4", + "@types/jest": "^30.0.0", + "react": "19.2.3", + "react-native": "0.85.3", + "react-native-gesture-handler": "^3.0.0", + "react-native-nitro-image": "0.15.0", + "react-native-nitro-modules": "0.35.9", + "react-native-reanimated": "4.4.0", + "react-native-safe-area-context": "^5.8.0", + "react-native-screens": "^4.25.2", + "react-native-vector-icons": "^10.3.0", + "react-native-video": "6.19.2", + "react-native-vision-camera": "../../packages/react-native-vision-camera", + "react-native-vision-camera-barcode-scanner": "../../packages/react-native-vision-camera-barcode-scanner", + "react-native-vision-camera-location": "../../packages/react-native-vision-camera-location", + "react-native-vision-camera-resizer": "../../packages/react-native-vision-camera-resizer", + "react-native-vision-camera-skia": "../../packages/react-native-vision-camera-skia", + "react-native-vision-camera-worklets": "../../packages/react-native-vision-camera-worklets", + "react-native-worklets": "0.9.1" + }, + "devDependencies": { + "@babel/core": "^7.29.7", + "@babel/preset-env": "^7.29.7", + "@babel/runtime": "^7.29.7", + "@react-native-community/cli": "20.1.3", + "@react-native-community/cli-platform-android": "20.1.3", + "@react-native-community/cli-platform-ios": "20.1.3", + "@react-native-harness/platform-android": "1.4.0-rc.1", + "@react-native-harness/platform-apple": "1.4.0-rc.1", + "@react-native/babel-preset": "0.85.3", + "@react-native/metro-config": "0.85.3", + "@react-native/typescript-config": "0.85.3", + "@types/react": "19.2.15", + "react-native-harness": "1.4.0-rc.1", + "typescript": "6.0.3", + "yargs": "18.0.0" + }, + "engines": { + "node": ">=20" + }, + "trustedDependencies": [ + "@shopify/react-native-skia" + ], + "version": "5.0.11" +} diff --git a/apps/simple-camera/rn-harness.config.mjs b/apps/simple-camera/rn-harness.config.mjs new file mode 100644 index 0000000000..a5d9f61598 --- /dev/null +++ b/apps/simple-camera/rn-harness.config.mjs @@ -0,0 +1,123 @@ +import { + androidEmulator, + androidPlatform, + physicalAndroidDevice, +} from '@react-native-harness/platform-android' +import { + applePhysicalDevice, + applePlatform, + appleSimulator, +} from '@react-native-harness/platform-apple' + +const androidEmulatorName = + process.env.HARNESS_ANDROID_EMULATOR ?? 'Pixel_API_35' +const androidApiLevel = Number.parseInt( + process.env.HARNESS_ANDROID_API_LEVEL ?? '35', + 10, +) +const androidDeviceProfile = + process.env.HARNESS_ANDROID_DEVICE_PROFILE ?? 'pixel' +const androidDiskSize = process.env.HARNESS_ANDROID_DISK_SIZE ?? '1G' +const androidHeapSize = process.env.HARNESS_ANDROID_HEAP_SIZE ?? '1G' +const androidBundleId = + process.env.HARNESS_ANDROID_BUNDLE_ID ?? + 'com.margelo.nitro.camera.example.simple' +const androidPhysicalManufacturer = + process.env.HARNESS_ANDROID_DEVICE_MANUFACTURER ?? 'Pixel' +const androidPhysicalModel = process.env.HARNESS_ANDROID_DEVICE_MODEL ?? 'Pro 7' +const androidDeviceMode = + process.env.HARNESS_ANDROID_DEVICE_MODE?.trim().toLowerCase() ?? 'physical' + +const iosBundleId = + process.env.HARNESS_IOS_BUNDLE_ID ?? 'com.margelo.nitro.camera.example.simple' +const iosSimulatorName = process.env.HARNESS_IOS_SIMULATOR ?? 'iPhone 16 Pro' +const iosSimulatorVersion = process.env.HARNESS_IOS_SIMULATOR_VERSION ?? '18.5' +const iosPhysicalDeviceIdentifier = + process.env.HARNESS_IOS_DEVICE_ID?.trim() || 'iPhone' +const iosMetroHostInput = process.env.HARNESS_IOS_METRO_HOST?.trim() ?? '' +const iosMetroPort = process.env.HARNESS_IOS_METRO_PORT ?? '8081' + +const formatIosMetroHostPort = (input, port) => { + if (input === '') { + return '' + } + + const bracketedIpv6Match = input.match(/^\[([^\]]+)\](?::(\d+))?$/) + if (bracketedIpv6Match != null) { + const [, host, explicitPort] = bracketedIpv6Match + return explicitPort == null ? `[${host}]:${port}` : input + } + + const colonCount = [...input].filter((char) => char === ':').length + if (colonCount > 1) { + return `[${input}]:${port}` + } + + return input.includes(':') ? input : `${input}:${port}` +} + +const iosMetroHostPort = formatIosMetroHostPort(iosMetroHostInput, iosMetroPort) +const metroBindHost = process.env.HARNESS_METRO_BIND_HOST?.trim() ?? '' +const iosAppLaunchOptions = iosMetroHostPort + ? { + arguments: [ + '-RCT_jsLocation', + iosMetroHostPort, + '-RCT_packager_scheme', + 'http', + ], + } + : undefined + +const isCI = process.env.CI === 'true' +const bundleStartTimeout = isCI ? 90_000 : 15_000 +const bridgeTimeout = isCI ? 120_000 : 45_000 +const maxAppRestarts = isCI ? 4 : 2 + +const useEmulator = androidDeviceMode === 'emulator' + +const androidDevice = useEmulator + ? androidEmulator(androidEmulatorName, { + apiLevel: androidApiLevel, + profile: androidDeviceProfile, + diskSize: androidDiskSize, + heapSize: androidHeapSize, + }) + : physicalAndroidDevice(androidPhysicalManufacturer, androidPhysicalModel) + +const iosDevice = isCI + ? applePhysicalDevice(iosPhysicalDeviceIdentifier, { + codeSign: { + teamId: 'TheTeamHereDoesntMatterOnCiButWeHaveToPassItStillIthink', + }, + }) + : appleSimulator(iosSimulatorName, iosSimulatorVersion) + +const config = { + entryPoint: './index.js', + appRegistryComponentName: 'SimpleCamera', + host: metroBindHost === '' ? undefined : metroBindHost, + runners: [ + androidPlatform({ + name: 'android', + device: androidDevice, + bundleId: androidBundleId, + }), + applePlatform({ + name: 'ios', + device: iosDevice, + bundleId: iosBundleId, + appLaunchOptions: iosAppLaunchOptions, + }), + ], + defaultRunner: 'android', + bridgeTimeout, + bundleStartTimeout, + maxAppRestarts, + detectNativeCrashes: true, + resetEnvironmentBetweenTestFiles: true, + forwardClientLogs: true, + permissions: true, +} + +export default config diff --git a/apps/simple-camera/scripts/run-harness-android-ci.sh b/apps/simple-camera/scripts/run-harness-android-ci.sh new file mode 100644 index 0000000000..bcbb64d0cc --- /dev/null +++ b/apps/simple-camera/scripts/run-harness-android-ci.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_APK_PATH="./android/app/build/outputs/apk/debug/app-debug.apk" +BUNDLE_ID="${HARNESS_ANDROID_BUNDLE_ID:?HARNESS_ANDROID_BUNDLE_ID is required}" +APP_ACTIVITY="${HARNESS_ANDROID_MAIN_ACTIVITY:-${BUNDLE_ID}/.MainActivity}" +STARTUP_TIMEOUT_SECONDS="${HARNESS_ANDROID_STARTUP_TIMEOUT_SECONDS:-60}" +HARNESS_TIMEOUT_SECONDS="${HARNESS_ANDROID_TEST_TIMEOUT_SECONDS:-720}" + +echo "Waiting for emulator..." +adb wait-for-device + +echo "Installing APK from ${APP_APK_PATH}..." +adb install -r "${APP_APK_PATH}" + +echo "Checking app startup..." +adb logcat -c || true +adb shell am force-stop "${BUNDLE_ID}" || true +adb shell am start -W -n "${APP_ACTIVITY}" + +launch_deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) +while true; do + if adb shell pidof "${BUNDLE_ID}" | tr -d '\r' | grep -Eq '[0-9]+'; then + break + fi + + if (( SECONDS >= launch_deadline )); then + echo "App ${BUNDLE_ID} did not start within ${STARTUP_TIMEOUT_SECONDS}s." + adb logcat -d -b crash || true + exit 1 + fi + + sleep 2 +done + +# Ensure the process does not die immediately after launch. +sleep 5 +if ! adb shell pidof "${BUNDLE_ID}" | tr -d '\r' | grep -Eq '[0-9]+'; then + echo "App ${BUNDLE_ID} crashed shortly after launch." + adb logcat -d -b crash || true + exit 1 +fi + +adb shell am force-stop "${BUNDLE_ID}" || true + +echo "Running harness tests (hard timeout: ${HARNESS_TIMEOUT_SECONDS}s)..." +set +e +timeout --foreground --kill-after=30s "${HARNESS_TIMEOUT_SECONDS}" bun run test:harness:android +exit_code=$? +set -e + +if [[ "${exit_code}" -eq 124 ]]; then + echo "Harness tests exceeded ${HARNESS_TIMEOUT_SECONDS}s and were aborted." + exit 1 +fi + +exit "${exit_code}" diff --git a/apps/simple-camera/src/App.tsx b/apps/simple-camera/src/App.tsx new file mode 100644 index 0000000000..dea154c2e6 --- /dev/null +++ b/apps/simple-camera/src/App.tsx @@ -0,0 +1,68 @@ +import { + createStaticNavigation, + type StaticParamList, +} from '@react-navigation/native' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { VisionCamera } from 'react-native-vision-camera' +import { CameraScreen } from './screens/CameraScreen' +import { PermissionsScreen } from './screens/PermissionsScreen' +import { PhotoScreen } from './screens/PhotoScreen' +import { VideoScreen } from './screens/VideoScreen' + +const RootStack = createNativeStackNavigator({ + initialRouteName: + VisionCamera.cameraPermissionStatus === 'authorized' + ? 'Camera' + : 'Permissions', + screens: { + Permissions: PermissionsScreen, + Camera: { + screen: CameraScreen, + options: { + orientation: 'portrait_up', + }, + }, + Photo: { + screen: PhotoScreen, + options: { + animation: 'none', + presentation: 'transparentModal', + }, + }, + Video: { + screen: VideoScreen, + options: { + animation: 'none', + presentation: 'transparentModal', + }, + }, + }, + screenOptions: { + navigationBarHidden: true, + headerShown: false, + contentStyle: { + backgroundColor: 'black', + }, + }, +}) + +type RootStackParamList = StaticParamList + +declare global { + namespace ReactNavigation { + interface RootParamList extends RootStackParamList {} + } +} + +const Navigation = createStaticNavigation(RootStack) + +function App() { + return ( + + + + ) +} + +export default App diff --git a/apps/simple-camera/src/assets/code-128-mrousavy.png b/apps/simple-camera/src/assets/code-128-mrousavy.png new file mode 100644 index 0000000000..295464fa27 Binary files /dev/null and b/apps/simple-camera/src/assets/code-128-mrousavy.png differ diff --git a/apps/simple-camera/src/assets/qr-code-margelo.png b/apps/simple-camera/src/assets/qr-code-margelo.png new file mode 100644 index 0000000000..fb389f2826 Binary files /dev/null and b/apps/simple-camera/src/assets/qr-code-margelo.png differ diff --git a/apps/simple-camera/src/components/BlurContainer.tsx b/apps/simple-camera/src/components/BlurContainer.tsx new file mode 100644 index 0000000000..25671c99af --- /dev/null +++ b/apps/simple-camera/src/components/BlurContainer.tsx @@ -0,0 +1,44 @@ +import { BlurView } from '@react-native-community/blur' +import type React from 'react' +import { Platform, StyleSheet, View, type ViewProps } from 'react-native' + +export interface BlurContainerProps extends ViewProps { + tint?: 'dark' | 'light' +} + +export function BlurContainer({ + tint = 'dark', + style, + children, + ...props +}: BlurContainerProps): React.ReactElement { + if (Platform.OS === 'ios') { + return ( + + + {children} + + ) + } else { + const bgStyle = tint === 'dark' ? styles.dark : styles.light + return ( + + {children} + + ) + } +} + +const styles = StyleSheet.create({ + dark: { + backgroundColor: 'rgba(0,0,0,0.8)', + }, + light: { + backgroundColor: 'rgba(255,255,255,0.8)', + }, +}) diff --git a/apps/simple-camera/src/components/CameraSelectorButton.tsx b/apps/simple-camera/src/components/CameraSelectorButton.tsx new file mode 100644 index 0000000000..21d186a483 --- /dev/null +++ b/apps/simple-camera/src/components/CameraSelectorButton.tsx @@ -0,0 +1,57 @@ +import { + type MenuAction, + MenuView, + type NativeActionEvent, +} from '@react-native-menu/menu' +import type React from 'react' +import { useCallback, useMemo } from 'react' +import type { CameraDevice, CameraPosition } from 'react-native-vision-camera' +import { IconButton } from './IconButton' + +interface Props { + devices: CameraDevice[] + setDevice: (device: CameraDevice) => void +} + +export function CameraSelectorButton({ + devices, + setDevice, +}: Props): React.ReactElement { + const menuActions = useMemo(() => { + const positions = ['back', 'front', 'external'].filter( + (p): p is CameraPosition => devices.some((d) => d.position === p), + ) + return positions.map((pos) => { + const devicesAtPosition = devices.filter((d) => d.position === pos) + return { + title: pos, + preferredElementSize: 'small', + displayInline: true, + subactions: devicesAtPosition.map((d) => { + return { + id: d.id, + subtitle: d.mediaTypes.join(' + '), + title: d.localizedName, + } + }), + } + }) + }, [devices]) + + const onMenuItemPressed = useCallback( + (event: NativeActionEvent) => { + const cameraId = event.nativeEvent.event + const targetDevice = devices.find((d) => d.id === cameraId) + if (targetDevice != null) { + setDevice(targetDevice) + } + }, + [devices, setDevice], + ) + + return ( + + {}} /> + + ) +} diff --git a/apps/simple-camera/src/components/CameraView.tsx b/apps/simple-camera/src/components/CameraView.tsx new file mode 100644 index 0000000000..cafd789764 --- /dev/null +++ b/apps/simple-camera/src/components/CameraView.tsx @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef } from 'react' +import { type GestureResponderEvent, StyleSheet, View } from 'react-native' +import { + Camera, + type CameraRef, + type CameraViewProps, +} from 'react-native-vision-camera' + +type Props = Omit< + CameraViewProps, + 'ref' | 'style' | 'enableNativeZoomGesture' | 'enableNativeTapToFocusGesture' +> + +export function CameraView({ device, constraints, ...props }: Props) { + const camera = useRef(null) + + useEffect(() => { + if (typeof device === 'string') { + console.log(`Device changed: "${device}"`) + } else { + console.log(`Device changed: ${device.localizedName}`) + console.log(` - Supported Pixel Formats:`, device.supportedPixelFormats) + console.log( + ` - Supported Photo Resolutions:`, + device.getSupportedResolutions('photo'), + ) + console.log( + ` - Supported Video Resolutions:`, + device.getSupportedResolutions('video'), + ) + console.log(` - Supported FPS Ranges:`, device.supportedFPSRanges) + console.log( + ` - Supported Dynamic Ranges:`, + device.supportedVideoDynamicRanges, + ) + } + }, [device]) + + const onPress = useCallback(async (event: GestureResponderEvent) => { + if (camera.current == null) throw new Error(`Camera ref is not yet ready!`) + + const point = { + x: event.nativeEvent.locationX, + y: event.nativeEvent.locationY, + } + + try { + const start = performance.now() + console.log(`Focusing to (${point.x}, ${point.y})...`) + await camera.current.focusTo(point, { + adaptiveness: 'continuous', + autoResetAfter: 3, + responsiveness: 'snappy', + }) + const end = performance.now() + console.log(`Focusing completed after ${(end - start).toFixed(2)}ms!`) + } catch (error) { + console.error(`Failed to focus!`, error) + } + }, []) + + return ( + + { + console.log(`Subject Area Changed! Resetting Focus...`) + camera.current?.resetFocus() + }} + onSessionConfigSelected={(config) => { + console.log(`Given Constraints:`, constraints) + console.log(`Resolved SessionConfig:`, config.toString()) + }} + /> + + ) +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + camera: { + flex: 1, + borderRadius: 25, + overflow: 'hidden', + }, +}) diff --git a/apps/simple-camera/src/components/CaptureButton.tsx b/apps/simple-camera/src/components/CaptureButton.tsx new file mode 100644 index 0000000000..0dc69564e3 --- /dev/null +++ b/apps/simple-camera/src/components/CaptureButton.tsx @@ -0,0 +1,169 @@ +import type React from 'react' +import { useCallback, useRef } from 'react' +import { StyleSheet } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import Animated, { + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, +} from 'react-native-reanimated' +import { BlurContainer } from './BlurContainer' + +export interface CaptureButtonProps { + takePhoto: () => Promise + startRecording: () => Promise + stopRecording: () => Promise +} + +const AnimatedBlurContainer = Animated.createAnimatedComponent(BlurContainer) +const LONG_PRESS_DURATION_MS = 300 + +export function CaptureButton({ + takePhoto, + startRecording, + stopRecording, +}: CaptureButtonProps): React.ReactElement { + const isPressed = useSharedValue(false) + const isCapturing = useSharedValue(false) + const didLongPressActivate = useRef(false) + const isRecording = useRef(false) + const recordingStartPromise = useRef | null>(null) + const shouldStopAfterStart = useRef(false) + + const outerScale = useDerivedValue(() => { + return withSpring(isCapturing.value ? 1.4 : 1.0, { + mass: 1, + stiffness: 1000, + damping: 500, + }) + }) + const innerScale = useDerivedValue(() => { + return withSpring(isPressed.value ? 0.7 : 1.0, { + mass: 1, + stiffness: 1500, + damping: 500, + }) + }) + + const tapGesture = Gesture.Tap() + .maxDuration(LONG_PRESS_DURATION_MS) + .maxDistance(20) + .onBegin(() => { + isPressed.set(true) + }) + .onFinalize(() => { + isPressed.set(false) + }) + .onEnd(async () => { + isCapturing.set(true) + try { + await takePhoto() + } finally { + isCapturing.set(false) + } + }) + .runOnJS(true) + + const stopRecordingNow = useCallback(async () => { + if (!isRecording.current) return + isRecording.current = false + try { + await stopRecording() + } catch (error) { + console.error(`Failed to stop recording!`, error) + } + }, [stopRecording]) + + const startRecordingSafely = useCallback(async () => { + if (recordingStartPromise.current != null || isRecording.current) return + shouldStopAfterStart.current = false + let startPromise: Promise | null = null + try { + startPromise = startRecording() + recordingStartPromise.current = startPromise + await startPromise + if (recordingStartPromise.current !== startPromise) return + recordingStartPromise.current = null + isRecording.current = true + if (shouldStopAfterStart.current) { + shouldStopAfterStart.current = false + await stopRecordingNow() + } + } catch (error) { + if ( + startPromise != null && + recordingStartPromise.current === startPromise + ) { + recordingStartPromise.current = null + } + isRecording.current = false + shouldStopAfterStart.current = false + console.error(`Failed to start recording!`, error) + } + }, [startRecording, stopRecordingNow]) + + const stopRecordingSafely = useCallback(async () => { + if (recordingStartPromise.current != null) { + shouldStopAfterStart.current = true + return + } + await stopRecordingNow() + }, [stopRecordingNow]) + + const longPressGesture = Gesture.LongPress() + .minDuration(LONG_PRESS_DURATION_MS) + .maxDistance(50) + .shouldCancelWhenOutside(false) + .onBegin(() => { + didLongPressActivate.current = false + isPressed.set(true) + }) + .onStart(() => { + didLongPressActivate.current = true + startRecordingSafely() + }) + .onFinalize(() => { + isPressed.set(false) + if (!didLongPressActivate.current) return + didLongPressActivate.current = false + stopRecordingSafely() + }) + .runOnJS(true) + + const captureGesture = Gesture.Exclusive(longPressGesture, tapGesture) + + const outerStyle = useAnimatedStyle(() => ({ + transform: [{ scale: outerScale.value }], + })) + const innerStyle = useAnimatedStyle(() => ({ + transform: [{ scale: innerScale.value }], + })) + + return ( + + + + + + ) +} + +const styles = StyleSheet.create({ + circle: { + borderRadius: 999, + overflow: 'hidden', + }, + outer: { + padding: 10, + }, + inner: { + padding: 30, + }, +}) diff --git a/apps/simple-camera/src/components/FullOverlay.tsx b/apps/simple-camera/src/components/FullOverlay.tsx new file mode 100644 index 0000000000..c9915c01fa --- /dev/null +++ b/apps/simple-camera/src/components/FullOverlay.tsx @@ -0,0 +1,17 @@ +import type React from 'react' +import { StyleSheet, View, type ViewProps } from 'react-native' + +export function FullOverlay({ + style, + ...props +}: ViewProps): React.ReactElement { + return +} + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFill, + marginTop: 15, + marginBottom: 25, + }, +}) diff --git a/apps/simple-camera/src/components/IconButton.tsx b/apps/simple-camera/src/components/IconButton.tsx new file mode 100644 index 0000000000..0573073695 --- /dev/null +++ b/apps/simple-camera/src/components/IconButton.tsx @@ -0,0 +1,35 @@ +import Ionicons, { + type IoniconsIconName, +} from '@react-native-vector-icons/ionicons' +import type React from 'react' +import { Pressable, StyleSheet } from 'react-native' +import { BlurContainer, type BlurContainerProps } from './BlurContainer' + +interface Props extends BlurContainerProps { + iconName: IoniconsIconName + onPress: () => void +} + +export function IconButton({ + iconName, + children, + onPress, + ...props +}: Props): React.ReactElement { + return ( + + + {children} + + + + ) +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 9999, + overflow: 'hidden', + padding: 10, + }, +}) diff --git a/apps/simple-camera/src/components/Row.tsx b/apps/simple-camera/src/components/Row.tsx new file mode 100644 index 0000000000..3fdb97f673 --- /dev/null +++ b/apps/simple-camera/src/components/Row.tsx @@ -0,0 +1,14 @@ +import type React from 'react' +import { StyleSheet, View, type ViewProps } from 'react-native' + +export function Row({ style, ...props }: ViewProps): React.ReactElement { + return +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + paddingHorizontal: 15, + gap: 15, + }, +}) diff --git a/apps/simple-camera/src/globals.d.ts b/apps/simple-camera/src/globals.d.ts new file mode 100644 index 0000000000..2213462ef6 --- /dev/null +++ b/apps/simple-camera/src/globals.d.ts @@ -0,0 +1,9 @@ +declare global { + var gc: () => void + var performance: { + now: () => number + } +} + +// export so this is treated as a module +export {} diff --git a/apps/simple-camera/src/hooks/useIsActive.ts b/apps/simple-camera/src/hooks/useIsActive.ts new file mode 100644 index 0000000000..fb486c5078 --- /dev/null +++ b/apps/simple-camera/src/hooks/useIsActive.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' +import { AppState } from 'react-native' + +export function useIsActive(): boolean { + const [isActive, setIsActive] = useState( + () => AppState.currentState === 'active', + ) + + useEffect(() => { + const listener = AppState.addEventListener('change', (state) => { + setIsActive(state === 'active') + }) + return () => listener.remove() + }, []) + + return isActive +} diff --git a/apps/simple-camera/src/hooks/useSafeAreaPadding.ts b/apps/simple-camera/src/hooks/useSafeAreaPadding.ts new file mode 100644 index 0000000000..87f0840be9 --- /dev/null +++ b/apps/simple-camera/src/hooks/useSafeAreaPadding.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' +import type { StyleProp, ViewStyle } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +export function useSafeAreaPadding() { + const safeArea = useSafeAreaInsets() + + return useMemo>(() => { + return { + paddingTop: safeArea.top, + paddingLeft: safeArea.left, + paddingRight: safeArea.right, + paddingBlock: safeArea.bottom, + } + }, [safeArea.bottom, safeArea.left, safeArea.right, safeArea.top]) +} diff --git a/apps/simple-camera/src/icons/CameraIcon.tsx b/apps/simple-camera/src/icons/CameraIcon.tsx new file mode 100644 index 0000000000..5b76297ddc --- /dev/null +++ b/apps/simple-camera/src/icons/CameraIcon.tsx @@ -0,0 +1,9 @@ +import Ionicons from '@react-native-vector-icons/ionicons' + +interface Props { + size: number +} + +export function CameraIcon({ size }: Props) { + return +} diff --git a/apps/simple-camera/src/logDevices.ts b/apps/simple-camera/src/logDevices.ts new file mode 100644 index 0000000000..4fb093855d --- /dev/null +++ b/apps/simple-camera/src/logDevices.ts @@ -0,0 +1,7 @@ +import type { CameraDevice } from 'react-native-vision-camera' + +export function logDevices(devices: CameraDevice[]): void { + for (const d of devices) { + console.log(`${d.id}: ${d.type} ${d.position} ("${d.localizedName}")`) + } +} diff --git a/apps/simple-camera/src/screens/CameraScreen.tsx b/apps/simple-camera/src/screens/CameraScreen.tsx new file mode 100644 index 0000000000..6813809c3c --- /dev/null +++ b/apps/simple-camera/src/screens/CameraScreen.tsx @@ -0,0 +1,297 @@ +import { useIsFocused, useNavigation } from '@react-navigation/native' +import { useCallback, useEffect, useRef, useState } from 'react' +import { StatusBar, StyleSheet, Text, View } from 'react-native' +import { + type Recorder, + useCameraDeviceExtensions, + useCameraDevices, + useDepthOutput, + useFrameOutput, + usePhotoOutput, + useVideoOutput, +} from 'react-native-vision-camera' +import { useLocation } from 'react-native-vision-camera-location' +import { useResizer } from 'react-native-vision-camera-resizer' +import { CameraSelectorButton } from '../components/CameraSelectorButton' +import { CameraView } from '../components/CameraView' +import { CaptureButton } from '../components/CaptureButton' +import { FullOverlay } from '../components/FullOverlay' +import { Row } from '../components/Row' +import { useIsActive } from '../hooks/useIsActive' +import { useSafeAreaPadding } from '../hooks/useSafeAreaPadding' +import { logDevices } from '../logDevices' + +export function CameraScreen() { + const navigation = useNavigation() + const isAppActive = useIsActive() + const isScreenFocused = useIsFocused() + const safePadding = useSafeAreaPadding() + const [enablePhoto, setEnablePhoto] = useState(true) + const [enableVideo, setEnableVideo] = useState(false) + const [enableFrameStream, setEnableFrameStream] = useState(false) + const [enableDepthStream, setEnableDepthStream] = useState(false) + + const devices = useCameraDevices() + const defaultDevice = devices[0] + const [device, setDevice] = useState(defaultDevice) + + useEffect(() => { + setDevice(defaultDevice) + }, [defaultDevice]) + + useEffect(() => logDevices(devices), [devices]) + + const location = useLocation({ + accuracy: 'balanced', + distanceFilter: 10, + }) + useEffect(() => { + if (!location.hasPermission) { + ;(async () => { + console.log(`requesting location permission...`) + const has = await location.requestPermission() + console.log(`location permssion: ${has}`) + })() + } + }, [location.hasPermission, location.requestPermission]) + useEffect(() => { + const l = location.currentLocation + if (l == null) return + console.log(`Location: ${l.latitude} ${l.longitude}`) + }, [location.currentLocation]) + + const photoOutput = usePhotoOutput({}) + const videoOutput = useVideoOutput({ + enableAudio: true, + }) + const { resizer, error } = useResizer({ + width: 192, + height: 192, + channelOrder: 'rgb', + dataType: 'float32', + scaleMode: 'cover', + pixelLayout: 'interleaved', + }) + useEffect(() => { + if (error != null) console.error('Failed to prepare Resizer!', error) + }, [error]) + const frameOutput1 = useFrameOutput({ + pixelFormat: 'yuv', + onFrame(frame) { + 'worklet' + if (resizer != null) { + const start = performance.now() + const resized = resizer.resize(frame) + const end = performance.now() + const time = `${(end - start).toFixed(2)}ms` + console.log( + `Resized ${frame.width}x${frame.height} ${frame.pixelFormat} -> ${resized.width}x${resized.height} rgb-float32 in ${time}`, + ) + const buffer = resized.getPixelBuffer() + const view = new Float32Array(buffer) + for (let i = 0; i < 3 * 10; i += 3) { + console.log( + ` Pixel [${i}] = [${view[i]}, ${view[i + 1]}, ${view[i + 2]}]`, + ) + } + resized.dispose() + } else { + console.log(`Resizer isn't ready yet...`) + } + frame.dispose() + }, + }) + const frameOutput2 = useFrameOutput({ + pixelFormat: 'native', + onFrame(frame) { + 'worklet' + console.log( + `frame output #2: ${frame.width}x${frame.height} in ${frame.pixelFormat}`, + ) + try { + const data = frame.getPixelBuffer() + console.log(`Pixels: ${data.byteLength}`) + } catch {} + frame.dispose() + }, + }) + const depthOutput = useDepthOutput({ + onDepth(depth) { + 'worklet' + console.log(`${depth.width}x${depth.height} depth frame.`) + const calibrationData = depth.cameraCalibrationData + if (calibrationData != null) { + console.log( + `.cameraExtrinsicsMatrix: ${calibrationData.cameraExtrinsicsMatrix.join(', ')}`, + ) + console.log( + `.cameraIntrinsicMatrix: ${calibrationData.cameraIntrinsicMatrix.join(', ')}`, + ) + console.log( + `.intrinsicMatrixReferenceDimensions: ${calibrationData.intrinsicMatrixReferenceDimensions.width}x${calibrationData.intrinsicMatrixReferenceDimensions.height}`, + ) + console.log(`.pixelSize: ${calibrationData.pixelSize}`) + console.log( + `.lensDistortionCenter: ${calibrationData.lensDistortionCenter}`, + ) + } else { + console.log(`no calibration data!`) + } + depth.dispose() + }, + }) + + const extensions = useCameraDeviceExtensions(device) + useEffect(() => { + if (extensions == null) return + console.log( + 'Available Camera Extensions:', + extensions.map((e) => e.type), + ) + }, [extensions]) + + const takePhoto = useCallback(async () => { + try { + console.log(`Capturing Photo...`) + const start = performance.now() + const photo = await photoOutput.capturePhoto( + { + location: location.currentLocation, + }, + {}, + ) + const end = performance.now() + const duration = (end - start).toFixed(2) + console.log( + `Captured ${photo.width}x${photo.height} ${photo.containerFormat} Photo in ${duration}ms!`, + ) + navigation.navigate('Photo', { photo: photo }) + } catch (e) { + console.error(`Failed to take Photo!`, e) + } + }, [navigation, photoOutput, location.currentLocation]) + + const preparedRecorder = useRef(undefined) + const activeRecorder = useRef(undefined) + const startRecording = useCallback(async () => { + console.log(`Starting Recording...`) + // get previously prepared recorder (cached) + let recorder = preparedRecorder.current + if (recorder == null) { + console.log(`No prepared Recorder available, creating one...`) + recorder = await videoOutput.createRecorder({}) + } + if (activeRecorder.current != null) { + // currently recording - abort + console.error(`Cannot start recording - already actively recording!`) + return + } + // setting it as actively recording + activeRecorder.current = recorder + // start recording + await recorder.startRecording( + (path) => { + console.log(`Recording finished! Path:`, path) + navigation.navigate('Video', { videoURL: path }) + activeRecorder.current = undefined + }, + (error) => { + console.error(`Failed to record!`, error) + activeRecorder.current = undefined + }, + () => console.log(`Recording paused`), + () => console.log(`Recording resumed.`), + ) + console.log(`Recording started!`) + // prepare a new recorder for the next call + preparedRecorder.current = await videoOutput.createRecorder({}) + }, [navigation.navigate, videoOutput.createRecorder]) + const stopRecording = useCallback(async () => { + console.log(`Stopping Recording...`) + const recorder = activeRecorder.current + if (recorder == null) { + console.error(`Not actively recording - cannot stop recording!`) + return + } + activeRecorder.current = undefined + await recorder.stopRecording() + console.log(`Recording stopped!`) + }, []) + + if (device == null) { + return ( + + No Camera Device! + + ) + } + return ( + + + + + console.log(`Camera interrupted! Reason: ${reason}`) + } + onInterruptionEnded={() => console.log(`Camera interruption over.`)} + onError={(error) => console.error(`Camera error:`, error)} + /> + + + + + { + setDevice(d) + }} + /> + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + flex: { + flex: 1, + }, + textContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + selectedDevice: { + paddingHorizontal: 20, + paddingVertical: 10, + backgroundColor: 'white', + }, + text: { + color: 'white', + }, + captureButtonRow: { + flexDirection: 'row', + justifyContent: 'center', + }, +}) diff --git a/apps/simple-camera/src/screens/PermissionsScreen.tsx b/apps/simple-camera/src/screens/PermissionsScreen.tsx new file mode 100644 index 0000000000..6ff8186478 --- /dev/null +++ b/apps/simple-camera/src/screens/PermissionsScreen.tsx @@ -0,0 +1,44 @@ +import { useNavigation } from '@react-navigation/native' +import type React from 'react' +import { useEffect } from 'react' +import { Button, StyleSheet, Text, View } from 'react-native' +import { + useCameraPermission, + useMicrophonePermission, +} from 'react-native-vision-camera' + +export function PermissionsScreen(): React.ReactElement { + const navigation = useNavigation() + const cameraPermission = useCameraPermission() + const microphonePermission = useMicrophonePermission() + + useEffect(() => { + if (cameraPermission.hasPermission) { + navigation.navigate('Camera') + } + }, [cameraPermission.hasPermission, navigation]) + + return ( + + No Camera Permission! +