react-native-rectangle-doc-scanner 3.130.0 → 3.131.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,128 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import android.graphics.BitmapFactory
4
+ import android.util.Log
5
+ import com.facebook.react.bridge.*
6
+ import com.facebook.react.uimanager.UIManagerModule
7
+ import kotlinx.coroutines.*
8
+
9
+ class DocumentScannerModule(reactContext: ReactApplicationContext) :
10
+ ReactContextBaseJavaModule(reactContext) {
11
+
12
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
13
+
14
+ companion object {
15
+ const val NAME = "RNPdfScannerManager"
16
+ private const val TAG = "DocumentScannerModule"
17
+ }
18
+
19
+ override fun getName() = NAME
20
+
21
+ /**
22
+ * Capture image from the document scanner view
23
+ * Matches iOS signature: capture(reactTag, resolver, rejecter)
24
+ */
25
+ @ReactMethod
26
+ fun capture(reactTag: Double?, promise: Promise) {
27
+ Log.d(TAG, "capture called with reactTag: $reactTag")
28
+
29
+ try {
30
+ val tag = reactTag?.toInt() ?: run {
31
+ promise.reject("NO_TAG", "React tag is required")
32
+ return
33
+ }
34
+
35
+ val uiManager = reactApplicationContext.getNativeModule(UIManagerModule::class.java)
36
+ ?: run {
37
+ promise.reject("NO_UI_MANAGER", "UIManager not available")
38
+ return
39
+ }
40
+
41
+ UiThreadUtil.runOnUiThread {
42
+ try {
43
+ val view = uiManager.resolveView(tag)
44
+
45
+ if (view is DocumentScannerView) {
46
+ Log.d(TAG, "Found DocumentScannerView, triggering capture")
47
+
48
+ // Store promise to be resolved when capture completes
49
+ // For simplicity, we'll trigger the capture which will emit the event
50
+ view.capture()
51
+
52
+ // Note: In the current implementation, we use events (onPictureTaken)
53
+ // iOS also uses events for the main flow, but has a promise-based method too
54
+ // For consistency with the event-based approach, resolve immediately
55
+ promise.resolve(Arguments.createMap().apply {
56
+ putString("status", "capturing")
57
+ })
58
+ } else {
59
+ Log.e(TAG, "View with tag $tag is not DocumentScannerView: ${view?.javaClass?.simpleName}")
60
+ promise.reject("INVALID_VIEW", "View is not a DocumentScannerView")
61
+ }
62
+ } catch (e: Exception) {
63
+ Log.e(TAG, "Error resolving view", e)
64
+ promise.reject("VIEW_ERROR", "Failed to resolve view: ${e.message}", e)
65
+ }
66
+ }
67
+ } catch (e: Exception) {
68
+ Log.e(TAG, "Error in capture method", e)
69
+ promise.reject("CAPTURE_ERROR", "Failed to capture: ${e.message}", e)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Apply color controls to an image
75
+ * Matches iOS: applyColorControls(imagePath, brightness, contrast, saturation, resolver, rejecter)
76
+ */
77
+ @ReactMethod
78
+ fun applyColorControls(
79
+ imagePath: String,
80
+ brightness: Double,
81
+ contrast: Double,
82
+ saturation: Double,
83
+ promise: Promise
84
+ ) {
85
+ scope.launch {
86
+ try {
87
+ withContext(Dispatchers.IO) {
88
+ val bitmap = BitmapFactory.decodeFile(imagePath)
89
+ ?: throw Exception("Failed to load image from path: $imagePath")
90
+
91
+ val processedBitmap = ImageProcessor.applyColorControls(
92
+ bitmap = bitmap,
93
+ brightness = brightness.toFloat(),
94
+ contrast = contrast.toFloat(),
95
+ saturation = saturation.toFloat()
96
+ )
97
+
98
+ val outputDir = reactApplicationContext.cacheDir
99
+ val timestamp = System.currentTimeMillis()
100
+ val outputPath = ImageProcessor.saveBitmapToFile(
101
+ bitmap = processedBitmap,
102
+ directory = outputDir,
103
+ filename = "docscanner_enhanced_$timestamp.jpg",
104
+ quality = 0.98f
105
+ )
106
+
107
+ // Cleanup
108
+ bitmap.recycle()
109
+ if (processedBitmap != bitmap) {
110
+ processedBitmap.recycle()
111
+ }
112
+
113
+ withContext(Dispatchers.Main) {
114
+ promise.resolve(outputPath)
115
+ }
116
+ }
117
+ } catch (e: Exception) {
118
+ Log.e(TAG, "Failed to apply color controls", e)
119
+ promise.reject("COLOR_CONTROLS_ERROR", "Failed to apply color controls: ${e.message}", e)
120
+ }
121
+ }
122
+ }
123
+
124
+ override fun onCatalystInstanceDestroy() {
125
+ super.onCatalystInstanceDestroy()
126
+ scope.cancel()
127
+ }
128
+ }
@@ -0,0 +1,16 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class DocumentScannerPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
+ return listOf(DocumentScannerModule(reactContext))
11
+ }
12
+
13
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
14
+ return listOf(DocumentScannerViewManager())
15
+ }
16
+ }
@@ -0,0 +1,363 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import android.content.Context
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.graphics.Paint
8
+ import android.graphics.PorterDuff
9
+ import android.graphics.PorterDuffXfermode
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.util.Base64
13
+ import android.util.Log
14
+ import android.view.View
15
+ import android.widget.FrameLayout
16
+ import androidx.camera.view.PreviewView
17
+ import androidx.lifecycle.LifecycleOwner
18
+ import com.facebook.react.bridge.Arguments
19
+ import com.facebook.react.bridge.WritableMap
20
+ import com.facebook.react.uimanager.ThemedReactContext
21
+ import com.facebook.react.uimanager.events.RCTEventEmitter
22
+ import kotlinx.coroutines.*
23
+ import java.io.File
24
+ import kotlin.math.max
25
+
26
+ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context) {
27
+ private val themedContext = context
28
+ private val previewView: PreviewView
29
+ private val overlayView: OverlayView
30
+ private var cameraController: CameraController? = null
31
+
32
+ // Props (matching iOS)
33
+ var overlayColor: Int = Color.parseColor("#80FFFFFF")
34
+ var enableTorch: Boolean = false
35
+ var useFrontCam: Boolean = false
36
+ var useBase64: Boolean = false
37
+ var saveInAppDocument: Boolean = false
38
+ var captureMultiple: Boolean = false
39
+ var manualOnly: Boolean = false
40
+ var detectionCountBeforeCapture: Int = 15
41
+ var detectionRefreshRateInMS: Int = 100
42
+ var quality: Float = 0.95f
43
+ var brightness: Float = 0f
44
+ var contrast: Float = 1f
45
+ var saturation: Float = 1f
46
+
47
+ // State
48
+ private var stableCounter = 0
49
+ private var lastDetectedRectangle: Rectangle? = null
50
+ private var lastDetectionQuality: RectangleQuality = RectangleQuality.TOO_FAR
51
+ private val detectionHandler = Handler(Looper.getMainLooper())
52
+ private var detectionRunnable: Runnable? = null
53
+ private var isCapturing = false
54
+
55
+ // Coroutine scope for async operations
56
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
57
+
58
+ companion object {
59
+ private const val TAG = "DocumentScannerView"
60
+ }
61
+
62
+ init {
63
+ // Create preview view
64
+ previewView = PreviewView(context).apply {
65
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
66
+ scaleType = PreviewView.ScaleType.FILL_CENTER
67
+ }
68
+ addView(previewView)
69
+
70
+ // Create overlay view for drawing rectangle
71
+ overlayView = OverlayView(context)
72
+ addView(overlayView)
73
+
74
+ // Setup camera
75
+ post {
76
+ setupCamera()
77
+ }
78
+ }
79
+
80
+ private fun setupCamera() {
81
+ try {
82
+ val lifecycleOwner = context as? LifecycleOwner ?: run {
83
+ Log.e(TAG, "Context is not a LifecycleOwner")
84
+ return
85
+ }
86
+
87
+ cameraController = CameraController(context, lifecycleOwner, previewView)
88
+ cameraController?.startCamera(useFrontCam, !manualOnly)
89
+
90
+ // Start detection loop
91
+ startDetectionLoop()
92
+
93
+ Log.d(TAG, "Camera setup completed")
94
+ } catch (e: Exception) {
95
+ Log.e(TAG, "Failed to setup camera", e)
96
+ }
97
+ }
98
+
99
+ private fun startDetectionLoop() {
100
+ detectionRunnable?.let { detectionHandler.removeCallbacks(it) }
101
+
102
+ detectionRunnable = object : Runnable {
103
+ override fun run() {
104
+ // Perform detection
105
+ performDetection()
106
+
107
+ // Schedule next detection
108
+ detectionHandler.postDelayed(this, detectionRefreshRateInMS.toLong())
109
+ }
110
+ }
111
+
112
+ detectionHandler.post(detectionRunnable!!)
113
+ }
114
+
115
+ private fun performDetection() {
116
+ // In a real implementation, we'd analyze the camera frames
117
+ // For now, we'll simulate detection based on capture
118
+ // The actual detection happens during capture in this simplified version
119
+ }
120
+
121
+ private fun onRectangleDetected(rectangle: Rectangle?, quality: RectangleQuality) {
122
+ lastDetectedRectangle = rectangle
123
+ lastDetectionQuality = quality
124
+
125
+ // Update overlay
126
+ overlayView.setRectangle(rectangle, overlayColor)
127
+
128
+ // Update stable counter based on quality
129
+ when (quality) {
130
+ RectangleQuality.GOOD -> {
131
+ if (rectangle != null) {
132
+ stableCounter++
133
+ Log.d(TAG, "Good rectangle detected, stableCounter: $stableCounter/$detectionCountBeforeCapture")
134
+ }
135
+ }
136
+ RectangleQuality.BAD_ANGLE, RectangleQuality.TOO_FAR -> {
137
+ if (stableCounter > 0) {
138
+ stableCounter--
139
+ }
140
+ Log.d(TAG, "Bad rectangle detected (type: $quality), stableCounter: $stableCounter")
141
+ }
142
+ }
143
+
144
+ // Send event to JavaScript
145
+ sendRectangleDetectEvent(rectangle, quality)
146
+
147
+ // Auto-capture if threshold reached
148
+ if (!manualOnly && stableCounter >= detectionCountBeforeCapture && rectangle != null) {
149
+ Log.d(TAG, "Auto-capture triggered! stableCounter: $stableCounter >= threshold: $detectionCountBeforeCapture")
150
+ stableCounter = 0
151
+ capture()
152
+ }
153
+ }
154
+
155
+ fun capture() {
156
+ if (isCapturing) {
157
+ Log.d(TAG, "Already capturing, ignoring request")
158
+ return
159
+ }
160
+
161
+ isCapturing = true
162
+ Log.d(TAG, "Capture initiated")
163
+
164
+ val outputDir = if (saveInAppDocument) {
165
+ context.filesDir
166
+ } else {
167
+ context.cacheDir
168
+ }
169
+
170
+ cameraController?.capturePhoto(
171
+ outputDirectory = outputDir,
172
+ onImageCaptured = { file ->
173
+ scope.launch {
174
+ processAndEmitImage(file)
175
+ }
176
+ },
177
+ onError = { exception ->
178
+ Log.e(TAG, "Capture failed", exception)
179
+ isCapturing = false
180
+ sendErrorEvent("capture_failed")
181
+ }
182
+ )
183
+ }
184
+
185
+ private suspend fun processAndEmitImage(imageFile: File) = withContext(Dispatchers.IO) {
186
+ try {
187
+ // Detect rectangle in captured image
188
+ val bitmap = BitmapFactory.decodeFile(imageFile.absolutePath)
189
+ val detectedRectangle = DocumentDetector.detectRectangle(bitmap)
190
+
191
+ // Process image with detected rectangle
192
+ val shouldCrop = detectedRectangle != null && stableCounter > 0
193
+ val processed = ImageProcessor.processImage(
194
+ imagePath = imageFile.absolutePath,
195
+ rectangle = detectedRectangle,
196
+ brightness = brightness,
197
+ contrast = contrast,
198
+ saturation = saturation,
199
+ shouldCrop = shouldCrop
200
+ )
201
+
202
+ // Save or encode images
203
+ val result = if (useBase64) {
204
+ Arguments.createMap().apply {
205
+ putString("croppedImage", ImageProcessor.bitmapToBase64(processed.croppedImage, quality))
206
+ putString("initialImage", ImageProcessor.bitmapToBase64(processed.initialImage, quality))
207
+ putMap("rectangleCoordinates", detectedRectangle?.toMap()?.toWritableMap())
208
+ }
209
+ } else {
210
+ val timestamp = System.currentTimeMillis()
211
+ val croppedPath = ImageProcessor.saveBitmapToFile(
212
+ processed.croppedImage,
213
+ if (saveInAppDocument) context.filesDir else context.cacheDir,
214
+ "cropped_img_$timestamp.jpeg",
215
+ quality
216
+ )
217
+ val initialPath = ImageProcessor.saveBitmapToFile(
218
+ processed.initialImage,
219
+ if (saveInAppDocument) context.filesDir else context.cacheDir,
220
+ "initial_img_$timestamp.jpeg",
221
+ quality
222
+ )
223
+
224
+ Arguments.createMap().apply {
225
+ putString("croppedImage", croppedPath)
226
+ putString("initialImage", initialPath)
227
+ putMap("rectangleCoordinates", detectedRectangle?.toMap()?.toWritableMap())
228
+ }
229
+ }
230
+
231
+ withContext(Dispatchers.Main) {
232
+ sendPictureTakenEvent(result)
233
+ isCapturing = false
234
+
235
+ if (!captureMultiple) {
236
+ stopCamera()
237
+ }
238
+ }
239
+ } catch (e: Exception) {
240
+ Log.e(TAG, "Failed to process image", e)
241
+ withContext(Dispatchers.Main) {
242
+ sendErrorEvent("processing_failed")
243
+ isCapturing = false
244
+ }
245
+ }
246
+ }
247
+
248
+ private fun sendPictureTakenEvent(data: WritableMap) {
249
+ val event = Arguments.createMap().apply {
250
+ merge(data)
251
+ }
252
+ themedContext.getJSModule(RCTEventEmitter::class.java)
253
+ .receiveEvent(id, "onPictureTaken", event)
254
+ }
255
+
256
+ private fun sendRectangleDetectEvent(rectangle: Rectangle?, quality: RectangleQuality) {
257
+ val event = Arguments.createMap().apply {
258
+ putInt("stableCounter", stableCounter)
259
+ putInt("lastDetectionType", quality.ordinal)
260
+ putMap("rectangleCoordinates", rectangle?.toMap()?.toWritableMap())
261
+ putMap("previewSize", Arguments.createMap().apply {
262
+ putInt("width", width)
263
+ putInt("height", height)
264
+ })
265
+ }
266
+ themedContext.getJSModule(RCTEventEmitter::class.java)
267
+ .receiveEvent(id, "onRectangleDetect", event)
268
+ }
269
+
270
+ private fun sendErrorEvent(error: String) {
271
+ val event = Arguments.createMap().apply {
272
+ putString("error", error)
273
+ }
274
+ themedContext.getJSModule(RCTEventEmitter::class.java)
275
+ .receiveEvent(id, "onPictureTaken", event)
276
+ }
277
+
278
+ fun setEnableTorch(enabled: Boolean) {
279
+ this.enableTorch = enabled
280
+ cameraController?.setTorchEnabled(enabled)
281
+ }
282
+
283
+ fun setUseFrontCam(enabled: Boolean) {
284
+ if (this.useFrontCam != enabled) {
285
+ this.useFrontCam = enabled
286
+ cameraController?.stopCamera()
287
+ setupCamera()
288
+ }
289
+ }
290
+
291
+ fun startCamera() {
292
+ cameraController?.startCamera(useFrontCam, !manualOnly)
293
+ startDetectionLoop()
294
+ }
295
+
296
+ fun stopCamera() {
297
+ detectionRunnable?.let { detectionHandler.removeCallbacks(it) }
298
+ cameraController?.stopCamera()
299
+ }
300
+
301
+ override fun onDetachedFromWindow() {
302
+ super.onDetachedFromWindow()
303
+ stopCamera()
304
+ cameraController?.shutdown()
305
+ scope.cancel()
306
+ }
307
+
308
+ /**
309
+ * Overlay view for drawing detected rectangle
310
+ */
311
+ private class OverlayView(context: Context) : View(context) {
312
+ private var rectangle: Rectangle? = null
313
+ private var overlayColor: Int = Color.parseColor("#80FFFFFF")
314
+ private val paint = Paint().apply {
315
+ style = Paint.Style.FILL
316
+ xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)
317
+ }
318
+
319
+ fun setRectangle(rect: Rectangle?, color: Int) {
320
+ this.rectangle = rect
321
+ this.overlayColor = color
322
+ invalidate()
323
+ }
324
+
325
+ override fun onDraw(canvas: Canvas) {
326
+ super.onDraw(canvas)
327
+
328
+ rectangle?.let { rect ->
329
+ paint.color = overlayColor
330
+
331
+ // Draw the rectangle overlay (simplified - just a filled polygon)
332
+ val path = android.graphics.Path().apply {
333
+ moveTo(rect.topLeft.x.toFloat(), rect.topLeft.y.toFloat())
334
+ lineTo(rect.topRight.x.toFloat(), rect.topRight.y.toFloat())
335
+ lineTo(rect.bottomRight.x.toFloat(), rect.bottomRight.y.toFloat())
336
+ lineTo(rect.bottomLeft.x.toFloat(), rect.bottomLeft.y.toFloat())
337
+ close()
338
+ }
339
+
340
+ canvas.drawPath(path, paint)
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Extension function to convert Map to WritableMap
348
+ */
349
+ private fun Map<String, Any?>.toWritableMap(): WritableMap {
350
+ val map = Arguments.createMap()
351
+ forEach { (key, value) ->
352
+ when (value) {
353
+ null -> map.putNull(key)
354
+ is Boolean -> map.putBoolean(key, value)
355
+ is Double -> map.putDouble(key, value)
356
+ is Int -> map.putInt(key, value)
357
+ is String -> map.putString(key, value)
358
+ is Map<*, *> -> map.putMap(key, (value as Map<String, Any?>).toWritableMap())
359
+ else -> Log.w("DocumentScannerView", "Unknown type for key $key: ${value::class.java}")
360
+ }
361
+ }
362
+ return map
363
+ }
@@ -0,0 +1,102 @@
1
+ package com.reactnativerectangledocscanner
2
+
3
+ import android.graphics.Color
4
+ import com.facebook.react.bridge.ReadableArray
5
+ import com.facebook.react.common.MapBuilder
6
+ import com.facebook.react.uimanager.SimpleViewManager
7
+ import com.facebook.react.uimanager.ThemedReactContext
8
+ import com.facebook.react.uimanager.annotations.ReactProp
9
+
10
+ class DocumentScannerViewManager : SimpleViewManager<DocumentScannerView>() {
11
+
12
+ companion object {
13
+ const val REACT_CLASS = "RNPdfScannerManager"
14
+ }
15
+
16
+ override fun getName() = REACT_CLASS
17
+
18
+ override fun createViewInstance(reactContext: ThemedReactContext): DocumentScannerView {
19
+ return DocumentScannerView(reactContext)
20
+ }
21
+
22
+ @ReactProp(name = "overlayColor", customType = "Color")
23
+ fun setOverlayColor(view: DocumentScannerView, color: Int?) {
24
+ color?.let {
25
+ view.overlayColor = it
26
+ }
27
+ }
28
+
29
+ @ReactProp(name = "enableTorch")
30
+ fun setEnableTorch(view: DocumentScannerView, enabled: Boolean) {
31
+ view.setEnableTorch(enabled)
32
+ }
33
+
34
+ @ReactProp(name = "useFrontCam")
35
+ fun setUseFrontCam(view: DocumentScannerView, enabled: Boolean) {
36
+ view.setUseFrontCam(enabled)
37
+ }
38
+
39
+ @ReactProp(name = "useBase64")
40
+ fun setUseBase64(view: DocumentScannerView, enabled: Boolean) {
41
+ view.useBase64 = enabled
42
+ }
43
+
44
+ @ReactProp(name = "saveInAppDocument")
45
+ fun setSaveInAppDocument(view: DocumentScannerView, enabled: Boolean) {
46
+ view.saveInAppDocument = enabled
47
+ }
48
+
49
+ @ReactProp(name = "captureMultiple")
50
+ fun setCaptureMultiple(view: DocumentScannerView, enabled: Boolean) {
51
+ view.captureMultiple = enabled
52
+ }
53
+
54
+ @ReactProp(name = "manualOnly")
55
+ fun setManualOnly(view: DocumentScannerView, enabled: Boolean) {
56
+ view.manualOnly = enabled
57
+ }
58
+
59
+ @ReactProp(name = "detectionCountBeforeCapture")
60
+ fun setDetectionCountBeforeCapture(view: DocumentScannerView, count: Int) {
61
+ view.detectionCountBeforeCapture = count
62
+ }
63
+
64
+ @ReactProp(name = "detectionRefreshRateInMS")
65
+ fun setDetectionRefreshRateInMS(view: DocumentScannerView, rate: Int) {
66
+ view.detectionRefreshRateInMS = rate
67
+ }
68
+
69
+ @ReactProp(name = "quality")
70
+ fun setQuality(view: DocumentScannerView, quality: Float) {
71
+ view.quality = quality
72
+ }
73
+
74
+ @ReactProp(name = "brightness")
75
+ fun setBrightness(view: DocumentScannerView, brightness: Float) {
76
+ view.brightness = brightness
77
+ }
78
+
79
+ @ReactProp(name = "contrast")
80
+ fun setContrast(view: DocumentScannerView, contrast: Float) {
81
+ view.contrast = contrast
82
+ }
83
+
84
+ @ReactProp(name = "saturation")
85
+ fun setSaturation(view: DocumentScannerView, saturation: Float) {
86
+ view.saturation = saturation
87
+ }
88
+
89
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
90
+ return MapBuilder.of(
91
+ "onPictureTaken",
92
+ MapBuilder.of("registrationName", "onPictureTaken"),
93
+ "onRectangleDetect",
94
+ MapBuilder.of("registrationName", "onRectangleDetect")
95
+ )
96
+ }
97
+
98
+ override fun onDropViewInstance(view: DocumentScannerView) {
99
+ super.onDropViewInstance(view)
100
+ view.stopCamera()
101
+ }
102
+ }