Compare commits

...

37 Commits

Author SHA1 Message Date
Ivan Vershigora
0ed049a62d fix: bump 2026-04-24 19:25:34 +01:00
Ivan Vershigora
58ff00300c feat: detectQRCodeInImage API 2026-04-24 19:25:34 +01:00
Ivan Vershigora
e6dd85b4ea
bump to v17.0.3 2026-04-20 16:11:12 +01:00
Ivan Vershigora
75117f1287 bump to v17.0.2 2026-04-20 14:05:59 +01:00
Ivan Vershigora
4323cb608d fix: bump com.github.limpbrains:qr:v0.0.3 2026-04-20 14:05:59 +01:00
Overtorment
448cf1a9e0 refactor: release 2026-04-20 12:36:06 +01:00
Overtorment
a8535da0d3 fix: duplicate method 2026-04-19 20:23:10 +01:00
Ivan Vershigora
edb054a8cb
fix: bugs 2026-01-17 11:24:47 +00:00
Ivan Vershigora
91cc291a62
Update sync state after v17.0.1 sync 2026-01-17 10:25:19 +00:00
Ivan Vershigora
202f9f6455
Resolve merge conflicts, preserve no-google customizations 2026-01-17 10:21:18 +00:00
Seph Soliman
8e5149a6e6 v17.0.1 2026-01-12 10:47:04 -08:00
Seph Soliman
3767ef668c Fixed Android compilation error
("'setIOsSleepBeforeStarting' overrides nothing")
2026-01-12 10:44:57 -08:00
Seph Soliman
611006999d v17.0.0 2026-01-08 16:44:52 -08:00
Seph Soliman
b2b06b425b Merge branch 'r/16.2.1' 2026-01-08 16:43:31 -08:00
Seph Soliman
a73b84ef78 Removed iOsSleepBeforeStarting
No longer needed with proper begin/commit handling
2026-01-08 16:41:43 -08:00
Seph Soliman
afeaac0996 v16.2.1 2026-01-08 13:43:53 -08:00
coreyphillips
dca0e421fc fix(android): add missing setIOsSleepBeforeStarting stub for new arch
- The CKCameraManagerInterface requires setIOsSleepBeforeStarting to be implemented, but the Android new architecture implementation was missing this method stub. This caused Kotlin compilation failures when building release APKs.
2026-01-08 13:41:08 -08:00
Seph Soliman
db93bcfc94 Fixed missing iOsDeferredStart on Android 2026-01-08 13:40:32 -08:00
Seph Soliman
00b953263d
Merge pull request #760 from coreyphillips/master
fix(android): add missing setIOsSleepBeforeStarting stub for new arch
2026-01-08 13:37:02 -08:00
Seph Soliman
a2f61a0cbb Added iOsDeferredStart for UI performance
Reformatted RealCamera.swift
2026-01-08 13:19:54 -08:00
Seph Soliman
cdc1cced0e Fixed #758
NSGenericException "startRunning may not be called between calls to
beginConfiguration and commitConfiguration"
The root cause was that AVCaptureSession.previewLayer.session = X
causes beginConfiguration + commitConfiguration, and since we had that
running on the main thread, it caused a race condition between the main
and sessionQueue threads.
iOsSleepBeforeStarting will be removed in an upcoming major release to
avoid breaking changes in v16.
2026-01-08 12:05:17 -08:00
coreyphillips
2c0a0e43f3
fix(android): add missing setIOsSleepBeforeStarting stub for new arch
- The CKCameraManagerInterface requires setIOsSleepBeforeStarting to be implemented, but the Android new architecture implementation was missing this method stub. This caused Kotlin compilation failures when building release APKs.
2026-01-07 12:26:43 -05:00
Seph Soliman
cc6515b914 v16.2.0 2026-01-05 15:55:07 -08:00
Seph Soliman
03763d0470
Merge pull request #759 from teslamotors/stress-test
Add mount stress test to CameraKit example app
2026-01-05 14:34:33 -08:00
Seph Soliman
f5e3bc98e9 Add mount stress test to CameraKit example app
Introduces a mount stress test feature in the example app, allowing repeated mounting and unmounting of the CameraExample component at a configurable interval. Updates CameraExample to support a 'stress' mode that triggers image capture and random zoom changes on mount.
2026-01-05 14:33:04 -08:00
Seph Soliman
6c7cfe44d5
Merge pull request #727 from IlyaPasternakAmitech/master
feat: Add forbiddenBarcodeTypes property
2025-12-30 16:13:47 -08:00
Seph Soliman
ea894d96ad Fixed Android allowed barcode types 2025-12-30 16:03:40 -08:00
Seph Soliman
2a1f06aa12 Fixed optional behavior of allowedBarcodeTypes
Added more barcode types
2025-12-30 15:11:19 -08:00
Seph Soliman
5a709e03b0
Merge pull request #757 from limpbrains/init
Fixed #756 Catch camera init and trigger onError
2025-12-30 11:00:17 -08:00
Ivan Vershigora
1371773530
fix: catch error in cameraProviderFuture.get() 2025-12-29 18:48:17 +00:00
Kseniya Vinnichek
d42ef6290b update README with allowedBarcodeTypes usage and supported formats 2025-11-20 13:37:03 +01:00
Kseniya Vinnichek
4cbec39042 refactoring 2025-11-20 13:08:08 +01:00
Kseniya Vinnichek
1ea413a099 Merge remote-tracking branch 'upstream/master' into refactoring
# Conflicts:
#	android/src/main/java/com/rncamerakit/CKCamera.kt
#	android/src/newarch/java/com/rncamerakit/CKCameraManager.kt
#	android/src/paper/java/com/facebook/react/viewmanagers/CKCameraManagerDelegate.java
#	android/src/paper/java/com/facebook/react/viewmanagers/CKCameraManagerInterface.java
2025-11-20 10:41:02 +01:00
Kseniya Vinnichek
f8be0f08e8 correct allowed barcode types validation, remove invalid reference, and prevent fallback errors 2025-11-19 11:32:43 +01:00
Ilya Pasternak
cc6d18ceaa change forbiddenBarcodeTypes property to allowedBarcodeTypes 2025-07-02 14:52:26 +03:00
Ilya Pasternak
b085a7801c Merge remote-tracking branch 'origin/master' 2025-06-26 19:57:43 +03:00
Ilya Pasternak
6d0bed7ce0 add forbiddenBarcodeTypes property 2025-06-16 09:54:16 +03:00
34 changed files with 3414 additions and 1428 deletions

1
.gitignore vendored
View File

@ -85,6 +85,7 @@ example/.yarn/*
ios/build/
ios/DerivedData/
dist/
.codegen/
### Xcode ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj

View File

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

View File

@ -178,84 +178,40 @@ The library is published to npm as `react-native-camera-kit` with the `files` ar
## Camera Kit Sync State
**Last synchronized upstream commit**: cc6515b914a34ef79d8fdba527e878761047b02a
**Upstream version**: 16.2.0
**Fork version**: 16.1.3
**Last sync date**: 2026-01-07T23:03:00Z
**Last synchronized upstream commit**: 8e5149a6e6d3902ae87dad50da0d06ec2c61d2b8
**Upstream version**: 17.0.1
**Fork version**: 17.0.3
**Last sync date**: 2026-01-17
**Sync status**: success
**Fork point**: 5a709e0
### Changes Synced (commits 5a709e0..cc6515b)
### Changes Synced (upstream v16.2.0 -> v17.0.1)
**Commit range**: 12 commits from upstream
**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
**iOS Improvements** (8 files, fully synced):
- Added mount stress test support (CameraView.swift, RealCamera.swift, SimulatorCamera.swift)
- Added `allowedBarcodeTypes` barcode filtering for iOS (CodeFormat.swift, CKCameraViewComponentView.mm, CameraView.swift)
- All iOS files synced successfully - no conflicts with QR-only fork
**TypeScript Layer** (7 files, fully synced):
- Updated Camera.ios.tsx, Camera.android.tsx with `allowedBarcodeTypes` prop
- Updated CameraProps.ts, types.ts, src/index.ts with new barcode filtering types
- Updated specs/CameraNativeComponent.ts with Codegen prop definitions
- All TypeScript changes synced - prop works on iOS, gracefully handled on Android (QR-only)
**Example App** (3 files, fully synced):
- Added mount stress test to App.tsx and CameraExample.tsx
- Added `allowedBarcodeTypes={['qr', 'ean-13']}` example to BarcodeScreenExample.tsx
- Example demonstrates the prop even though Android fork only scans QR codes
**Config Files**:
- Moved .nvmrc from example/ to root directory
**Documentation**:
- README.md: Added `allowedBarcodeTypes` prop documentation with note: "Android only supports `'qr'` in this fork. iOS supports all formats."
### Changes Skipped (Android Barcode Conflicts)
**android/src/main/java/com/rncamerakit/CKCamera.kt** (commits ea894d9, 2a1f06a, f8be0f0, cc6d18c, 4cbec39):
- **Upstream changes**: Added `allowedBarcodeTypes` property and barcode filtering logic using `List<Barcode>` callback
- **Fork incompatibility**: Fork uses `onBarcodeRead(String)` callback (single QR string), upstream uses `onBarcodeRead(List<Barcode>, Size)` (multiple barcodes with bounding boxes)
- **Action**: Skipped all barcode filtering logic entirely
- **Rationale**: Fork's QR-only architecture with limpbrains/qr decoder is fundamentally incompatible with multi-format filtering
**android/src/main/java/com/rncamerakit/CKCameraManager.kt** (commits cc6d18c, 6d0bed7):
- **Upstream changes**: Added `setAllowedBarcodeTypes` property setter
- **Action**: Skipped entirely
- **Rationale**: Fork doesn't use barcode type filtering (QR-only)
**package.json**:
- **Upstream change**: Version bump from 16.1.3 → 16.2.0
- **Action**: Skipped version change
- **Rationale**: Fork maintains independent versioning (currently 16.1.3)
### Selective Sync Summary
**CodeFormat.kt**: ✅ Partial sync (1 hunk applied, 1 hunk skipped)
- ✅ **Applied**: Added `UPC_A("upc-a")` enum value (line 11)
- ❌ **Skipped**: `fromName()` helper method (fork doesn't need it, has no ML Kit conversions)
- **Rationale**: New enum values are harmless future-proofing, even if fork doesn't use them
**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` - Still uses `implementation 'com.github.limpbrains:qr:v0.0.1'`
- `android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt` - Still uses `QRDecoder.decode()` from limpbrains/qr
- `android/src/main/java/com/rncamerakit/CodeFormat.kt` - Simplified enum structure (no ML Kit conversions)
- `android/src/main/java/com/rncamerakit/CKCamera.kt` - String callback signature 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)`
### Sync Statistics
### Validation
- **Files auto-synced (Category A)**: 19 files (iOS, TypeScript, Example, Config)
- **Files selectively synced (Category B)**: 2 files (CodeFormat.kt partial, README.md with fork note)
- **Files skipped (Category B)**: 3 files (CKCamera.kt, CKCameraManager.kt, package.json)
- **Hunks applied**: 1 hunk (UPC_A enum)
- **Hunks skipped**: ~50+ hunks (all Android barcode filtering logic)
### Notes
- Fork successfully synced all upstream improvements to iOS and TypeScript layers
- `allowedBarcodeTypes` prop is now documented and works on iOS
- Android continues to use QR-only scanning with limpbrains/qr decoder
- No Google ML Kit dependencies introduced
- All build integrity checks passed (see test results below)
- `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

103
README.md
View File

@ -201,40 +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-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` |
| `onCaptureButtonPressIn` | Function | Callback when iPhone capture button is pressed in or Android volume or camera button is pressed in. Ex: `onCaptureButtonPressIn={() => console.log("volume button pressed in")}` |
| `onCaptureButtonPressOut` | Function | Callback when iPhone capture button is released or Android volume or camera button is released. Ex: `onCaptureButtonPressOut={() => console.log("volume button released")}` |
| **Barcode only** |
| `scanBarcode` | `boolean` | Enable barcode scanner. Default: `false` |
| `showFrame` | `boolean` | Show frame in barcode scanner. Default: `false` |
| `barcodeFrameSize` | `object` | Frame size of barcode scanner. Default: `{ width: 300, height: 150 }` |
| `laserColor` | Color | Color of barcode scanner laser visualization. Default: `red` |
| `frameColor` | Color | Color of barcode scanner frame visualization. Default: `yellow` |
| `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` |
| Props | Type | Description |
| ------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ref` | Ref | Reference on the camera view |
| `style` | StyleProp\<ViewStyle> | Style to apply on the camera view |
| `flashMode` | `'on'`/`'off'`/`'auto'` | Camera flash mode. Default: `auto` |
| `focusMode` | `'on'`/`'off'` | Camera focus mode. Default: `on` |
| `zoomMode` | `'on'`/`'off'` | Enable the pinch to zoom gesture. Default: `on`. If `on`, you must pass `zoom` as `undefined` or avoid setting `zoomMode` to allow pinch to zoom |
| `zoom` | `number` | Control the zoom. Default: `1.0` |
| `maxZoom` | `number` | Maximum zoom allowed (but not beyond what camera allows). Default: `undefined` (camera default max) |
| `onZoom` | Function | Callback when user makes a pinch gesture, regardless of what the `zoom` prop was set to. Returned event contains `zoom`. Ex: `onZoom={(e) => console.log(e.nativeEvent.zoom)}`. |
| `torchMode` | `'on'`/`'off'` | Toggle flash light when camera is active. Default: `off` |
| `cameraType` | CameraType.Back/CameraType.Front | Choose what camera to use. Default: `CameraType.Back` |
| `onOrientationChange` | Function | Callback when physical device orientation changes. Returned event contains `orientation`. Ex: `onOrientationChange={(event) => console.log(event.nativeEvent.orientation)}`. Use `import { Orientation } from 'react-native-camera-kit-no-google'; if (event.nativeEvent.orientation === Orientation.PORTRAIT) { ... }` to understand the new value |
| `allowedBarcodeTypes` | string[] | Limits which barcode formats can be detected. Ex: `['qr', 'ean-13', 'code-128']`. If empty or omitted, all supported formats are scanned. **Note**: Android only supports `'qr'` in this fork. iOS supports all formats. |
| **Android only** |
| `onError` | Function | Android only. Callback when camera fails to initialize. Ex: `onError={(e) => console.log(e.nativeEvent.errorMessage)}`. |
| `shutterPhotoSound` | `boolean` | Android only. Enable or disable the shutter sound when capturing a photo. Default: `true` |
| **iOS only** |
| `ratioOverlay` | `'int:int'` | Show a guiding overlay in the camera preview for the selected ratio. Does not crop image as of v9.0. Example: `'16:9'` |
| `ratioOverlayColor` | Color | Any color with alpha. Default: `'#ffffff77'` |
| `resetFocusTimeout` | `number` | Dismiss tap to focus after this many milliseconds. Default `0` (disabled). Example: `5000` is 5 seconds. |
| `resetFocusWhenMotionDetected` | Boolean | Dismiss tap to focus when focus area content changes. Native iOS feature, see documentation: https://developer.apple.com/documentation/avfoundation/avcapturedevice/1624644-subjectareachangemonitoringenabl?language=objc). Default `true`. |
| `resizeMode` | `'cover' / 'contain'` | Determines the scaling and cropping behavior of content within the view. `cover` (resizeAspectFill on iOS) scales the content to fill the view completely, potentially cropping content if its aspect ratio differs from the view. `contain` (resizeAspect on iOS) scales the content to fit within the view's bounds without cropping, ensuring all content is visible but may introduce letterboxing. Default behavior depends on the specific use case. |
| `scanThrottleDelay` | `number` | Duration between scan detection in milliseconds. Default 2000 (2s) |
| `maxPhotoQualityPrioritization` | `'balanced'` / `'quality'` / `'speed'` | [iOS 13 and newer](https://developer.apple.com/documentation/avfoundation/avcapturephotooutput/3182995-maxphotoqualityprioritization). `'speed'` provides a 60-80% median capture time reduction vs 'quality' setting. Tested on iPhone 6S Max (66% faster) and iPhone 15 Pro Max (76% faster!). Default `balanced` |
| `iOsDeferredStart` | `boolean` | iOS 26+ only. Enables `AVCaptureOutput.deferredStartEnabled` when supported to get the preview visible faster. Default `true`. When enabled, the first capture can be delayed by a few hundred milliseconds. Ignored on Android and on older iOS versions. |
| `onCaptureButtonPressIn` | Function | Callback when iPhone capture button is pressed in or Android volume or camera button is pressed in. Ex: `onCaptureButtonPressIn={() => console.log("volume button pressed in")}` |
| `onCaptureButtonPressOut` | Function | Callback when iPhone capture button is released or Android volume or camera button is released. Ex: `onCaptureButtonPressOut={() => console.log("volume button released")}` |
| **Barcode only** |
| `scanBarcode` | `boolean` | Enable barcode scanner. Default: `false` |
| `showFrame` | `boolean` | Show frame in barcode scanner. Default: `false` |
| `barcodeFrameSize` | `object` | Frame size of barcode scanner. Default: `{ width: 300, height: 150 }` |
| `laserColor` | Color | Color of barcode scanner laser visualization. Default: `red` |
| `frameColor` | Color | Color of barcode scanner frame visualization. Default: `yellow` |
| `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` |
### detectQRCodeInImage(base64)
Detect and decode a QR code from a static image. Takes a base64-encoded image string. Returns a promise that resolves with the decoded string, or `null` if the image is valid but contains no QR code. Rejects only on actual errors (invalid base64, undecodable image data, detector failure).
```ts
import { detectQRCodeInImage } from 'react-native-camera-kit-no-google';
try {
const decoded = await detectQRCodeInImage(base64ImageData);
if (decoded === null) {
// Image was valid but no QR code was found
} else {
console.log('QR code:', decoded);
}
} catch (e) {
// Invalid image data or internal detection failure — e.message has details
}
```
Rejection error codes: `E_INVALID_IMAGE` (bad base64 or undecodable image) and `E_QR_DETECTION_FAILED` (Android: QR decoder error; iOS: Vision framework error or detector init failure).
This API is exclusive to this fork and is not available in the original react-native-camera-kit.
It does **not** require camera permissions — it works purely on image data. Useful for detecting QR codes from:
- Images picked from the photo library
- Clipboard images
- Files received via deep links or share extensions
| Platform | Implementation |
|----------|---------------|
| iOS (device) | Vision framework (`VNDetectBarcodesRequest`) |
| iOS (simulator) | CoreImage (`CIDetector`) |
| Android | [limpbrains/qr](https://github.com/limpbrains/qr) |
### Imperative API

View File

@ -66,7 +66,7 @@ dependencies {
// If you want to additionally use the CameraX Extensions library
// implementation "androidx.camera:camera-extensions:${camerax_version}"
implementation 'com.github.limpbrains:qr:v0.0.2'
implementation 'com.github.limpbrains:qr:v0.0.3'
}
repositories {
mavenCentral()

View File

@ -28,6 +28,7 @@ import androidx.lifecycle.LifecycleObserver
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.events.RCTEventEmitter
import com.rncamerakit.barcode.BarcodeFrame
@ -108,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!!
@ -338,8 +340,8 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
val useCases = mutableListOf(preview, imageCapture)
if (scanBarcode) {
val analyzer = QRCodeAnalyzer({ decodedValue ->
if (scanBarcode) {
val analyzer = QRCodeAnalyzer({ decodedValue ->
onBarcodeRead(decodedValue)
}, scanThrottleDelay)
imageAnalyzer!!.setAnalyzer(cameraExecutor, analyzer)
@ -464,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)
@ -684,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()
@ -704,6 +726,10 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs
return false
}
private fun convertAllowedBarcodeTypes(): Set<Int> {
return allowedBarcodeTypes?.map { it.toBarcodeType() }?.toSet() ?: emptySet()
}
companion object {
private const val TAG = "CameraKit"

View File

@ -15,4 +15,18 @@ enum class CodeFormat(val code: String) {
AZTEC("aztec"),
DATA_MATRIX("data-matrix"),
UNKNOWN("unknown");
companion object {
fun fromName(name: String): CodeFormat? {
val normalized = name.trim().lowercase()
return values().firstOrNull { format ->
format.code == normalized || format.name.lowercase() == normalized
}
}
}
}
fun CodeFormat.toBarcodeType(): Int {
// QR-only Android implementation; no barcode type mapping required.
return 0
}

View File

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

View File

@ -150,15 +150,14 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager<CKCa
}
@ReactProp(name = "allowedBarcodeTypes")
override fun setAllowedBarcodeTypes(view: CKCamera?, types: ReadableArray?) {
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 setIOsSleepBeforeStarting(view: CKCamera?, value: Int) = Unit
override fun setRatioOverlay(view: CKCamera?, value: String?) = Unit
override fun setRatioOverlayColor(view: CKCamera?, value: Int?) = Unit
@ -169,5 +168,7 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager<CKCa
override fun setResizeMode(view: CKCamera?, value: String?) = Unit
override fun setIOsDeferredStart(view: CKCamera?, value: Boolean) = Unit
override fun setMaxPhotoQualityPrioritization(view: CKCamera?, value: String?) = Unit
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import React, { useState } from 'react';
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<any>(undefined);
@ -10,6 +11,7 @@ const App = () => {
const [interval, setIntervalId] = useState<number | null>(null);
const [speed, setSpeed] = useState('1000');
const onBack = () => setExample(undefined);
const testStart = useRef(0);
if (example) {
return example;
@ -20,12 +22,9 @@ const App = () => {
<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' }}>
@ -53,12 +52,22 @@ const App = () => {
{
text: 'OK',
onPress: () => {
testStart.current = Date.now();
setTestNo(0);
setIntervalId(
setInterval(() => {
setTestNo((prev) => {
const newR = prev + 1;
if (newR % 2 === 0) {
setExample(<CameraExample key={String(Math.random())} stress onBack={onBack} />);
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);
}

View File

@ -198,6 +198,8 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => {
onReadCode={(event) => {
setScanCount((prev) => prev + 1);
setBarcode(event.nativeEvent.codeStringValue);
console.log('barcode', event.nativeEvent.codeStringValue);
console.log('codeFormat', event.nativeEvent.codeFormat);
}}
/>
</View>
@ -276,8 +278,7 @@ const styles = StyleSheet.create({
backBtnContainer: {
alignItems: 'flex-start',
},
captureButtonContainer: {
},
captureButtonContainer: {},
textNumberContainer: {
position: 'absolute',
top: 0,

View File

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

View File

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

View File

@ -6,18 +6,19 @@
#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)

View File

@ -16,44 +16,47 @@
using namespace facebook::react;
static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
{
static id CKConvertFollyDynamicToId(const folly::dynamic &dyn) {
// I could imagine an implementation which avoids copies by wrapping the
// dynamic in a derived class of NSDictionary. We can do that if profiling
// implies it will help.
switch (dyn.type()) {
case folly::dynamic::NULLT:
return nil;
case folly::dynamic::BOOL:
return dyn.getBool() ? @YES : @NO;
case folly::dynamic::INT64:
return @(dyn.getInt());
case folly::dynamic::DOUBLE:
return @(dyn.getDouble());
case folly::dynamic::STRING:
return [[NSString alloc] initWithBytes:dyn.c_str() length:dyn.size() encoding:NSUTF8StringEncoding];
case folly::dynamic::ARRAY: {
NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn) {
id value = CKConvertFollyDynamicToId(elem);
if (value) {
[array addObject:value];
}
case folly::dynamic::NULLT:
return nil;
case folly::dynamic::BOOL:
return dyn.getBool() ? @YES : @NO;
case folly::dynamic::INT64:
return @(dyn.getInt());
case folly::dynamic::DOUBLE:
return @(dyn.getDouble());
case folly::dynamic::STRING:
return [[NSString alloc] initWithBytes:dyn.c_str()
length:dyn.size()
encoding:NSUTF8StringEncoding];
case folly::dynamic::ARRAY: {
NSMutableArray *array =
[[NSMutableArray alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn) {
id value = CKConvertFollyDynamicToId(elem);
if (value) {
[array addObject:value];
}
return array;
}
case folly::dynamic::OBJECT: {
NSMutableDictionary *dict = [[NSMutableDictionary alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn.items()) {
id key = CKConvertFollyDynamicToId(elem.first);
id value = CKConvertFollyDynamicToId(elem.second);
if (key && value) {
dict[key] = value;
}
return array;
}
case folly::dynamic::OBJECT: {
NSMutableDictionary *dict =
[[NSMutableDictionary alloc] initWithCapacity:dyn.size()];
for (const auto &elem : dyn.items()) {
id key = CKConvertFollyDynamicToId(elem.first);
id value = CKConvertFollyDynamicToId(elem.second);
if (key && value) {
dict[key] = value;
}
return dict;
}
return dict;
}
}
}
@ -61,234 +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"];
}
if (oldViewProps.iOsSleepBeforeStarting != newProps.iOsSleepBeforeStarting) {
_view.iOsSleepBeforeStarting = newProps.iOsSleepBeforeStarting >= 0 ? @(newProps.iOsSleepBeforeStarting) : nil;
[changedProps addObject:@"iOsSleepBeforeStarting"];
}
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 allowedBarcodeTypes = CKConvertFollyDynamicToId(allowedBarcodeTypesDynamic);
if (allowedBarcodeTypes != nil && [allowedBarcodeTypes isKindOfClass:NSArray.class]) {
_view.allowedBarcodeTypes = allowedBarcodeTypes;
[changedProps addObject:@"allowedBarcodeTypes"];
}
}
[super updateProps:props oldProps:oldProps];
[_view didSetProps:changedProps];
}
[super updateProps:props oldProps:oldProps];
[_view didSetProps:changedProps];
}
+ (BOOL)shouldBeRecycled
{
// Disable recycling as cameras are expensive to keep in memory and may cause unintended behaviors
// (we need to reset the camera properly when recycling)
// We can enable it later if find that the performance is needed
return NO;
+ (BOOL)shouldBeRecycled {
// Disable recycling as cameras are expensive to keep in memory and may cause
// unintended behaviors (we need to reset the camera properly when recycling)
// We can enable it later if find that the performance is needed
return NO;
}
- (void)prepareForRecycle
{
[super prepareForRecycle];
[self prepareView];
- (void)prepareForRecycle {
[super prepareForRecycle];
[self prepareView];
}
@end
Class<RCTComponentViewProtocol> CKCameraCls(void)
{
Class<RCTComponentViewProtocol> CKCameraCls(void) {
return CKCameraViewComponentView.class;
}

View File

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

View File

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

View File

@ -4,9 +4,9 @@
//
import AVFoundation
import UIKit
import AVKit
import React
import UIKit
/*
* View abtracting the logic unrelated to the actual camera
@ -22,7 +22,7 @@ public class CameraView: UIView {
// scanner
private var lastBarcodeDetectedTime: TimeInterval = 0
private var scannerInterfaceView: ScannerInterfaceView
// camera
private var ratioOverlayView: RatioOverlayView?
@ -58,11 +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 iOsSleepBeforeStarting: NSNumber?
@objc public var iOsDeferredStart: Bool = true
@objc public var onCaptureButtonPressIn: RCTDirectEventBlock?
@objc public var onCaptureButtonPressOut: RCTDirectEventBlock?
var eventInteraction: Any? = nil
// MARK: - Setup
@ -81,16 +81,22 @@ public class CameraView: UIView {
}
private func setupCamera() {
if hasPropBeenSetup && hasPermissionBeenGranted && !hasCameraBeenSetup {
let convertedAllowedTypes = convertAllowedBarcodeTypes()
let convertedAllowedTypes = convertAllowedBarcodeTypes()
camera.update(iOsSleepBeforeStartingMs: iOsSleepBeforeStarting?.intValue)
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 ? convertedAllowedTypes : [])
// 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 ? convertedAllowedTypes : [])
camera.setup(
cameraType: cameraType,
supportedBarcodeType: scanBarcode && onReadCode != nil
? convertedAllowedTypes : [])
#endif
}
}
@ -103,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),
])
}
@ -115,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()
@ -146,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
}
@ -181,7 +187,7 @@ public class CameraView: UIView {
super.reactSetFrame(frame)
self.updateSubviewsBounds(frame)
}
@objc public func updateSubviewsBounds(_ frame: CGRect) {
camera.previewView.frame = bounds
@ -207,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") {
@ -230,7 +236,7 @@ public class CameraView: UIView {
if changedProps.contains("onZoom") {
camera.update(onZoom: onZoom)
}
if changedProps.contains("resizeMode") {
camera.update(resizeMode: resizeMode)
}
@ -241,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 {
@ -255,26 +262,32 @@ public class CameraView: UIView {
}
// Scanner
if changedProps.contains("scanBarcode") || changedProps.contains("onReadCode") || changedProps.contains("allowedBarcodeTypes") {
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)
})
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)
}
@ -289,8 +302,8 @@ public class CameraView: UIView {
}
// Others
if changedProps.contains("iOsSleepBeforeStarting") {
camera.update(iOsSleepBeforeStartingMs: iOsSleepBeforeStarting?.intValue)
if changedProps.contains("iOsDeferredStart") {
camera.update(iOsDeferredStartEnabled: iOsDeferredStart)
}
if changedProps.contains("focusMode") {
focusInterfaceView.update(focusMode: focusMode)
@ -317,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
@ -345,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
}
@ -359,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)
@ -410,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)
}
@ -425,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)
@ -436,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 {
@ -445,7 +472,7 @@ public class CameraView: UIView {
lastBarcodeDetectedTime = now
onReadCode?(["codeStringValue": barcode,"codeFormat":codeFormat.rawValue])
onReadCode?(["codeStringValue": barcode, "codeFormat": codeFormat.rawValue])
}
private func convertAllowedBarcodeTypes() -> [CodeFormat] {

View File

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

View File

@ -6,9 +6,9 @@
// swiftlint:disable file_length
import AVFoundation
import UIKit
import CoreMotion
import React
import UIKit
/*
* Real camera implementation that uses AVFoundation
@ -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,7 +45,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
private var lastOnZoom: Double?
private var zoom: Double?
private var maxZoom: Double?
private var sleepBeforeStartingMs: Int = 100
private var deferredStartEnabled: Bool = true
// orientation
private var deviceOrientation = UIDeviceOrientation.unknown
@ -59,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")
@ -95,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
}
@ -110,36 +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 {
let delay = self.sleepBeforeStartingMs
// Guard against calling startRunning while commitConfiguration is still finishing.
// See README iOsSleepBeforeStarting for details about preventing occasional crashes.
if delay > 0 {
Thread.sleep(forTimeInterval: Double(delay) / 1000.0)
if self.setupResult == .success {
self.session.startRunning()
}
self.session.startRunning()
}
DispatchQueue.main.async {
self.setVideoOrientationToInterfaceOrientation()
}
DispatchQueue.main.async {
self.setVideoOrientationToInterfaceOrientation()
}
}
}
}
@ -157,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) {
@ -214,20 +216,26 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
self.onZoomCallback = onZoom
}
func update(iOsSleepBeforeStartingMs: Int?) {
let defaultDelayMs = 100
let providedDelay = iOsSleepBeforeStartingMs ?? defaultDelayMs
sleepBeforeStartingMs = max(0, providedDelay)
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 {
@ -239,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 {
@ -266,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()
@ -281,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 }
@ -289,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
}
}
@ -301,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
}
@ -340,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
@ -350,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 {
@ -371,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
@ -380,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 = []
}
@ -421,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 {
@ -429,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)
}
}
}
@ -437,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 {
@ -468,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
@ -485,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
}
@ -515,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
@ -546,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 }
@ -563,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
@ -584,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 {
@ -621,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
}
@ -667,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)
})
}
@ -695,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)
@ -715,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)")
@ -755,10 +850,13 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
var showResumeButton = false
if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue) {
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue)
{
print("Capture session was interrupted with reason \(reason)")
if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient {
if reason == .audioDeviceInUseByAnotherClient
|| reason == .videoDeviceInUseByAnotherClient
{
showResumeButton = true
}
}

View File

@ -4,8 +4,8 @@
//
import AVFoundation
import UIKit
import React
import UIKit
/*
* Fake camera implementation to be used on simulator
@ -37,16 +37,20 @@ class SimulatorCamera: CameraProtocol {
// Listen to orientation changes
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: nil,
using: { [weak self] notification in self?.orientationChanged(notification: notification) })
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
queue: nil,
using: { [weak self] notification in
self?.orientationChanged(notification: notification)
})
}
private func orientationChanged(notification: Notification) {
guard let device = notification.object as? UIDevice,
let orientation = Orientation(from: device.orientation) else {
let orientation = Orientation(from: device.orientation)
else {
return
}
@ -54,7 +58,8 @@ class SimulatorCamera: CameraProtocol {
}
func cameraRemovedFromSuperview() {
NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
NotificationCenter.default.removeObserver(
self, name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
}
@ -66,8 +71,8 @@ class SimulatorCamera: CameraProtocol {
self.onZoom = onZoom
}
func update(iOsSleepBeforeStartingMs: Int?) {
// No-op on simulator; startup delay only applies to real devices.
func update(iOsDeferredStartEnabled: Bool?) {
// Not applicable on simulator; deferred start only matters for real capture outputs.
}
func setVideoDevice(zoomFactor: Double) {
@ -107,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()
}
}
@ -124,7 +131,7 @@ class SimulatorCamera: CameraProtocol {
self.mockPreview.torchModeLabel.text = "Torch mode: \(torchMode)"
}
}
func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) {
}
@ -176,16 +183,21 @@ class SimulatorCamera: CameraProtocol {
self.mockPreview.randomize()
}
}
func isBarcodeScannerEnabled(_ isEnabled: Bool,
supportedBarcodeTypes: [CodeFormat],
onBarcodeRead: ((_ barcode: String,_ codeFormat:CodeFormat) -> Void)?) {}
func isBarcodeScannerEnabled(
_ isEnabled: Bool,
supportedBarcodeTypes: [CodeFormat],
onBarcodeRead: ((_ barcode: String, _ codeFormat: CodeFormat) -> Void)?
) {}
func update(scannerFrameSize: CGRect?) {}
func capturePicture(onWillCapture: @escaping () -> Void,
onSuccess: @escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions) -> Void,
onError: @escaping (_ message: String) -> Void) {
func capturePicture(
onWillCapture: @escaping () -> Void,
onSuccess:
@escaping (_ imageData: Data, _ thumbnailData: Data?, _ dimensions: CMVideoDimensions)
-> Void,
onError: @escaping (_ message: String) -> Void
) {
onWillCapture()
DispatchQueue.main.async {

View File

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

View File

@ -14,7 +14,7 @@ const Camera = React.forwardRef<CameraApi, CameraProps>((props, ref) => {
props.zoom = props.zoom ?? -1;
props.maxZoom = props.maxZoom ?? -1;
props.scanThrottleDelay = props.scanThrottleDelay ?? -1;
props.iOsSleepBeforeStarting = props.iOsSleepBeforeStarting ?? -1;
props.iOsDeferredStart = props.iOsDeferredStart ?? true;
props.allowedBarcodeTypes = props.allowedBarcodeTypes ?? supportedCodeFormats;

View File

@ -113,8 +113,8 @@ export interface CameraProps extends ViewProps {
scanThrottleDelay?: number;
/** **iOS Only**. 'speed' provides 60-80% faster image capturing */
maxPhotoQualityPrioritization?: 'balanced' | 'quality' | 'speed';
/** **iOS Only**. Delay in milliseconds before the camera session starts; default `100`. Set to `0` to skip. Helpful to ensure `session.commitConfiguration()` finishes before `session.startRunning()`, reducing occasional startup crashes when toggling cameras repeatedly. */
iOsSleepBeforeStarting?: number;
/** **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;

View File

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

View File

@ -1,6 +1,7 @@
import { NativeModules } from 'react-native';
import Camera from './Camera';
import NativeCameraKitModule from './specs/NativeCameraKitModule';
import {
CameraType,
type CameraApi,
@ -15,6 +16,10 @@ import {
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, // ⬆️

View File

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

View File

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

2893
yarn.lock

File diff suppressed because it is too large Load Diff