Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed049a62d | ||
|
|
58ff00300c | ||
|
|
e6dd85b4ea | ||
|
|
75117f1287 | ||
|
|
4323cb608d | ||
|
|
448cf1a9e0 | ||
|
|
a8535da0d3 | ||
|
|
edb054a8cb | ||
|
|
91cc291a62 | ||
|
|
202f9f6455 | ||
|
|
8e5149a6e6 | ||
|
|
3767ef668c | ||
|
|
611006999d | ||
|
|
b2b06b425b | ||
|
|
a73b84ef78 | ||
|
|
afeaac0996 | ||
|
|
dca0e421fc | ||
|
|
db93bcfc94 | ||
|
|
00b953263d | ||
|
|
a2f61a0cbb | ||
|
|
cdc1cced0e | ||
|
|
6120da218f | ||
|
|
4e7527cbe3 | ||
|
|
daaa65be3a | ||
|
|
05ae12c0b1 | ||
|
|
2c0a0e43f3 | ||
|
|
cc6515b914 | ||
|
|
03763d0470 | ||
|
|
f5e3bc98e9 | ||
|
|
6c7cfe44d5 | ||
|
|
ea894d96ad | ||
|
|
2a1f06aa12 | ||
|
|
5a709e03b0 | ||
|
|
1131e81f85 | ||
|
|
1371773530 | ||
|
|
91909ff575 | ||
|
|
d42ef6290b | ||
|
|
4cbec39042 | ||
|
|
1ea413a099 | ||
|
|
f8be0f08e8 | ||
|
|
cc6d18ceaa | ||
|
|
b085a7801c | ||
|
|
6d0bed7ce0 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.codegen/
|
||||
old-example/
|
||||
example/
|
||||
example-js-code/
|
||||
|
||||
217
CLAUDE.md
Normal file
217
CLAUDE.md
Normal 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
147
README.md
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' }
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
99
android/src/main/java/com/rncamerakit/ImageQRCodeDecoder.kt
Normal file
99
android/src/main/java/com/rncamerakit/ImageQRCodeDecoder.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>3B52.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
152
example/src/DetectQRExample.tsx
Normal file
152
example/src/DetectQRExample.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
20
package.json
20
package.json
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
51
src/__tests__/detectQRCodeInImage.test.tsx
Normal file
51
src/__tests__/detectQRCodeInImage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
17
src/index.ts
17
src/index.ts
@ -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,
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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');
|
||||
|
||||
54
src/types.ts
54
src/types.ts
@ -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';
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user