react-native-rectangle-doc-scanner 3.144.0 → 3.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Paint
9
+ import android.graphics.Path
10
+ import android.util.Log
11
+ import android.view.View
12
+ import android.widget.FrameLayout
13
+ import androidx.camera.core.Camera
14
+ import androidx.camera.core.CameraSelector
15
+ import androidx.camera.core.ImageAnalysis
16
+ import androidx.camera.core.ImageProxy
17
+ import androidx.camera.core.Preview
18
+ import androidx.camera.lifecycle.ProcessCameraProvider
19
+ import androidx.camera.view.PreviewView
20
+ import androidx.core.content.ContextCompat
21
+ import androidx.lifecycle.LifecycleOwner
22
+ import com.google.common.util.concurrent.ListenableFuture
23
+ import java.util.concurrent.ExecutorService
24
+ import java.util.concurrent.Executors
25
+
26
+ /**
27
+ * CameraView with real-time document detection, grid overlay, and rectangle overlay
28
+ * Matches iOS implementation behavior
29
+ */
30
+ class CameraView(context: Context) : FrameLayout(context) {
31
+ private val TAG = "CameraView"
32
+
33
+ private val previewView: PreviewView
34
+ private val overlayView: OverlayView
35
+
36
+ private var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>? = null
37
+ private var cameraProvider: ProcessCameraProvider? = null
38
+ private var preview: Preview? = null
39
+ private var imageAnalysis: ImageAnalysis? = null
40
+ private var camera: Camera? = null
41
+
42
+ private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
43
+ // Callback for detected rectangles
44
+ var onRectangleDetected: ((Rectangle?) -> Unit)? = null
45
+
46
+ init {
47
+ // Create preview view
48
+ previewView = PreviewView(context).apply {
49
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
50
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
51
+ }
52
+ addView(previewView)
53
+
54
+ // Create overlay view for grid and rectangle
55
+ overlayView = OverlayView(context)
56
+ addView(overlayView)
57
+
58
+ Log.d(TAG, "CameraView initialized")
59
+ }
60
+
61
+ /**
62
+ * Start camera and document detection
63
+ */
64
+ fun startCamera() {
65
+ if (!hasPermissions()) {
66
+ Log.e(TAG, "Camera permissions not granted")
67
+ return
68
+ }
69
+
70
+ cameraProviderFuture = ProcessCameraProvider.getInstance(context)
71
+ cameraProviderFuture?.addListener({
72
+ try {
73
+ cameraProvider = cameraProviderFuture?.get()
74
+ bindCamera()
75
+ } catch (e: Exception) {
76
+ Log.e(TAG, "Failed to get camera provider", e)
77
+ }
78
+ }, ContextCompat.getMainExecutor(context))
79
+ }
80
+
81
+ /**
82
+ * Stop camera and release resources
83
+ */
84
+ fun stopCamera() {
85
+ cameraProvider?.unbindAll()
86
+ cameraExecutor.shutdown()
87
+ }
88
+
89
+ /**
90
+ * Bind camera use cases
91
+ */
92
+ private fun bindCamera() {
93
+ val lifecycleOwner = context as? LifecycleOwner
94
+ if (lifecycleOwner == null) {
95
+ Log.e(TAG, "Context is not a LifecycleOwner")
96
+ return
97
+ }
98
+
99
+ val provider = cameraProvider ?: return
100
+
101
+ // Unbind all before rebinding
102
+ provider.unbindAll()
103
+
104
+ // Preview use case
105
+ preview = Preview.Builder()
106
+ .build()
107
+ .also {
108
+ it.setSurfaceProvider(previewView.surfaceProvider)
109
+ }
110
+
111
+ // Image analysis use case for document detection
112
+ imageAnalysis = ImageAnalysis.Builder()
113
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
114
+ .build()
115
+ .also {
116
+ it.setAnalyzer(cameraExecutor, DocumentAnalyzer())
117
+ }
118
+
119
+ // Select back camera
120
+ val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
121
+
122
+ try {
123
+ // Bind use cases to camera
124
+ camera = provider.bindToLifecycle(
125
+ lifecycleOwner,
126
+ cameraSelector,
127
+ preview,
128
+ imageAnalysis
129
+ )
130
+
131
+ Log.d(TAG, "Camera bound successfully")
132
+ } catch (e: Exception) {
133
+ Log.e(TAG, "Failed to bind camera", e)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Check if camera permissions are granted
139
+ */
140
+ private fun hasPermissions(): Boolean {
141
+ return ContextCompat.checkSelfPermission(
142
+ context,
143
+ Manifest.permission.CAMERA
144
+ ) == PackageManager.PERMISSION_GRANTED
145
+ }
146
+
147
+ /**
148
+ * Image analyzer for document detection
149
+ */
150
+ private inner class DocumentAnalyzer : ImageAnalysis.Analyzer {
151
+ override fun analyze(imageProxy: ImageProxy) {
152
+ try {
153
+ val nv21 = imageProxy.toNv21()
154
+ val rotationDegrees = imageProxy.imageInfo.rotationDegrees
155
+
156
+ val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) {
157
+ imageProxy.height
158
+ } else {
159
+ imageProxy.width
160
+ }
161
+
162
+ val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) {
163
+ imageProxy.width
164
+ } else {
165
+ imageProxy.height
166
+ }
167
+
168
+ val rectangle = DocumentDetector.detectRectangleInYUV(
169
+ nv21,
170
+ imageProxy.width,
171
+ imageProxy.height,
172
+ rotationDegrees
173
+ )
174
+
175
+ val transformedRectangle = rectangle?.let {
176
+ val viewWidth = if (overlayView.width > 0) overlayView.width else width
177
+ val viewHeight = if (overlayView.height > 0) overlayView.height else height
178
+
179
+ DocumentDetector.transformRectangleToViewCoordinates(
180
+ it,
181
+ frameWidth,
182
+ frameHeight,
183
+ viewWidth,
184
+ viewHeight
185
+ )
186
+ }
187
+
188
+ post {
189
+ overlayView.setDetectedRectangle(transformedRectangle)
190
+ onRectangleDetected?.invoke(transformedRectangle)
191
+ }
192
+ } catch (e: Exception) {
193
+ Log.e(TAG, "Failed to analyze frame", e)
194
+ post {
195
+ overlayView.setDetectedRectangle(null)
196
+ onRectangleDetected?.invoke(null)
197
+ }
198
+ } finally {
199
+ imageProxy.close()
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Overlay view for grid and rectangle
206
+ */
207
+ private class OverlayView(context: Context) : View(context) {
208
+ private var detectedRectangle: Rectangle? = null
209
+
210
+ private val gridPaint = Paint().apply {
211
+ color = Color.parseColor("#80FFFFFF") // 50% white
212
+ strokeWidth = 2f
213
+ style = Paint.Style.STROKE
214
+ }
215
+
216
+ private val rectanglePaint = Paint().apply {
217
+ color = Color.parseColor("#00FF00") // Green
218
+ strokeWidth = 4f
219
+ style = Paint.Style.STROKE
220
+ }
221
+
222
+ private val rectangleFillPaint = Paint().apply {
223
+ color = Color.parseColor("#2000FF00") // 12% green
224
+ style = Paint.Style.FILL
225
+ }
226
+
227
+ init {
228
+ setWillNotDraw(false)
229
+ }
230
+
231
+ fun setDetectedRectangle(rectangle: Rectangle?) {
232
+ detectedRectangle = rectangle
233
+ invalidate()
234
+ }
235
+
236
+ override fun onDraw(canvas: Canvas) {
237
+ super.onDraw(canvas)
238
+
239
+ // Draw 3x3 grid
240
+ drawGrid(canvas)
241
+
242
+ // Draw detected rectangle if available
243
+ detectedRectangle?.let { rect ->
244
+ drawRectangle(canvas, rect)
245
+ }
246
+ }
247
+
248
+ private fun drawGrid(canvas: Canvas) {
249
+ val width = width.toFloat()
250
+ val height = height.toFloat()
251
+
252
+ // Draw vertical lines (2 lines for 3x3 grid)
253
+ canvas.drawLine(width / 3f, 0f, width / 3f, height, gridPaint)
254
+ canvas.drawLine(2f * width / 3f, 0f, 2f * width / 3f, height, gridPaint)
255
+
256
+ // Draw horizontal lines (2 lines for 3x3 grid)
257
+ canvas.drawLine(0f, height / 3f, width, height / 3f, gridPaint)
258
+ canvas.drawLine(0f, 2f * height / 3f, width, 2f * height / 3f, gridPaint)
259
+ }
260
+
261
+ private fun drawRectangle(canvas: Canvas, rect: Rectangle) {
262
+ val path = Path().apply {
263
+ moveTo(rect.topLeft.x, rect.topLeft.y)
264
+ lineTo(rect.topRight.x, rect.topRight.y)
265
+ lineTo(rect.bottomRight.x, rect.bottomRight.y)
266
+ lineTo(rect.bottomLeft.x, rect.bottomLeft.y)
267
+ close()
268
+ }
269
+
270
+ // Draw filled rectangle
271
+ canvas.drawPath(path, rectangleFillPaint)
272
+
273
+ // Draw rectangle outline
274
+ canvas.drawPath(path, rectanglePaint)
275
+ }
276
+ }
277
+ }
@@ -0,0 +1,98 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.bridge.Arguments
5
+ import com.facebook.react.bridge.ReactContext
6
+ import com.facebook.react.bridge.WritableMap
7
+ import com.facebook.react.common.MapBuilder
8
+ import com.facebook.react.uimanager.SimpleViewManager
9
+ import com.facebook.react.uimanager.ThemedReactContext
10
+ import com.facebook.react.uimanager.events.RCTEventEmitter
11
+
12
+ /**
13
+ * ViewManager for CameraView
14
+ * Bridges native CameraView to React Native
15
+ */
16
+ class CameraViewManager : SimpleViewManager<CameraView>() {
17
+ private val TAG = "CameraViewManager"
18
+
19
+ companion object {
20
+ const val REACT_CLASS = "RNDocScannerCamera"
21
+ }
22
+
23
+ override fun getName(): String = REACT_CLASS
24
+
25
+ override fun createViewInstance(reactContext: ThemedReactContext): CameraView {
26
+ Log.d(TAG, "Creating CameraView instance")
27
+
28
+ val cameraView = CameraView(reactContext)
29
+
30
+ // Set up rectangle detection callback
31
+ cameraView.onRectangleDetected = { rectangle ->
32
+ sendRectangleDetectedEvent(reactContext, cameraView, rectangle)
33
+ }
34
+
35
+ return cameraView
36
+ }
37
+
38
+ override fun onAfterUpdateTransaction(view: CameraView) {
39
+ super.onAfterUpdateTransaction(view)
40
+ // Start camera after view is ready
41
+ view.post {
42
+ view.startCamera()
43
+ }
44
+ }
45
+
46
+ override fun onDropViewInstance(view: CameraView) {
47
+ super.onDropViewInstance(view)
48
+ view.stopCamera()
49
+ }
50
+
51
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
52
+ return MapBuilder.of(
53
+ "onRectangleDetected",
54
+ MapBuilder.of("registrationName", "onRectangleDetected")
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Send rectangle detected event to React Native
60
+ */
61
+ private fun sendRectangleDetectedEvent(
62
+ reactContext: ReactContext,
63
+ view: CameraView,
64
+ rectangle: Rectangle?
65
+ ) {
66
+ val event: WritableMap = Arguments.createMap()
67
+
68
+ if (rectangle != null) {
69
+ val topLeft = Arguments.createMap().apply {
70
+ putDouble("x", rectangle.topLeft.x.toDouble())
71
+ putDouble("y", rectangle.topLeft.y.toDouble())
72
+ }
73
+ val topRight = Arguments.createMap().apply {
74
+ putDouble("x", rectangle.topRight.x.toDouble())
75
+ putDouble("y", rectangle.topRight.y.toDouble())
76
+ }
77
+ val bottomRight = Arguments.createMap().apply {
78
+ putDouble("x", rectangle.bottomRight.x.toDouble())
79
+ putDouble("y", rectangle.bottomRight.y.toDouble())
80
+ }
81
+ val bottomLeft = Arguments.createMap().apply {
82
+ putDouble("x", rectangle.bottomLeft.x.toDouble())
83
+ putDouble("y", rectangle.bottomLeft.y.toDouble())
84
+ }
85
+
86
+ event.putMap("topLeft", topLeft)
87
+ event.putMap("topRight", topRight)
88
+ event.putMap("bottomRight", bottomRight)
89
+ event.putMap("bottomLeft", bottomLeft)
90
+ } else {
91
+ event.putNull("rectangle")
92
+ }
93
+
94
+ reactContext
95
+ .getJSModule(RCTEventEmitter::class.java)
96
+ .receiveEvent(view.id, "onRectangleDetected", event)
97
+ }
98
+ }
@@ -248,7 +248,8 @@ class DocumentDetector {
248
248
  imageWidth: Int,
249
249
  imageHeight: Int,
250
250
  viewWidth: Int,
251
- viewHeight: Int
251
+ viewHeight: Int,
252
+ rotationDegrees: Int = 0
252
253
  ): Rectangle {
253
254
  if (imageWidth == 0 || imageHeight == 0 || viewWidth == 0 || viewHeight == 0) {
254
255
  return rectangle
@@ -11,6 +11,9 @@ class DocumentScannerPackage : ReactPackage {
11
11
  }
12
12
 
13
13
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
14
- return listOf(DocumentScannerViewManager())
14
+ return listOf(
15
+ DocumentScannerViewManager(),
16
+ CameraViewManager()
17
+ )
15
18
  }
16
19
  }
@@ -0,0 +1,51 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import androidx.camera.core.ImageProxy
4
+
5
+ /**
6
+ * Convert [ImageProxy] in YUV_420_888 format to NV21 byte array.
7
+ * This mirrors the logic used by the existing DocumentScannerView pipeline so CameraView can reuse
8
+ * the same OpenCV detection utilities.
9
+ */
10
+ fun ImageProxy.toNv21(): ByteArray {
11
+ val width = width
12
+ val height = height
13
+
14
+ val ySize = width * height
15
+ val uvSize = width * height / 2
16
+ val nv21 = ByteArray(ySize + uvSize)
17
+
18
+ val yBuffer = planes[0].buffer
19
+ val uBuffer = planes[1].buffer
20
+ val vBuffer = planes[2].buffer
21
+
22
+ val yRowStride = planes[0].rowStride
23
+ val yPixelStride = planes[0].pixelStride
24
+ var outputOffset = 0
25
+ for (row in 0 until height) {
26
+ var inputOffset = row * yRowStride
27
+ for (col in 0 until width) {
28
+ nv21[outputOffset++] = yBuffer.get(inputOffset)
29
+ inputOffset += yPixelStride
30
+ }
31
+ }
32
+
33
+ val uvRowStride = planes[1].rowStride
34
+ val uvPixelStride = planes[1].pixelStride
35
+ val vRowStride = planes[2].rowStride
36
+ val vPixelStride = planes[2].pixelStride
37
+ val uvHeight = height / 2
38
+ val uvWidth = width / 2
39
+ for (row in 0 until uvHeight) {
40
+ var uInputOffset = row * uvRowStride
41
+ var vInputOffset = row * vRowStride
42
+ for (col in 0 until uvWidth) {
43
+ nv21[outputOffset++] = vBuffer.get(vInputOffset)
44
+ nv21[outputOffset++] = uBuffer.get(uInputOffset)
45
+ uInputOffset += uvPixelStride
46
+ vInputOffset += vPixelStride
47
+ }
48
+ }
49
+
50
+ return nv21
51
+ }
@@ -228,11 +228,21 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
228
228
  try {
229
229
  console.log('[FullDocScanner] openCropper called with path:', imagePath);
230
230
  setProcessing(true);
231
- // Clean path - remove file:// prefix for react-native-image-crop-picker
232
- // The library handles the prefix internally and double prefixing causes issues
231
+ // Clean path handling differs by platform
232
+ // iOS: react-native-image-crop-picker handles file:// prefix internally
233
+ // Android: needs file:// prefix for proper URI handling
233
234
  let cleanPath = imagePath;
234
- if (cleanPath.startsWith('file://')) {
235
- cleanPath = cleanPath.replace('file://', '');
235
+ if (react_native_1.Platform.OS === 'ios') {
236
+ // iOS: remove file:// prefix as the library adds it
237
+ if (cleanPath.startsWith('file://')) {
238
+ cleanPath = cleanPath.replace('file://', '');
239
+ }
240
+ }
241
+ else {
242
+ // Android: ensure file:// prefix exists
243
+ if (!cleanPath.startsWith('file://')) {
244
+ cleanPath = 'file://' + cleanPath;
245
+ }
236
246
  }
237
247
  console.log('[FullDocScanner] Clean path for cropper:', cleanPath);
238
248
  const shouldWaitForPickerDismissal = options?.waitForPickerDismissal ?? true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "3.144.0",
3
+ "version": "3.145.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -5,6 +5,7 @@ import {
5
5
  Image,
6
6
  InteractionManager,
7
7
  NativeModules,
8
+ Platform,
8
9
  StyleSheet,
9
10
  Text,
10
11
  TouchableOpacity,
@@ -337,11 +338,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
337
338
  console.log('[FullDocScanner] openCropper called with path:', imagePath);
338
339
  setProcessing(true);
339
340
 
340
- // Clean path - remove file:// prefix for react-native-image-crop-picker
341
- // The library handles the prefix internally and double prefixing causes issues
341
+ // Clean path handling differs by platform
342
+ // iOS: react-native-image-crop-picker handles file:// prefix internally
343
+ // Android: needs file:// prefix for proper URI handling
342
344
  let cleanPath = imagePath;
343
- if (cleanPath.startsWith('file://')) {
344
- cleanPath = cleanPath.replace('file://', '');
345
+ if (Platform.OS === 'ios') {
346
+ // iOS: remove file:// prefix as the library adds it
347
+ if (cleanPath.startsWith('file://')) {
348
+ cleanPath = cleanPath.replace('file://', '');
349
+ }
350
+ } else {
351
+ // Android: ensure file:// prefix exists
352
+ if (!cleanPath.startsWith('file://')) {
353
+ cleanPath = 'file://' + cleanPath;
354
+ }
345
355
  }
346
356
  console.log('[FullDocScanner] Clean path for cropper:', cleanPath);
347
357