From 8b4ce387be3e0d79d7eb5ab9adc16f8b092311b6 Mon Sep 17 00:00:00 2001 From: Pushpender Singh Date: Fri, 23 Jan 2026 16:36:37 +0530 Subject: [PATCH 1/6] feat: add image scanning functionality and update permissions for gallery access --- README.md | 77 +++++++++- .../reactnativescanner/CameraManager.kt | 136 ++++++++++++++++- .../ReactNativeScannerModule.kt | 11 ++ .../android/app/src/main/AndroidManifest.xml | 2 + example/ios/Podfile.lock | 36 ++++- .../ios/ReactNativeScannerExample/Info.plist | 2 + .../PrivacyInfo.xcprivacy | 1 + example/package.json | 1 + example/src/App.tsx | 41 ++++++ ios/CameraManager.swift | 139 ++++++++++++++++++ ios/ReactNativeScanner.mm | 13 ++ src/NativeReactNativeScanner.ts | 3 + src/__tests__/index.test.tsx | 23 +++ src/index.tsx | 6 + yarn.lock | 11 ++ 15 files changed, 497 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e57fdd8..6c79485 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,13 @@ 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 We need camera access to scan barcodes +NSPhotoLibraryUsageDescription +We need access to your photo library to scan barcodes from images ``` ### Android Setup @@ -75,6 +77,8 @@ Add camera permission to `AndroidManifest.xml`: ```xml + + ``` @@ -180,6 +184,35 @@ const styles = StyleSheet.create({ }); ``` +### Scanning from Image (Gallery) + +You can also scan QR codes and barcodes from existing images (e.g., from the gallery). + +```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 +234,43 @@ 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. + +```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/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt index ebcb9e5..d70b447 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 @@ -421,6 +427,134 @@ class CameraManager(private val reactContext: ReactApplicationContext) { } } + fun scanImage(imageUri: String, callback: (WritableArray) -> Unit) { + try { + val uri = if (imageUri.startsWith("file://")) { + // Remove the prefix to get the path, then create a file Uri + // This handles encoded characters and ensures a clean file URI + val path = imageUri.replace("file://", "") + Uri.fromFile(java.io.File(path)) + } else { + Uri.parse(imageUri) + } + + Log.d(TAG, "Scanning image from URI: $uri") + + // Strategy 1: Try InputImage.fromFilePath (Recommended) + // It handles Exif and memory efficiently + try { + val image = com.google.mlkit.vision.common.InputImage.fromFilePath(reactContext, uri) + processImage(image, callback) + } catch (e: java.io.IOException) { + Log.w(TAG, "InputImage.fromFilePath failed (${e.message}), falling back to Bitmap loader") + + // Strategy 2: Fallback to manual Bitmap loading + val bitmap = loadBitmap(uri) + if (bitmap != null) { + Log.d(TAG, "Fallback Bitmap loaded: ${bitmap.width}x${bitmap.height}") + val image = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0) + processImage(image, callback) + } else { + Log.e(TAG, "Failed to load bitmap from URI") + callback(Arguments.createArray()) + } + } + } catch (e: Exception) { + Log.e(TAG, "Scan image failed", e) + throw e + } + } + + private fun processImage(image: com.google.mlkit.vision.common.InputImage, callback: (WritableArray) -> Unit) { + val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) + .build() + ) + + 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.") + callback(results) + } + .addOnFailureListener { e -> + Log.e(TAG, "Barcode scanning failed: ${e.message}", e) + callback(Arguments.createArray()) + } + .addOnCompleteListener { + scanner.close() + } + } + + + private fun loadBitmap(uri: Uri): Bitmap? { + try { + val inputStream = reactContext.contentResolver.openInputStream(uri) + val originalBitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (originalBitmap == null) return null + + // Handle Rotation + val rotation = getRotation(uri) + val matrix = Matrix() + if (rotation != 0) { + matrix.postRotate(rotation.toFloat()) + } + + // Handle Transparency: Draw on white background + // We create a new bitmap that is ARGB_8888 (no transparency issues for ML Kit) + 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 + ) + + val canvas = Canvas(newBitmap) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(originalBitmap, matrix, null) + + return newBitmap + } catch (e: Exception) { + Log.e(TAG, "Error loading bitmap", e) + return null + } + } + + 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 + } + } + + 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 + } + } + + fun releaseCamera() { try { Log.d(TAG, "Releasing camera resources...") diff --git a/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt b/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt index c9a5692..9670398 100644 --- a/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt +++ b/android/src/main/java/com/pushpendersingh/reactnativescanner/ReactNativeScannerModule.kt @@ -156,6 +156,17 @@ 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 = null 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..1a2c775 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.0.0): - 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: 3075b85cf5d587ce00709a1efa0f384dbb53ff64 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..18d1046 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,122 @@ 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 + completion(results) + } + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { 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 + } + + guard let image = UIImage(contentsOfFile: path), + let cgImage = image.cgImage else { + print("Failed to load image from path: \(path)") + safeCompletion([]) + return + } + + // Convert UIImageOrientation to CGImagePropertyOrientation + let orientation = self.cgImageOrientation(from: image.imageOrientation) + + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: orientation, options: [:]) + let request = VNDetectBarcodesRequest { [weak self] request, error in + guard let self = self else { 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: image) ?? 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 let messageString = feature.messageString { + 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 +739,27 @@ 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) + let boundingBox = feature.bounds + let bounds: [String: Any] = [ + "width": boundingBox.width, + "height": boundingBox.height, + "origin": [ + "x": boundingBox.origin.x, + "y": boundingBox.origin.y + ] + ] + 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/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..bd9e8b8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -84,6 +84,12 @@ export class BarcodeScanner { static async requestCameraPermission(): Promise { return NativeReactNativeScanner.requestCameraPermission(); } + + static async scanImage(imageUri: string): Promise { + 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" From 20a387c66aca113f93cc3ea3c565e816f39e5e26 Mon Sep 17 00:00:00 2001 From: Pushpender Singh Date: Fri, 23 Jan 2026 16:53:08 +0530 Subject: [PATCH 2/6] docs: update README and add project wiki for comprehensive usage instructions --- README.md | 10 +- WIKI.md | 381 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 WIKI.md diff --git a/README.md b/README.md index 6c79485..e980729 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,6 @@ cd ios && pod install && cd .. ```xml NSCameraUsageDescription We need camera access to scan barcodes -NSPhotoLibraryUsageDescription -We need access to your photo library to scan barcodes from images ``` ### Android Setup @@ -77,8 +75,6 @@ Add camera permission to `AndroidManifest.xml`: ```xml - - ``` @@ -186,7 +182,9 @@ const styles = StyleSheet.create({ ### Scanning from Image (Gallery) -You can also scan QR codes and barcodes from existing images (e.g., from the 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'; @@ -245,6 +243,8 @@ BarcodeScanner.startScanning((barcodes: BarcodeResult[]) => { 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'); ``` 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 From 844fd3208d9fb420f00d2d41428d7b80aceba976 Mon Sep 17 00:00:00 2001 From: Pushpender Singh Date: Fri, 23 Jan 2026 16:53:56 +0530 Subject: [PATCH 3/6] chore: release v3.1.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 56116fb..4509b5a 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.1", "description": "A QR code & Barcode Scanner for React Native Projects.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From 5b549365238c4c101ee0d73d8317c986ed175548 Mon Sep 17 00:00:00 2001 From: Pushpender Singh Date: Wed, 28 Jan 2026 01:13:30 +0530 Subject: [PATCH 4/6] fix(camera): prevent memory leaks by recycling bitmaps after processing - Added optional bitmap parameter to processImage for recycling after processing - Implemented bitmap recycle logic with safety check to avoid double recycling - Passed bitmap to processImage call to enable cleanup of fallback bitmaps - Recycled original bitmap after drawing to free memory resources - Added debug logs for bitmap recycling events to track memory management --- .../reactnativescanner/CameraManager.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt index d70b447..ca66301 100644 --- a/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt +++ b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt @@ -453,7 +453,7 @@ class CameraManager(private val reactContext: ReactApplicationContext) { if (bitmap != null) { Log.d(TAG, "Fallback Bitmap loaded: ${bitmap.width}x${bitmap.height}") val image = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0) - processImage(image, callback) + processImage(image, callback, bitmap) // Pass bitmap for cleanup after processing } else { Log.e(TAG, "Failed to load bitmap from URI") callback(Arguments.createArray()) @@ -465,7 +465,17 @@ class CameraManager(private val reactContext: ReactApplicationContext) { } } - private fun processImage(image: com.google.mlkit.vision.common.InputImage, callback: (WritableArray) -> Unit) { + /** + * Process image for barcode scanning. + * @param image The InputImage to scan + * @param callback Callback to receive scan results + * @param bitmapToRecycle Optional bitmap to recycle after processing completes (for memory cleanup) + */ + private fun processImage( + image: com.google.mlkit.vision.common.InputImage, + callback: (WritableArray) -> Unit, + bitmapToRecycle: Bitmap? = null + ) { val scanner = BarcodeScanning.getClient( BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) @@ -489,6 +499,13 @@ class CameraManager(private val reactContext: ReactApplicationContext) { } .addOnCompleteListener { 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") + } + } } } @@ -520,6 +537,9 @@ class CameraManager(private val reactContext: ReactApplicationContext) { 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) From 00a7c35a46cbf3b2d85e0afa33fd657d8a9c84d5 Mon Sep 17 00:00:00 2001 From: Pushpender Singh Date: Thu, 29 Jan 2026 22:47:20 +0530 Subject: [PATCH 5/6] fix: improve image scanning security, memory management, and error handling - Add URI scheme validation and path traversal prevention for scanImage - Android: implement retry logic with progressive resolution scaling - Android: add proper thread factory and executor lifecycle management - iOS: ensure callbacks run on MainActor with proper error handling - iOS: wrap image processing in autoreleasepool for better memory management - Add comprehensive input validation on TypeScript layer --- .../reactnativescanner/CameraManager.kt | 365 +++++++++++++----- .../ReactNativeScannerModule.kt | 31 ++ example/ios/Podfile.lock | 4 +- ios/CameraManager.swift | 174 +++++---- src/index.tsx | 23 ++ 5 files changed, 419 insertions(+), 178 deletions(-) diff --git a/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt index ca66301..b6bea1b 100644 --- a/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt +++ b/android/src/main/java/com/pushpendersingh/reactnativescanner/CameraManager.kt @@ -29,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 @@ -49,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 { @@ -92,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") } } @@ -427,54 +435,101 @@ class CameraManager(private val reactContext: ReactApplicationContext) { } } + /** + * 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) { - try { - val uri = if (imageUri.startsWith("file://")) { - // Remove the prefix to get the path, then create a file Uri - // This handles encoded characters and ensures a clean file URI - val path = imageUri.replace("file://", "") - Uri.fromFile(java.io.File(path)) - } else { - Uri.parse(imageUri) + // 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") + Log.d(TAG, "Scanning image from URI: $uri") - // Strategy 1: Try InputImage.fromFilePath (Recommended) - // It handles Exif and memory efficiently - try { - val image = com.google.mlkit.vision.common.InputImage.fromFilePath(reactContext, uri) - processImage(image, callback) - } catch (e: java.io.IOException) { - Log.w(TAG, "InputImage.fromFilePath failed (${e.message}), falling back to Bitmap loader") - - // Strategy 2: Fallback to manual Bitmap loading - val bitmap = loadBitmap(uri) - if (bitmap != null) { - Log.d(TAG, "Fallback Bitmap loaded: ${bitmap.width}x${bitmap.height}") - val image = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0) - processImage(image, callback, bitmap) // Pass bitmap for cleanup after processing - } else { - Log.e(TAG, "Failed to load bitmap from URI") - callback(Arguments.createArray()) + // 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: Exception) { - Log.e(TAG, "Scan image failed", e) - throw e + } catch (e: RejectedExecutionException) { + Log.w(TAG, "Executor was shutdown, cannot scan image: ${e.message}") + ContextCompat.getMainExecutor(reactContext).execute { + callback(Arguments.createArray()) + } } } - + /** - * Process image for barcode scanning. - * @param image The InputImage to scan - * @param callback Callback to receive scan results - * @param bitmapToRecycle Optional bitmap to recycle after processing completes (for memory cleanup) + * Retry mechanism with higher resolution if first scan fails + * First attempt uses MAX_BITMAP_PIXELS (4MP), retry uses FALLBACK_MAX_PIXELS (8MP) */ - private fun processImage( - image: com.google.mlkit.vision.common.InputImage, + private fun scanImageWithRetry( + uri: Uri, callback: (WritableArray) -> Unit, - bitmapToRecycle: Bitmap? = null + 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() @@ -482,6 +537,9 @@ class CameraManager(private val reactContext: ReactApplicationContext) { .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() @@ -491,42 +549,112 @@ class CameraManager(private val reactContext: ReactApplicationContext) { } } Log.d(TAG, "Scan complete. Found ${barcodes.size} barcodes.") - callback(results) + + // 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) - callback(Arguments.createArray()) + // Always callback on main thread + ContextCompat.getMainExecutor(reactContext).execute { + callback(Arguments.createArray()) + } } .addOnCompleteListener { - 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") + // 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") + } } } } } - - private fun loadBitmap(uri: Uri): Bitmap? { + /** + * 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 { - val inputStream = reactContext.contentResolver.openInputStream(uri) - val originalBitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() + // Decode bounds only (no memory allocation) + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + reactContext.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, options) + } + + 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) + } - if (originalBitmap == null) return null + 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() - if (rotation != 0) { - matrix.postRotate(rotation.toFloat()) + 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 - // We create a new bitmap that is ARGB_8888 (no transparency issues for ML Kit) val newBitmap = Bitmap.createBitmap( if (rotation % 180 == 0) originalBitmap.width else originalBitmap.height, if (rotation % 180 == 0) originalBitmap.height else originalBitmap.width, @@ -546,6 +674,20 @@ class CameraManager(private val reactContext: ReactApplicationContext) { 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 { @@ -575,37 +717,62 @@ class CameraManager(private val reactContext: ReactApplicationContext) { } + /** + * Release camera resources asynchronously. + * Full cleanup INCLUDING executor shutdown. + * Executor shutdown is scheduled on main thread to avoid deadlock. + */ fun releaseCamera() { - try { - Log.d(TAG, "Releasing camera resources...") - - // Stop scanning first using atomic operation - if (isScanning.compareAndSet(true, false)) { - scanCallbackRef.set(null) - } + Log.d(TAG, "๐Ÿงน releaseCamera() called") + + // Stop scanning first using atomic operation + if (isScanning.compareAndSet(true, false)) { + scanCallbackRef.set(null) + } - // 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++ + // 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) { - Log.w(TAG, "โš ๏ธ Binding still in progress after 5s, forcing release") + 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 + } } - - // Now safely unbind with lock (binding should be complete) + + // Unbind camera cameraBindLock.withLock { - // Double-check binding state and unbind - if (isBinding) { - Log.w(TAG, "โš ๏ธ Binding flag still set, unbinding anyway") - } cameraProvider?.unbindAll() } - + // Clear all references cameraProvider = null cameraControl = null @@ -613,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 9670398..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 @@ -169,7 +198,9 @@ class ReactNativeScannerModule(reactContext: ReactApplicationContext) : override fun invalidate() { super.invalidate() + permissionPromise?.reject("MODULE_INVALIDATED", "Scanner module was invalidated") permissionPromise = null + reactApplicationContext.removeLifecycleEventListener(lifecycleEventListener) cameraManager.releaseCamera() } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 1a2c775..15d8720 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2368,7 +2368,7 @@ PODS: - React-perflogger (= 0.81.1) - React-utils (= 0.81.1) - SocketRocket - - ReactNativeScanner (3.0.0): + - ReactNativeScanner (3.1.0-beta.1): - boost - DoubleConversion - fast_float @@ -2736,7 +2736,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 3eb9096cb139eb433965693bbe541d96eb3d3ec9 ReactCodegen: 4d203eddf6f977caa324640a20f92e70408d648b ReactCommon: ce5d4226dfaf9d5dacbef57b4528819e39d3a120 - ReactNativeScanner: 3075b85cf5d587ce00709a1efa0f384dbb53ff64 + ReactNativeScanner: 0025511e6da1e8abc603ac9e582218b395021873 RNPermissions: 380b0ddaff0bba3d4d0bbe4ed402044bc695752b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 11c9686a21e2cd82a094a723649d9f4507200fb0 diff --git a/ios/CameraManager.swift b/ios/CameraManager.swift index 18d1046..73c37ef 100644 --- a/ios/CameraManager.swift +++ b/ios/CameraManager.swift @@ -351,94 +351,112 @@ actor CallbackActor { defer { completionLock.unlock() } if !hasCompleted { hasCompleted = true - completion(results) + Task { @MainActor in + completion(results) + } } } DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { 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 - } - - guard let image = UIImage(contentsOfFile: path), - let cgImage = image.cgImage else { - print("Failed to load image from path: \(path)") - safeCompletion([]) - return - } - - // Convert UIImageOrientation to CGImagePropertyOrientation - let orientation = self.cgImageOrientation(from: image.imageOrientation) - - let handler = VNImageRequestHandler(cgImage: cgImage, orientation: orientation, options: [:]) - let request = VNDetectBarcodesRequest { [weak self] request, error in - guard let self = self else { 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 - } + // 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 } - // 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: image) ?? CIImage(cgImage: cgImage) + 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 + } - guard let finalCIImage = ciImage else { + // 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) - 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] + // 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 - var fallbackResults: [[String: Any]] = [] - if let features = features { - for feature in features { - if let messageString = feature.messageString { - fallbackResults.append(self.createFallbackResult(feature: feature)) + 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) } - 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([]) + request.symbologies = self.supportedBarcodeTypes + + do { + try handler.perform([request]) + } catch { + print("Failed to perform barcode request: \(error)") + safeCompletion([]) + } } } } @@ -744,16 +762,18 @@ extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate { "data": feature.messageString ?? "", "type": "QR_CODE" ] - - // Add bounds if needed (converting from CoreImage coordinates) + + // 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": [ - "x": boundingBox.origin.x, - "y": boundingBox.origin.y - ] + "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 diff --git a/src/index.tsx b/src/index.tsx index bd9e8b8..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) { @@ -86,6 +89,26 @@ export class BarcodeScanner { } 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[] >; From d74d5bae2da8ca110b1faef67d5291376ab07314 Mon Sep 17 00:00:00 2001 From: Pushpender Singh Date: Thu, 29 Jan 2026 22:48:52 +0530 Subject: [PATCH 6/6] chore: release v3.1.0-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4509b5a..11d47ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pushpendersingh/react-native-scanner", - "version": "3.1.0-beta.1", + "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",