diff --git a/README.md b/README.md
index e57fdd8..e980729 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ yarn add @pushpendersingh/react-native-scanner
cd ios && pod install && cd ..
```
-2. Add camera permission to `Info.plist`:
+1. Add camera permission to `Info.plist`:
```xml
NSCameraUsageDescription
@@ -180,6 +180,37 @@ const styles = StyleSheet.create({
});
```
+### Scanning from Image (Gallery)
+
+You can also scan QR codes and barcodes from existing images (e.g., from the gallery). This library only requires the image file path - it does not handle image picking.
+
+To pick images from the gallery, you'll need a third-party library like [`react-native-image-picker`](https://github.com/react-native-image-picker/react-native-image-picker). Please refer to that library's documentation for setup and required permissions (e.g., `NSPhotoLibraryUsageDescription` for iOS, `READ_MEDIA_IMAGES` for Android).
+
+```tsx
+import { launchImageLibrary } from 'react-native-image-picker';
+import { BarcodeScanner } from '@pushpendersingh/react-native-scanner';
+
+const scanFromGallery = async () => {
+ const result = await launchImageLibrary({ mediaType: 'photo' });
+
+ if (result.assets && result.assets.length > 0) {
+ const imageUri = result.assets[0].uri;
+ if (imageUri) {
+ try {
+ const barcodes = await BarcodeScanner.scanImage(imageUri);
+ if (barcodes.length > 0) {
+ console.log('Found barcode:', barcodes[0].data);
+ } else {
+ console.log('No barcode found');
+ }
+ } catch (error) {
+ console.error('Scan failed:', error);
+ }
+ }
+ }
+};
+```
+
---
## ๐ API Reference
@@ -201,12 +232,45 @@ BarcodeScanner.startScanning((barcodes: BarcodeResult[]) => {
```
**Parameters:**
+
- `callback: (barcodes: BarcodeResult[]) => void` - Called when barcodes are detected
**Returns:** `Promise`
---
+#### `scanImage(imageUri)`
+
+Scans a static image for barcodes. This is useful for scanning QR codes from gallery images, screenshots, or when live camera scanning fails on low-end devices.
+
+**Note:** This method only requires the image file path/URI. It does not handle image picking - you'll need a third-party library like [`react-native-image-picker`](https://github.com/react-native-image-picker/react-native-image-picker) to select images from the gallery.
+
+```typescript
+const barcodes = await BarcodeScanner.scanImage('file:///path/to/image.jpg');
+```
+
+**Parameters:**
+
+- `imageUri: string` - URI of the image file (supports `file://` and `content://` schemes)
+
+**Returns:** `Promise` - Array of detected barcodes
+
+**Supported Image Formats:**
+
+| Format | Android | iOS | Notes |
+|--------|---------|-----|-------|
+| JPEG/JPG | โ
| โ
| Full support |
+| PNG | โ
| โ
| Transparency handled (white background) |
+| WebP | โ
| โ
| iOS 14+ only |
+| GIF | โ ๏ธ | โ ๏ธ | First frame only |
+| BMP | โ
| โ
| Full support |
+| HEIF/HEIC | โ
| โ
| Android 8.0+, iOS 11+ |
+| TIFF | โ | โ
| iOS only |
+
+**Not Supported:** SVG, PDF, RAW (CR2, NEF, etc.), ICO, PSD
+
+---
+
#### `stopScanning()`
Stops the barcode scanning process.
@@ -278,6 +342,7 @@ if (granted) {
**Returns:** `Promise` - `true` if user grants permission, `false` if denied
**Platform Support:**
+
- โ
**iOS**: Fully supported with native callback
- โ
**Android**: Fully supported with native callback (API 23+)
@@ -294,6 +359,7 @@ React component that renders the camera preview.
```
**Props:**
+
- `style?: ViewStyle` - Style for the camera view container
---
@@ -439,6 +505,7 @@ export default function App() {
```
**Key Features:**
+
- โ
**Cross-platform**: Works on both iOS (API 10+) and Android (API 23+)
- โ
**Promise-based**: Returns `true` when granted, `false` when denied
- โ
**Native callbacks**: Waits for actual user response from system dialog
@@ -502,6 +569,7 @@ const requestCameraPermission = async () => {
return result === RESULTS.GRANTED;
};
```
+
---
## ๐ Supported Barcode Formats
@@ -555,10 +623,12 @@ This library supports a wide range of barcode formats across different categorie
### Camera Preview Not Showing
**iOS:**
+
- Check camera permission in `Info.plist`
- Ensure you're running on a physical device (simulator doesn't have camera)
**Android:**
+
- Check camera permission in `AndroidManifest.xml`
- Verify Google Play Services is installed
@@ -570,6 +640,7 @@ This library supports a wide range of barcode formats across different categorie
- Verify barcode is not damaged or distorted
**Tips for scanning IMEI:**
+
- Ensure the IMEI barcode is clean and undamaged
- Use good lighting (enable flashlight if needed)
- Hold device steady at 10-15cm distance from the barcode
@@ -578,11 +649,13 @@ This library supports a wide range of barcode formats across different categorie
### Build Issues
**iOS:**
+
```bash
cd ios && pod deintegrate && pod install && cd ..
```
**Android:**
+
```bash
cd android && ./gradlew clean && cd ..
```
@@ -615,7 +688,7 @@ We're constantly working to improve this library. Here are some planned enhancem
### Planned Features
- [ ] **Barcode Generation** - Add ability to generate barcodes/QR codes
-- [ ] **Image Analysis** - Support scanning barcodes from gallery images
+- [x] **Image Analysis** - Support scanning barcodes from gallery images
- [ ] **Advanced Camera Controls** - Zoom, focus, and exposure controls
---
diff --git a/WIKI.md b/WIKI.md
new file mode 100644
index 0000000..056aeec
--- /dev/null
+++ b/WIKI.md
@@ -0,0 +1,381 @@
+# @pushpendersingh/react-native-scanner - Project Wiki
+
+## Overview
+
+This is a React Native library for scanning QR codes and barcodes. It's built with the **New Architecture** (Turbo Modules + Fabric) and supports both iOS and Android platforms.
+
+**Version:** 3.0.0
+**Minimum React Native:** 0.80+
+**Languages:** TypeScript, Kotlin (Android), Swift/Objective-C++ (iOS)
+
+---
+
+## Architecture
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ JavaScript Layer โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ src/index.tsx - BarcodeScanner class (main API) โ
+โ src/CameraView.tsx - React component for camera preview โ
+โ src/NativeReactNativeScanner.ts - TurboModule spec (codegen) โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโดโโโโโโโโโโโโ
+ โ React Native Bridge โ
+ โ (Turbo Modules) โ
+ โโโโโโโโโโโโโฌโโโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ โ
+โโโโโโโโโดโโโโโโโโ โโโโโโโโโดโโโโโโโโ
+โ Android โ โ iOS โ
+โโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโค
+โ ReactNative- โ โ ReactNative- โ
+โ ScannerModule โ โ Scanner.mm โ
+โ (.kt) โ โ (Obj-C++) โ
+โโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโค
+โ CameraManager โ โ CameraManager โ
+โ (.kt) โ โ (.swift) โ
+โโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโค
+โ CameraX + โ โ AVFoundation +โ
+โ ML Kit โ โ Vision โ
+โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
+```
+
+---
+
+## Project Structure
+
+```
+react-native-scanner/
+โโโ src/ # TypeScript source code
+โ โโโ index.tsx # Main exports & BarcodeScanner class
+โ โโโ CameraView.tsx # Camera preview component
+โ โโโ NativeReactNativeScanner.ts# TurboModule interface spec
+โ โโโ ReactNativeScannerViewNativeComponent.ts
+โ โโโ __tests__/ # Jest unit tests
+โ
+โโโ android/ # Android native code
+โ โโโ src/main/java/.../
+โ โโโ ReactNativeScannerModule.kt # Turbo Module impl
+โ โโโ ReactNativeScannerViewManager.kt # Fabric View Manager
+โ โโโ ReactNativeScannerView.kt # Native View
+โ โโโ ReactNativeScannerPackage.kt # Package registration
+โ โโโ CameraManager.kt # Camera & ML Kit logic
+โ
+โโโ ios/ # iOS native code
+โ โโโ ReactNativeScanner.mm # Turbo Module impl (Obj-C++)
+โ โโโ ReactNativeScanner.h # Header file
+โ โโโ ReactNativeScanner-Bridging-Header.h # Swift-ObjC bridging
+โ โโโ CameraViewManager.mm # Fabric View Manager (Obj-C++)
+โ โโโ CameraManager.swift # Camera & Vision logic
+โ โโโ CameraView.swift # Native UIView wrapper
+โ
+โโโ example/ # Example React Native app
+โ โโโ src/App.tsx # Demo implementation
+โ
+โโโ lib/ # Compiled output (generated)
+โโโ package.json # Package configuration
+```
+
+---
+
+## Key Components
+
+### 1. BarcodeScanner Class (`src/index.tsx`)
+
+The main API exposed to JavaScript. It's a static class that wraps native module calls.
+
+**Methods:**
+| Method | Description |
+|--------|-------------|
+| `startScanning(callback)` | Starts camera scanning, calls callback on detection |
+| `stopScanning()` | Stops the scanning process |
+| `enableFlashlight()` | Turns on camera torch |
+| `disableFlashlight()` | Turns off camera torch |
+| `releaseCamera()` | Releases all camera resources |
+| `hasCameraPermission()` | Checks if camera permission granted |
+| `requestCameraPermission()` | Requests camera permission from user |
+| `scanImage(imageUri)` | Scans a static image for barcodes |
+
+### 2. CameraView Component (`src/CameraView.tsx`)
+
+A React component that renders the native camera preview. It's a Fabric Native Component.
+
+```tsx
+
+```
+
+### 3. TurboModule Spec (`src/NativeReactNativeScanner.ts`)
+
+Defines the interface between JavaScript and native code using React Native's Codegen system.
+
+```typescript
+export interface Spec extends TurboModule {
+ startScanning(): Promise;
+ stopScanning(): Promise;
+ // ... other methods
+ readonly onBarcodeScanned: CodegenTypes.EventEmitter;
+}
+```
+
+---
+
+## Native Implementation Details
+
+### Android (Kotlin)
+
+**Key files:**
+- `ReactNativeScannerModule.kt` - Implements TurboModule spec
+- `CameraManager.kt` - Core camera and barcode scanning logic
+
+**Technologies used:**
+- **CameraX 1.5.0** - Modern Android camera API with lifecycle awareness
+- **ML Kit Barcode Scanning 17.3.0** - Google's ML-powered barcode detection
+
+**Thread Safety:**
+- Uses `@Volatile` for visibility of shared state
+- Uses `AtomicBoolean` and `AtomicReference` for lock-free atomic operations
+- Uses `ReentrantLock` with `withLock` for synchronized camera binding
+- Ensures safe concurrent access to camera state
+
+### iOS (Swift + Objective-C++)
+
+**Key files:**
+- `ReactNativeScanner.mm` - Objective-C++ bridge to TurboModule
+- `CameraManager.swift` - Core camera and barcode scanning logic
+- `CameraView.swift` - Native UIView for camera preview
+
+**Technologies used:**
+- **AVFoundation** - Native camera framework
+- **Vision Framework** - Apple's barcode detection (primary)
+- **CIDetector** - Core Image QR code detection (fallback for image scanning)
+
+**Thread Safety:**
+- Uses Swift Actors (`CameraSessionActor`, `CallbackActor`) for isolated state management
+- Actors provide automatic serialization of concurrent access
+- Uses `DispatchQueue` for session operations on dedicated background queue
+- Ensures thread-safe operations across async/await boundaries
+
+---
+
+## Data Flow
+
+### Scanning Flow
+
+```
+1. User calls BarcodeScanner.startScanning(callback)
+ โ
+ โผ
+2. JS sets up event listener for 'onBarcodeScanned'
+ โ
+ โผ
+3. Native module starts camera preview
+ โ
+ โผ
+4. CameraManager processes camera frames
+ โ
+ โผ
+5. ML Kit (Android) / Vision (iOS) detects barcode
+ โ
+ โผ
+6. Native emits 'onBarcodeScanned' event with result
+ โ
+ โผ
+7. JS callback receives BarcodeResult[]
+```
+
+### Image Scanning Flow
+
+```
+1. User calls BarcodeScanner.scanImage(imageUri)
+ โ
+ โผ
+2. Native loads image from URI
+ โ
+ โผ
+3. ML Kit / Vision processes static image
+ โ
+ โผ
+4. Promise resolves with BarcodeResult[]
+```
+
+---
+
+## Types
+
+### BarcodeResult
+
+```typescript
+interface BarcodeResult {
+ data: string; // Decoded barcode content
+ type: BarcodeType; // Format type (QR_CODE, EAN_13, etc.)
+ bounds?: { // Optional bounding box
+ width: number;
+ height: number;
+ origin: {
+ topLeft: { x: number; y: number };
+ bottomLeft: { x: number; y: number };
+ bottomRight: { x: number; y: number };
+ topRight: { x: number; y: number };
+ };
+ };
+}
+```
+
+### BarcodeType
+
+Supported barcode formats:
+
+| Category | Formats | Platform Notes |
+|----------|---------|----------------|
+| **2D Codes** | QR_CODE, DATA_MATRIX, AZTEC, PDF417 | Both platforms |
+| **1D Product** | EAN_13, EAN_8, UPC_E | Both platforms |
+| **1D Product** | UPC_A | Android only |
+| **1D Industrial** | CODE_128, CODE_39, CODE_93, CODABAR, ITF | Both platforms |
+| **Other** | UNKNOWN | Fallback type |
+
+**Note:** iOS uses Vision framework's `.itf14` symbology which maps to "ITF" type.
+
+---
+
+## Build System
+
+### Codegen Configuration (`package.json`)
+
+```json
+{
+ "codegenConfig": {
+ "name": "ReactNativeScannerSpec",
+ "type": "modules",
+ "jsSrcsDir": "src",
+ "android": {
+ "javaPackageName": "com.pushpendersingh.reactnativescanner"
+ }
+ }
+}
+```
+
+### Build Output
+
+Uses `react-native-builder-bob` to compile:
+- ESM modules to `lib/module/`
+- TypeScript declarations to `lib/typescript/`
+
+---
+
+## Testing
+
+**Framework:** Jest with React Native preset
+
+**Run tests:**
+```bash
+yarn test
+```
+
+**Test coverage:**
+- BarcodeScanner method calls
+- Event listener management
+- Permission handling
+- Multiple barcode type support
+
+---
+
+## Development Scripts
+
+| Script | Description |
+|--------|-------------|
+| `yarn test` | Run Jest tests |
+| `yarn typecheck` | TypeScript type checking |
+| `yarn lint` | ESLint code linting |
+| `yarn prepare` | Build library with bob |
+| `yarn clean` | Clean build artifacts |
+| `yarn example` | Run example app commands |
+
+---
+
+## Dependencies
+
+### Runtime
+- No external JS dependencies (peer deps: `react`, `react-native`)
+
+### Development
+- TypeScript 5.9+
+- ESLint 9.x
+- Jest 29.x
+- react-native-builder-bob
+- release-it (for publishing)
+
+### Native (Android)
+- CameraX 1.5.0
+- ML Kit Barcode Scanning 17.3.0
+
+### Native (iOS)
+- AVFoundation (system framework)
+- Vision (system framework)
+
+---
+
+## Lifecycle Management
+
+The library handles camera lifecycle automatically:
+
+1. **Module Creation** - CameraManager initialized
+2. **Start Scanning** - Camera session starts, preview begins
+3. **Stop Scanning** - Scanning paused, camera may stay active
+4. **Release Camera** - Full cleanup of camera resources
+5. **Module Invalidation** - Automatic cleanup on unmount
+
+**Best Practice:**
+```tsx
+useEffect(() => {
+ BarcodeScanner.startScanning(handleBarcode);
+
+ return () => {
+ BarcodeScanner.stopScanning();
+ BarcodeScanner.releaseCamera();
+ };
+}, []);
+```
+
+---
+
+## Error Handling
+
+Native errors are propagated to JavaScript with error codes:
+
+| Error Code | Description |
+|------------|-------------|
+| `PERMISSION_DENIED` | Camera permission not granted |
+| `START_SCANNING_ERROR` | Failed to start camera |
+| `STOP_SCANNING_ERROR` | Failed to stop scanning |
+| `FLASHLIGHT_ERROR` | Flashlight toggle failed |
+| `RELEASE_CAMERA_ERROR` | Camera cleanup failed |
+| `SCAN_IMAGE_ERROR` | Image scanning failed |
+
+---
+
+## Platform-Specific Notes
+
+### iOS
+- Requires physical device (simulator has no camera)
+- Info.plist permission required: `NSCameraUsageDescription`
+- Minimum iOS version determined by Vision framework availability
+
+**Note:** If using `scanImage()` with gallery images, you'll need a third-party image picker library (e.g., `react-native-image-picker`) which may require additional permissions like `NSPhotoLibraryUsageDescription`. See that library's documentation for details.
+
+### Android
+- Requires Google Play Services for ML Kit
+- Manifest permission required: `CAMERA`
+- API 23+ for runtime permissions
+
+**Note:** If using `scanImage()` with gallery images, you'll need a third-party image picker library (e.g., `react-native-image-picker`) which may require additional permissions like `READ_MEDIA_IMAGES` or `READ_EXTERNAL_STORAGE`. See that library's documentation for details.
+
+---
+
+## Further Reading
+
+- [README.md](./README.md) - Installation and usage guide
+- [CONTRIBUTING.md](./CONTRIBUTING.md) - Contribution guidelines
+- [example/src/App.tsx](./example/src/App.tsx) - Full example implementation
diff --git a/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt
index ebcb9e5..b6bea1b 100644
--- a/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt
+++ b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt
@@ -3,7 +3,13 @@ package com.pushpendersingh.reactnativescanner
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
-import android.graphics.Rect
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.media.ExifInterface
+import android.net.Uri
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraControl
@@ -23,8 +29,11 @@ import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.ThreadFactory
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -43,21 +52,26 @@ class CameraManager(private val reactContext: ReactApplicationContext) {
private var preview: Preview? = null
private var previewView: PreviewView? = null
- // AtomicBoolean for lock-free scanning flag
private val isScanning = AtomicBoolean(false)
- // AtomicReference for thread-safe callback
private val scanCallbackRef = AtomicReference<((WritableArray) -> Unit)?>(null)
- // Lock for synchronizing camera binding operations
private val cameraBindLock = ReentrantLock()
- // Flag to prevent concurrent binding
@Volatile
private var isBinding = false
- // Lock for executor lifecycle management
private val executorLock = ReentrantLock()
companion object {
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
+ private const val MAX_BITMAP_PIXELS = 4_000_000 // ~4MP (2000ร2000) for first attempt
+ private const val FALLBACK_MAX_PIXELS = 8_000_000 // ~8MP for retry with higher resolution
+ private const val MAX_CLEANUP_RETRIES = 10 // Max retries for performFullCleanup
+ private val threadCount = AtomicInteger(0)
+ }
+
+ private val executorThreadFactory = ThreadFactory { r ->
+ Thread(r, "CameraManager-${threadCount.incrementAndGet()}").apply {
+ isDaemon = true
+ }
}
fun hasCameraPermission(): Boolean {
@@ -86,7 +100,7 @@ class CameraManager(private val reactContext: ReactApplicationContext) {
private fun ensureExecutor() {
executorLock.withLock {
if (cameraExecutor.isShutdown) {
- cameraExecutor = Executors.newSingleThreadExecutor()
+ cameraExecutor = Executors.newSingleThreadExecutor(executorThreadFactory)
Log.d(TAG, "โป๏ธ Recreated camera executor")
}
}
@@ -421,37 +435,344 @@ class CameraManager(private val reactContext: ReactApplicationContext) {
}
}
- fun releaseCamera() {
+ /**
+ * All file operations are performed on background thread.
+ * Callback is always dispatched to main thread for React Native compatibility.
+ */
+ fun scanImage(imageUri: String, callback: (WritableArray) -> Unit) {
+ // Validate imageUri before processing
+ if (imageUri.isBlank()) {
+ Log.w(TAG, "scanImage called with blank imageUri")
+ ContextCompat.getMainExecutor(reactContext).execute {
+ callback(Arguments.createArray())
+ }
+ return
+ }
+
+ // Ensure executor is available
+ ensureExecutor()
+
+ // Execute all file I/O on background thread
+ // Wrap in try-catch to handle race condition where executor could be shutdown
+ // between ensureExecutor() and execute()
+ try {
+ cameraExecutor.execute {
+ try {
+ val uri = if (imageUri.startsWith("file://")) {
+ val path = imageUri.replace("file://", "")
+ Uri.fromFile(java.io.File(path))
+ } else {
+ Uri.parse(imageUri)
+ }
+
+ Log.d(TAG, "Scanning image from URI: $uri")
+
+ // Try InputImage.fromFilePath
+ // It handles Exif and memory efficiently
+ try {
+ val image = com.google.mlkit.vision.common.InputImage.fromFilePath(reactContext, uri)
+ processImageOnBackground(image, null, uri, false, callback)
+ } catch (e: java.io.IOException) {
+ Log.w(TAG, "InputImage.fromFilePath failed (${e.message}), falling back to Bitmap loader")
+ // Fallback to manual Bitmap loading with size limits
+ scanImageWithRetry(uri, callback, isRetry = false)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Scan image failed", e)
+ // Always callback on main thread, even on error
+ ContextCompat.getMainExecutor(reactContext).execute {
+ callback(Arguments.createArray())
+ }
+ }
+ }
+ } catch (e: RejectedExecutionException) {
+ Log.w(TAG, "Executor was shutdown, cannot scan image: ${e.message}")
+ ContextCompat.getMainExecutor(reactContext).execute {
+ callback(Arguments.createArray())
+ }
+ }
+ }
+
+ /**
+ * Retry mechanism with higher resolution if first scan fails
+ * First attempt uses MAX_BITMAP_PIXELS (4MP), retry uses FALLBACK_MAX_PIXELS (8MP)
+ */
+ private fun scanImageWithRetry(
+ uri: Uri,
+ callback: (WritableArray) -> Unit,
+ isRetry: Boolean = false
+ ) {
+ val maxPixels = if (isRetry) FALLBACK_MAX_PIXELS else MAX_BITMAP_PIXELS
+ val bitmap = loadBitmap(uri, maxPixels)
+
+ if (bitmap != null) {
+ Log.d(TAG, "Bitmap loaded: ${bitmap.width}x${bitmap.height}, isRetry=$isRetry")
+ val image = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0)
+ processImageOnBackground(image, bitmap, uri, isRetry, callback)
+ } else {
+ Log.e(TAG, "Failed to load bitmap from URI")
+ // Always callback on main thread
+ ContextCompat.getMainExecutor(reactContext).execute {
+ callback(Arguments.createArray())
+ }
+ }
+ }
+
+ /**
+ * Process image for barcode scanning (for static images).
+ * Includes retry logic if no barcodes found on first attempt.
+ * Always calls callback, even on error.
+ * Callback is always dispatched to main thread.
+ */
+ private fun processImageOnBackground(
+ image: com.google.mlkit.vision.common.InputImage,
+ bitmapToRecycle: Bitmap?,
+ uri: Uri,
+ isRetry: Boolean,
+ callback: (WritableArray) -> Unit
+ ) {
+ val scanner = BarcodeScanning.getClient(
+ BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
+ .build()
+ )
+
+ // Flag to track if we're retrying (to avoid double cleanup in onCompleteListener)
+ var didRetry = false
+
+ scanner.process(image)
+ .addOnSuccessListener { barcodes ->
+ val results = Arguments.createArray()
+ for (barcode in barcodes) {
+ if (!barcode.rawValue.isNullOrEmpty()) {
+ results.pushMap(createBarcodeResult(barcode))
+ }
+ }
+ Log.d(TAG, "Scan complete. Found ${barcodes.size} barcodes.")
+
+ // Retry with higher resolution if no results and not already retrying
+ if (results.size() == 0 && !isRetry && bitmapToRecycle != null) {
+ Log.d(TAG, "No barcodes found, retrying with higher resolution")
+ didRetry = true
+ // Recycle current bitmap before retry
+ if (!bitmapToRecycle.isRecycled) {
+ bitmapToRecycle.recycle()
+ }
+ scanner.close()
+ scanImageWithRetry(uri, callback, isRetry = true)
+ } else {
+ // Dispatch callback to main thread
+ ContextCompat.getMainExecutor(reactContext).execute {
+ callback(results)
+ }
+ }
+ }
+ .addOnFailureListener { e ->
+ Log.e(TAG, "Barcode scanning failed: ${e.message}", e)
+ // Always callback on main thread
+ ContextCompat.getMainExecutor(reactContext).execute {
+ callback(Arguments.createArray())
+ }
+ }
+ .addOnCompleteListener {
+ // Only cleanup if we didn't retry (retry handles its own cleanup)
+ if (!didRetry) {
+ scanner.close()
+ // Recycle bitmap after processing to free memory
+ bitmapToRecycle?.let { bitmap ->
+ if (!bitmap.isRecycled) {
+ bitmap.recycle()
+ Log.d(TAG, "Recycled bitmap after image processing")
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Load bitmap with size limits to prevent OOM.
+ * Uses two-pass decoding: first decode bounds only, then decode with sample size.
+ * @param uri The URI of the image to load
+ * @param maxPixels Maximum pixels allowed (width * height)
+ */
+ private fun loadBitmap(uri: Uri, maxPixels: Int = MAX_BITMAP_PIXELS): Bitmap? {
try {
- Log.d(TAG, "Releasing camera resources...")
+ // Decode bounds only (no memory allocation)
+ val options = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ reactContext.contentResolver.openInputStream(uri)?.use { stream ->
+ BitmapFactory.decodeStream(stream, null, options)
+ }
- // Stop scanning first using atomic operation
- if (isScanning.compareAndSet(true, false)) {
- scanCallbackRef.set(null)
+ if (options.outWidth <= 0 || options.outHeight <= 0) {
+ Log.e(TAG, "Failed to decode image bounds")
+ return null
+ }
+
+ // Calculate sample size for memory efficiency
+ // Use Long arithmetic to prevent integer overflow for very large images
+ // (e.g., 50000ร50000 = 2.5 billion pixels exceeds Int.MAX_VALUE)
+ val currentPixels = options.outWidth.toLong() * options.outHeight.toLong()
+ options.inSampleSize = if (currentPixels > maxPixels) {
+ calculateInSampleSize(options.outWidth, options.outHeight, maxPixels)
+ } else {
+ 1
+ }
+ options.inJustDecodeBounds = false
+ options.inPreferredConfig = Bitmap.Config.ARGB_8888
+
+ Log.d(TAG, "Loading bitmap: ${options.outWidth}x${options.outHeight}, sampleSize=${options.inSampleSize}")
+
+ // Decode with sample size
+ val originalBitmap = reactContext.contentResolver.openInputStream(uri)?.use { stream ->
+ BitmapFactory.decodeStream(stream, null, options)
}
- // Wait for binding to complete WITHOUT holding the lock
- // This prevents deadlock when startScanning() is called during release
- var attempts = 0
- while (isBinding && attempts < 100) { // Max wait: 5 seconds
- Log.d(TAG, "โณ Waiting for camera binding to complete before release...")
- Thread.sleep(50)
- attempts++
+ if (originalBitmap == null) {
+ Log.e(TAG, "Failed to decode bitmap")
+ return null
}
+
+ // Handle Rotation
+ // postRotate() rotates around origin (0,0), so we need to translate
+ // the rotated image back into the visible canvas area
+ val rotation = getRotation(uri)
+ val matrix = Matrix()
+ when (rotation) {
+ 90 -> {
+ matrix.postRotate(90f)
+ matrix.postTranslate(originalBitmap.height.toFloat(), 0f)
+ }
+ 180 -> {
+ matrix.postRotate(180f)
+ matrix.postTranslate(originalBitmap.width.toFloat(), originalBitmap.height.toFloat())
+ }
+ 270 -> {
+ matrix.postRotate(270f)
+ matrix.postTranslate(0f, originalBitmap.width.toFloat())
+ }
+ }
+
+ // Handle Transparency: Draw on white background
+ val newBitmap = Bitmap.createBitmap(
+ if (rotation % 180 == 0) originalBitmap.width else originalBitmap.height,
+ if (rotation % 180 == 0) originalBitmap.height else originalBitmap.width,
+ Bitmap.Config.ARGB_8888
+ )
- if (isBinding) {
- Log.w(TAG, "โ ๏ธ Binding still in progress after 5s, forcing release")
+ val canvas = Canvas(newBitmap)
+ canvas.drawColor(Color.WHITE)
+ canvas.drawBitmap(originalBitmap, matrix, null)
+
+ // Recycle original bitmap as it's no longer needed
+ originalBitmap.recycle()
+
+ return newBitmap
+ } catch (e: Exception) {
+ Log.e(TAG, "Error loading bitmap", e)
+ return null
+ }
+ }
+
+ /**
+ * Calculate inSampleSize to downsample image to target pixel count.
+ * Uses power of 2 sampling for efficient decoding.
+ */
+ private fun calculateInSampleSize(width: Int, height: Int, maxPixels: Int): Int {
+ // Use Long arithmetic to prevent integer overflow for very large images
+ val pixels = width.toLong() * height.toLong()
+ var inSampleSize = 1
+ while ((pixels / (inSampleSize * inSampleSize)) > maxPixels) {
+ inSampleSize *= 2
+ }
+ return inSampleSize
+ }
+
+ private fun getRotation(uri: Uri): Int {
+ try {
+ val exifInterface = if (uri.scheme == "file") {
+ uri.path?.let { ExifInterface(it) }
+ } else {
+ // For content://, use inputStream (API 24+) or fallback to 0
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+ reactContext.contentResolver.openInputStream(uri)?.use {
+ ExifInterface(it)
+ }
+ } else {
+ null
+ }
}
- // Now safely unbind with lock (binding should be complete)
- cameraBindLock.withLock {
- // Double-check binding state and unbind
- if (isBinding) {
- Log.w(TAG, "โ ๏ธ Binding flag still set, unbinding anyway")
+ return when (exifInterface?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
+ ExifInterface.ORIENTATION_ROTATE_90 -> 90
+ ExifInterface.ORIENTATION_ROTATE_180 -> 180
+ ExifInterface.ORIENTATION_ROTATE_270 -> 270
+ else -> 0
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to read Exif", e)
+ return 0
+ }
+ }
+
+
+ /**
+ * Release camera resources asynchronously.
+ * Full cleanup INCLUDING executor shutdown.
+ * Executor shutdown is scheduled on main thread to avoid deadlock.
+ */
+ fun releaseCamera() {
+ Log.d(TAG, "๐งน releaseCamera() called")
+
+ // Stop scanning first using atomic operation
+ if (isScanning.compareAndSet(true, false)) {
+ scanCallbackRef.set(null)
+ }
+
+ // Clear the analyzer immediately to stop processing new frames
+ imageAnalysis?.clearAnalyzer()
+
+ // Schedule cleanup on main thread (non-blocking)
+ ContextCompat.getMainExecutor(reactContext).execute {
+ performFullCleanup()
+ }
+ }
+
+ /**
+ * Full cleanup on main thread including executor shutdown.
+ * Guards against race condition: if scanning restarted, skip cleanup.
+ */
+ private fun performFullCleanup(retryCount: Int = 0) {
+ try {
+ // Race condition guard: if scanning restarted, skip this cleanup
+ if (isScanning.get()) {
+ Log.d(TAG, "โญ๏ธ Skipping cleanup - scanning was restarted")
+ return
+ }
+
+ // Handle binding in progress with retry and max retry limit
+ if (isBinding) {
+ if (retryCount >= MAX_CLEANUP_RETRIES) {
+ Log.e(TAG, "โ Max cleanup retries ($MAX_CLEANUP_RETRIES) reached, forcing cleanup")
+ // Force reset isBinding flag and continue with cleanup
+ cameraBindLock.withLock {
+ isBinding = false
+ }
+ } else {
+ Log.d(TAG, "โณ Binding in progress, scheduling delayed cleanup (retry ${retryCount + 1}/$MAX_CLEANUP_RETRIES)")
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
+ performFullCleanup(retryCount + 1)
+ }, 100)
+ return
}
+ }
+
+ // Unbind camera
+ cameraBindLock.withLock {
cameraProvider?.unbindAll()
}
-
+
// Clear all references
cameraProvider = null
cameraControl = null
@@ -459,30 +780,30 @@ class CameraManager(private val reactContext: ReactApplicationContext) {
preview = null
previewView = null
scanCallbackRef.set(null)
-
+
// Close the barcode scanner
scanner?.close()
scanner = null
-
- // Use dedicated executor lock for shutdown
- // This prevents race with ensureExecutor() and getExecutorSafely()
- executorLock.withLock {
- if (!cameraExecutor.isShutdown) {
- cameraExecutor.shutdown()
- try {
- if (!cameraExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
+
+ // Shutdown executor on a separate thread to avoid blocking main thread
+ Thread {
+ executorLock.withLock {
+ if (!cameraExecutor.isShutdown) {
+ cameraExecutor.shutdown()
+ try {
+ if (!cameraExecutor.awaitTermination(2, TimeUnit.SECONDS)) {
+ cameraExecutor.shutdownNow()
+ Log.w(TAG, "โ ๏ธ Executor forced shutdown")
+ }
+ } catch (e: InterruptedException) {
cameraExecutor.shutdownNow()
- Log.w(TAG, "โ ๏ธ Executor did not terminate gracefully, forced shutdown")
+ Thread.currentThread().interrupt()
}
- } catch (e: InterruptedException) {
- cameraExecutor.shutdownNow()
- Thread.currentThread().interrupt()
- Log.w(TAG, "โ ๏ธ Executor shutdown interrupted")
}
}
- }
-
- Log.d(TAG, "โ
Camera resources released successfully")
+ Log.d(TAG, "โ
Camera fully released (including executor)")
+ }.start()
+
} catch (e: Exception) {
Log.e(TAG, "โ Error releasing camera: ${e.message}", e)
}
diff --git a/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt b/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt
index c9a5692..5f164d4 100644
--- a/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt
+++ b/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt
@@ -21,6 +21,24 @@ class ReactNativeScannerModule(reactContext: ReactApplicationContext) :
private val cameraManager: CameraManager = CameraManager(reactContext)
private var permissionPromise: Promise? = null
+ // Track permission request time for timeout-based cleanup
+ private var permissionRequestTime: Long = 0
+ private val PERMISSION_TIMEOUT_MS = 60_000L // 1 minute timeout
+
+ // Lifecycle listener to clean up permission promise on activity destroy
+ private val lifecycleEventListener = object : com.facebook.react.bridge.LifecycleEventListener {
+ override fun onHostResume() {}
+ override fun onHostPause() {}
+ override fun onHostDestroy() {
+ permissionPromise?.reject("ACTIVITY_DESTROYED", "Activity was destroyed before permission result")
+ permissionPromise = null
+ }
+ }
+
+ init {
+ reactContext.addLifecycleEventListener(lifecycleEventListener)
+ }
+
private val permissionListener = PermissionListener { requestCode, permissions, grantResults ->
if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
// Validate that we're handling the correct permission
@@ -126,6 +144,16 @@ class ReactNativeScannerModule(reactContext: ReactApplicationContext) :
return
}
+ // Clear stale permission promise based on timeout
+ if (permissionPromise != null) {
+ val elapsed = System.currentTimeMillis() - permissionRequestTime
+ if (elapsed > PERMISSION_TIMEOUT_MS) {
+ android.util.Log.w(NAME, "Clearing stale permission promise after ${elapsed}ms")
+ permissionPromise?.reject("PERMISSION_TIMEOUT", "Permission request timed out")
+ permissionPromise = null
+ }
+ }
+
// Check if there's already a pending permission request
if (permissionPromise != null) {
promise.reject(
@@ -137,6 +165,7 @@ class ReactNativeScannerModule(reactContext: ReactApplicationContext) :
// Store promise to be resolved in permission callback
permissionPromise = promise
+ permissionRequestTime = System.currentTimeMillis() // Track request time
// Request permission using PermissionAwareActivity
val permissionAwareActivity = currentActivity as? PermissionAwareActivity
@@ -156,9 +185,22 @@ class ReactNativeScannerModule(reactContext: ReactApplicationContext) :
}
}
+ @ReactMethod
+ override fun scanImage(imageUri: String, promise: Promise) {
+ try {
+ cameraManager.scanImage(imageUri) { result ->
+ promise.resolve(result)
+ }
+ } catch (e: Exception) {
+ promise.reject("SCAN_IMAGE_ERROR", e.message, e)
+ }
+ }
+
override fun invalidate() {
super.invalidate()
+ permissionPromise?.reject("MODULE_INVALIDATED", "Scanner module was invalidated")
permissionPromise = null
+ reactApplicationContext.removeLifecycleEventListener(lifecycleEventListener)
cameraManager.releaseCamera()
}
diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
index e134f41..6bec511 100644
--- a/example/android/app/src/main/AndroidManifest.xml
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index cd12ac4..15d8720 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -1748,6 +1748,34 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- SocketRocket
+ - react-native-image-picker (8.2.1):
+ - 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-native-safe-area-context (5.6.1):
- boost
- DoubleConversion
@@ -2340,7 +2368,7 @@ PODS:
- React-perflogger (= 0.81.1)
- React-utils (= 0.81.1)
- SocketRocket
- - ReactNativeScanner (2.0.1):
+ - ReactNativeScanner (3.1.0-beta.1):
- boost
- DoubleConversion
- fast_float
@@ -2441,6 +2469,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-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
@@ -2563,6 +2592,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-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple:
@@ -2673,6 +2704,7 @@ SPEC CHECKSUMS:
React-logger: 7aef4d74123e5e3d267e5af1fbf5135b5a0d8381
React-Mapbuffer: 91e0eab42a6ae7f3e34091a126d70fc53bd3823e
React-microtasksnativemodule: 1ead4fe154df3b1ba34b5a9e35ef3c4bdfa72ccb
+ react-native-image-picker: 0314366753615115fa55c3cc937ac44cb7e75702
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
React-NativeModulesApple: eff2eba56030eb0d107b1642b8f853bc36a833ac
React-oscompat: b12c633e9c00f1f99467b1e0e0b8038895dae436
@@ -2704,7 +2736,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 3eb9096cb139eb433965693bbe541d96eb3d3ec9
ReactCodegen: 4d203eddf6f977caa324640a20f92e70408d648b
ReactCommon: ce5d4226dfaf9d5dacbef57b4528819e39d3a120
- ReactNativeScanner: df211791142d25d9f5a0cc56790172b0b81bb7ad
+ ReactNativeScanner: 0025511e6da1e8abc603ac9e582218b395021873
RNPermissions: 380b0ddaff0bba3d4d0bbe4ed402044bc695752b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 11c9686a21e2cd82a094a723649d9f4507200fb0
diff --git a/example/ios/ReactNativeScannerExample/Info.plist b/example/ios/ReactNativeScannerExample/Info.plist
index 647ddaf..12f6a11 100644
--- a/example/ios/ReactNativeScannerExample/Info.plist
+++ b/example/ios/ReactNativeScannerExample/Info.plist
@@ -51,5 +51,7 @@
NSCameraUsageDescription
We need access to your camera to scan barcodes.
+ NSPhotoLibraryUsageDescription
+ We need access to your photo library to scan barcodes from images.
diff --git a/example/ios/ReactNativeScannerExample/PrivacyInfo.xcprivacy b/example/ios/ReactNativeScannerExample/PrivacyInfo.xcprivacy
index bad3276..d6dd5b2 100644
--- a/example/ios/ReactNativeScannerExample/PrivacyInfo.xcprivacy
+++ b/example/ios/ReactNativeScannerExample/PrivacyInfo.xcprivacy
@@ -18,6 +18,7 @@
NSPrivacyAccessedAPITypeReasons
C617.1
+ 3B52.1
diff --git a/example/package.json b/example/package.json
index 4bbfaaa..8984123 100644
--- a/example/package.json
+++ b/example/package.json
@@ -17,6 +17,7 @@
"@react-native/new-app-screen": "0.81.1",
"react": "19.1.0",
"react-native": "0.81.1",
+ "react-native-image-picker": "^8.2.1",
"react-native-permissions": "^5.4.2",
"react-native-safe-area-context": "^5.5.2"
},
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 10f51c1..495af15 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -14,6 +14,7 @@ import BarcodeScanner, {
type BarcodeResult,
CameraView,
} from '@pushpendersingh/react-native-scanner';
+import { launchImageLibrary } from 'react-native-image-picker';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function App() {
@@ -92,6 +93,42 @@ export default function App() {
}
};
+ const pickImage = async () => {
+ try {
+ const result = await launchImageLibrary({
+ mediaType: 'photo',
+ selectionLimit: 1,
+ });
+
+ if (result.didCancel) {
+ console.log('User cancelled image picker');
+ } else if (result.errorMessage) {
+ console.error('ImagePicker Error: ', result.errorMessage);
+ Alert.alert('Error', result.errorMessage);
+ } else if (result.assets && result.assets.length > 0) {
+ const asset = result.assets[0];
+ if (asset && asset.uri) {
+ console.log('Scanning image:', asset.uri);
+ try {
+ const barcodes = await BarcodeScanner.scanImage(asset.uri);
+ handleBarcodeScanned(barcodes);
+ if (barcodes.length === 0) {
+ Alert.alert(
+ 'No QR Code Found',
+ 'Could not detect any QR code in the selected image.'
+ );
+ }
+ } catch (e: any) {
+ console.error('Scan Error:', e);
+ Alert.alert('Error', 'Failed to scan image');
+ }
+ }
+ }
+ } catch (e) {
+ console.error('Pick Image Error:', e);
+ }
+ };
+
const clearHistory = () => {
setScanHistory([]);
setScannedData(null);
@@ -196,6 +233,10 @@ export default function App() {
>
๐ Release Camera
+
+
+ ๐ผ๏ธ Scan from Gallery
+
{scannedData && (
diff --git a/ios/CameraManager.swift b/ios/CameraManager.swift
index 1f02364..73c37ef 100644
--- a/ios/CameraManager.swift
+++ b/ios/CameraManager.swift
@@ -7,6 +7,8 @@
@preconcurrency import AVFoundation
import Foundation
+import UIKit
+import CoreImage
@preconcurrency import Vision
// Actor that manages camera session state and operations
@@ -339,6 +341,140 @@ actor CallbackActor {
}
}
+ @objc public func scanImage(_ imagePath: String, completion: @escaping ([[String: Any]]) -> Void) {
+ // Ensure completion is called exactly once
+ let completionLock = NSLock()
+ var hasCompleted = false
+
+ let safeCompletion: ([[String: Any]]) -> Void = { results in
+ completionLock.lock()
+ defer { completionLock.unlock() }
+ if !hasCompleted {
+ hasCompleted = true
+ Task { @MainActor in
+ completion(results)
+ }
+ }
+ }
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ // Wrap in autoreleasepool for proper memory management on background thread
+ autoreleasepool {
+ // Always call completion, even if CameraManager is deallocated
+ guard let self = self else {
+ safeCompletion([["error": "cancelled", "reason": "CameraManager deallocated"]])
+ return
+ }
+
+ let path: String
+ if imagePath.hasPrefix("file://") {
+ if let url = URL(string: imagePath) {
+ path = url.path
+ } else {
+ // Try to strip file:// manually if URL parsing fails
+ path = String(imagePath.dropFirst(7))
+ }
+ } else {
+ path = imagePath
+ }
+
+ // Load image and extract what we need, allowing UIImage to be released earlier
+ guard let image = UIImage(contentsOfFile: path),
+ let cgImage = image.cgImage else {
+ print("Failed to load image from path: \(path)")
+ safeCompletion([])
+ return
+ }
+
+ // Get orientation before we might release the UIImage reference
+ let orientation = self.cgImageOrientation(from: image.imageOrientation)
+
+ // Keep a reference to the original image for CIDetector fallback
+ // This is needed because CIImage(image:) may be more reliable for orientation
+ let originalImage = image
+
+ let handler = VNImageRequestHandler(cgImage: cgImage, orientation: orientation, options: [:])
+ let request = VNDetectBarcodesRequest { [weak self] request, error in
+ // Always call completion, even if CameraManager is deallocated
+ guard let self = self else {
+ safeCompletion([["error": "cancelled", "reason": "CameraManager deallocated"]])
+ return
+ }
+
+ // 1. Check Vision Results
+ if error == nil, let observations = request.results as? [VNBarcodeObservation], !observations.isEmpty {
+ var results: [[String: Any]] = []
+ for barcode in observations {
+ if let payloadString = barcode.payloadStringValue {
+ results.append(self.createBarcodeResult(barcode: barcode, payloadString: payloadString))
+ }
+ }
+ if !results.isEmpty {
+ safeCompletion(results)
+ return
+ }
+ }
+
+ // 2. Fallback to CIDetector if Vision fails or returns empty
+ print("Vision returned no results, trying CIDetector fallback...")
+
+ // Create CIImage from UIImage to preserve orientation
+ // CIImage(image:) returns an optional CIImage?
+ // CIImage(cgImage:) returns a non-optional CIImage
+ let ciImage: CIImage? = CIImage(image: originalImage) ?? CIImage(cgImage: cgImage)
+
+ guard let finalCIImage = ciImage else {
+ safeCompletion([])
+ return
+ }
+
+ let context = CIContext()
+ let options = [CIDetectorAccuracy: CIDetectorAccuracyHigh]
+ let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: context, options: options)
+
+ // Use CIDetector features
+ // Note: CIImage(image: UIImage) should handle orientation automatically
+ let features = detector?.features(in: finalCIImage) as? [CIQRCodeFeature]
+
+ var fallbackResults: [[String: Any]] = []
+ if let features = features {
+ for feature in features {
+ if feature.messageString != nil {
+ fallbackResults.append(self.createFallbackResult(feature: feature))
+ }
+ }
+ }
+
+ print("CIDetector found \(fallbackResults.count) results")
+ safeCompletion(fallbackResults)
+ }
+
+ request.symbologies = self.supportedBarcodeTypes
+
+ do {
+ try handler.perform([request])
+ } catch {
+ print("Failed to perform barcode request: \(error)")
+ safeCompletion([])
+ }
+ }
+ }
+ }
+
+ private func cgImageOrientation(from uiOrientation: UIImage.Orientation) -> CGImagePropertyOrientation {
+ switch uiOrientation {
+ case .up: return .up
+ case .down: return .down
+ case .left: return .left
+ case .right: return .right
+ case .upMirrored: return .upMirrored
+ case .downMirrored: return .downMirrored
+ case .leftMirrored: return .leftMirrored
+ case .rightMirrored: return .rightMirrored
+ @unknown default: return .up
+ }
+ }
+
@objc public func enableFlashlight() {
// Execute on sessionQueue for thread safety
sessionQueue.async { [weak self] in
@@ -621,6 +757,29 @@ extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate {
return result
}
+ private func createFallbackResult(feature: CIQRCodeFeature) -> [String: Any] {
+ var result: [String: Any] = [
+ "data": feature.messageString ?? "",
+ "type": "QR_CODE"
+ ]
+
+ // Add bounds if needed (converting from CoreImage coordinates to 4-corner format)
+ let boundingBox = feature.bounds
+ let bounds: [String: Any] = [
+ "width": boundingBox.width,
+ "height": boundingBox.height,
+ "origin": [
+ "topLeft": ["x": boundingBox.minX, "y": boundingBox.maxY],
+ "bottomLeft": ["x": boundingBox.minX, "y": boundingBox.minY],
+ "bottomRight": ["x": boundingBox.maxX, "y": boundingBox.minY],
+ "topRight": ["x": boundingBox.maxX, "y": boundingBox.maxY],
+ ],
+ ]
+ result["bounds"] = bounds
+
+ return result
+ }
+
private func getBarcodeTypeName(_ symbology: VNBarcodeSymbology) -> String {
switch symbology {
case .qr:
diff --git a/ios/ReactNativeScanner.mm b/ios/ReactNativeScanner.mm
index 38682e7..02ec985 100644
--- a/ios/ReactNativeScanner.mm
+++ b/ios/ReactNativeScanner.mm
@@ -46,6 +46,19 @@ - (void)startScanning:(RCTPromiseResolveBlock)resolve
}
}
+- (void)scanImage:(NSString *)imageUri
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject {
+ @try {
+ [_cameraManager scanImage:imageUri
+ completion:^(NSArray *result) {
+ resolve(result);
+ }];
+ } @catch (NSException *exception) {
+ reject(@"SCAN_IMAGE_ERROR", exception.reason, nil);
+ }
+}
+
- (void)stopScanning:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
@try {
diff --git a/package.json b/package.json
index 56116fb..11d47ae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@pushpendersingh/react-native-scanner",
- "version": "3.0.0",
+ "version": "3.1.0-beta.2",
"description": "A QR code & Barcode Scanner for React Native Projects.",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
diff --git a/src/NativeReactNativeScanner.ts b/src/NativeReactNativeScanner.ts
index a0ce97d..6fa63f0 100644
--- a/src/NativeReactNativeScanner.ts
+++ b/src/NativeReactNativeScanner.ts
@@ -40,6 +40,9 @@ export interface Spec extends TurboModule {
// Request camera permission
requestCameraPermission(): Promise;
+ // Scan a static image
+ scanImage(imageUri: string): Promise;
+
readonly onBarcodeScanned: CodegenTypes.EventEmitter;
}
diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx
index d6343bb..4793903 100644
--- a/src/__tests__/index.test.tsx
+++ b/src/__tests__/index.test.tsx
@@ -11,6 +11,7 @@ jest.mock('../NativeReactNativeScanner', () => ({
releaseCamera: jest.fn(),
hasCameraPermission: jest.fn(),
requestCameraPermission: jest.fn(),
+ scanImage: jest.fn(),
}));
describe('BarcodeScanner', () => {
@@ -98,6 +99,28 @@ describe('BarcodeScanner', () => {
expect(NativeReactNativeScanner.hasCameraPermission).toHaveBeenCalled();
});
+ // Test 6: Scan image
+ test('should scan image from uri', async () => {
+ const mockImageUri = 'file:///path/to/image.jpg';
+ const mockBarcodeResults: BarcodeResult[] = [
+ {
+ data: 'QR_CODE_DATA',
+ type: 'QR_CODE' as BarcodeType,
+ },
+ ];
+
+ (NativeReactNativeScanner.scanImage as jest.Mock).mockResolvedValue(
+ mockBarcodeResults
+ );
+
+ const results = await BarcodeScanner.scanImage(mockImageUri);
+
+ expect(NativeReactNativeScanner.scanImage).toHaveBeenCalledWith(
+ mockImageUri
+ );
+ expect(results).toEqual(mockBarcodeResults);
+ });
+
// Test 6: Request camera permission
test('should request camera permission', async () => {
(
diff --git a/src/index.tsx b/src/index.tsx
index e11a409..96c8538 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -38,6 +38,9 @@ export type BarcodeScannerCallback = (results: BarcodeResult[]) => void;
export class BarcodeScanner {
private static listener: any = null;
+ // Allowed URI schemes for scanImage
+ private static readonly ALLOWED_SCHEMES = ['file://', 'content://', 'ph://'];
+
static async startScanning(callback: BarcodeScannerCallback): Promise {
// Remove existing listener
if (this.listener) {
@@ -84,6 +87,32 @@ export class BarcodeScanner {
static async requestCameraPermission(): Promise {
return NativeReactNativeScanner.requestCameraPermission();
}
+
+ static async scanImage(imageUri: string): Promise {
+ // Validate input type
+ if (!imageUri || typeof imageUri !== 'string') {
+ throw new Error('Invalid image URI: must be a non-empty string');
+ }
+
+ // Block path traversal
+ if (imageUri.includes('..')) {
+ throw new Error('Invalid image URI: path traversal not allowed');
+ }
+
+ // Validate scheme (allow file://, content://, ph://)
+ const hasValidScheme = this.ALLOWED_SCHEMES.some((scheme) =>
+ imageUri.startsWith(scheme)
+ );
+ if (!hasValidScheme) {
+ throw new Error(
+ `Invalid image URI: unsupported scheme. Use: ${this.ALLOWED_SCHEMES.join(', ')}`
+ );
+ }
+
+ return NativeReactNativeScanner.scanImage(imageUri) as unknown as Promise<
+ BarcodeResult[]
+ >;
+ }
}
// Export camera view
diff --git a/yarn.lock b/yarn.lock
index 1c31458..33ee16d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2727,6 +2727,7 @@ __metadata:
react: 19.1.0
react-native: 0.81.1
react-native-builder-bob: ^0.40.13
+ react-native-image-picker: ^8.2.1
react-native-monorepo-config: ^0.1.9
react-native-permissions: ^5.4.2
react-native-safe-area-context: ^5.5.2
@@ -10267,6 +10268,16 @@ __metadata:
languageName: node
linkType: hard
+"react-native-image-picker@npm:^8.2.1":
+ version: 8.2.1
+ resolution: "react-native-image-picker@npm:8.2.1"
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: fc85bc278c0ba5ccef8cbacfce3001e994b34b0895be34e0c884021d2215802c2e553c5e97ff7f21a30e502be60000f90cf9555e9c2a19c71fdc76120d30e158
+ languageName: node
+ linkType: hard
+
"react-native-monorepo-config@npm:^0.1.8, react-native-monorepo-config@npm:^0.1.9":
version: 0.1.10
resolution: "react-native-monorepo-config@npm:0.1.10"