Compare commits

...

43 Commits
qr ... master

Author SHA1 Message Date
Ivan Vershigora
0ed049a62d fix: bump 2026-04-24 19:25:34 +01:00
Ivan Vershigora
58ff00300c feat: detectQRCodeInImage API 2026-04-24 19:25:34 +01:00
Ivan Vershigora
e6dd85b4ea
bump to v17.0.3 2026-04-20 16:11:12 +01:00
Ivan Vershigora
75117f1287 bump to v17.0.2 2026-04-20 14:05:59 +01:00
Ivan Vershigora
4323cb608d fix: bump com.github.limpbrains:qr:v0.0.3 2026-04-20 14:05:59 +01:00
Overtorment
448cf1a9e0 refactor: release 2026-04-20 12:36:06 +01:00
Overtorment
a8535da0d3 fix: duplicate method 2026-04-19 20:23:10 +01:00
Ivan Vershigora
edb054a8cb
fix: bugs 2026-01-17 11:24:47 +00:00
Ivan Vershigora
91cc291a62
Update sync state after v17.0.1 sync 2026-01-17 10:25:19 +00:00
Ivan Vershigora
202f9f6455
Resolve merge conflicts, preserve no-google customizations 2026-01-17 10:21:18 +00:00
Seph Soliman
8e5149a6e6 v17.0.1 2026-01-12 10:47:04 -08:00
Seph Soliman
3767ef668c Fixed Android compilation error
("'setIOsSleepBeforeStarting' overrides nothing")
2026-01-12 10:44:57 -08:00
Seph Soliman
611006999d v17.0.0 2026-01-08 16:44:52 -08:00
Seph Soliman
b2b06b425b Merge branch 'r/16.2.1' 2026-01-08 16:43:31 -08:00
Seph Soliman
a73b84ef78 Removed iOsSleepBeforeStarting
No longer needed with proper begin/commit handling
2026-01-08 16:41:43 -08:00
Seph Soliman
afeaac0996 v16.2.1 2026-01-08 13:43:53 -08:00
coreyphillips
dca0e421fc fix(android): add missing setIOsSleepBeforeStarting stub for new arch
- The CKCameraManagerInterface requires setIOsSleepBeforeStarting to be implemented, but the Android new architecture implementation was missing this method stub. This caused Kotlin compilation failures when building release APKs.
2026-01-08 13:41:08 -08:00
Seph Soliman
db93bcfc94 Fixed missing iOsDeferredStart on Android 2026-01-08 13:40:32 -08:00
Seph Soliman
00b953263d
Merge pull request #760 from coreyphillips/master
fix(android): add missing setIOsSleepBeforeStarting stub for new arch
2026-01-08 13:37:02 -08:00
Seph Soliman
a2f61a0cbb Added iOsDeferredStart for UI performance
Reformatted RealCamera.swift
2026-01-08 13:19:54 -08:00
Seph Soliman
cdc1cced0e Fixed #758
NSGenericException "startRunning may not be called between calls to
beginConfiguration and commitConfiguration"
The root cause was that AVCaptureSession.previewLayer.session = X
causes beginConfiguration + commitConfiguration, and since we had that
running on the main thread, it caused a race condition between the main
and sessionQueue threads.
iOsSleepBeforeStarting will be removed in an upcoming major release to
avoid breaking changes in v16.
2026-01-08 12:05:17 -08:00
Ivan Vershigora
6120da218f
Release v16.2.0
## What's Changed

### Upstream Sync (teslamotors/react-native-camera-kit@cc6515b)
- iOS: Added allowedBarcodeTypes barcode filtering and mount stress test support
- TypeScript: Added allowedBarcodeTypes prop with type definitions
- Example app: Added stress test and barcode filtering examples
- Config: Moved .nvmrc to root directory
- Docs: Added allowedBarcodeTypes documentation with fork notes

### Android Improvements
- Fixed build error by adding Codegen-required stub implementations
- Updated to limpbrains/qr v0.0.2 (ECI encoding + inverted QR code support)
- Preserved QR-only scanning architecture (no Google ML Kit)

### Fork Integrity
 Build and lint tests pass
 No Google ML Kit dependencies
 QR-only Android barcode scanning preserved

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 09:07:41 +00:00
Ivan Vershigora
4e7527cbe3
Fix Android build: Add stub implementations for new Codegen methods
After syncing TypeScript layer with allowedBarcodeTypes and iOsSleepBeforeStarting props,
the auto-generated CKCameraManagerInterface requires these methods to be implemented.

Added stub implementations in both newarch and oldarch:
- setAllowedBarcodeTypes: No-op (fork is QR-only, doesn't use filtering)
- setIOsSleepBeforeStarting: No-op (iOS-only prop)

Both methods accept the props for API compatibility but have no effect on Android.

Verified: Android build now compiles successfully

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 00:05:54 +00:00
Ivan Vershigora
daaa65be3a
Update limpbrains/qr dependency to v0.0.2
Upgrade from v0.0.1 to v0.0.2 for new features:
- ECI (Extended Channel Interpretation) support for 20+ character encodings
- Inverted QR code detection (white-on-black QR codes)

Build and lint verified passing.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 23:53:32 +00:00
Ivan Vershigora
05ae12c0b1
Sync from teslamotors/react-native-camera-kit@cc6515b
Selectively synced upstream changes while preserving QR-only Android implementation.

Changes auto-synced (Category A - 19 files):
- iOS: All 8 files (stress test support + allowedBarcodeTypes filtering)
- TypeScript: All 7 files (Camera components, props, types, specs)
- Example app: All 3 files (stress test + allowedBarcodeTypes example)
- Config: Moved .nvmrc to root

Changes selectively synced (Category B - 2 files):
- CodeFormat.kt: Added UPC_A("upc-a") enum value (1 hunk applied, 1 skipped)
- README.md: Added allowedBarcodeTypes docs with QR-only note

Changes skipped (Android barcode conflicts):
- CKCamera.kt: All barcode filtering logic (~50+ hunks)
  Reason: Fork uses onBarcodeRead(String), upstream uses onBarcodeRead(List<Barcode>, Size)
- CKCameraManager.kt: setAllowedBarcodeTypes property setter
- package.json: Version bump (fork maintains independent versioning)

Upstream range: 5a709e0..cc6515b (12 commits)
Main feature: allowedBarcodeTypes barcode filtering (iOS synced, Android QR-only preserved)

Fork integrity checks:
 No Google ML Kit dependencies
 QRDecoder.decode() preserved
 limpbrains/qr dependency intact
 yarn build && yarn lint passed

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 23:27:08 +00:00
coreyphillips
2c0a0e43f3
fix(android): add missing setIOsSleepBeforeStarting stub for new arch
- The CKCameraManagerInterface requires setIOsSleepBeforeStarting to be implemented, but the Android new architecture implementation was missing this method stub. This caused Kotlin compilation failures when building release APKs.
2026-01-07 12:26:43 -05:00
Seph Soliman
cc6515b914 v16.2.0 2026-01-05 15:55:07 -08:00
Seph Soliman
03763d0470
Merge pull request #759 from teslamotors/stress-test
Add mount stress test to CameraKit example app
2026-01-05 14:34:33 -08:00
Seph Soliman
f5e3bc98e9 Add mount stress test to CameraKit example app
Introduces a mount stress test feature in the example app, allowing repeated mounting and unmounting of the CameraExample component at a configurable interval. Updates CameraExample to support a 'stress' mode that triggers image capture and random zoom changes on mount.
2026-01-05 14:33:04 -08:00
Seph Soliman
6c7cfe44d5
Merge pull request #727 from IlyaPasternakAmitech/master
feat: Add forbiddenBarcodeTypes property
2025-12-30 16:13:47 -08:00
Seph Soliman
ea894d96ad Fixed Android allowed barcode types 2025-12-30 16:03:40 -08:00
Seph Soliman
2a1f06aa12 Fixed optional behavior of allowedBarcodeTypes
Added more barcode types
2025-12-30 15:11:19 -08:00
Seph Soliman
5a709e03b0
Merge pull request #757 from limpbrains/init
Fixed #756 Catch camera init and trigger onError
2025-12-30 11:00:17 -08:00
Ivan Vershigora
1131e81f85 fix: catch error in cameraProviderFuture.get() 2025-12-29 18:58:29 +00:00
Ivan Vershigora
1371773530
fix: catch error in cameraProviderFuture.get() 2025-12-29 18:48:17 +00:00
Ivan
91909ff575
replace google mlkit with limpbrains/qr 2025-12-29 14:36:52 +00:00
Kseniya Vinnichek
d42ef6290b update README with allowedBarcodeTypes usage and supported formats 2025-11-20 13:37:03 +01:00
Kseniya Vinnichek
4cbec39042 refactoring 2025-11-20 13:08:08 +01:00
Kseniya Vinnichek
1ea413a099 Merge remote-tracking branch 'upstream/master' into refactoring
# Conflicts:
#	android/src/main/java/com/rncamerakit/CKCamera.kt
#	android/src/newarch/java/com/rncamerakit/CKCameraManager.kt
#	android/src/paper/java/com/facebook/react/viewmanagers/CKCameraManagerDelegate.java
#	android/src/paper/java/com/facebook/react/viewmanagers/CKCameraManagerInterface.java
2025-11-20 10:41:02 +01:00
Kseniya Vinnichek
f8be0f08e8 correct allowed barcode types validation, remove invalid reference, and prevent fallback errors 2025-11-19 11:32:43 +01:00
Ilya Pasternak
cc6d18ceaa change forbiddenBarcodeTypes property to allowedBarcodeTypes 2025-07-02 14:52:26 +03:00
Ilya Pasternak
b085a7801c Merge remote-tracking branch 'origin/master' 2025-06-26 19:57:43 +03:00
Ilya Pasternak
6d0bed7ce0 add forbiddenBarcodeTypes property 2025-06-16 09:54:16 +03:00
43 changed files with 3992 additions and 1466 deletions

3
.gitignore vendored
View File

@ -85,6 +85,7 @@ example/.yarn/*
ios/build/
ios/DerivedData/
dist/
.codegen/
### Xcode ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
@ -95,3 +96,5 @@ dist/
android/generated/ReactCodegen.podspec
example/.rock/cache/
.claude/

View File

@ -1,4 +1,5 @@
node_modules/
.codegen/
old-example/
example/
example-js-code/

217
CLAUDE.md Normal file
View File

@ -0,0 +1,217 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
React Native Camera Kit is a high-performance, cross-platform camera library for React Native applications (iOS and Android). It provides:
- Photo capture with high performance optimization
- Barcode/QR code scanning capabilities
- Camera preview support (including iOS simulator)
- Extensive camera control options (flash, focus, zoom, torch)
- Device orientation detection
This is a native library that uses React Native Codegen for cross-platform native module bindings.
## Architecture
### JavaScript/TypeScript Layer (`src/`)
The TypeScript codebase provides the React wrapper and type definitions:
- **Entry point** (`src/index.ts`): Exports the Camera component, types, and Orientation constants
- **Camera component** (`src/Camera.tsx`): Platform-agnostic wrapper that lazy-loads platform-specific implementations
- `src/Camera.ios.tsx`: iOS-specific camera component
- `src/Camera.android.tsx`: Android-specific camera component
- **Types and props**:
- `src/types.ts`: Core type definitions (CameraType, CodeFormat, TorchMode, FlashMode, FocusMode, ZoomMode, ResizeMode, CameraApi, CaptureData)
- `src/CameraProps.ts`: Complete Camera component props interface (both platform-specific and shared)
- **Native specs** (`src/specs/`):
- `CameraNativeComponent.ts`: React Native Codegen definition for the camera view component (NativeProps)
- `NativeCameraKitModule.ts`: TurboModule specification for native camera functions (capture, authorization)
**Key Pattern**: Optional numeric props are represented as `-1` or `undefined` until React Native Fabric supports optional values. Both platform-specific implementations handle this conversion.
### Native Layer
- **iOS** (`ios/ReactNativeCameraKit/`): Swift implementation
- `RealCamera.swift` / `SimulatorCamera.swift`: Core camera implementation (real and simulator)
- `CameraManager.swift`: Manages camera state and configuration
- `PhotoCaptureDelegate.swift`: Handles photo capture logic
- `ScannerInterfaceView.swift` / `ScannerFrameView.swift`: Barcode scanning UI
- `RatioOverlayView.swift`: Aspect ratio guide overlay
- **Android** (`android/src/main/java/com/rncamerakit/`): Kotlin implementation
- `CKCamera.kt`: Main camera view component
- `QRCodeAnalyzer.kt`: Barcode scanning using CameraX
- Event classes in `events/`: Handle camera callbacks (zoom, orientation, errors, etc.)
- Platform-specific code split between `newarch/` (React Native 0.73+, Fabric) and `oldarch/` (legacy)
## Development Commands
### Build and Compilation
```bash
# Build TypeScript to JavaScript (outputs to dist/)
yarn build
# Clean build artifacts
yarn clean
# Run both clean and build
yarn clean && yarn build
```
### Linting and Code Quality
```bash
# Run ESLint
yarn lint
# ESLint rules are configured in .eslintrc.js with:
# - Max line length: 120 characters
# - Required semicolons, proper indentation (2 spaces)
# - No console.log or debugger statements allowed
# - Strict import resolution checking
```
### Testing
```bash
# Run all tests
yarn test
# Run tests for a specific file
yarn test -- src/__tests__/index.test.tsx
# The project uses Jest with minimal test configuration
# Tests should be placed in __tests__ directories
```
### Example Project
```bash
# Bootstrap the example app (installs dependencies and pods)
yarn bootstrap
# For Linux:
yarn bootstrap-linux
```
## Key Development Notes
### Native Component Integration
The camera component uses **React Native Codegen** to auto-generate native binding code:
- Props are defined in `src/specs/CameraNativeComponent.ts` (NativeProps interface)
- Changes to props require running: `yarn codegen` (generates `build/` directory)
- The codegen config is in `package.json` under `codegenConfig`
### Platform-Specific Code
The library uses React Native's platform module for loading platform-specific implementations:
```typescript
// In src/Camera.tsx
const Camera = lazy(() =>
Platform.OS === 'ios'
? import('./Camera.ios')
: import('./Camera.android'),
);
```
Both implementations handle color props differently:
- **Android**: Uses `processColor()` to convert color values
- **iOS**: Passes colors as-is
### Optional Props Pattern
React Native Codegen doesn't support optional numeric props, so:
- Numeric props default to `-1` to indicate "undefined"
- The native layer interprets `-1` as "no value provided"
- This affects: `zoom`, `maxZoom`, `scanThrottleDelay`, `resetFocusTimeout`, `shutterAnimationDuration`
### Type System
The project uses strict TypeScript (`strict: true` in tsconfig.json):
- `@ts-expect-error` comments are used for Codegen type mismatches (see Camera.ios.tsx line 33)
- Type definitions must be accurate between user-facing types and native specs
- All numeric types from Codegen props must be converted in both platform files
## Testing and Releases
### Running Tests
```bash
# Run Jest tests
yarn test
# Build TypeScript (validates code)
yarn build
# Test files follow Jest conventions and are excluded from build (tsconfig.json excludes *.test.tsx)
```
### Release Process
```bash
# Standard npm release
yarn release
# Beta release
yarn release:beta
# Local testing (creates and opens tar.gz)
yarn release:local
```
The library is published to npm as `react-native-camera-kit` with the `files` array in package.json controlling what gets included in the published bundle.
## Important Configuration Details
- **TypeScript**: Strict mode enabled, targets ESNext, outputs to `dist/` with declaration files
- **Package managers**: Yarn 1.22.22 required (specified in package.json engines)
- **Node**: Requires Node.js >= 18
- **React Native**: Uses version 0.79.0, supports both legacy and new architecture (Fabric)
- **Import resolution**: ESLint is configured to recognize `.ios.tsx`, `.android.tsx`, and `.js` platform variants
---
## Camera Kit Sync State
**Last synchronized upstream commit**: 8e5149a6e6d3902ae87dad50da0d06ec2c61d2b8
**Upstream version**: 17.0.1
**Fork version**: 17.0.3
**Last sync date**: 2026-01-17
**Sync status**: success
### Changes Synced (upstream v16.2.0 -> v17.0.1)
**Key changes from upstream**:
- Replaced `iOsSleepBeforeStarting` (Int) with `iOsDeferredStart` (Bool) for iOS camera startup optimization
- iOS 26+ deferred start support via `AVCaptureOutput.deferredStartEnabled`
- Improved stress test logging with elapsed time tracking
- Various iOS formatting and code organization improvements
**Files synced (16 files)**:
- All iOS Swift files (CameraView.swift, RealCamera.swift, SimulatorCamera.swift, CameraProtocol.swift)
- CKCameraViewComponentView.mm (Objective-C++ bridge)
- TypeScript layer (Camera.ios.tsx, CameraProps.ts, CameraNativeComponent.ts)
- Example app improvements (App.tsx, BarcodeScreenExample.tsx)
- README.md with updated props documentation
### Fork-Specific Code Preserved
All QR-only Android architecture preserved:
- `android/build.gradle` - Uses `implementation 'com.github.limpbrains:qr:v0.0.3'`
- `android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt` - Uses `QRDecoder.decode()` from limpbrains/qr
- `android/src/main/java/com/rncamerakit/CodeFormat.kt` - Simplified enum (no ML Kit conversions)
- `android/src/main/java/com/rncamerakit/CKCamera.kt` - String callback `onQRCodeDetected(String)`
### Validation
- `yarn build` - PASSED
- `yarn lint` - PASSED
- `yarn test` - PASSED
- No `google.mlkit` references in android/ - VERIFIED
- `limpbrains/qr` dependency present - VERIFIED
- `QRDecoder.decode()` preserved - VERIFIED

147
README.md
View File

@ -1,20 +1,48 @@
<h1 align="center">
🎈 React Native Camera Kit
🎈 React Native Camera Kit (No Google)
</h1>
<p align="center">
A <strong>high performance, easy to use, rock solid</strong><br>
camera library for React Native apps.
camera library for React Native apps.<br>
<strong>No Google ML Kit dependency.</strong>
</p>
<p align="center">
<a href="https://github.com/teslamotors/react-native-camera-kit/blob/master/LICENSE">
<a href="https://github.com/limpbrains/react-native-camera-kit-no-google/blob/master/LICENSE">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="React Native Camera Kit is released under the MIT license." />
</a>
<a href="https://www.npmjs.org/package/react-native-camera-kit">
<img src="https://badge.fury.io/js/react-native-camera-kit.svg" alt="Current npm package version." />
<a href="https://www.npmjs.org/package/react-native-camera-kit-no-google">
<img src="https://badge.fury.io/js/react-native-camera-kit-no-google.svg" alt="Current npm package version." />
</a>
</p>
> **⚠️ Fork Notice**
>
> This is a fork of [teslamotors/react-native-camera-kit](https://github.com/teslamotors/react-native-camera-kit) with **Google ML Kit removed** from Android.
>
> **Why?** Google ML Kit is closed source. This fork uses a completely open source pure Kotlin QR decoder instead.
## Key Differences from Original
| Feature | Original | This Fork |
|---------|----------|-----------|
| Android barcode library | Google ML Kit (closed source) | Pure Kotlin ([limpbrains/qr](https://github.com/limpbrains/qr)) |
| Source code | Closed source (ML Kit) | Fully open source |
| Google Play Services | Required | Not required |
| Barcode formats | Multiple formats | **QR codes only** |
### Limitations
- **Android barcode scanning supports QR codes only** (no EAN, UPC, Code128, etc.)
- iOS barcode scanning is unchanged (uses native AVFoundation, supports all formats)
### QR Decoder Attribution
The Android QR decoder is based on:
- [limpbrains/qr](https://github.com/limpbrains/qr) - Kotlin QR code reader library
- [paulmillr/qr](https://github.com/paulmillr/qr) - Original JavaScript implementation by Paul Miller
<table>
<tr>
<td>
@ -24,8 +52,9 @@
<ul>
<li><h3>Cross Platform (iOS and Android)</h3></li>
<li><h3>Optimized for performance and high photo capture rate</h3></li>
<li><h3>QR / Barcode scanning support</h3></li>
<li><h3>QR Code scanning support (QR only on Android)</h3></li>
<li><h3>Camera preview support in iOS simulator</h3></li>
<li><h3>No Google dependencies</h3></li>
</ul>
</td>
</tr>
@ -34,7 +63,7 @@
## Installation (RN > 0.60)
```bash
yarn add react-native-camera-kit
yarn add react-native-camera-kit-no-google
```
```bash
@ -143,7 +172,7 @@ Add the following usage descriptions to your `Info.plist` (usually found at: `io
Barebones camera component if you need advanced/customized interface
```ts
import { Camera, CameraType } from 'react-native-camera-kit';
import { Camera, CameraType } from 'react-native-camera-kit-no-google';
```
```tsx
@ -172,39 +201,75 @@ Additionally, the Camera can be used for barcode scanning
### Camera Props (Optional)
| Props | Type | Description |
| ------------------------------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ref` | Ref | Reference on the camera view |
| `style` | StyleProp\<ViewStyle> | Style to apply on the camera view |
| `flashMode` | `'on'`/`'off'`/`'auto'` | Camera flash mode. Default: `auto` |
| `focusMode` | `'on'`/`'off'` | Camera focus mode. Default: `on` |
| `zoomMode` | `'on'`/`'off'` | Enable the pinch to zoom gesture. Default: `on`. If `on`, you must pass `zoom` as `undefined` or avoid setting `zoomMode` to allow pinch to zoom |
| `zoom` | `number` | Control the zoom. Default: `1.0` |
| `maxZoom` | `number` | Maximum zoom allowed (but not beyond what camera allows). Default: `undefined` (camera default max) |
| `onZoom` | Function | Callback when user makes a pinch gesture, regardless of what the `zoom` prop was set to. Returned event contains `zoom`. Ex: `onZoom={(e) => console.log(e.nativeEvent.zoom)}`. |
| `torchMode` | `'on'`/`'off'` | Toggle flash light when camera is active. Default: `off` |
| `cameraType` | CameraType.Back/CameraType.Front | Choose what camera to use. Default: `CameraType.Back` |
| `onOrientationChange` | Function | Callback when physical device orientation changes. Returned event contains `orientation`. Ex: `onOrientationChange={(event) => console.log(event.nativeEvent.orientation)}`. Use `import { Orientation } from 'react-native-camera-kit'; if (event.nativeEvent.orientation === Orientation.PORTRAIT) { ... }` to understand the new value |
| **Android only** |
| `onError` | Function | Android only. Callback when camera fails to initialize. Ex: `onError={(e) => console.log(e.nativeEvent.errorMessage)}`. |
| `shutterPhotoSound` | `boolean` | Android only. Enable or disable the shutter sound when capturing a photo. Default: `true` |
| **iOS only** |
| `ratioOverlay` | `'int:int'` | Show a guiding overlay in the camera preview for the selected ratio. Does not crop image as of v9.0. Example: `'16:9'` |
| `ratioOverlayColor` | Color | Any color with alpha. Default: `'#ffffff77'` |
| `resetFocusTimeout` | `number` | Dismiss tap to focus after this many milliseconds. Default `0` (disabled). Example: `5000` is 5 seconds. |
| `resetFocusWhenMotionDetected` | Boolean | Dismiss tap to focus when focus area content changes. Native iOS feature, see documentation: https://developer.apple.com/documentation/avfoundation/avcapturedevice/1624644-subjectareachangemonitoringenabl?language=objc). Default `true`. |
| `resizeMode` | `'cover' / 'contain'` | Determines the scaling and cropping behavior of content within the view. `cover` (resizeAspectFill on iOS) scales the content to fill the view completely, potentially cropping content if its aspect ratio differs from the view. `contain` (resizeAspect on iOS) scales the content to fit within the view's bounds without cropping, ensuring all content is visible but may introduce letterboxing. Default behavior depends on the specific use case. |
| `scanThrottleDelay` | `number` | Duration between scan detection in milliseconds. Default 2000 (2s) |
| `maxPhotoQualityPrioritization` | `'balanced'` / `'quality'` / `'speed'` | [iOS 13 and newer](https://developer.apple.com/documentation/avfoundation/avcapturephotooutput/3182995-maxphotoqualityprioritization). `'speed'` provides a 60-80% median capture time reduction vs 'quality' setting. Tested on iPhone 6S Max (66% faster) and iPhone 15 Pro Max (76% faster!). Default `balanced` |
| `onCaptureButtonPressIn` | Function | Callback when iPhone capture button is pressed in or Android volume or camera button is pressed in. Ex: `onCaptureButtonPressIn={() => console.log("volume button pressed in")}` |
| `onCaptureButtonPressOut` | Function | Callback when iPhone capture button is released or Android volume or camera button is released. Ex: `onCaptureButtonPressOut={() => console.log("volume button released")}` |
| **Barcode only** |
| `scanBarcode` | `boolean` | Enable barcode scanner. Default: `false` |
| `showFrame` | `boolean` | Show frame in barcode scanner. Default: `false` |
| `barcodeFrameSize` | `object` | Frame size of barcode scanner. Default: `{ width: 300, height: 150 }` |
| `laserColor` | Color | Color of barcode scanner laser visualization. Default: `red` |
| `frameColor` | Color | Color of barcode scanner frame visualization. Default: `yellow` |
| `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` |
| Props | Type | Description |
| ------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ref` | Ref | Reference on the camera view |
| `style` | StyleProp\<ViewStyle> | Style to apply on the camera view |
| `flashMode` | `'on'`/`'off'`/`'auto'` | Camera flash mode. Default: `auto` |
| `focusMode` | `'on'`/`'off'` | Camera focus mode. Default: `on` |
| `zoomMode` | `'on'`/`'off'` | Enable the pinch to zoom gesture. Default: `on`. If `on`, you must pass `zoom` as `undefined` or avoid setting `zoomMode` to allow pinch to zoom |
| `zoom` | `number` | Control the zoom. Default: `1.0` |
| `maxZoom` | `number` | Maximum zoom allowed (but not beyond what camera allows). Default: `undefined` (camera default max) |
| `onZoom` | Function | Callback when user makes a pinch gesture, regardless of what the `zoom` prop was set to. Returned event contains `zoom`. Ex: `onZoom={(e) => console.log(e.nativeEvent.zoom)}`. |
| `torchMode` | `'on'`/`'off'` | Toggle flash light when camera is active. Default: `off` |
| `cameraType` | CameraType.Back/CameraType.Front | Choose what camera to use. Default: `CameraType.Back` |
| `onOrientationChange` | Function | Callback when physical device orientation changes. Returned event contains `orientation`. Ex: `onOrientationChange={(event) => console.log(event.nativeEvent.orientation)}`. Use `import { Orientation } from 'react-native-camera-kit-no-google'; if (event.nativeEvent.orientation === Orientation.PORTRAIT) { ... }` to understand the new value |
| `allowedBarcodeTypes` | string[] | Limits which barcode formats can be detected. Ex: `['qr', 'ean-13', 'code-128']`. If empty or omitted, all supported formats are scanned. **Note**: Android only supports `'qr'` in this fork. iOS supports all formats. |
| **Android only** |
| `onError` | Function | Android only. Callback when camera fails to initialize. Ex: `onError={(e) => console.log(e.nativeEvent.errorMessage)}`. |
| `shutterPhotoSound` | `boolean` | Android only. Enable or disable the shutter sound when capturing a photo. Default: `true` |
| **iOS only** |
| `ratioOverlay` | `'int:int'` | Show a guiding overlay in the camera preview for the selected ratio. Does not crop image as of v9.0. Example: `'16:9'` |
| `ratioOverlayColor` | Color | Any color with alpha. Default: `'#ffffff77'` |
| `resetFocusTimeout` | `number` | Dismiss tap to focus after this many milliseconds. Default `0` (disabled). Example: `5000` is 5 seconds. |
| `resetFocusWhenMotionDetected` | Boolean | Dismiss tap to focus when focus area content changes. Native iOS feature, see documentation: https://developer.apple.com/documentation/avfoundation/avcapturedevice/1624644-subjectareachangemonitoringenabl?language=objc). Default `true`. |
| `resizeMode` | `'cover' / 'contain'` | Determines the scaling and cropping behavior of content within the view. `cover` (resizeAspectFill on iOS) scales the content to fill the view completely, potentially cropping content if its aspect ratio differs from the view. `contain` (resizeAspect on iOS) scales the content to fit within the view's bounds without cropping, ensuring all content is visible but may introduce letterboxing. Default behavior depends on the specific use case. |
| `scanThrottleDelay` | `number` | Duration between scan detection in milliseconds. Default 2000 (2s) |
| `maxPhotoQualityPrioritization` | `'balanced'` / `'quality'` / `'speed'` | [iOS 13 and newer](https://developer.apple.com/documentation/avfoundation/avcapturephotooutput/3182995-maxphotoqualityprioritization). `'speed'` provides a 60-80% median capture time reduction vs 'quality' setting. Tested on iPhone 6S Max (66% faster) and iPhone 15 Pro Max (76% faster!). Default `balanced` |
| `iOsDeferredStart` | `boolean` | iOS 26+ only. Enables `AVCaptureOutput.deferredStartEnabled` when supported to get the preview visible faster. Default `true`. When enabled, the first capture can be delayed by a few hundred milliseconds. Ignored on Android and on older iOS versions. |
| `onCaptureButtonPressIn` | Function | Callback when iPhone capture button is pressed in or Android volume or camera button is pressed in. Ex: `onCaptureButtonPressIn={() => console.log("volume button pressed in")}` |
| `onCaptureButtonPressOut` | Function | Callback when iPhone capture button is released or Android volume or camera button is released. Ex: `onCaptureButtonPressOut={() => console.log("volume button released")}` |
| **Barcode only** |
| `scanBarcode` | `boolean` | Enable barcode scanner. Default: `false` |
| `showFrame` | `boolean` | Show frame in barcode scanner. Default: `false` |
| `barcodeFrameSize` | `object` | Frame size of barcode scanner. Default: `{ width: 300, height: 150 }` |
| `laserColor` | Color | Color of barcode scanner laser visualization. Default: `red` |
| `frameColor` | Color | Color of barcode scanner frame visualization. Default: `yellow` |
| `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` |
### detectQRCodeInImage(base64)
Detect and decode a QR code from a static image. Takes a base64-encoded image string. Returns a promise that resolves with the decoded string, or `null` if the image is valid but contains no QR code. Rejects only on actual errors (invalid base64, undecodable image data, detector failure).
```ts
import { detectQRCodeInImage } from 'react-native-camera-kit-no-google';
try {
const decoded = await detectQRCodeInImage(base64ImageData);
if (decoded === null) {
// Image was valid but no QR code was found
} else {
console.log('QR code:', decoded);
}
} catch (e) {
// Invalid image data or internal detection failure — e.message has details
}
```
Rejection error codes: `E_INVALID_IMAGE` (bad base64 or undecodable image) and `E_QR_DETECTION_FAILED` (Android: QR decoder error; iOS: Vision framework error or detector init failure).
This API is exclusive to this fork and is not available in the original react-native-camera-kit.
It does **not** require camera permissions — it works purely on image data. Useful for detecting QR codes from:
- Images picked from the photo library
- Clipboard images
- Files received via deep links or share extensions
| Platform | Implementation |
|----------|---------------|
| iOS (device) | Vision framework (`VNDetectBarcodesRequest`) |
| iOS (simulator) | CoreImage (`CIDetector`) |
| Android | [limpbrains/qr](https://github.com/limpbrains/qr) |
### Imperative API

View File

@ -9,10 +9,10 @@ Pod::Spec.new do |s|
s.license = "MIT"
s.authors = "CameraKit"
s.homepage = "https://github.com/teslamotors/react-native-camera-kit"
s.homepage = "https://github.com/limpbrains/react-native-camera-kit-no-google"
s.platform = :ios, "15.0"
s.source = { :git => "https://github.com/teslamotors/react-native-camera-kit.git", :tag => "v#{s.version}" }
s.source = { :git => "https://github.com/limpbrains/react-native-camera-kit-no-google.git", :tag => "v#{s.version}" }
s.source_files = [
# Exclude .h files as they cause Swift compiler to treat them as C files, but they are C++
# See https://github.com/facebook/react-native/issues/45424#issuecomment-2354737063

View File

@ -66,8 +66,9 @@ dependencies {
// If you want to additionally use the CameraX Extensions library
// implementation "androidx.camera:camera-extensions:${camerax_version}"
implementation 'com.google.mlkit:barcode-scanning:17.3.0'
implementation 'com.github.limpbrains:qr:v0.0.3'
}
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}

View File

@ -28,6 +28,7 @@ import androidx.lifecycle.LifecycleObserver
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.events.RCTEventEmitter
import com.rncamerakit.barcode.BarcodeFrame
@ -44,7 +45,6 @@ import android.graphics.Rect
import android.graphics.RectF
import android.util.Size
import com.facebook.react.uimanager.UIManagerHelper
import com.google.mlkit.vision.barcode.common.Barcode
import com.rncamerakit.events.*
class RectOverlay constructor(context: Context) :
@ -109,6 +109,7 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
private var frameColor = Color.GREEN
private var laserColor = Color.RED
private var barcodeFrameSize: Size? = null
private var allowedBarcodeTypes: Array<CodeFormat>? = null
private fun getActivity() : Activity {
return currentContext.currentActivity!!
@ -186,7 +187,17 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
val cameraProviderFuture = ProcessCameraProvider.getInstance(getActivity())
cameraProviderFuture.addListener({
// Used to bind the lifecycle of cameras to the lifecycle owner
cameraProvider = cameraProviderFuture.get()
try {
cameraProvider = cameraProviderFuture.get()
} catch (exc: Exception) {
val rootCause = exc.cause?.cause?.message ?: exc.cause?.message ?: exc.message ?: "Camera initialization failed"
Log.e(TAG, "Camera initialization failed: $rootCause", exc)
val surfaceId = UIManagerHelper.getSurfaceId(currentContext)
UIManagerHelper
.getEventDispatcherForReactTag(currentContext, id)
?.dispatchEvent(ErrorEvent(surfaceId, id, rootCause))
return@addListener
}
// Rotate the image according to device orientation, even when UI orientation is locked
orientationListener = object : OrientationEventListener(context, SensorManager.SENSOR_DELAY_UI) {
@ -329,36 +340,9 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
val useCases = mutableListOf(preview, imageCapture)
if (scanBarcode) {
val analyzer = QRCodeAnalyzer(analyzerBlock@{ barcodes, imageSize ->
if (barcodes.isEmpty()) {
return@analyzerBlock
}
val barcodeFrame = barcodeFrame
if (barcodeFrame == null) {
onBarcodeRead(barcodes)
return@analyzerBlock
}
// Calculate scaling factors (image is always rotated by 90 degrees)
val scaleX = viewFinder.width.toFloat() / imageSize.height
val scaleY = viewFinder.height.toFloat() / imageSize.width
val filteredBarcodes = barcodes.filter { barcode ->
val barcodeBoundingBox = barcode.boundingBox ?: return@filter false;
val scaledBarcodeBoundingBox = Rect(
(barcodeBoundingBox.left * scaleX).toInt(),
(barcodeBoundingBox.top * scaleY).toInt(),
(barcodeBoundingBox.right * scaleX).toInt(),
(barcodeBoundingBox.bottom * scaleY).toInt()
)
barcodeFrame.frameRect.contains(scaledBarcodeBoundingBox)
}
if (filteredBarcodes.isNotEmpty()) {
onBarcodeRead(filteredBarcodes)
}
if (scanBarcode) {
val analyzer = QRCodeAnalyzer({ decodedValue ->
onBarcodeRead(decodedValue)
}, scanThrottleDelay)
imageAnalyzer!!.setAnalyzer(cameraExecutor, analyzer)
useCases.add(imageAnalyzer)
@ -482,8 +466,8 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
val imageFile = File(path)
val imageSize = imageFile.length() // size in bytes
imageInfo.putDouble("size", imageSize.toDouble())
imageInfo.putDouble("size", imageSize.toDouble())
promise.resolve(imageInfo)
} catch (ex: Exception) {
Log.e(TAG, "Error while saving or decoding saved photo: ${ex.message}", ex)
@ -509,12 +493,11 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
rectOverlay.drawRectBounds(focusRects)
}
private fun onBarcodeRead(barcodes: List<Barcode>) {
val codeFormat = CodeFormat.fromBarcodeType(barcodes.first().format);
private fun onBarcodeRead(decodedValue: String) {
val surfaceId = UIManagerHelper.getSurfaceId(currentContext)
UIManagerHelper
.getEventDispatcherForReactTag(currentContext, id)
?.dispatchEvent(ReadCodeEvent(surfaceId, id, barcodes.first().rawValue, codeFormat.code))
?.dispatchEvent(ReadCodeEvent(surfaceId, id, decodedValue, CodeFormat.QR.code))
}
private fun onOrientationChange(orientation: Int) {
@ -703,6 +686,26 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
}
}
fun setAllowedBarcodeTypes(types: ReadableArray?) {
if (types == null || types.size() == 0) {
allowedBarcodeTypes = emptyArray()
return
}
// Convert only valid CodeFormat values
val converted = mutableListOf<CodeFormat>()
for (i in 0 until types.size()) {
val name = types.getString(i) ?: continue
val format = CodeFormat.fromName(name)
if (format != null) {
converted.add(format)
}
}
allowedBarcodeTypes = converted.toTypedArray()
}
private fun convertDeviceHeightToSupportedAspectRatio(actualWidth: Int, actualHeight: Int): Int {
val maxScreenRatio = 16 / 9f
return (if (actualHeight / actualWidth > maxScreenRatio) actualWidth * maxScreenRatio else actualHeight).toInt()
@ -723,6 +726,10 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
return false
}
private fun convertAllowedBarcodeTypes(): Set<Int> {
return allowedBarcodeTypes?.map { it.toBarcodeType() }?.toSet() ?: emptySet()
}
companion object {
private const val TAG = "CameraKit"

View File

@ -1,7 +1,5 @@
package com.rncamerakit
import com.google.mlkit.vision.barcode.common.Barcode
enum class CodeFormat(val code: String) {
CODE_128("code-128"),
CODE_39("code-39"),
@ -10,6 +8,7 @@ enum class CodeFormat(val code: String) {
EAN_13("ean-13"),
EAN_8("ean-8"),
ITF("itf"),
UPC_A("upc-a"),
UPC_E("upc-e"),
QR("qr"),
PDF_417("pdf-417"),
@ -17,40 +16,17 @@ enum class CodeFormat(val code: String) {
DATA_MATRIX("data-matrix"),
UNKNOWN("unknown");
fun toBarcodeType(): Int {
return when (this) {
CODE_128 -> Barcode.FORMAT_CODE_128
CODE_39 -> Barcode.FORMAT_CODE_39
CODE_93 -> Barcode.FORMAT_CODE_93
CODABAR -> Barcode.FORMAT_CODABAR
EAN_13 -> Barcode.FORMAT_EAN_13
EAN_8 -> Barcode.FORMAT_EAN_8
ITF -> Barcode.FORMAT_ITF
UPC_E -> Barcode.FORMAT_UPC_E
QR -> Barcode.FORMAT_QR_CODE
PDF_417 -> Barcode.FORMAT_PDF417
AZTEC -> Barcode.FORMAT_AZTEC
DATA_MATRIX -> Barcode.FORMAT_DATA_MATRIX
UNKNOWN -> -1 // Or any other default value you prefer
companion object {
fun fromName(name: String): CodeFormat? {
val normalized = name.trim().lowercase()
return values().firstOrNull { format ->
format.code == normalized || format.name.lowercase() == normalized
}
}
}
companion object {
fun fromBarcodeType(@Barcode.BarcodeFormat barcodeType: Int): CodeFormat =
when (barcodeType) {
Barcode.FORMAT_CODE_128 -> CODE_128
Barcode.FORMAT_CODE_39 -> CODE_39
Barcode.FORMAT_CODE_93 -> CODE_93
Barcode.FORMAT_CODABAR -> CODABAR
Barcode.FORMAT_EAN_13 -> EAN_13
Barcode.FORMAT_EAN_8 -> EAN_8
Barcode.FORMAT_ITF -> ITF
Barcode.FORMAT_UPC_E -> UPC_E
Barcode.FORMAT_QR_CODE -> QR
Barcode.FORMAT_PDF417 -> PDF_417
Barcode.FORMAT_AZTEC -> AZTEC
Barcode.FORMAT_DATA_MATRIX -> DATA_MATRIX
else -> UNKNOWN
}
}
}
fun CodeFormat.toBarcodeType(): Int {
// QR-only Android implementation; no barcode type mapping required.
return 0
}

View File

@ -0,0 +1,99 @@
package com.rncamerakit
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import qr.QRDecoder
object ImageQRCodeDecoder {
// Center-crop retry removes a 10% border on each side. Borders often contain
// background clutter (fingers, page edges, UI chrome) that confuses the
// detector without contributing QR data.
private const val CROP_FRACTION = 0.1
// The underlying QR decoder works best on images around ~600px on the longest
// side. Larger inputs waste CPU on pixel scanning; much smaller inputs lose
// the finder-pattern resolution. 600 is the empirical sweet spot.
private const val RETRY_MAX_DIM = 600
/**
* Decodes a QR code from a base64-encoded image.
* Returns the decoded string, or null if no QR code could be found.
* Throws IllegalArgumentException if the input is not a valid image.
*/
fun decode(base64: String): String? {
val imageBytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
?: throw IllegalArgumentException("Could not decode base64 image data")
return try {
decodeFromBitmap(bitmap)
} finally {
bitmap.recycle()
}
}
private fun decodeFromBitmap(bitmap: Bitmap): String? {
val rgba = bitmapToRgba(bitmap)
return try {
QRDecoder.decode(bitmap.width, bitmap.height, rgba)
} catch (e: Exception) {
// Retry with cropped-and-scaled image for hard-to-read QR codes
decodeCroppedAndScaled(bitmap)
}
}
private fun decodeCroppedAndScaled(original: Bitmap): String? {
val x = (original.width * CROP_FRACTION).toInt()
val y = (original.height * CROP_FRACTION).toInt()
val cropW = original.width - 2 * x
val cropH = original.height - 2 * y
// Image is too small to retry with a center-crop — treat as "no QR found".
if (cropW < 1 || cropH < 1) return null
val cropped = Bitmap.createBitmap(original, x, y, cropW, cropH)
try {
val longest = maxOf(cropW, cropH)
val scale = if (longest > RETRY_MAX_DIM) RETRY_MAX_DIM.toFloat() / longest else 1f
val scaled = if (scale < 1f) {
Bitmap.createScaledBitmap(cropped, (cropW * scale).toInt(), (cropH * scale).toInt(), true)
} else {
cropped
}
try {
val rgba = bitmapToRgba(scaled)
return try {
QRDecoder.decode(scaled.width, scaled.height, rgba)
} catch (e: Exception) {
null
}
} finally {
if (scaled !== cropped) scaled.recycle()
}
} finally {
cropped.recycle()
}
}
private fun bitmapToRgba(bitmap: Bitmap): ByteArray {
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val rgba = ByteArray(width * height * 4)
for (i in pixels.indices) {
val pixel = pixels[i]
val offset = i * 4
rgba[offset] = ((pixel shr 16) and 0xFF).toByte() // R
rgba[offset + 1] = ((pixel shr 8) and 0xFF).toByte() // G
rgba[offset + 2] = (pixel and 0xFF).toByte() // B
rgba[offset + 3] = ((pixel shr 24) and 0xFF).toByte() // A
}
return rgba
}
}

View File

@ -1,48 +1,65 @@
package com.rncamerakit
import android.annotation.SuppressLint
import android.util.Size
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import qr.QRDecoder
import qr.QRDecodingException
class QRCodeAnalyzer (
private val onQRCodesDetected: (qrCodes: List<Barcode>, imageSize: Size) -> Unit,
class QRCodeAnalyzer(
private val onQRCodeDetected: (decodedValue: String) -> Unit,
private val scanThrottleDelay: Long = 0L
) : ImageAnalysis.Analyzer {
// Time in milliseconds of the last time we dispatched detected barcodes
private var lastBarcodeDetectedTime: Long = 0L
@SuppressLint("UnsafeExperimentalUsageError")
// Time in milliseconds of the last time we dispatched detected QR code
private var lastQRDetectedTime: Long = 0L
@ExperimentalGetImage
override fun analyze(image: ImageProxy) {
val mediaImage = image.image ?: return
try {
val grayscaleData = extractYPlane(image)
val decoded = QRDecoder.decode(image.width, image.height, grayscaleData)
val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
val scanner = BarcodeScanning.getClient()
scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
// Throttle callback invocations based on scanThrottleDelay (ms)
val now = System.currentTimeMillis()
if (scanThrottleDelay > 0 && (now - lastBarcodeDetectedTime) < scanThrottleDelay) {
return@addOnSuccessListener
}
val strBarcodes = mutableListOf<Barcode>()
barcodes.forEach { barcode ->
strBarcodes.add(barcode ?: return@forEach)
}
if (strBarcodes.isNotEmpty()) {
lastBarcodeDetectedTime = now
onQRCodesDetected(strBarcodes, Size(image.width, image.height))
}
// Throttle callback invocations based on scanThrottleDelay (ms)
val now = System.currentTimeMillis()
if (scanThrottleDelay > 0 && (now - lastQRDetectedTime) < scanThrottleDelay) {
return
}
.addOnCompleteListener {
image.close()
lastQRDetectedTime = now
onQRCodeDetected(decoded)
} catch (e: QRDecodingException) {
// No QR code found or decoding error - this is expected for most frames
} finally {
image.close()
}
}
/**
* Extracts the Y (luminance) plane from a YUV_420_888 ImageProxy.
* The Y plane is already grayscale data (1 byte per pixel).
* Handles row stride padding when rowStride > width.
*/
private fun extractYPlane(image: ImageProxy): ByteArray {
val yPlane = image.planes[0]
val yBuffer = yPlane.buffer
val rowStride = yPlane.rowStride
val width = image.width
val height = image.height
val yBytes = ByteArray(width * height)
if (rowStride == width) {
// Fast path: contiguous data, no padding
yBuffer.rewind()
yBuffer.get(yBytes, 0, width * height)
} else {
// Slow path: handle row stride padding
yBuffer.rewind()
for (row in 0 until height) {
yBuffer.position(row * rowStride)
yBuffer.get(yBytes, row * width, width)
}
}
return yBytes
}
}

View File

@ -149,6 +149,14 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager<CKCa
view?.setScanThrottleDelay(value)
}
@ReactProp(name = "allowedBarcodeTypes")
override fun setAllowedBarcodeTypes(view: CKCamera, types: ReadableArray?) {
// Fork note: Android uses limpbrains/qr which is QR-only, no barcode filtering.
// This prop is accepted for API compatibility but has no effect on Android.
// iOS implementation supports this prop.
view.setAllowedBarcodeTypes(types)
}
// Methods only available on iOS
override fun setRatioOverlay(view: CKCamera?, value: String?) = Unit
@ -160,5 +168,7 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager<CKCa
override fun setResizeMode(view: CKCamera?, value: String?) = Unit
override fun setIOsDeferredStart(view: CKCamera?, value: Boolean) = Unit
override fun setMaxPhotoQualityPrioritization(view: CKCamera?, value: String?) = Unit
}

View File

@ -2,6 +2,7 @@ package com.rncamerakit
import com.facebook.react.bridge.*
import com.facebook.react.uimanager.UIManagerHelper
import java.util.concurrent.Executors
import com.rncamerakit.NativeCameraKitModuleSpec
@ -38,6 +39,8 @@ class RNCameraKitModule(private val reactContext: ReactApplicationContext) : Nat
const val LANDSCAPE_RIGHT = 3 // ➡️
const val REACT_CLASS = "RNCameraKitModule"
private val qrDecodeExecutor = Executors.newCachedThreadPool()
}
override fun getName(): String {
@ -62,6 +65,19 @@ class RNCameraKitModule(private val reactContext: ReactApplicationContext) : Nat
override fun checkDeviceCameraAuthorizationStatus(promise: Promise?) = Unit
@ReactMethod
override fun detectQRCodeInImage(base64: String, promise: Promise) {
qrDecodeExecutor.execute {
try {
promise.resolve(ImageQRCodeDecoder.decode(base64))
} catch (e: IllegalArgumentException) {
promise.reject("E_INVALID_IMAGE", e.message ?: "Invalid image data", e)
} catch (e: Exception) {
promise.reject("E_QR_DETECTION_FAILED", e.message ?: "QR detection failed", e)
}
}
}
/**
* Captures a photo using the camera.
*

View File

@ -143,7 +143,16 @@ class CKCameraManager(var context: ReactApplicationContext) : SimpleViewManager<
view?.setScanThrottleDelay(value)
}
@ReactProp(name = "allowedBarcodeTypes")
fun setAllowedBarcodeTypes(view: CKCamera?, types: ReadableArray?) {
// Fork note: Android uses limpbrains/qr which is QR-only, no barcode filtering.
// This prop is accepted for API compatibility but has no effect on Android.
// iOS implementation supports this prop.
}
// Methods only available on iOS
fun setIOsSleepBeforeStarting(view: CKCamera?, value: Int) = Unit
fun setRatioOverlay(view: CKCamera?, value: String?) = Unit
fun setRatioOverlayColor(view: CKCamera?, value: Int?) = Unit

View File

@ -2,6 +2,7 @@ package com.rncamerakit
import com.facebook.react.bridge.*
import com.facebook.react.uimanager.UIManagerHelper
import java.util.concurrent.Executors
/**
* Native module for interacting with the camera in React Native applications.
@ -36,6 +37,8 @@ class RNCameraKitModule(private val reactContext: ReactApplicationContext) : Rea
const val LANDSCAPE_RIGHT = 3 // ➡️
const val REACT_CLASS = "RNCameraKitModule"
private val qrDecodeExecutor = Executors.newCachedThreadPool()
}
override fun getName(): String {
@ -60,6 +63,19 @@ class RNCameraKitModule(private val reactContext: ReactApplicationContext) : Rea
fun checkDeviceCameraAuthorizationStatus(promise: Promise?) = Unit
@ReactMethod
fun detectQRCodeInImage(base64: String, promise: Promise) {
qrDecodeExecutor.execute {
try {
promise.resolve(ImageQRCodeDecoder.decode(base64))
} catch (e: IllegalArgumentException) {
promise.reject("E_INVALID_IMAGE", e.message ?: "Invalid image data", e)
} catch (e: Exception) {
promise.reject("E_QR_DETECTION_FAILED", e.message ?: "QR detection failed", e)
}
}
}
/**
* Captures a photo using the camera.
*

View File

@ -4,3 +4,4 @@ extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autoli
rootProject.name = 'CameraKitExample'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')
// includeBuild('../../../qr')

View File

@ -10,6 +10,7 @@
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>

View File

@ -1719,6 +1719,34 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- SocketRocket
- react-native-image-picker (7.2.3):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- 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
- SocketRocket
- Yoga
- React-NativeModulesApple (0.81.0):
- boost
- DoubleConversion
@ -2223,7 +2251,7 @@ PODS:
- React-perflogger (= 0.81.0)
- React-utils (= 0.81.0)
- SocketRocket
- ReactNativeCameraKit (16.1.1):
- ReactNativeCameraKit (17.0.4):
- boost
- DoubleConversion
- fast_float
@ -2296,6 +2324,7 @@ DEPENDENCIES:
- 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-native-image-picker (from `../node_modules/react-native-image-picker`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
@ -2416,6 +2445,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
React-NativeModulesApple:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-oscompat:
@ -2522,6 +2553,7 @@ SPEC CHECKSUMS:
React-logger: 04ce9229cb57db2c2a8164eaec1105f89da7fb22
React-Mapbuffer: e402e7a0535b2213c50727553621480fe8cd8ade
React-microtasksnativemodule: a63ce5595016996a9bac1f10c70a7a7fe6506649
react-native-image-picker: b16541b587b275e81a12f9b82f215c5e9b0ccbf3
React-NativeModulesApple: b3766e1f87b08064ebc459b9e1538da2447ca874
React-oscompat: 34f3d3c06cadcbc470bc4509c717fb9b919eaa8b
React-perflogger: a1edb025fd5d44f61bf09307e248f7608d7b2dcf
@ -2552,7 +2584,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: c91900fa724baee992f01c05eeb4c9e01a807f78
ReactCodegen: a55799cae416c387aeaae3aabc1bc0289ac19cee
ReactCommon: 116d6ee71679243698620d8cd9a9042541e44aa6
ReactNativeCameraKit: b01e637c97fb6eefe43eff31917d1410fc77e1f8
ReactNativeCameraKit: 21c717ad3fc6f040b4293a07c4300aae3f1007d0
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 00013dd9cde63a2d98e8002fcc4f5ddb66c10782

View File

@ -15,7 +15,8 @@
"dependencies": {
"react": "19.1.0",
"react-native": "0.81.0",
"react-native-camera-kit": "link:../"
"react-native-camera-kit": "link:../",
"react-native-image-picker": "^7.1.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View File

@ -1,29 +1,101 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, ScrollView } from 'react-native';
import React, { useRef, useState } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, ScrollView, Button, Alert, TextInput } from 'react-native';
import BarcodeScreenExample from './BarcodeScreenExample';
import CameraExample from './CameraExample';
import DetectQRExample from './DetectQRExample';
const App = () => {
const [example, setExample] = useState<JSX.Element>();
const [example, setExample] = useState<any>(undefined);
const [testNo, setTestNo] = useState(0);
const [interval, setIntervalId] = useState<number | null>(null);
const [speed, setSpeed] = useState('1000');
const onBack = () => setExample(undefined);
const testStart = useRef(0);
if (example) {
return example;
}
const onBack = () => setExample(undefined);
return (
<ScrollView style={styles.scroll}>
<ScrollView style={styles.scroll} scrollEnabled={false}>
<View style={styles.container}>
<Text style={{ fontSize: 60 }}>🎈</Text>
<Text style={styles.headerText}>React Native Camera Kit</Text>
<TouchableOpacity style={styles.button} onPress={() => setExample(<CameraExample onBack={onBack} />)}>
<Text style={styles.buttonText}>Camera</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => setExample(<BarcodeScreenExample onBack={onBack} />)}>
<Text style={styles.buttonText}>Barcode Scanner</Text>
</TouchableOpacity>
<Button title="Camera" onPress={() => setExample(<CameraExample onBack={onBack} />)}></Button>
<Button title="Barcode Scanner" onPress={() => setExample(<BarcodeScreenExample onBack={onBack} />)}></Button>
<Button title="Detect QR from Image" onPress={() => setExample(<DetectQRExample onBack={onBack} />)}></Button>
<View>
<Text style={[styles.stressHeader, { marginTop: 12 }]}>Mount Stress Test</Text>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{!testNo ? (
<>
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>Speed (ms):</Text>
<TextInput
style={styles.input}
value={speed}
onChangeText={setSpeed}
keyboardType="number-pad"
placeholder="1000"
placeholderTextColor="#999"
/>
</View>
<Button
title="Start"
onPress={() => {
Alert.alert(
'2 min or more',
'The mount stress test should run for at least 2 minutes on an iPhone 17 Pro before you can declare it a success. You need to press the stop button yourself.',
[
{
text: 'OK',
onPress: () => {
testStart.current = Date.now();
setTestNo(0);
setIntervalId(
setInterval(() => {
setTestNo((prev) => {
const newR = prev + 1;
if (newR % 2 === 0) {
const elapsedMs = Date.now() - (testStart.current ?? Date.now());
const minutes = Math.floor(elapsedMs / 60000);
const seconds = Math.floor((elapsedMs % 60000) / 1000);
console.log(
`Stress test iteration ${newR / 2}${
testStart.current ? `, elapsed time: ${minutes}m ${seconds}s` : ''
}`,
);
setExample(<CameraExample key={`test-${Date.now()}`} stress onBack={onBack} />);
} else {
setExample(undefined);
}
return newR;
});
}, parseInt(speed, 10) || 1000),
);
},
},
],
);
}}
/>
</>
) : (
<Button
title="STOP STRESS TEST"
onPress={() => {
setTestNo(0);
if (interval) {
clearInterval(interval);
setIntervalId(null);
}
}}
/>
)}
</View>
</View>
</View>
</ScrollView>
);
@ -49,6 +121,11 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
marginBlockEnd: 24,
},
stressHeader: {
color: 'white',
fontSize: 24,
fontWeight: 'bold',
},
button: {
height: 60,
borderRadius: 30,
@ -62,4 +139,24 @@ const styles = StyleSheet.create({
textAlign: 'center',
fontSize: 20,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 12,
minWidth: 170,
},
inputLabel: {
color: 'white',
fontSize: 16,
marginRight: 12,
},
input: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: '#333',
color: 'white',
paddingHorizontal: 12,
fontSize: 16,
},
});

View File

@ -46,6 +46,46 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => {
const [cameraType, setCameraType] = useState(CameraType.Back);
const [barcode, setBarcode] = useState<string>('');
const [fps, setFps] = useState(0);
const [scanCount, setScanCount] = useState(0);
const [scansPerSec, setScansPerSec] = useState(0);
// FPS counter using requestAnimationFrame
useEffect(() => {
let frameCount = 0;
let lastTime = performance.now();
let animationId: number;
const measureFps = () => {
frameCount++;
const now = performance.now();
const elapsed = now - lastTime;
if (elapsed >= 1000) {
setFps(Math.round((frameCount * 1000) / elapsed));
frameCount = 0;
lastTime = now;
}
animationId = requestAnimationFrame(measureFps);
};
animationId = requestAnimationFrame(measureFps);
return () => {
cancelAnimationFrame(animationId);
};
}, []);
// Scans per second counter
useEffect(() => {
const interval = setInterval(() => {
setScansPerSec(scanCount);
setScanCount(0);
}, 1000);
return () => clearInterval(interval);
}, [scanCount]);
useEffect(() => {
const t = setTimeout(() => {
@ -109,6 +149,11 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => {
resizeMode="contain"
/>
</TouchableOpacity>
<View style={styles.fpsContainer}>
<Text style={styles.fpsText}>{fps} FPS</Text>
<Text style={styles.fpsText}>{scansPerSec} scans/s</Text>
</View>
</SafeAreaView>
<View style={styles.cameraContainer}>
@ -119,7 +164,7 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => {
flashMode={flashData?.mode}
zoomMode="on"
focusMode="on"
scanThrottleDelay={2000}
scanThrottleDelay={0}
torchMode={torchMode ? 'on' : 'off'}
onOrientationChange={(e) => {
// We recommend locking the camera UI to portrait (using a different library)
@ -148,13 +193,13 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => {
frameColor="white"
scanBarcode
showFrame
allowedBarcodeTypes={['qr', 'ean-13']}
barcodeFrameSize={{ width: 300, height: 150 }}
onReadCode={(event) => {
Vibration.vibrate(100);
setScanCount((prev) => prev + 1);
setBarcode(event.nativeEvent.codeStringValue);
console.log('barcode', event.nativeEvent.codeStringValue);
console.log('codeFormat', event.nativeEvent.codeFormat);
}}
/>
</View>
@ -233,8 +278,7 @@ const styles = StyleSheet.create({
backBtnContainer: {
alignItems: 'flex-start',
},
captureButtonContainer: {
},
captureButtonContainer: {},
textNumberContainer: {
position: 'absolute',
top: 0,
@ -254,4 +298,17 @@ const styles = StyleSheet.create({
color: 'white',
fontSize: 20,
},
fpsContainer: {
backgroundColor: '#222',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 4,
justifyContent: 'center',
alignItems: 'flex-end',
},
fpsText: {
color: '#0f0',
fontSize: 12,
fontFamily: 'monospace',
},
});

View File

@ -1,5 +1,5 @@
import type React from 'react';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, Image, Animated, ScrollView } from 'react-native';
import Camera from '../../src/Camera';
import { type CameraApi, CameraType, type CaptureData } from '../../src/types';
@ -33,7 +33,7 @@ function median(values: number[]): number {
return sortedValues.length % 2 ? sortedValues[half] : (sortedValues[half - 1] + sortedValues[half]) / 2;
}
const CameraExample = ({ onBack }: { onBack: () => void }) => {
const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolean }) => {
const cameraRef = useRef<CameraApi>(null);
const [currentFlashArrayPosition, setCurrentFlashArrayPosition] = useState(0);
const [captureImages, setCaptureImages] = useState<CaptureData[]>([]);
@ -46,6 +46,15 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => {
const [orientationAnim] = useState(new Animated.Value(3));
const [resize, setResize] = useState<'contain' | 'cover'>('contain');
// zoom to random positions every 10ms:
useEffect(() => {
if (stress !== true) return;
const interval = setInterval(() => {
setZoom(Math.random() * 10);
}, 500);
return () => clearInterval(interval);
}, [stress]);
// iOS will error out if capturing too fast,
// so block capturing until the current capture is done
// This also minimizes issues of delayed capturing
@ -107,7 +116,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => {
if (!image) return;
setCaptured(true);
setCaptureImages(prev => [...prev, image]);
setCaptureImages((prev) => [...prev, image]);
console.log('image', image);
times.push(Date.now() - start);
}
@ -215,10 +224,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => {
<View style={styles.cameraContainer}>
{showImageUri ? (
<ScrollView
maximumZoomScale={10}
contentContainerStyle={{ flexGrow: 1 }}
>
<ScrollView maximumZoomScale={10} contentContainerStyle={{ flexGrow: 1 }}>
<Image source={{ uri: showImageUri }} style={styles.cameraPreview} />
</ScrollView>
) : (
@ -237,6 +243,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => {
}}
torchMode={torchMode ? 'on' : 'off'}
shutterPhotoSound
iOsSleepBeforeStarting={100}
maxPhotoQualityPrioritization="speed"
onCaptureButtonPressIn={() => {
console.log('capture button pressed in');
@ -299,8 +306,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => {
} else {
setShowImageUri(captureImages[captureImages.length - 1].uri);
}
}}
>
}}>
<Image source={{ uri: captureImages[captureImages.length - 1].uri }} style={styles.thumbnail} />
</TouchableOpacity>
)}

View File

@ -0,0 +1,152 @@
import { useState } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, Image, ScrollView, ActivityIndicator } from 'react-native';
import { launchImageLibrary } from 'react-native-image-picker';
import { detectQRCodeInImage } from '../../src';
import SafeAreaView from './SafeAreaView';
const DetectQRExample = ({ onBack }: { onBack: () => void }) => {
const [imageUri, setImageUri] = useState<string | undefined>();
const [result, setResult] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const onPickImage = async () => {
setResult(undefined);
setError(undefined);
const response = await launchImageLibrary({
mediaType: 'photo',
selectionLimit: 1,
includeBase64: true,
});
if (response.didCancel || !response.assets?.length) return;
const asset = response.assets[0];
setImageUri(asset.uri);
const base64 = asset.base64;
if (!base64) {
setError('No base64 data returned from image picker');
return;
}
setLoading(true);
try {
const decoded = await detectQRCodeInImage(base64);
setResult(decoded === null ? '(no QR code found)' : decoded);
} catch (e: any) {
setError(e.message ?? 'Detection failed');
} finally {
setLoading(false);
}
};
return (
<View style={styles.screen}>
<SafeAreaView style={styles.header}>
<TouchableOpacity onPress={onBack}>
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Detect QR from Image</Text>
<View style={{ width: 50 }} />
</SafeAreaView>
<ScrollView contentContainerStyle={styles.content}>
<TouchableOpacity style={styles.pickButton} onPress={onPickImage}>
<Text style={styles.pickButtonText}>Pick Image</Text>
</TouchableOpacity>
{imageUri && <Image source={{ uri: imageUri }} style={styles.preview} resizeMode="contain" />}
{loading && <ActivityIndicator size="large" color="#ffffff" style={{ marginTop: 20 }} />}
{result !== undefined && (
<View style={styles.resultBox}>
<Text style={styles.resultLabel}>Decoded:</Text>
<Text style={styles.resultValue} selectable>
{result}
</Text>
</View>
)}
{error !== undefined && (
<View style={[styles.resultBox, styles.errorBox]}>
<Text style={styles.resultLabel}>Error:</Text>
<Text style={styles.errorValue}>{error}</Text>
</View>
)}
</ScrollView>
</View>
);
};
export default DetectQRExample;
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 10,
},
backText: {
color: '#fff',
fontSize: 18,
},
title: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
content: {
alignItems: 'center',
padding: 24,
},
pickButton: {
backgroundColor: '#2196F3',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 8,
},
pickButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
preview: {
width: 280,
height: 280,
marginTop: 24,
borderRadius: 8,
backgroundColor: '#111',
},
resultBox: {
marginTop: 20,
backgroundColor: '#1a3a1a',
borderRadius: 8,
padding: 16,
width: '100%',
},
errorBox: {
backgroundColor: '#3a1a1a',
},
resultLabel: {
color: '#aaa',
fontSize: 14,
marginBottom: 4,
},
resultValue: {
color: '#4caf50',
fontSize: 16,
},
errorValue: {
color: '#f44336',
fontSize: 16,
},
});

View File

@ -4817,6 +4817,11 @@ react-is@^19.1.0:
version "0.0.0"
uid ""
react-native-image-picker@^7.1.2:
version "7.2.3"
resolved "https://registry.yarnpkg.com/react-native-image-picker/-/react-native-image-picker-7.2.3.tgz#9c402591462af256cdd9aed796c28083a48f90cd"
integrity sha512-zKIZUlQNU3EtqizsXSH92zPeve4vpUrsqHu2kkpCxWE9TZhJFZBb+irDsBOY8J21k0+Edgt06TMQGJ+iPUIXyA==
react-native@0.81.0:
version "0.81.0"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.81.0.tgz#ebb645f3fb2fc2ffb222d2f294ca4e81e6568f15"

View File

@ -6,22 +6,24 @@
#import <AVFoundation/AVFoundation.h>
#if __has_include(<React/RCTBridge.h>)
#import <React/RCTViewManager.h>
#import <React/RCTConvert.h>
#import <React/RCTViewManager.h>
#else
#import "RCTViewManager.h"
#import "RCTConvert.h"
#import "RCTViewManager.h"
#endif
@interface RCT_EXTERN_MODULE(CKCameraManager, RCTViewManager)
@interface RCT_EXTERN_MODULE (CKCameraManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(cameraType, CKCameraType)
RCT_EXPORT_VIEW_PROPERTY(flashMode, CKFlashMode)
RCT_EXPORT_VIEW_PROPERTY(maxPhotoQualityPrioritization, CKMaxPhotoQualityPrioritization)
RCT_EXPORT_VIEW_PROPERTY(maxPhotoQualityPrioritization,
CKMaxPhotoQualityPrioritization)
RCT_EXPORT_VIEW_PROPERTY(torchMode, CKTorchMode)
RCT_EXPORT_VIEW_PROPERTY(ratioOverlay, NSString)
RCT_EXPORT_VIEW_PROPERTY(ratioOverlayColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(resizeMode, CKResizeMode)
RCT_EXPORT_VIEW_PROPERTY(iOsSleepBeforeStarting, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(scanBarcode, BOOL)
RCT_EXPORT_VIEW_PROPERTY(onReadCode, RCTDirectEventBlock)
@ -30,6 +32,7 @@ RCT_EXPORT_VIEW_PROPERTY(scanThrottleDelay, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(laserColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(frameColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(barcodeFrameSize, NSDictionary)
RCT_EXPORT_VIEW_PROPERTY(allowedBarcodeTypes, NSArray)
RCT_EXPORT_VIEW_PROPERTY(onOrientationChange, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onCaptureButtonPressIn, RCTDirectEventBlock)

View File

@ -16,44 +16,47 @@
using namespace facebook::react;
static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
{
static id CKConvertFollyDynamicToId(const folly::dynamic &dyn) {
// I could imagine an implementation which avoids copies by wrapping the
// dynamic in a derived class of NSDictionary. We can do that if profiling
// implies it will help.
switch (dyn.type()) {
case folly::dynamic::NULLT:
return nil;
case folly::dynamic::BOOL:
return dyn.getBool() ? @YES : @NO;
case folly::dynamic::INT64:
return @(dyn.getInt());
case folly::dynamic::DOUBLE:
return @(dyn.getDouble());
case folly::dynamic::STRING:
return [[NSString alloc] initWithBytes:dyn.c_str() length:dyn.size() encoding:NSUTF8StringEncoding];
case folly::dynamic::ARRAY: {
NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn) {
id value = CKConvertFollyDynamicToId(elem);
if (value) {
[array addObject:value];
}
case folly::dynamic::NULLT:
return nil;
case folly::dynamic::BOOL:
return dyn.getBool() ? @YES : @NO;
case folly::dynamic::INT64:
return @(dyn.getInt());
case folly::dynamic::DOUBLE:
return @(dyn.getDouble());
case folly::dynamic::STRING:
return [[NSString alloc] initWithBytes:dyn.c_str()
length:dyn.size()
encoding:NSUTF8StringEncoding];
case folly::dynamic::ARRAY: {
NSMutableArray *array =
[[NSMutableArray alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn) {
id value = CKConvertFollyDynamicToId(elem);
if (value) {
[array addObject:value];
}
return array;
}
case folly::dynamic::OBJECT: {
NSMutableDictionary *dict = [[NSMutableDictionary alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn.items()) {
id key = CKConvertFollyDynamicToId(elem.first);
id value = CKConvertFollyDynamicToId(elem.second);
if (key && value) {
dict[key] = value;
}
return array;
}
case folly::dynamic::OBJECT: {
NSMutableDictionary *dict =
[[NSMutableDictionary alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn.items()) {
id key = CKConvertFollyDynamicToId(elem.first);
id value = CKConvertFollyDynamicToId(elem.second);
if (key && value) {
dict[key] = value;
}
return dict;
}
return dict;
}
}
}
@ -61,217 +64,270 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
@end
@implementation CKCameraViewComponentView {
CKCameraView *_view;
CKCameraView *_view;
}
// Needed because of this: https://github.com/facebook/react-native/pull/37274
+ (void)load
{
+ (void)load {
[super load];
}
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const CKCameraProps>();
_props = defaultProps;
[self prepareView];
}
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const CKCameraProps>();
_props = defaultProps;
[self prepareView];
}
return self;
return self;
}
- (void)prepareView
{
_view = [[CKCameraView alloc] init];
// just need to pass something, it won't really be used on fabric, but it's used to create events (it won't impact sending them)
_view.reactTag = @-1;
__weak __typeof__(self) weakSelf = self;
- (void)prepareView {
_view = [[CKCameraView alloc] init];
[_view setOnReadCode:^(NSDictionary* event) {
__typeof__(self) strongSelf = weakSelf;
// just need to pass something, it won't really be used on fabric, but it's
// used to create events (it won't impact sending them)
_view.reactTag = @-1;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
std::string codeStringValue = [event valueForKey:@"codeStringValue"] == nil ? "" : std::string([[event valueForKey:@"codeStringValue"] UTF8String]);
std::string codeFormat = [event valueForKey:@"codeFormat"] == nil ? "" : std::string([[event valueForKey:@"codeFormat"] UTF8String]);
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onReadCode({.codeStringValue = codeStringValue, .codeFormat = codeFormat});
}
}];
[_view setOnOrientationChange:^(NSDictionary* event) {
__typeof__(self) strongSelf = weakSelf;
__weak __typeof__(self) weakSelf = self;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
id orientation = [event valueForKey:@"orientation"] == nil ? 0 : [event valueForKey:@"orientation"];
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onOrientationChange({.orientation = [orientation intValue]});
}
}];
[_view setOnZoom:^(NSDictionary* event) {
__typeof__(self) strongSelf = weakSelf;
[_view setOnReadCode:^(NSDictionary *event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
id zoom = [event valueForKey:@"zoom"] == nil ? 0 : [event valueForKey:@"zoom"];
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onZoom({.zoom = [zoom doubleValue]});
}
}];
[_view setOnCaptureButtonPressIn:^(NSDictionary* event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
std::string codeStringValue =
[event valueForKey:@"codeStringValue"] == nil
? ""
: std::string(
[[event valueForKey:@"codeStringValue"] UTF8String]);
std::string codeFormat =
[event valueForKey:@"codeFormat"] == nil
? ""
: std::string([[event valueForKey:@"codeFormat"] UTF8String]);
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(
strongSelf->_eventEmitter)
->onReadCode(
{.codeStringValue = codeStringValue, .codeFormat = codeFormat});
}
}];
[_view setOnOrientationChange:^(NSDictionary *event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onCaptureButtonPressIn({});
}
}];
[_view setOnCaptureButtonPressOut:^(NSDictionary* event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
id orientation = [event valueForKey:@"orientation"] == nil
? 0
: [event valueForKey:@"orientation"];
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(
strongSelf->_eventEmitter)
->onOrientationChange({.orientation = [orientation intValue]});
}
}];
[_view setOnZoom:^(NSDictionary *event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onCaptureButtonPressOut({});
}
}];
self.contentView = _view;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
id zoom =
[event valueForKey:@"zoom"] == nil ? 0 : [event valueForKey:@"zoom"];
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(
strongSelf->_eventEmitter)
->onZoom({.zoom = [zoom doubleValue]});
}
}];
[_view setOnCaptureButtonPressIn:^(NSDictionary *event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(
strongSelf->_eventEmitter)
->onCaptureButtonPressIn({});
}
}];
[_view setOnCaptureButtonPressOut:^(NSDictionary *event) {
__typeof__(self) strongSelf = weakSelf;
if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(
strongSelf->_eventEmitter)
->onCaptureButtonPressOut({});
}
}];
self.contentView = _view;
}
#pragma mark - RCTComponentViewProtocol
+ (ComponentDescriptorProvider)componentDescriptorProvider
{
+ (ComponentDescriptorProvider)componentDescriptorProvider {
return concreteComponentDescriptorProvider<CKCameraComponentDescriptor>();
}
- (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetrics oldLayoutMetrics:(const facebook::react::LayoutMetrics &)oldLayoutMetrics
{
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
[_view updateSubviewsBounds:RCTCGRectFromRect(layoutMetrics.frame)];
- (void)updateLayoutMetrics:
(const facebook::react::LayoutMetrics &)layoutMetrics
oldLayoutMetrics:
(const facebook::react::LayoutMetrics &)oldLayoutMetrics {
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
[_view updateSubviewsBounds:RCTCGRectFromRect(layoutMetrics.frame)];
}
- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
{
const auto &oldViewProps = *std::static_pointer_cast<CKCameraProps const>(_props);
const auto &newProps = *std::static_pointer_cast<CKCameraProps const>(props);
- (void)updateProps:(const Props::Shared &)props
oldProps:(const Props::Shared &)oldProps {
const auto &oldViewProps =
*std::static_pointer_cast<CKCameraProps const>(_props);
const auto &newProps = *std::static_pointer_cast<CKCameraProps const>(props);
NSMutableArray<NSString *> *changedProps = [NSMutableArray new];
// Keep changedProps aligned with CameraView.swift didSetProps
// Include event-related props so CameraView can update listeners/state
[changedProps addObject:@"onOrientationChange"];
[changedProps addObject:@"onZoom"];
[changedProps addObject:@"onReadCode"];
NSMutableArray<NSString *> *changedProps = [NSMutableArray new];
if (oldViewProps.cameraType != newProps.cameraType) {
_view.cameraType = newProps.cameraType == "back" ? CKCameraTypeBack : CKCameraTypeFront;
[changedProps addObject:@"cameraType"];
// Keep changedProps aligned with CameraView.swift didSetProps
// Include event-related props so CameraView can update listeners/state
[changedProps addObject:@"onOrientationChange"];
[changedProps addObject:@"onZoom"];
[changedProps addObject:@"onReadCode"];
if (oldViewProps.cameraType != newProps.cameraType) {
_view.cameraType =
newProps.cameraType == "back" ? CKCameraTypeBack : CKCameraTypeFront;
[changedProps addObject:@"cameraType"];
}
if (oldViewProps.resizeMode != newProps.resizeMode) {
_view.resizeMode = newProps.resizeMode == "contain" ? CKResizeModeContain
: CKResizeModeCover;
[changedProps addObject:@"resizeMode"];
}
id flashMode = CKConvertFollyDynamicToId(newProps.flashMode);
if (oldViewProps.flashMode != newProps.flashMode) {
_view.flashMode = [flashMode isEqualToString:@"auto"] ? CKFlashModeAuto
: [flashMode isEqualToString:@"on"] ? CKFlashModeOn
: CKFlashModeOff;
[changedProps addObject:@"flashMode"];
}
if (oldViewProps.maxPhotoQualityPrioritization !=
newProps.maxPhotoQualityPrioritization) {
if (newProps.maxPhotoQualityPrioritization == "balanced") {
_view.maxPhotoQualityPrioritization =
CKMaxPhotoQualityPrioritizationBalanced;
} else if (newProps.maxPhotoQualityPrioritization == "quality") {
_view.maxPhotoQualityPrioritization =
CKMaxPhotoQualityPrioritizationQuality;
} else {
_view.maxPhotoQualityPrioritization =
CKMaxPhotoQualityPrioritizationSpeed;
}
if (oldViewProps.resizeMode != newProps.resizeMode) {
_view.resizeMode = newProps.resizeMode == "contain" ? CKResizeModeContain : CKResizeModeCover;
[changedProps addObject:@"resizeMode"];
[changedProps addObject:@"maxPhotoQualityPrioritization"];
}
if (oldViewProps.torchMode != newProps.torchMode) {
_view.torchMode =
newProps.torchMode == "on" ? CKTorchModeOn : CKTorchModeOff;
[changedProps addObject:@"torchMode"];
}
id ratioOverlay = CKConvertFollyDynamicToId(newProps.ratioOverlay);
if (ratioOverlay != nil) {
_view.ratioOverlay = ratioOverlay;
[changedProps addObject:@"ratioOverlay"];
}
if (oldViewProps.ratioOverlayColor != newProps.ratioOverlayColor) {
_view.ratioOverlayColor =
RCTUIColorFromSharedColor(newProps.ratioOverlayColor);
[changedProps addObject:@"ratioOverlayColor"];
}
if (_view.scanBarcode != newProps.scanBarcode) {
_view.scanBarcode = newProps.scanBarcode;
[changedProps addObject:@"scanBarcode"];
}
if (_view.showFrame != newProps.showFrame) {
_view.showFrame = newProps.showFrame;
[changedProps addObject:@"showFrame"];
}
if (newProps.scanThrottleDelay > -1) {
_view.scanThrottleDelay = newProps.scanThrottleDelay;
[changedProps addObject:@"scanThrottleDelay"];
}
if (oldViewProps.frameColor != newProps.frameColor) {
_view.frameColor = RCTUIColorFromSharedColor(newProps.frameColor);
[changedProps addObject:@"frameColor"];
}
if (oldViewProps.laserColor != newProps.laserColor) {
UIColor *laserColor = RCTUIColorFromSharedColor(newProps.laserColor);
_view.laserColor = laserColor;
[changedProps addObject:@"laserColor"];
}
if (oldViewProps.resetFocusTimeout != newProps.resetFocusTimeout) {
_view.resetFocusTimeout = newProps.resetFocusTimeout;
[changedProps addObject:@"resetFocusTimeout"];
}
if (_view.resetFocusWhenMotionDetected !=
newProps.resetFocusWhenMotionDetected) {
_view.resetFocusWhenMotionDetected = newProps.resetFocusWhenMotionDetected;
[changedProps addObject:@"resetFocusWhenMotionDetected"];
}
if (oldViewProps.focusMode != newProps.focusMode) {
id focusMode = CKConvertFollyDynamicToId(newProps.focusMode);
_view.focusMode =
[focusMode isEqualToString:@"on"] ? CKFocusModeOn : CKFocusModeOff;
[changedProps addObject:@"focusMode"];
}
if (oldViewProps.zoomMode != newProps.zoomMode) {
id zoomMode = CKConvertFollyDynamicToId(newProps.zoomMode);
_view.zoomMode =
[zoomMode isEqualToString:@"on"] ? CKZoomModeOn : CKZoomModeOff;
[changedProps addObject:@"zoomMode"];
}
if (oldViewProps.zoom != newProps.zoom) {
_view.zoom = newProps.zoom > -1 ? @(newProps.zoom) : nil;
[changedProps addObject:@"zoom"];
}
if (oldViewProps.maxZoom != newProps.maxZoom) {
_view.maxZoom = newProps.maxZoom > -1 ? @(newProps.maxZoom) : nil;
[changedProps addObject:@"maxZoom"];
}
if (oldViewProps.iOsDeferredStart != newProps.iOsDeferredStart) {
_view.iOsDeferredStart = newProps.iOsDeferredStart;
[changedProps addObject:@"iOsDeferredStart"];
}
float barcodeWidth = newProps.barcodeFrameSize.width;
float barcodeHeight = newProps.barcodeFrameSize.height;
if (barcodeWidth != [_view.barcodeFrameSize[@"width"] floatValue] ||
barcodeHeight != [_view.barcodeFrameSize[@"height"] floatValue]) {
_view.barcodeFrameSize =
@{@"width" : @(barcodeWidth), @"height" : @(barcodeHeight)};
[changedProps addObject:@"barcodeFrameSize"];
}
// Since viewprops optional props isn't supported in all RN versions,
// we assume empty arrays mean it's not defined / ignore changes to it.
// if the user/dev wants to NOT define the prop, they can simply use
// scanBarcode={false}
if (!newProps.allowedBarcodeTypes.empty()) {
folly::dynamic allowedBarcodeTypesDynamic = folly::dynamic::array();
for (const auto &type : newProps.allowedBarcodeTypes) {
allowedBarcodeTypesDynamic.push_back(type);
}
id flashMode = CKConvertFollyDynamicToId(newProps.flashMode);
if (oldViewProps.flashMode != newProps.flashMode) {
_view.flashMode = [flashMode isEqualToString:@"auto"] ? CKFlashModeAuto : [flashMode isEqualToString:@"on"] ? CKFlashModeOn : CKFlashModeOff;
[changedProps addObject:@"flashMode"];
id allowedBarcodeTypes =
CKConvertFollyDynamicToId(allowedBarcodeTypesDynamic);
if (allowedBarcodeTypes != nil &&
[allowedBarcodeTypes isKindOfClass:NSArray.class]) {
_view.allowedBarcodeTypes = allowedBarcodeTypes;
[changedProps addObject:@"allowedBarcodeTypes"];
}
if (oldViewProps.maxPhotoQualityPrioritization != newProps.maxPhotoQualityPrioritization) {
if (newProps.maxPhotoQualityPrioritization == "balanced") {
_view.maxPhotoQualityPrioritization = CKMaxPhotoQualityPrioritizationBalanced;
} else if (newProps.maxPhotoQualityPrioritization == "quality") {
_view.maxPhotoQualityPrioritization = CKMaxPhotoQualityPrioritizationQuality;
} else {
_view.maxPhotoQualityPrioritization = CKMaxPhotoQualityPrioritizationSpeed;
}
[changedProps addObject:@"maxPhotoQualityPrioritization"];
}
if (oldViewProps.torchMode != newProps.torchMode) {
_view.torchMode = newProps.torchMode == "on" ? CKTorchModeOn : CKTorchModeOff;
[changedProps addObject:@"torchMode"];
}
id ratioOverlay = CKConvertFollyDynamicToId(newProps.ratioOverlay);
if (ratioOverlay != nil) {
_view.ratioOverlay = ratioOverlay;
[changedProps addObject:@"ratioOverlay"];
}
if (oldViewProps.ratioOverlayColor != newProps.ratioOverlayColor) {
_view.ratioOverlayColor = RCTUIColorFromSharedColor(newProps.ratioOverlayColor);
[changedProps addObject:@"ratioOverlayColor"];
}
if (_view.scanBarcode != newProps.scanBarcode) {
_view.scanBarcode = newProps.scanBarcode;
[changedProps addObject:@"scanBarcode"];
}
if (_view.showFrame != newProps.showFrame) {
_view.showFrame = newProps.showFrame;
[changedProps addObject:@"showFrame"];
}
if (newProps.scanThrottleDelay > -1) {
_view.scanThrottleDelay = newProps.scanThrottleDelay;
[changedProps addObject:@"scanThrottleDelay"];
}
if (oldViewProps.frameColor != newProps.frameColor) {
_view.frameColor = RCTUIColorFromSharedColor(newProps.frameColor);
[changedProps addObject:@"frameColor"];
}
if (oldViewProps.laserColor != newProps.laserColor) {
UIColor *laserColor = RCTUIColorFromSharedColor(newProps.laserColor);
_view.laserColor = laserColor;
[changedProps addObject:@"laserColor"];
}
if (oldViewProps.resetFocusTimeout != newProps.resetFocusTimeout) {
_view.resetFocusTimeout = newProps.resetFocusTimeout;
[changedProps addObject:@"resetFocusTimeout"];
}
if (_view.resetFocusWhenMotionDetected != newProps.resetFocusWhenMotionDetected) {
_view.resetFocusWhenMotionDetected = newProps.resetFocusWhenMotionDetected;
[changedProps addObject:@"resetFocusWhenMotionDetected"];
}
if (oldViewProps.focusMode != newProps.focusMode) {
id focusMode = CKConvertFollyDynamicToId(newProps.focusMode);
_view.focusMode = [focusMode isEqualToString:@"on"] ? CKFocusModeOn : CKFocusModeOff;
[changedProps addObject:@"focusMode"];
}
if (oldViewProps.zoomMode != newProps.zoomMode) {
id zoomMode = CKConvertFollyDynamicToId(newProps.zoomMode);
_view.zoomMode = [zoomMode isEqualToString:@"on"] ? CKZoomModeOn : CKZoomModeOff;
[changedProps addObject:@"zoomMode"];
}
if (oldViewProps.zoom != newProps.zoom) {
_view.zoom = newProps.zoom > -1 ? @(newProps.zoom) : nil;
[changedProps addObject:@"zoom"];
}
if (oldViewProps.maxZoom != newProps.maxZoom) {
_view.maxZoom = newProps.maxZoom > -1 ? @(newProps.maxZoom) : nil;
[changedProps addObject:@"maxZoom"];
}
float barcodeWidth = newProps.barcodeFrameSize.width;
float barcodeHeight = newProps.barcodeFrameSize.height;
if (barcodeWidth != [_view.barcodeFrameSize[@"width"] floatValue] || barcodeHeight != [_view.barcodeFrameSize[@"height"] floatValue]) {
_view.barcodeFrameSize = @{@"width": @(barcodeWidth), @"height": @(barcodeHeight)};
[changedProps addObject:@"barcodeFrameSize"];
}
[super updateProps:props oldProps:oldProps];
[_view didSetProps:changedProps];
}
[super updateProps:props oldProps:oldProps];
[_view didSetProps:changedProps];
}
+ (BOOL)shouldBeRecycled
{
// Disable recycling as cameras are expensive to keep in memory and may cause unintended behaviors
// (we need to reset the camera properly when recycling)
// We can enable it later if find that the performance is needed
return NO;
+ (BOOL)shouldBeRecycled {
// Disable recycling as cameras are expensive to keep in memory and may cause
// unintended behaviors (we need to reset the camera properly when recycling)
// We can enable it later if find that the performance is needed
return NO;
}
- (void)prepareForRecycle
{
[super prepareForRecycle];
[self prepareView];
- (void)prepareForRecycle {
[super prepareForRecycle];
[self prepareView];
}
@end
Class<RCTComponentViewProtocol> CKCameraCls(void)
{
Class<RCTComponentViewProtocol> CKCameraCls(void) {
return CKCameraViewComponentView.class;
}

View File

@ -4,8 +4,10 @@
//
import AVFoundation
import CoreImage
import Foundation
import React
import Vision
/*
* Class managing the communication between React Native and the native implementation
@ -60,4 +62,48 @@ import React
AVCaptureDevice.requestAccess(for: .video, completionHandler: { resolve($0) })
#endif
}
@objc public static func detectQRCodeInImage(_ base64: String,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .userInitiated).async {
guard let data = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else {
reject("E_INVALID_IMAGE", "Could not decode base64 image data", nil)
return
}
#if targetEnvironment(simulator)
guard let ciImage = CIImage(data: data) else {
reject("E_INVALID_IMAGE", "Could not decode base64 image data", nil)
return
}
guard let detector = CIDetector(ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) else {
reject("E_QR_DETECTION_FAILED", "Could not initialize QR detector", nil)
return
}
let features = detector.features(in: ciImage) as? [CIQRCodeFeature]
let value = features?.first?.messageString
resolve(value?.isEmpty == false ? value : nil)
#else
guard let uiImage = UIImage(data: data),
let cgImage = uiImage.cgImage else {
reject("E_INVALID_IMAGE", "Could not decode base64 image data", nil)
return
}
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr]
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
reject("E_QR_DETECTION_FAILED", "Vision request failed: \(error.localizedDescription)", error)
return
}
let value = request.results?.first?.payloadStringValue
resolve(value?.isEmpty == false ? value : nil)
#endif
}
}
}

View File

@ -17,6 +17,7 @@ protocol CameraProtocol: AnyObject, FocusInterfaceViewDelegate {
func update(cameraType: CameraType)
func update(onOrientationChange: RCTDirectEventBlock?)
func update(onZoom: RCTDirectEventBlock?)
func update(iOsDeferredStartEnabled: Bool?)
func update(zoom: Double?)
func update(maxZoom: Double?)
func update(resizeMode: ResizeMode)
@ -26,13 +27,17 @@ protocol CameraProtocol: AnyObject, FocusInterfaceViewDelegate {
func zoomPinchStart()
func zoomPinchChange(pinchScale: CGFloat)
func isBarcodeScannerEnabled(_ isEnabled: Bool,
supportedBarcodeTypes: [CodeFormat],
onBarcodeRead: ((_ barcode: String, _ codeFormat: CodeFormat) -> Void)?)
func isBarcodeScannerEnabled(
_ isEnabled: Bool,
supportedBarcodeTypes: [CodeFormat],
onBarcodeRead: ((_ barcode: String, _ codeFormat: CodeFormat) -> Void)?)
func update(scannerFrameSize: CGRect?)
func capturePicture(onWillCapture: @escaping () -> Void,
onSuccess: @escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions) -> Void,
onError: @escaping (_ message: String) -> Void)
func capturePicture(
onWillCapture: @escaping () -> Void,
onSuccess:
@escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions)
-> Void,
onError: @escaping (_ message: String) -> Void)
}

View File

@ -4,9 +4,9 @@
//
import AVFoundation
import UIKit
import AVKit
import React
import UIKit
/*
* View abtracting the logic unrelated to the actual camera
@ -22,10 +22,7 @@ public class CameraView: UIView {
// scanner
private var lastBarcodeDetectedTime: TimeInterval = 0
private var scannerInterfaceView: ScannerInterfaceView
private var supportedBarcodeType: [CodeFormat] = {
return CodeFormat.allCases
}()
// camera
private var ratioOverlayView: RatioOverlayView?
@ -50,6 +47,7 @@ public class CameraView: UIView {
@objc public var frameColor: UIColor?
@objc public var laserColor: UIColor?
@objc public var barcodeFrameSize: NSDictionary?
@objc public var allowedBarcodeTypes: NSArray?
// other
@objc public var onOrientationChange: RCTDirectEventBlock?
@ -60,10 +58,11 @@ public class CameraView: UIView {
@objc public var zoomMode: ZoomMode = .on
@objc public var zoom: NSNumber?
@objc public var maxZoom: NSNumber?
@objc public var iOsDeferredStart: Bool = true
@objc public var onCaptureButtonPressIn: RCTDirectEventBlock?
@objc public var onCaptureButtonPressOut: RCTDirectEventBlock?
var eventInteraction: Any? = nil
// MARK: - Setup
@ -82,12 +81,22 @@ public class CameraView: UIView {
}
private func setupCamera() {
if hasPropBeenSetup && hasPermissionBeenGranted && !hasCameraBeenSetup {
let convertedAllowedTypes = convertAllowedBarcodeTypes()
camera.update(iOsDeferredStartEnabled: iOsDeferredStart)
hasCameraBeenSetup = true
#if targetEnvironment(macCatalyst)
// Force front camera on Mac Catalyst during initial setup
camera.setup(cameraType: .front, supportedBarcodeType: scanBarcode && onReadCode != nil ? supportedBarcodeType : [])
// Force front camera on Mac Catalyst during initial setup
camera.setup(
cameraType: .front,
supportedBarcodeType: scanBarcode && onReadCode != nil
? convertedAllowedTypes : [])
#else
camera.setup(cameraType: cameraType, supportedBarcodeType: scanBarcode && onReadCode != nil ? supportedBarcodeType : [])
camera.setup(
cameraType: cameraType,
supportedBarcodeType: scanBarcode && onReadCode != nil
? convertedAllowedTypes : [])
#endif
}
}
@ -100,7 +109,7 @@ public class CameraView: UIView {
subview.topAnchor.constraint(equalTo: self.topAnchor),
subview.bottomAnchor.constraint(equalTo: self.bottomAnchor),
subview.leadingAnchor.constraint(equalTo: self.leadingAnchor),
subview.trailingAnchor.constraint(equalTo: self.trailingAnchor)
subview.trailingAnchor.constraint(equalTo: self.trailingAnchor),
])
}
@ -112,11 +121,11 @@ public class CameraView: UIView {
}
override init(frame: CGRect) {
#if targetEnvironment(simulator)
camera = SimulatorCamera()
#else
camera = RealCamera()
#endif
#if targetEnvironment(simulator)
camera = SimulatorCamera()
#else
camera = RealCamera()
#endif
scannerInterfaceView = ScannerInterfaceView(frameColor: .white, laserColor: .red)
focusInterfaceView = FocusInterfaceView()
@ -143,26 +152,26 @@ public class CameraView: UIView {
focusInterfaceView.delegate = camera
handleCameraPermission()
configureHardwareInteraction()
}
private func configureHardwareInteraction() {
#if !targetEnvironment(macCatalyst)
// Create a new capture event interaction with a handler that captures a photo.
if #available(iOS 17.2, *) {
let interaction = AVCaptureEventInteraction { [weak self] event in
// Capture a photo on "press up" of a hardware button.
if event.phase == .began {
self?.onCaptureButtonPressIn?(nil)
} else if event.phase == .ended {
self?.onCaptureButtonPressOut?(nil)
// Create a new capture event interaction with a handler that captures a photo.
if #available(iOS 17.2, *) {
let interaction = AVCaptureEventInteraction { [weak self] event in
// Capture a photo on "press up" of a hardware button.
if event.phase == .began {
self?.onCaptureButtonPressIn?(nil)
} else if event.phase == .ended {
self?.onCaptureButtonPressOut?(nil)
}
}
// Add the interaction to the view controller's view.
self.addInteraction(interaction)
eventInteraction = interaction
}
// Add the interaction to the view controller's view.
self.addInteraction(interaction)
eventInteraction = interaction
}
#endif
}
@ -178,7 +187,7 @@ public class CameraView: UIView {
super.reactSetFrame(frame)
self.updateSubviewsBounds(frame)
}
@objc public func updateSubviewsBounds(_ frame: CGRect) {
camera.previewView.frame = bounds
@ -204,10 +213,10 @@ public class CameraView: UIView {
// Camera settings
if changedProps.contains("cameraType") {
#if targetEnvironment(macCatalyst)
// Force front camera on Mac Catalyst regardless of what's passed
camera.update(cameraType: .front)
// Force front camera on Mac Catalyst regardless of what's passed
camera.update(cameraType: .front)
#else
camera.update(cameraType: cameraType)
camera.update(cameraType: cameraType)
#endif
}
if changedProps.contains("flashMode") {
@ -227,7 +236,7 @@ public class CameraView: UIView {
if changedProps.contains("onZoom") {
camera.update(onZoom: onZoom)
}
if changedProps.contains("resizeMode") {
camera.update(resizeMode: resizeMode)
}
@ -238,7 +247,8 @@ public class CameraView: UIView {
if let ratioOverlayView {
ratioOverlayView.setRatio(ratioOverlay)
} else {
ratioOverlayView = RatioOverlayView(frame: bounds, ratioString: ratioOverlay, overlayColor: ratioOverlayColor)
ratioOverlayView = RatioOverlayView(
frame: bounds, ratioString: ratioOverlay, overlayColor: ratioOverlayColor)
addSubview(ratioOverlayView!)
}
} else {
@ -252,24 +262,32 @@ public class CameraView: UIView {
}
// Scanner
if changedProps.contains("scanBarcode") || changedProps.contains("onReadCode") {
camera.isBarcodeScannerEnabled(scanBarcode,
supportedBarcodeTypes: supportedBarcodeType,
onBarcodeRead: { [weak self] (barcode, codeFormat) in
self?.onBarcodeRead(barcode: barcode, codeFormat: codeFormat)
})
if changedProps.contains("scanBarcode") || changedProps.contains("onReadCode")
|| changedProps.contains("allowedBarcodeTypes")
{
let convertedAllowedTypes: [CodeFormat] = convertAllowedBarcodeTypes()
camera.isBarcodeScannerEnabled(
scanBarcode,
supportedBarcodeTypes: convertedAllowedTypes,
onBarcodeRead: { [weak self] (barcode, codeFormat) in
self?.onBarcodeRead(barcode: barcode, codeFormat: codeFormat)
})
}
if changedProps.contains("showFrame") || changedProps.contains("scanBarcode") {
DispatchQueue.main.async {
self.scannerInterfaceView.isHidden = !self.showFrame
self.camera.update(scannerFrameSize: self.showFrame ? self.scannerInterfaceView.frameSize : nil)
self.camera.update(
scannerFrameSize: self.showFrame ? self.scannerInterfaceView.frameSize : nil)
}
}
if changedProps.contains("barcodeFrameSize"), let barcodeFrameSize, showFrame, scanBarcode {
if let width = barcodeFrameSize["width"] as? CGFloat, let height = barcodeFrameSize["height"] as? CGFloat {
if let width = barcodeFrameSize["width"] as? CGFloat,
let height = barcodeFrameSize["height"] as? CGFloat
{
scannerInterfaceView.update(frameSize: CGSize(width: width, height: height))
camera.update(scannerFrameSize: showFrame ? scannerInterfaceView.frameSize : nil)
}
@ -284,6 +302,9 @@ public class CameraView: UIView {
}
// Others
if changedProps.contains("iOsDeferredStart") {
camera.update(iOsDeferredStartEnabled: iOsDeferredStart)
}
if changedProps.contains("focusMode") {
focusInterfaceView.update(focusMode: focusMode)
}
@ -309,27 +330,34 @@ public class CameraView: UIView {
// MARK: Public
@objc public func capture(onSuccess: @escaping (_ imageObject: [String: Any]) -> Void,
onError: @escaping (_ error: String) -> Void) {
camera.capturePicture(onWillCapture: { [weak self] in
// Flash/dim preview to indicate shutter action
DispatchQueue.main.async {
self?.camera.previewView.alpha = 0
UIView.animate(withDuration: 0.35, animations: {
self?.camera.previewView.alpha = 1
})
}
}, onSuccess: { [weak self] imageData, thumbnailData, dimensions in
DispatchQueue.global(qos: .default).async {
self?.writeCaptured(imageData: imageData,
thumbnailData: thumbnailData,
dimensions: dimensions,
onSuccess: onSuccess,
onError: onError)
@objc public func capture(
onSuccess: @escaping (_ imageObject: [String: Any]) -> Void,
onError: @escaping (_ error: String) -> Void
) {
camera.capturePicture(
onWillCapture: { [weak self] in
// Flash/dim preview to indicate shutter action
DispatchQueue.main.async {
self?.camera.previewView.alpha = 0
UIView.animate(
withDuration: 0.35,
animations: {
self?.camera.previewView.alpha = 1
})
}
},
onSuccess: { [weak self] imageData, thumbnailData, dimensions in
DispatchQueue.global(qos: .default).async {
self?.writeCaptured(
imageData: imageData,
thumbnailData: thumbnailData,
dimensions: dimensions,
onSuccess: onSuccess,
onError: onError)
self?.focusInterfaceView.resetFocus()
}
}, onError: onError)
self?.focusInterfaceView.resetFocus()
}
}, onError: onError)
}
// MARK: - Private Helper
@ -337,7 +365,8 @@ public class CameraView: UIView {
private func update(zoomMode: ZoomMode) {
if zoomMode == .on {
if zoomGestureRecognizer == nil {
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchToZoomRecognizer(_:)))
let pinchGesture = UIPinchGestureRecognizer(
target: self, action: #selector(handlePinchToZoomRecognizer(_:)))
addGestureRecognizer(pinchGesture)
zoomGestureRecognizer = pinchGesture
}
@ -351,48 +380,50 @@ public class CameraView: UIView {
private func handleCameraPermission() {
#if targetEnvironment(macCatalyst)
// On macOS, camera permissions are handled differently
if #available(macCatalyst 14.0, *) {
// On macOS, camera permissions are handled differently
if #available(macCatalyst 14.0, *) {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
hasPermissionBeenGranted = true
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
if granted {
DispatchQueue.main.async {
self?.hasPermissionBeenGranted = true
}
}
}
default:
break
}
}
#else
// iOS permission handling
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
// The user has previously granted access to the camera.
hasPermissionBeenGranted = true
case .notDetermined:
// The user has not yet been presented with the option to grant video access.
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
if granted {
DispatchQueue.main.async {
self?.hasPermissionBeenGranted = true
}
self?.hasPermissionBeenGranted = true
}
}
default:
// The user has previously denied access.
break
}
}
#else
// iOS permission handling
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
// The user has previously granted access to the camera.
hasPermissionBeenGranted = true
case .notDetermined:
// The user has not yet been presented with the option to grant video access.
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
if granted {
self?.hasPermissionBeenGranted = true
}
}
default:
// The user has previously denied access.
break
}
#endif
}
private func writeCaptured(imageData: Data,
thumbnailData: Data?,
dimensions: CMVideoDimensions,
onSuccess: @escaping (_ imageObject: [String: Any]) -> Void,
onError: @escaping (_ error: String) -> Void) {
private func writeCaptured(
imageData: Data,
thumbnailData: Data?,
dimensions: CMVideoDimensions,
onSuccess: @escaping (_ imageObject: [String: Any]) -> Void,
onError: @escaping (_ error: String) -> Void
) {
do {
let temporaryImageFileURL = try saveToTmpFolder(imageData)
@ -402,10 +433,11 @@ public class CameraView: UIView {
"name": temporaryImageFileURL.lastPathComponent,
"thumb": "",
"height": dimensions.height,
"width": dimensions.width
"width": dimensions.width,
])
} catch {
let errorMessage = "Error occurred while writing image data to a temporary file: \(error)"
let errorMessage =
"Error occurred while writing image data to a temporary file: \(error)"
print(errorMessage)
onError(errorMessage)
}
@ -417,10 +449,13 @@ public class CameraView: UIView {
let cachesUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
var temporaryFolderURL = cachesUrl
if let bundleId = Bundle.main.bundleIdentifier {
temporaryFolderURL = temporaryFolderURL.appendingPathComponent(bundleId, isDirectory: true)
temporaryFolderURL = temporaryFolderURL.appendingPathComponent(
bundleId, isDirectory: true)
}
temporaryFolderURL = temporaryFolderURL.appendingPathComponent("com.tesla.react-native-camera-kit", isDirectory: true)
try FileManager.default.createDirectory(at: temporaryFolderURL, withIntermediateDirectories: true)
temporaryFolderURL = temporaryFolderURL.appendingPathComponent(
"com.tesla.react-native-camera-kit", isDirectory: true)
try FileManager.default.createDirectory(
at: temporaryFolderURL, withIntermediateDirectories: true)
let temporaryFileURL = temporaryFolderURL.appendingPathComponent("\(temporaryFileName).jpg")
try data.write(to: temporaryFileURL, options: .atomic)
@ -428,7 +463,7 @@ public class CameraView: UIView {
return temporaryFileURL
}
private func onBarcodeRead(barcode: String, codeFormat:CodeFormat) {
private func onBarcodeRead(barcode: String, codeFormat: CodeFormat) {
// Throttle barcode detection
let now = Date.timeIntervalSinceReferenceDate
guard lastBarcodeDetectedTime + Double(scanThrottleDelay) / 1000 < now else {
@ -437,7 +472,15 @@ public class CameraView: UIView {
lastBarcodeDetectedTime = now
onReadCode?(["codeStringValue": barcode,"codeFormat":codeFormat.rawValue])
onReadCode?(["codeStringValue": barcode, "codeFormat": codeFormat.rawValue])
}
private func convertAllowedBarcodeTypes() -> [CodeFormat] {
guard let allowedTypes = allowedBarcodeTypes as? [String], !allowedTypes.isEmpty else {
return CodeFormat.allCases
}
return allowedTypes.compactMap { CodeFormat(rawValue: $0) }
}
// MARK: - Gesture selectors

View File

@ -12,6 +12,7 @@ enum CodeFormat: String, CaseIterable {
case code128 = "code-128"
case code39 = "code-39"
case code93 = "code-93"
case codabar = "codabar"
case ean13 = "ean-13"
case ean8 = "ean-8"
case itf14 = "itf-14"
@ -20,13 +21,21 @@ enum CodeFormat: String, CaseIterable {
case pdf417 = "pdf-417"
case aztec = "aztec"
case dataMatrix = "data-matrix"
case code39Mod43 = "code-39-mod-43"
case interleaved2of5 = "interleaved-2of5"
case unknown = "unknown"
// Convert from AVMetadataObject.ObjectType to CodeFormat
static func fromAVMetadataObjectType(_ type: AVMetadataObject.ObjectType) -> CodeFormat {
if #available(iOS 15.4, *) {
if (type == .codabar) {
return .codabar
}
}
switch type {
case .code128: return .code128
case .code39: return .code39
case .code39Mod43: return .code39Mod43
case .code93: return .code93
case .ean13: return .ean13
case .ean8: return .ean8
@ -36,15 +45,22 @@ enum CodeFormat: String, CaseIterable {
case .pdf417: return .pdf417
case .aztec: return .aztec
case .dataMatrix: return .dataMatrix
case .interleaved2of5: return .interleaved2of5
default: return .unknown
}
}
// Convert from CodeFormat to AVMetadataObject.ObjectType
func toAVMetadataObjectType() -> AVMetadataObject.ObjectType {
if #available(iOS 15.4, *) {
if (self == .codabar) {
return .codabar
}
}
switch self {
case .code128: return .code128
case .code39: return .code39
case .code39Mod43: return .code39Mod43
case .code93: return .code93
case .ean13: return .ean13
case .ean8: return .ean8
@ -54,7 +70,9 @@ enum CodeFormat: String, CaseIterable {
case .pdf417: return .pdf417
case .aztec: return .aztec
case .dataMatrix: return .dataMatrix
case .unknown: return .init(rawValue: "unknown")
case .interleaved2of5: return .interleaved2of5
case .unknown: fallthrough
default: return .init(rawValue: "unknown")
}
}
}

View File

@ -58,6 +58,10 @@ RCT_EXPORT_METHOD(capture:(NSDictionary *)options tag:(nonnull NSNumber *)tag re
[CKCameraManager requestDeviceCameraAuthorization:resolve reject:reject];
}
RCT_EXPORT_METHOD(detectQRCodeInImage:(NSString *)base64 resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
[CKCameraManager detectQRCodeInImage:base64 resolve:resolve reject:reject];
}
// Thanks to this guard, we won't compile this code when we build for the old architecture.
#ifdef RCT_NEW_ARCH_ENABLED
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:

View File

@ -6,9 +6,9 @@
// swiftlint:disable file_length
import AVFoundation
import UIKit
import CoreMotion
import React
import UIKit
/*
* Real camera implementation that uses AVFoundation
@ -21,7 +21,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private let session = AVCaptureSession()
// Communicate with the session and other session objects on this queue.
private let sessionQueue = DispatchQueue(label: "com.tesla.react-native-camera-kit")
// utilities
private var setupResult: SetupResult = .notStarted
private var isSessionRunning: Bool = false
@ -37,7 +37,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private var maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?
private var resetFocus: (() -> Void)?
private var focusFinished: (() -> Void)?
private var onBarcodeRead: ((_ barcode: String,_ codeFormat : CodeFormat) -> Void)?
private var onBarcodeRead: ((_ barcode: String, _ codeFormat: CodeFormat) -> Void)?
private var scannerFrameSize: CGRect? = nil
private var barcodeFrameSize: CGSize? = nil
private var onOrientationChange: RCTDirectEventBlock?
@ -45,6 +45,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private var lastOnZoom: Double?
private var zoom: Double?
private var maxZoom: Double?
private var deferredStartEnabled: Bool = true
// orientation
private var deviceOrientation = UIDeviceOrientation.unknown
@ -58,28 +59,29 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private var inProgressPhotoCaptureDelegates = [Int64: PhotoCaptureDelegate]()
// MARK: - Lifecycle
#if !targetEnvironment(macCatalyst)
override init() {
super.init()
// In addition to using accelerometer to determine REAL orientation
// we also listen to UI orientation changes (UIDevice does not report rotation if orientation lock is on, so photos aren't rotated correctly)
// When UIDevice reports rotation to the left, UI is rotated right to compensate, but that means we need to re-rotate left
// to make camera appear correctly (see self.uiOrientationChanged)
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
orientationObserver = NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: nil,
using: { _ in self.setVideoOrientationToInterfaceOrientation() })
}
#if !targetEnvironment(macCatalyst)
override init() {
super.init()
// In addition to using accelerometer to determine REAL orientation
// we also listen to UI orientation changes (UIDevice does not report rotation if orientation lock is on, so photos aren't rotated correctly)
// When UIDevice reports rotation to the left, UI is rotated right to compensate, but that means we need to re-rotate left
// to make camera appear correctly (see self.uiOrientationChanged)
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
orientationObserver = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: nil,
using: { _ in self.setVideoOrientationToInterfaceOrientation() })
}
#else
override init() {
super.init()
// Mac Catalyst doesn't support device orientation notifications
}
override init() {
super.init()
// Mac Catalyst doesn't support device orientation notifications
}
#endif
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
@ -94,11 +96,12 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
}
#if !targetEnvironment(macCatalyst)
motionManager?.stopAccelerometerUpdates()
motionManager?.stopAccelerometerUpdates()
NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
NotificationCenter.default.removeObserver(
self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
UIDevice.current.endGeneratingDeviceOrientationNotifications()
UIDevice.current.endGeneratingDeviceOrientationNotifications()
#endif
}
@ -109,30 +112,35 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
// MARK: - Public
func setup(cameraType: CameraType, supportedBarcodeType: [CodeFormat]) {
// cameraPreview.session = ... causes begin/commitConfiguration on the current thread
// so it must be on the same thread as setupCaptureSession to avoid startRunning executing inbetween
// Observe this with a breakpoint on `.session = X` and look in Console.app for
// "AVCaptureSession beginConfiguration" from the com.apple.cameracapture subsystem
DispatchQueue.main.async {
self.cameraPreview.session = self.session
self.cameraPreview.previewLayer.videoGravity = .resizeAspect
}
self.initializeMotionManager()
self.initializeMotionManager()
// Setup the capture session.
// In general, it is not safe to mutate an AVCaptureSession or any of its inputs, outputs, or connections from multiple threads at the same time.
// Why not do all of this on the main queue?
// Because -[AVCaptureSession startRunning] is a blocking call which can take a long time. We dispatch session setup to the sessionQueue
// so that the main queue isn't blocked, which keeps the UI responsive.
sessionQueue.async {
self.setupResult = self.setupCaptureSession(cameraType: cameraType, supportedBarcodeType: supportedBarcodeType)
// Setup the capture session.
// In general, it is not safe to mutate an AVCaptureSession or any of its inputs, outputs, or connections from multiple threads at the same time.
// Why not do all of this on the main queue?
// Because -[AVCaptureSession startRunning] is a blocking call which can take a long time. We dispatch session setup to the sessionQueue
// so that the main queue isn't blocked, which keeps the UI responsive.
self.sessionQueue.async {
self.setupResult = self.setupCaptureSession(
cameraType: cameraType, supportedBarcodeType: supportedBarcodeType)
self.addObservers()
self.addObservers()
if self.setupResult == .success {
self.session.startRunning()
if self.setupResult == .success {
self.session.startRunning()
}
DispatchQueue.main.async {
self.setVideoOrientationToInterfaceOrientation()
}
}
DispatchQueue.main.async {
self.setVideoOrientationToInterfaceOrientation()
}
}
}
@ -150,7 +158,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
sessionQueue.async {
guard let videoDevice = self.videoDeviceInput?.device else { return }
let desiredZoomFactor = (self.zoomStartedAt / self.defaultZoomFactor(for: videoDevice)) * pinchScale
let desiredZoomFactor =
(self.zoomStartedAt / self.defaultZoomFactor(for: videoDevice)) * pinchScale
let zoomForDevice = self.getValidZoom(forDevice: videoDevice, zoom: desiredZoomFactor)
if zoomForDevice != self.normalizedZoom(for: videoDevice) {
@ -207,14 +216,26 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
self.onZoomCallback = onZoom
}
func update(iOsDeferredStartEnabled: Bool?) {
let defaultDeferredStart = true
let shouldEnableDeferredStart = iOsDeferredStartEnabled ?? defaultDeferredStart
sessionQueue.async {
guard shouldEnableDeferredStart != self.deferredStartEnabled else { return }
self.deferredStartEnabled = shouldEnableDeferredStart
self.applyDeferredStartConfiguration()
}
}
func focus(at touchPoint: CGPoint, focusBehavior: FocusBehavior) {
DispatchQueue.main.async {
let devicePoint = self.cameraPreview.previewLayer.captureDevicePointConverted(fromLayerPoint: touchPoint)
let devicePoint = self.cameraPreview.previewLayer.captureDevicePointConverted(
fromLayerPoint: touchPoint)
self.sessionQueue.async {
guard let videoDevice = self.videoDeviceInput?.device else { return }
if case let .customFocus(_, resetFocus, focusFinished) = focusBehavior {
if case .customFocus(_, let resetFocus, let focusFinished) = focusBehavior {
self.resetFocus = resetFocus
self.focusFinished = focusFinished
} else {
@ -226,17 +247,22 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
try videoDevice.lockForConfiguration()
self.reconfigureLockedVideoDevice(videoDevice)
if videoDevice.isFocusPointOfInterestSupported && videoDevice.isFocusModeSupported(focusBehavior.avFocusMode) {
if videoDevice.isFocusPointOfInterestSupported
&& videoDevice.isFocusModeSupported(focusBehavior.avFocusMode)
{
videoDevice.focusPointOfInterest = devicePoint
videoDevice.focusMode = focusBehavior.avFocusMode
}
if videoDevice.isExposurePointOfInterestSupported && videoDevice.isExposureModeSupported(focusBehavior.exposureMode) {
if videoDevice.isExposurePointOfInterestSupported
&& videoDevice.isExposureModeSupported(focusBehavior.exposureMode)
{
videoDevice.exposurePointOfInterest = devicePoint
videoDevice.exposureMode = focusBehavior.exposureMode
}
videoDevice.isSubjectAreaChangeMonitoringEnabled = focusBehavior.isSubjectAreaChangeMonitoringEnabled
videoDevice.isSubjectAreaChangeMonitoringEnabled =
focusBehavior.isSubjectAreaChangeMonitoringEnabled
videoDevice.unlockForConfiguration()
} catch {
@ -253,7 +279,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
func update(torchMode: TorchMode) {
sessionQueue.async {
self.torchMode = torchMode
guard let videoDevice = self.videoDeviceInput?.device, videoDevice.torchMode != torchMode.avTorchMode else { return }
guard let videoDevice = self.videoDeviceInput?.device,
videoDevice.torchMode != torchMode.avTorchMode
else { return }
do {
try videoDevice.lockForConfiguration()
@ -268,7 +296,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
func update(flashMode: FlashMode) {
self.flashMode = flashMode
}
func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) {
guard #available(iOS 13.0, *) else { return }
guard maxPhotoQualityPrioritization != self.maxPhotoQualityPrioritization else { return }
@ -276,7 +304,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
self.session.beginConfiguration()
defer { self.session.commitConfiguration() }
self.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization
self.photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization?.avQualityPrioritization ?? .balanced
self.photoOutput.maxPhotoQualityPrioritization =
maxPhotoQualityPrioritization?.avQualityPrioritization ?? .balanced
}
}
@ -288,9 +317,10 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
// Avoid chaining device inputs when camera input is denied by the user, since both front and rear vido input devices will be nil
guard self.setupResult == .success,
let currentViewDeviceInput = self.videoDeviceInput,
let videoDevice = self.getBestDevice(for: cameraType),
let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
let currentViewDeviceInput = self.videoDeviceInput,
let videoDevice = self.getBestDevice(for: cameraType),
let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice)
else {
return
}
@ -327,9 +357,13 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
}
}
func capturePicture(onWillCapture: @escaping () -> Void,
onSuccess: @escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions) -> Void,
onError: @escaping (_ message: String) -> Void) {
func capturePicture(
onWillCapture: @escaping () -> Void,
onSuccess:
@escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions)
-> Void,
onError: @escaping (_ message: String) -> Void
) {
/*
Retrieve the video preview layer's video orientation on the main queue before
entering the session queue. Do this to ensure that UI elements are accessed on
@ -337,16 +371,22 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
*/
DispatchQueue.main.async {
let videoPreviewLayerOrientation =
self.videoOrientation(from: self.deviceOrientation) ?? self.cameraPreview.previewLayer.connection?.videoOrientation
self.videoOrientation(from: self.deviceOrientation)
?? self.cameraPreview.previewLayer.connection?.videoOrientation
self.sessionQueue.async {
if let photoOutputConnection = self.photoOutput.connection(with: .video), let videoPreviewLayerOrientation {
if let photoOutputConnection = self.photoOutput.connection(with: .video),
let videoPreviewLayerOrientation
{
photoOutputConnection.videoOrientation = videoPreviewLayerOrientation
}
let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
let settings = AVCapturePhotoSettings(format: [
AVVideoCodecKey: AVVideoCodecType.jpeg
])
if #available(iOS 13.0, *) {
settings.photoQualityPrioritization = self.photoOutput.maxPhotoQualityPrioritization
settings.photoQualityPrioritization =
self.photoOutput.maxPhotoQualityPrioritization
}
if self.videoDeviceInput?.device.isFlashAvailable == true {
@ -358,7 +398,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
onWillCapture: onWillCapture,
onCaptureSuccess: { uniqueID, imageData, thumbnailData, dimensions in
self.inProgressPhotoCaptureDelegates[uniqueID] = nil
onSuccess(imageData, thumbnailData, dimensions)
},
onCaptureError: { uniqueID, errorMessage in
@ -367,22 +407,25 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
}
)
self.inProgressPhotoCaptureDelegates[photoCaptureDelegate.requestedPhotoSettings.uniqueID] = photoCaptureDelegate
self.inProgressPhotoCaptureDelegates[
photoCaptureDelegate.requestedPhotoSettings.uniqueID] = photoCaptureDelegate
self.photoOutput.capturePhoto(with: settings, delegate: photoCaptureDelegate)
}
}
}
func isBarcodeScannerEnabled(_ isEnabled: Bool,
supportedBarcodeTypes supportedBarcodeType: [CodeFormat],
onBarcodeRead: ((_ barcode: String,_ codeFormat:CodeFormat) -> Void)?) {
func isBarcodeScannerEnabled(
_ isEnabled: Bool,
supportedBarcodeTypes supportedBarcodeType: [CodeFormat],
onBarcodeRead: ((_ barcode: String, _ codeFormat: CodeFormat) -> Void)?
) {
sessionQueue.async {
self.onBarcodeRead = onBarcodeRead
let newTypes: [AVMetadataObject.ObjectType]
if isEnabled && onBarcodeRead != nil {
let availableTypes = self.metadataOutput.availableMetadataObjectTypes
newTypes = supportedBarcodeType.map { $0.toAVMetadataObjectType() }
.filter { availableTypes.contains($0) }
.filter { availableTypes.contains($0) }
} else {
newTypes = []
}
@ -408,7 +451,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
DispatchQueue.main.async {
var visibleRect: CGRect?
if scannerFrameSize != nil && scannerFrameSize != .zero {
visibleRect = self.cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scannerFrameSize!)
visibleRect = self.cameraPreview.previewLayer.metadataOutputRectConverted(
fromLayerRect: scannerFrameSize!)
}
self.sessionQueue.async {
@ -416,7 +460,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
return
}
self.metadataOutput.rectOfInterest = visibleRect ?? CGRect(x: 0, y: 0, width: 1, height: 1)
self.metadataOutput.rectOfInterest =
visibleRect ?? CGRect(x: 0, y: 0, width: 1, height: 1)
}
}
}
@ -424,21 +469,29 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
// MARK: - AVCaptureMetadataOutputObjectsDelegate
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
func metadataOutput(
_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
// Try to retrieve the barcode from the metadata extracted
guard let machineReadableCodeObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let codeStringValue = machineReadableCodeObject.stringValue else {
guard
let machineReadableCodeObject = metadataObjects.first
as? AVMetadataMachineReadableCodeObject,
let codeStringValue = machineReadableCodeObject.stringValue
else {
return
}
// Determine the barcode type and convert it to CodeFormat
let barcodeType = CodeFormat.fromAVMetadataObjectType(machineReadableCodeObject.type)
let barcodeType = CodeFormat.fromAVMetadataObjectType(machineReadableCodeObject.type)
onBarcodeRead?(codeStringValue,barcodeType)
onBarcodeRead?(codeStringValue, barcodeType)
}
// MARK: - Private
private func videoOrientation(from deviceOrientation: UIDeviceOrientation) -> AVCaptureVideoOrientation? {
private func videoOrientation(from deviceOrientation: UIDeviceOrientation)
-> AVCaptureVideoOrientation?
{
// Device orientation counter-rotate interface when in landscapeLeft/Right so it appears level
// (note how landscapeLeft sets landscapeRight)
switch deviceOrientation {
@ -455,7 +508,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
}
}
private func videoOrientation(from interfaceOrientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation {
private func videoOrientation(from interfaceOrientation: UIInterfaceOrientation)
-> AVCaptureVideoOrientation
{
switch interfaceOrientation {
case .portrait:
return .portrait
@ -472,26 +527,37 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private func getBestDevice(for cameraType: CameraType) -> AVCaptureDevice? {
if #available(iOS 13.0, *) {
if let device = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: cameraType.avPosition) {
return device // multi-lens/logical device, ultra-wide & wide & tele
if let device = AVCaptureDevice.default(
.builtInTripleCamera, for: .video, position: cameraType.avPosition)
{
return device // multi-lens/logical device, ultra-wide & wide & tele
}
if let device = AVCaptureDevice.default(.builtInDualWideCamera, for: .video, position: cameraType.avPosition) {
return device // multi-lens/logical device, ultra-wide & wide
if let device = AVCaptureDevice.default(
.builtInDualWideCamera, for: .video, position: cameraType.avPosition)
{
return device // multi-lens/logical device, ultra-wide & wide
}
}
if let device = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: cameraType.avPosition) {
return device // multi-lens/logical device, wide & tele (no ultra-wide)
if let device = AVCaptureDevice.default(
.builtInDualCamera, for: .video, position: cameraType.avPosition)
{
return device // multi-lens/logical device, wide & tele (no ultra-wide)
}
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraType.avPosition) {
return device // single-lens/physical device
if let device = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: cameraType.avPosition)
{
return device // single-lens/physical device
}
return nil
}
private func setupCaptureSession(cameraType: CameraType,
supportedBarcodeType: [CodeFormat]) -> SetupResult {
private func setupCaptureSession(
cameraType: CameraType,
supportedBarcodeType: [CodeFormat]
) -> SetupResult {
guard let videoDevice = self.getBestDevice(for: cameraType),
let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice)
else {
return .sessionConfigurationFailed
}
@ -502,13 +568,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
}
session.sessionPreset = .photo
if #available(iOS 13.0, *) {
if let maxPhotoQualityPrioritization {
photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization.avQualityPrioritization
photoOutput.maxPhotoQualityPrioritization =
maxPhotoQualityPrioritization.avQualityPrioritization
}
}
if session.canAddInput(videoDeviceInput) {
session.addInput(videoDeviceInput)
self.videoDeviceInput = videoDeviceInput
@ -533,16 +600,33 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
let availableTypes = self.metadataOutput.availableMetadataObjectTypes
let filteredTypes = supportedBarcodeType
.map { $0.toAVMetadataObjectType() }
.filter { availableTypes.contains($0) }
let filteredTypes =
supportedBarcodeType
.map { $0.toAVMetadataObjectType() }
.filter { availableTypes.contains($0) }
metadataOutput.metadataObjectTypes = filteredTypes
}
applyDeferredStartConfiguration()
return .success
}
private func applyDeferredStartConfiguration() {
guard #available(iOS 26.0, *) else { return }
let enableDeferredStart = deferredStartEnabled
if photoOutput.isDeferredStartSupported {
photoOutput.isDeferredStartEnabled = enableDeferredStart
}
if metadataOutput.isDeferredStartSupported {
metadataOutput.isDeferredStartEnabled = enableDeferredStart
}
}
private func defaultZoomFactor(for videoDevice: AVCaptureDevice) -> CGFloat {
let fallback = 1.0
guard #available(iOS 13.0, *) else { return fallback }
@ -550,11 +634,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
// Devices that have multiple physical cameras are hidden behind one virtual camera input
// The zoom factor defines what physical camera it actually uses
// The default lens on the native camera app is the wide angle
if let wideAngleIndex = videoDevice.constituentDevices.firstIndex(where: { $0.deviceType == .builtInWideAngleCamera }) {
if let wideAngleIndex = videoDevice.constituentDevices.firstIndex(where: {
$0.deviceType == .builtInWideAngleCamera
}) {
// .virtualDeviceSwitchOverVideoZoomFactors has the .constituentDevices zoom factor which borders the NEXT device
// so we grab the one PRIOR to the wide angle to get the wide angle's zoom factor
guard wideAngleIndex >= 1 else { return fallback }
return videoDevice.virtualDeviceSwitchOverVideoZoomFactors[wideAngleIndex - 1].doubleValue
return videoDevice.virtualDeviceSwitchOverVideoZoomFactors[wideAngleIndex - 1]
.doubleValue
}
return fallback
@ -571,7 +658,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
print("CKCameraKit: setZoomFor error: \(error))")
}
}
// Torch mode will turn off unless set again when the videoDevice is locked and unlocked
private func reconfigureLockedVideoDevice(_ videoDevice: AVCaptureDevice) {
if videoDevice.isTorchModeSupported(torchMode.avTorchMode) && videoDevice.hasTorch {
@ -608,27 +695,34 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private func initializeMotionManager() {
#if !targetEnvironment(macCatalyst)
motionManager = CMMotionManager()
motionManager?.accelerometerUpdateInterval = 0.2
motionManager?.gyroUpdateInterval = 0.2
motionManager?.startAccelerometerUpdates(to: OperationQueue(), withHandler: { (accelerometerData, error) -> Void in
guard error == nil else {
print("\(error!)")
return
}
guard let accelerometerData else {
print("no acceleration data")
return
}
motionManager = CMMotionManager()
motionManager?.accelerometerUpdateInterval = 0.2
motionManager?.gyroUpdateInterval = 0.2
motionManager?.startAccelerometerUpdates(
to: OperationQueue(),
withHandler: { (accelerometerData, error) -> Void in
guard error == nil else {
print("\(error!)")
return
}
guard let accelerometerData else {
print("no acceleration data")
return
}
guard let newOrientation = self.deviceOrientation(from: accelerometerData.acceleration),
newOrientation != self.deviceOrientation else {
return
}
guard
let newOrientation = self.deviceOrientation(
from: accelerometerData.acceleration),
newOrientation != self.deviceOrientation
else {
return
}
self.deviceOrientation = newOrientation
self.onOrientationChange?(["orientation": Orientation.init(from: newOrientation)!.rawValue])
})
self.deviceOrientation = newOrientation
self.onOrientationChange?([
"orientation": Orientation.init(from: newOrientation)!.rawValue
])
})
#endif
}
@ -654,26 +748,36 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private func addObservers() {
guard adjustingFocusObservation == nil else { return }
adjustingFocusObservation = videoDeviceInput?.device.observe(\.isAdjustingFocus,
options: .new,
changeHandler: { [weak self] _, change in
guard let self, let isFocusing = change.newValue else { return }
adjustingFocusObservation = videoDeviceInput?.device.observe(
\.isAdjustingFocus,
options: .new,
changeHandler: { [weak self] _, change in
guard let self, let isFocusing = change.newValue else { return }
self.isAdjustingFocus(isFocusing: isFocusing)
})
self.isAdjustingFocus(isFocusing: isFocusing)
})
NotificationCenter.default.addObserver(forName: .AVCaptureDeviceSubjectAreaDidChange,
object: videoDeviceInput?.device,
queue: nil,
using: { [weak self] notification in self?.subjectAreaDidChange(notification: notification) })
NotificationCenter.default.addObserver(forName: .AVCaptureSessionRuntimeError,
object: session,
queue: nil,
using: { [weak self] notification in self?.sessionRuntimeError(notification: notification) })
NotificationCenter.default.addObserver(forName: .AVCaptureSessionWasInterrupted,
object: session,
queue: nil,
using: { [weak self] notification in self?.sessionWasInterrupted(notification: notification) })
NotificationCenter.default.addObserver(
forName: .AVCaptureDeviceSubjectAreaDidChange,
object: videoDeviceInput?.device,
queue: nil,
using: { [weak self] notification in
self?.subjectAreaDidChange(notification: notification)
})
NotificationCenter.default.addObserver(
forName: .AVCaptureSessionRuntimeError,
object: session,
queue: nil,
using: { [weak self] notification in
self?.sessionRuntimeError(notification: notification)
})
NotificationCenter.default.addObserver(
forName: .AVCaptureSessionWasInterrupted,
object: session,
queue: nil,
using: { [weak self] notification in
self?.sessionWasInterrupted(notification: notification)
})
}
@ -682,7 +786,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
NotificationCenter.default.removeObserver(orientationObserver)
self.orientationObserver = nil
}
// swiftlint:disable:next notification_center_detachment
NotificationCenter.default.removeObserver(self)
@ -702,21 +806,25 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private func setVideoOrientationToInterfaceOrientation() {
#if !targetEnvironment(macCatalyst)
var interfaceOrientation: UIInterfaceOrientation
if #available(iOS 13.0, *) {
interfaceOrientation = self.previewView.window?.windowScene?.interfaceOrientation ?? .portrait
} else {
interfaceOrientation = UIApplication.shared.statusBarOrientation
}
self.cameraPreview.previewLayer.connection?.videoOrientation = self.videoOrientation(from: interfaceOrientation)
var interfaceOrientation: UIInterfaceOrientation
if #available(iOS 13.0, *) {
interfaceOrientation =
self.previewView.window?.windowScene?.interfaceOrientation ?? .portrait
} else {
interfaceOrientation = UIApplication.shared.statusBarOrientation
}
self.cameraPreview.previewLayer.connection?.videoOrientation = self.videoOrientation(
from: interfaceOrientation)
#else
// Mac Catalyst always uses portrait orientation
self.cameraPreview.previewLayer.connection?.videoOrientation = .portrait
// Mac Catalyst always uses portrait orientation
self.cameraPreview.previewLayer.connection?.videoOrientation = .portrait
#endif
}
private func sessionRuntimeError(notification: Notification) {
guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return }
guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else {
return
}
print("Capture session runtime error: \(error)")
@ -742,10 +850,13 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
var showResumeButton = false
if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue) {
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue)
{
print("Capture session was interrupted with reason \(reason)")
if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient {
if reason == .audioDeviceInUseByAnotherClient
|| reason == .videoDeviceInUseByAnotherClient
{
showResumeButton = true
}
}

View File

@ -4,8 +4,8 @@
//
import AVFoundation
import UIKit
import React
import UIKit
/*
* Fake camera implementation to be used on simulator
@ -37,16 +37,20 @@ class SimulatorCamera: CameraProtocol {
// Listen to orientation changes
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: nil,
using: { [weak self] notification in self?.orientationChanged(notification: notification) })
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: nil,
using: { [weak self] notification in
self?.orientationChanged(notification: notification)
})
}
private func orientationChanged(notification: Notification) {
guard let device = notification.object as? UIDevice,
let orientation = Orientation(from: device.orientation) else {
let orientation = Orientation(from: device.orientation)
else {
return
}
@ -54,7 +58,8 @@ class SimulatorCamera: CameraProtocol {
}
func cameraRemovedFromSuperview() {
NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
NotificationCenter.default.removeObserver(
self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
}
@ -66,6 +71,10 @@ class SimulatorCamera: CameraProtocol {
self.onZoom = onZoom
}
func update(iOsDeferredStartEnabled: Bool?) {
// Not applicable on simulator; deferred start only matters for real capture outputs.
}
func setVideoDevice(zoomFactor: Double) {
self.videoDeviceZoomFactor = zoomFactor
self.mockPreview.zoomLabel.text = "Zoom: \(zoomFactor)"
@ -103,13 +112,15 @@ class SimulatorCamera: CameraProtocol {
func focus(at: CGPoint, focusBehavior: FocusBehavior) {
DispatchQueue.main.async {
self.mockPreview.focusAtLabel.text = "Focus at: (\(Int(at.x)), \(Int(at.y))), focusMode: \(focusBehavior.avFocusMode)"
self.mockPreview.focusAtLabel.text =
"Focus at: (\(Int(at.x)), \(Int(at.y))), focusMode: \(focusBehavior.avFocusMode)"
}
// Fake focus finish after a second
fakeFocusFinishedTimer?.invalidate()
if case let .customFocus(_, _, focusFinished) = focusBehavior {
fakeFocusFinishedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
if case .customFocus(_, _, let focusFinished) = focusBehavior {
fakeFocusFinishedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) {
_ in
focusFinished()
}
}
@ -120,7 +131,7 @@ class SimulatorCamera: CameraProtocol {
self.mockPreview.torchModeLabel.text = "Torch mode: \(torchMode)"
}
}
func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) {
}
@ -172,16 +183,21 @@ class SimulatorCamera: CameraProtocol {
self.mockPreview.randomize()
}
}
func isBarcodeScannerEnabled(_ isEnabled: Bool,
supportedBarcodeTypes: [CodeFormat],
onBarcodeRead: ((_ barcode: String,_ codeFormat:CodeFormat) -> Void)?) {}
func isBarcodeScannerEnabled(
_ isEnabled: Bool,
supportedBarcodeTypes: [CodeFormat],
onBarcodeRead: ((_ barcode: String, _ codeFormat: CodeFormat) -> Void)?
) {}
func update(scannerFrameSize: CGRect?) {}
func capturePicture(onWillCapture: @escaping () -> Void,
onSuccess: @escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions) -> Void,
onError: @escaping (_ message: String) -> Void) {
func capturePicture(
onWillCapture: @escaping () -> Void,
onSuccess:
@escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions)
-> Void,
onError: @escaping (_ message: String) -> Void
) {
onWillCapture()
DispatchQueue.main.async {

View File

@ -1,18 +1,19 @@
{
"name": "react-native-camera-kit",
"name": "react-native-camera-kit-no-google",
"repository": {
"type": "git",
"url": "https://github.com/teslamotors/react-native-camera-kit.git"
"url": "https://github.com/limpbrains/react-native-camera-kit-no-google.git"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"version": "16.1.3",
"version": "17.0.4",
"description": "A high performance, fully featured, rock solid camera library for React Native applications",
"nativePackage": true,
"scripts": {
"build": "tsc --project tsconfig.json",
"clean": "rm -rf dist/",
"clean": "rm -rf dist/ build/ .codegen/ android/app/build/",
"prepare": "tsc --project tsconfig.json",
"test": "jest",
"lint": "yarn eslint -c .eslintrc.js",
"check-ios": "scripts/check-ios.sh",
@ -20,7 +21,7 @@
"release:beta": "yarn clean && yarn build && yarn publish --tag beta --verbose",
"release:local": "yarn clean && yarn build && tmp=$(mktemp) && yarn pack --filename $tmp.tar.gz && open -R $tmp.tar.gz",
"start": "watchman watch-del-all && node node_modules/react-native/local-cli/cli.js start",
"codegen": "react-native codegen --verbose --path . --platform ios --source library && react-native codegen --verbose --path . --platform android --source library",
"codegen": "rm -rf .codegen/ && react-native codegen --verbose --path . --platform ios --outputPath .codegen --source library && react-native codegen --verbose --path . --platform android --outputPath .codegen --source library",
"bootstrap": "cd example/ && bundle install && yarn && cd ios/ && bundle exec pod install",
"bootstrap-linux": "cd example/ && yarn"
},
@ -30,8 +31,11 @@
"types": "dist/index.d.ts",
"react-native": "src/index",
"files": [
"android",
"build",
"android/build.gradle",
"android/gradle",
"android/gradlew",
"android/gradlew.bat",
"android/src",
"dist",
"ios",
"src",
@ -50,9 +54,11 @@
"@react-native/eslint-config": "0.79.0",
"@react-native/metro-config": "0.79.0",
"@react-native/typescript-config": "0.79.0",
"@types/jest": "^30.0.0",
"@types/react": "^19.0.0",
"@types/react-test-renderer": "^19.0.0",
"eslint": "^8.19.0",
"jest": "^30.3.0",
"prettier": "2.8.8",
"react": "19.0.0",
"react-native": "0.79.0",

View File

@ -1,6 +1,6 @@
import React from 'react';
import { findNodeHandle, processColor } from 'react-native';
import type { CameraApi } from './types';
import { supportedCodeFormats, type CameraApi } from './types';
import type { CameraProps } from './CameraProps';
import NativeCamera from './specs/CameraNativeComponent';
import NativeCameraKitModule from './specs/NativeCameraKitModule';
@ -15,6 +15,8 @@ const Camera = React.forwardRef<CameraApi, CameraProps>((props, ref) => {
props.maxZoom = props.maxZoom ?? -1;
props.scanThrottleDelay = props.scanThrottleDelay ?? -1;
props.allowedBarcodeTypes = props.allowedBarcodeTypes ?? supportedCodeFormats;
React.useImperativeHandle(ref, () => ({
capture: async (options = {}) => {
return await NativeCameraKitModule.capture(options, findNodeHandle(nativeRef.current) ?? undefined);

View File

@ -1,6 +1,6 @@
import React from 'react';
import { findNodeHandle } from 'react-native';
import type { CameraApi } from './types';
import { supportedCodeFormats, type CameraApi } from './types';
import type { CameraProps } from './CameraProps';
import NativeCamera from './specs/CameraNativeComponent';
import NativeCameraKitModule from './specs/NativeCameraKitModule';
@ -14,6 +14,9 @@ const Camera = React.forwardRef<CameraApi, CameraProps>((props, ref) => {
props.zoom = props.zoom ?? -1;
props.maxZoom = props.maxZoom ?? -1;
props.scanThrottleDelay = props.scanThrottleDelay ?? -1;
props.iOsDeferredStart = props.iOsDeferredStart ?? true;
props.allowedBarcodeTypes = props.allowedBarcodeTypes ?? supportedCodeFormats;
props.resetFocusTimeout = props.resetFocusTimeout ?? 0;
props.resetFocusWhenMotionDetected = props.resetFocusWhenMotionDetected ?? true;

View File

@ -113,8 +113,11 @@ export interface CameraProps extends ViewProps {
scanThrottleDelay?: number;
/** **iOS Only**. 'speed' provides 60-80% faster image capturing */
maxPhotoQualityPrioritization?: 'balanced' | 'quality' | 'speed';
/** **iOS Only (iOS 26+)**. Enables `AVCaptureOutput.deferredStartEnabled` when supported to prioritize getting the preview visible faster. Default: `true`. When enabled, the first capture may be delayed by a few hundred milliseconds. Not supported on Android. */
iOsDeferredStart?: boolean;
/** **Android only**. Play a shutter capture sound when capturing a photo */
shutterPhotoSound?: boolean;
onCaptureButtonPressIn?: ({ nativeEvent: {} }) => void;
onCaptureButtonPressOut?: ({ nativeEvent: {} }) => void;
allowedBarcodeTypes?: CodeFormat[];
}

View File

@ -0,0 +1,51 @@
jest.mock('react-native', () => ({
NativeModules: { CameraKit: {} },
Platform: { OS: 'ios' },
TurboModuleRegistry: { getEnforcing: jest.fn(() => ({})) },
}));
jest.mock('../specs/NativeCameraKitModule', () => ({
__esModule: true,
default: {
detectQRCodeInImage: jest.fn(),
},
}));
import NativeCameraKitModule from '../specs/NativeCameraKitModule';
import { detectQRCodeInImage } from '../index';
const mockedDetect = NativeCameraKitModule.detectQRCodeInImage as jest.Mock;
describe('detectQRCodeInImage', () => {
beforeEach(() => {
mockedDetect.mockReset();
});
it('forwards the base64 argument to the native module', async () => {
mockedDetect.mockResolvedValueOnce('decoded-value');
await detectQRCodeInImage('base64-data');
expect(mockedDetect).toHaveBeenCalledTimes(1);
expect(mockedDetect).toHaveBeenCalledWith('base64-data');
});
it('resolves with the decoded string from the native module', async () => {
mockedDetect.mockResolvedValueOnce('https://example.com');
await expect(detectQRCodeInImage('xyz')).resolves.toBe('https://example.com');
});
it('resolves with null when no QR code was found', async () => {
mockedDetect.mockResolvedValueOnce(null);
await expect(detectQRCodeInImage('xyz')).resolves.toBeNull();
});
it('propagates native rejections (e.g. invalid image)', async () => {
const err = new Error('Could not decode base64 image data');
mockedDetect.mockRejectedValueOnce(err);
await expect(detectQRCodeInImage('xyz')).rejects.toBe(err);
});
});

View File

@ -1,6 +1,7 @@
import { NativeModules } from 'react-native';
import Camera from './Camera';
import NativeCameraKitModule from './specs/NativeCameraKitModule';
import {
CameraType,
type CameraApi,
@ -10,10 +11,15 @@ import {
type TorchMode,
type ZoomMode,
type ResizeMode,
type CodeFormat,
} from './types';
const { CameraKit } = NativeModules;
export const detectQRCodeInImage = (base64: string): Promise<string | null> => {
return NativeCameraKitModule.detectQRCodeInImage(base64);
};
// Start with portrait/pointing up, increment while moving counter-clockwise
export const Orientation = {
PORTRAIT: 0, // ⬆️
@ -25,4 +31,13 @@ export const Orientation = {
export default CameraKit;
export { Camera, CameraType };
export type { TorchMode, FlashMode, FocusMode, ZoomMode, CameraApi, CaptureData, ResizeMode };
export type {
TorchMode,
FlashMode,
FocusMode,
ZoomMode,
CameraApi,
CaptureData,
ResizeMode,
CodeFormat,
};

View File

@ -10,6 +10,9 @@ import type {
Int32,
WithDefault
} from 'react-native/Libraries/Types/CodegenTypes';
// While this import is deprecated, official docs still shows this as valid
// and the alternative doesn't work (import doesn't exist for this RN version)
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
type OnReadCodeData = {
@ -46,6 +49,7 @@ export interface NativeProps extends ViewProps {
resetFocusWhenMotionDetected?: boolean;
resizeMode?: string;
scanThrottleDelay?: WithDefault<Int32, -1>;
iOsDeferredStart?: boolean;
barcodeFrameSize?: { width?: WithDefault<Float, 300>; height?: WithDefault<Float, 150> };
shutterPhotoSound?: boolean;
onOrientationChange?: DirectEventHandler<OnOrientationChangeData>;
@ -54,6 +58,7 @@ export interface NativeProps extends ViewProps {
onReadCode?: DirectEventHandler<OnReadCodeData>;
onCaptureButtonPressIn?: DirectEventHandler<{}>;
onCaptureButtonPressOut?: DirectEventHandler<{}>;
allowedBarcodeTypes?: string[];
// not mentioned in props but available on the native side
shutterAnimationDuration?: WithDefault<Int32, -1>;

View File

@ -18,6 +18,7 @@ export interface Spec extends TurboModule {
capture(options?: UnsafeObject, tag?: Double): Promise<CaptureData>;
requestDeviceCameraAuthorization: () => Promise<boolean>;
checkDeviceCameraAuthorizationStatus: () => Promise<boolean>;
detectQRCodeInImage(base64: string): Promise<string | null>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNCameraKitModule');

View File

@ -3,20 +3,46 @@ export enum CameraType {
Back = 'back',
}
export type CodeFormat =
| 'code-128'
| 'code-39'
| 'code-93'
| 'codabar'
| 'ean-13'
| 'ean-8'
| 'itf'
| 'upc-e'
| 'qr'
| 'pdf-417'
| 'aztec'
| 'data-matrix'
| 'unknown';
const codeFormatAndroid = [
'code-128',
'code-39',
'code-93',
'codabar',
'ean-13',
'ean-8',
'itf',
'upc-a',
'upc-e',
'qr',
'pdf-417',
'aztec',
'data-matrix',
'unknown',
] as const;
const codeFormatIOS = [
'code-128',
'code-39',
'code-93',
'codabar', // only iOS 15.4+
'ean-13',
'ean-8',
'itf-14',
'upc-e',
'qr',
'pdf-417',
'aztec',
'data-matrix',
'code-39-mod-43',
'interleaved-2of5',
] as const;
export const supportedCodeFormats = Array.from(new Set([...codeFormatAndroid, ...codeFormatIOS]));
type CodeFormatAndroid = (typeof codeFormatAndroid)[number];
type CodeFormatIOS = (typeof codeFormatIOS)[number];
export type CodeFormat = CodeFormatAndroid | CodeFormatIOS | 'unknown';
export type TorchMode = 'on' | 'off';

2893
yarn.lock

File diff suppressed because it is too large Load Diff