react-native-rectangle-doc-scanner 13.11.0 → 15.0.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.
- package/android/build.gradle +2 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerModule.kt +161 -1
- package/dist/DocScanner.d.ts +5 -0
- package/dist/DocScanner.js +93 -1
- package/dist/FullDocScanner.js +17 -10
- package/package.json +1 -1
- package/src/DocScanner.tsx +135 -1
- package/src/FullDocScanner.tsx +19 -11
package/android/build.gradle
CHANGED
|
@@ -86,6 +86,8 @@ dependencies {
|
|
|
86
86
|
|
|
87
87
|
// ML Kit object detection for live rectangle hints (Camera2 mode)
|
|
88
88
|
implementation 'com.google.mlkit:object-detection:17.0.1'
|
|
89
|
+
// ML Kit document scanner (Activity-based flow)
|
|
90
|
+
implementation 'com.google.android.gms:play-services-mlkit-document-scanner:16.0.0'
|
|
89
91
|
|
|
90
92
|
if (hasVisionCamera) {
|
|
91
93
|
// VisionCamera mode - include VisionCamera dependency
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
package com.reactnativerectangledocscanner
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
3
5
|
import android.graphics.BitmapFactory
|
|
6
|
+
import android.net.Uri
|
|
4
7
|
import android.util.Log
|
|
5
8
|
import com.facebook.react.bridge.*
|
|
6
9
|
import com.facebook.react.uimanager.UIManagerModule
|
|
10
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions
|
|
11
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScanning
|
|
12
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult
|
|
7
13
|
import kotlinx.coroutines.*
|
|
8
14
|
import org.opencv.core.Point
|
|
15
|
+
import java.io.File
|
|
16
|
+
import java.io.FileOutputStream
|
|
17
|
+
import java.io.InputStream
|
|
9
18
|
|
|
10
19
|
class DocumentScannerModule(reactContext: ReactApplicationContext) :
|
|
11
|
-
ReactContextBaseJavaModule(reactContext) {
|
|
20
|
+
ReactContextBaseJavaModule(reactContext), ActivityEventListener {
|
|
12
21
|
|
|
13
22
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
23
|
+
private var pendingScanPromise: Promise? = null
|
|
24
|
+
|
|
25
|
+
private val scanRequestCode = 39201
|
|
26
|
+
|
|
27
|
+
init {
|
|
28
|
+
reactContext.addActivityEventListener(this)
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
companion object {
|
|
16
32
|
const val NAME = "RNPdfScannerManager"
|
|
@@ -19,6 +35,55 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) :
|
|
|
19
35
|
|
|
20
36
|
override fun getName() = NAME
|
|
21
37
|
|
|
38
|
+
@ReactMethod
|
|
39
|
+
fun startDocumentScanner(options: ReadableMap?, promise: Promise) {
|
|
40
|
+
val activity = currentActivity
|
|
41
|
+
if (activity == null) {
|
|
42
|
+
promise.reject("NO_ACTIVITY", "Activity doesn't exist")
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (pendingScanPromise != null) {
|
|
47
|
+
promise.reject("SCAN_IN_PROGRESS", "Document scanner already in progress")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
val pageLimit = options?.takeIf { it.hasKey("pageLimit") }?.getInt("pageLimit") ?: 2
|
|
52
|
+
|
|
53
|
+
val scannerOptions = GmsDocumentScannerOptions.Builder()
|
|
54
|
+
.setScannerMode(GmsDocumentScannerOptions.SCANNER_MODE_FULL)
|
|
55
|
+
.setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG)
|
|
56
|
+
.setPageLimit(pageLimit.coerceAtMost(2))
|
|
57
|
+
.setGalleryImportAllowed(false)
|
|
58
|
+
.build()
|
|
59
|
+
|
|
60
|
+
val scanner = GmsDocumentScanning.getClient(scannerOptions)
|
|
61
|
+
pendingScanPromise = promise
|
|
62
|
+
|
|
63
|
+
scanner.getStartScanIntent(activity)
|
|
64
|
+
.addOnSuccessListener { intentSender ->
|
|
65
|
+
try {
|
|
66
|
+
activity.startIntentSenderForResult(
|
|
67
|
+
intentSender,
|
|
68
|
+
scanRequestCode,
|
|
69
|
+
null,
|
|
70
|
+
0,
|
|
71
|
+
0,
|
|
72
|
+
0
|
|
73
|
+
)
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
Log.e(TAG, "Failed to launch document scanner", e)
|
|
76
|
+
pendingScanPromise = null
|
|
77
|
+
promise.reject("SCAN_LAUNCH_FAILED", "Failed to launch scanner: ${e.message}", e)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
.addOnFailureListener { e ->
|
|
81
|
+
Log.e(TAG, "Failed to get document scanner intent", e)
|
|
82
|
+
pendingScanPromise = null
|
|
83
|
+
promise.reject("SCAN_INIT_FAILED", "Failed to initialize scanner: ${e.message}", e)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
22
87
|
/**
|
|
23
88
|
* Capture image from the document scanner view
|
|
24
89
|
* Matches iOS signature: capture(reactTag, resolver, rejecter)
|
|
@@ -238,6 +303,101 @@ class DocumentScannerModule(reactContext: ReactApplicationContext) :
|
|
|
238
303
|
}
|
|
239
304
|
}
|
|
240
305
|
|
|
306
|
+
override fun onActivityResult(activity: Activity?, requestCode: Int, resultCode: Int, data: Intent?) {
|
|
307
|
+
if (requestCode != scanRequestCode) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
val promise = pendingScanPromise
|
|
312
|
+
pendingScanPromise = null
|
|
313
|
+
|
|
314
|
+
if (promise == null) {
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (resultCode != Activity.RESULT_OK) {
|
|
319
|
+
promise.reject("SCAN_CANCELLED", "Document scan cancelled")
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
val result = GmsDocumentScanningResult.fromActivityResultIntent(data)
|
|
324
|
+
if (result == null) {
|
|
325
|
+
promise.reject("SCAN_NO_RESULT", "No scan result returned")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
val pages = result.pages
|
|
330
|
+
if (pages.isNullOrEmpty()) {
|
|
331
|
+
promise.reject("SCAN_NO_PAGES", "No pages returned from scanner")
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
val outputPages = Arguments.createArray()
|
|
337
|
+
var firstPath: String? = null
|
|
338
|
+
var firstWidth = 0
|
|
339
|
+
var firstHeight = 0
|
|
340
|
+
|
|
341
|
+
pages.forEachIndexed { index, page ->
|
|
342
|
+
val imageUri = page.imageUri
|
|
343
|
+
val outputFile = copyUriToCache(imageUri, index)
|
|
344
|
+
val (width, height) = readImageSize(outputFile.absolutePath)
|
|
345
|
+
|
|
346
|
+
if (index == 0) {
|
|
347
|
+
firstPath = outputFile.absolutePath
|
|
348
|
+
firstWidth = width
|
|
349
|
+
firstHeight = height
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
val pageMap = Arguments.createMap().apply {
|
|
353
|
+
putString("path", outputFile.absolutePath)
|
|
354
|
+
putInt("width", width)
|
|
355
|
+
putInt("height", height)
|
|
356
|
+
}
|
|
357
|
+
outputPages.pushMap(pageMap)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
val response = Arguments.createMap().apply {
|
|
361
|
+
putString("croppedImage", firstPath)
|
|
362
|
+
putString("initialImage", firstPath)
|
|
363
|
+
putInt("width", firstWidth)
|
|
364
|
+
putInt("height", firstHeight)
|
|
365
|
+
putArray("pages", outputPages)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
promise.resolve(response)
|
|
369
|
+
} catch (e: Exception) {
|
|
370
|
+
Log.e(TAG, "Failed to handle scan result", e)
|
|
371
|
+
promise.reject("SCAN_PROCESS_FAILED", "Failed to handle scan result: ${e.message}", e)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
override fun onNewIntent(intent: Intent?) {
|
|
376
|
+
// No-op
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private fun copyUriToCache(uri: Uri, index: Int): File {
|
|
380
|
+
val filename = "docscanner_page_${System.currentTimeMillis()}_$index.jpg"
|
|
381
|
+
val outputFile = File(reactApplicationContext.cacheDir, filename)
|
|
382
|
+
val resolver = reactApplicationContext.contentResolver
|
|
383
|
+
val inputStream: InputStream = resolver.openInputStream(uri)
|
|
384
|
+
?: throw IllegalStateException("Failed to open input stream for $uri")
|
|
385
|
+
|
|
386
|
+
inputStream.use { input ->
|
|
387
|
+
FileOutputStream(outputFile).use { output ->
|
|
388
|
+
input.copyTo(output)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return outputFile
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private fun readImageSize(path: String): Pair<Int, Int> {
|
|
396
|
+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
397
|
+
BitmapFactory.decodeFile(path, options)
|
|
398
|
+
return options.outWidth to options.outHeight
|
|
399
|
+
}
|
|
400
|
+
|
|
241
401
|
override fun onCatalystInstanceDestroy() {
|
|
242
402
|
super.onCatalystInstanceDestroy()
|
|
243
403
|
scope.cancel()
|
package/dist/DocScanner.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ type PictureEvent = {
|
|
|
8
8
|
height?: number;
|
|
9
9
|
rectangleCoordinates?: NativeRectangle | null;
|
|
10
10
|
rectangleOnScreen?: NativeRectangle | null;
|
|
11
|
+
pages?: Array<{
|
|
12
|
+
path: string;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}> | null;
|
|
11
16
|
};
|
|
12
17
|
export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
|
|
13
18
|
rectangleCoordinates?: Rectangle | null;
|
package/dist/DocScanner.js
CHANGED
|
@@ -44,6 +44,7 @@ const coordinate_1 = require("./utils/coordinate");
|
|
|
44
44
|
const overlay_1 = require("./utils/overlay");
|
|
45
45
|
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
46
46
|
const { RNPdfScannerManager } = react_native_1.NativeModules;
|
|
47
|
+
const isAndroidDocumentScanner = react_native_1.Platform.OS === 'android' && typeof RNPdfScannerManager?.startDocumentScanner === 'function';
|
|
47
48
|
const safeRequire = (moduleName) => {
|
|
48
49
|
try {
|
|
49
50
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -87,6 +88,89 @@ const RectangleQuality = {
|
|
|
87
88
|
BAD_ANGLE: 1,
|
|
88
89
|
TOO_FAR: 2,
|
|
89
90
|
};
|
|
91
|
+
const DEFAULT_PAGE_LIMIT = 2;
|
|
92
|
+
const ActivityScanner = (0, react_1.forwardRef)(({ onCapture, children, showManualCaptureButton = false, }, ref) => {
|
|
93
|
+
const captureOriginRef = (0, react_1.useRef)('auto');
|
|
94
|
+
const captureResolvers = (0, react_1.useRef)(null);
|
|
95
|
+
const handlePictureTaken = (0, react_1.useCallback)((event) => {
|
|
96
|
+
const captureError = event?.error;
|
|
97
|
+
if (captureError) {
|
|
98
|
+
captureOriginRef.current = 'auto';
|
|
99
|
+
if (captureResolvers.current) {
|
|
100
|
+
captureResolvers.current.reject(new Error(String(captureError)));
|
|
101
|
+
captureResolvers.current = null;
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const normalizedRectangle = normalizeRectangle(event.rectangleCoordinates ?? null) ??
|
|
106
|
+
normalizeRectangle(event.rectangleOnScreen ?? null) ??
|
|
107
|
+
null;
|
|
108
|
+
const quad = normalizedRectangle ? (0, coordinate_1.rectangleToQuad)(normalizedRectangle) : null;
|
|
109
|
+
const origin = captureOriginRef.current;
|
|
110
|
+
captureOriginRef.current = 'auto';
|
|
111
|
+
const initialPath = event.initialImage ?? null;
|
|
112
|
+
const croppedPath = event.croppedImage ?? null;
|
|
113
|
+
const editablePath = initialPath ?? croppedPath;
|
|
114
|
+
if (editablePath) {
|
|
115
|
+
onCapture?.({
|
|
116
|
+
path: editablePath,
|
|
117
|
+
initialPath,
|
|
118
|
+
croppedPath,
|
|
119
|
+
quad,
|
|
120
|
+
rectangle: normalizedRectangle,
|
|
121
|
+
width: event.width ?? 0,
|
|
122
|
+
height: event.height ?? 0,
|
|
123
|
+
origin,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (captureResolvers.current) {
|
|
127
|
+
captureResolvers.current.resolve(event);
|
|
128
|
+
captureResolvers.current = null;
|
|
129
|
+
}
|
|
130
|
+
}, [onCapture]);
|
|
131
|
+
const handleError = (0, react_1.useCallback)((error) => {
|
|
132
|
+
if (captureResolvers.current) {
|
|
133
|
+
captureResolvers.current.reject(error);
|
|
134
|
+
captureResolvers.current = null;
|
|
135
|
+
}
|
|
136
|
+
}, []);
|
|
137
|
+
const capture = (0, react_1.useCallback)(() => {
|
|
138
|
+
captureOriginRef.current = 'manual';
|
|
139
|
+
if (!RNPdfScannerManager?.startDocumentScanner) {
|
|
140
|
+
captureOriginRef.current = 'auto';
|
|
141
|
+
return Promise.reject(new Error('document_scanner_not_available'));
|
|
142
|
+
}
|
|
143
|
+
if (captureResolvers.current) {
|
|
144
|
+
captureOriginRef.current = 'auto';
|
|
145
|
+
return Promise.reject(new Error('capture_in_progress'));
|
|
146
|
+
}
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
captureResolvers.current = { resolve, reject };
|
|
149
|
+
RNPdfScannerManager.startDocumentScanner({ pageLimit: DEFAULT_PAGE_LIMIT })
|
|
150
|
+
.then(handlePictureTaken)
|
|
151
|
+
.catch((error) => {
|
|
152
|
+
captureOriginRef.current = 'auto';
|
|
153
|
+
handleError(error);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}, [handleError, handlePictureTaken]);
|
|
157
|
+
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
158
|
+
capture,
|
|
159
|
+
reset: () => {
|
|
160
|
+
if (captureResolvers.current) {
|
|
161
|
+
captureResolvers.current.reject(new Error('reset'));
|
|
162
|
+
captureResolvers.current = null;
|
|
163
|
+
}
|
|
164
|
+
captureOriginRef.current = 'auto';
|
|
165
|
+
},
|
|
166
|
+
}), [capture]);
|
|
167
|
+
const handleManualCapture = (0, react_1.useCallback)(() => {
|
|
168
|
+
capture().catch(() => null);
|
|
169
|
+
}, [capture]);
|
|
170
|
+
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
171
|
+
showManualCaptureButton && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: handleManualCapture })),
|
|
172
|
+
children));
|
|
173
|
+
});
|
|
90
174
|
const evaluateRectangleQualityInView = (rectangle, viewWidth, viewHeight) => {
|
|
91
175
|
if (!viewWidth || !viewHeight) {
|
|
92
176
|
return RectangleQuality.TOO_FAR;
|
|
@@ -725,9 +809,17 @@ exports.DocScanner = (0, react_1.forwardRef)((props, ref) => {
|
|
|
725
809
|
if (react_native_1.Platform.OS !== 'android') {
|
|
726
810
|
return;
|
|
727
811
|
}
|
|
728
|
-
|
|
812
|
+
if (isAndroidDocumentScanner) {
|
|
813
|
+
console.log('[DocScanner] Using ML Kit document scanner activity on Android');
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
console.log('[DocScanner] Using native CameraX pipeline on Android');
|
|
817
|
+
}
|
|
729
818
|
}, []);
|
|
730
819
|
if (react_native_1.Platform.OS === 'android') {
|
|
820
|
+
if (isAndroidDocumentScanner) {
|
|
821
|
+
return react_1.default.createElement(ActivityScanner, { ref: ref, ...props });
|
|
822
|
+
}
|
|
731
823
|
return react_1.default.createElement(NativeScanner, { ref: ref, ...props });
|
|
732
824
|
}
|
|
733
825
|
if (hasVisionCamera) {
|
package/dist/FullDocScanner.js
CHANGED
|
@@ -157,6 +157,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
157
157
|
const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
|
|
158
158
|
const captureReadyTimeoutRef = (0, react_1.useRef)(null);
|
|
159
159
|
const isBusinessMode = type === 'business';
|
|
160
|
+
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
161
|
+
const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
|
|
162
|
+
const usesAndroidScannerActivity = react_native_1.Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
|
|
160
163
|
const resetScannerView = (0, react_1.useCallback)((options) => {
|
|
161
164
|
setProcessing(false);
|
|
162
165
|
setCroppedImageData(null);
|
|
@@ -165,7 +168,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
165
168
|
setRotationDegrees(0);
|
|
166
169
|
setRectangleDetected(false);
|
|
167
170
|
setRectangleHint(false);
|
|
168
|
-
setCaptureReady(false);
|
|
171
|
+
setCaptureReady(usesAndroidScannerActivity ? true : false);
|
|
169
172
|
captureModeRef.current = null;
|
|
170
173
|
captureInProgressRef.current = false;
|
|
171
174
|
if (rectangleCaptureTimeoutRef.current) {
|
|
@@ -186,7 +189,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
186
189
|
if (options?.remount) {
|
|
187
190
|
setScannerSession((prev) => prev + 1);
|
|
188
191
|
}
|
|
189
|
-
}, []);
|
|
192
|
+
}, [usesAndroidScannerActivity]);
|
|
190
193
|
const mergedStrings = (0, react_1.useMemo)(() => ({
|
|
191
194
|
captureHint: strings?.captureHint,
|
|
192
195
|
manualHint: strings?.manualHint,
|
|
@@ -202,8 +205,6 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
202
205
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
203
206
|
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
204
207
|
}), [strings]);
|
|
205
|
-
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
206
|
-
const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
|
|
207
208
|
const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
|
|
208
209
|
const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
|
|
209
210
|
if (image.base64) {
|
|
@@ -473,7 +474,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
473
474
|
currentCaptureMode: captureModeRef.current,
|
|
474
475
|
captureInProgress: captureInProgressRef.current,
|
|
475
476
|
});
|
|
476
|
-
if (react_native_1.Platform.OS === 'android' && !captureReady) {
|
|
477
|
+
if (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
|
|
477
478
|
console.log('[FullDocScanner] Capture not ready yet, skipping');
|
|
478
479
|
return;
|
|
479
480
|
}
|
|
@@ -490,7 +491,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
490
491
|
return;
|
|
491
492
|
}
|
|
492
493
|
console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
|
|
493
|
-
const captureMode = rectangleDetected ? 'grid' : 'no-grid';
|
|
494
|
+
const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
|
|
494
495
|
captureModeRef.current = captureMode;
|
|
495
496
|
captureInProgressRef.current = true;
|
|
496
497
|
// Add timeout to reset state if capture hangs
|
|
@@ -522,7 +523,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
522
523
|
emitError(error, 'Failed to capture image. Please try again.');
|
|
523
524
|
}
|
|
524
525
|
});
|
|
525
|
-
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
|
|
526
|
+
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
|
|
526
527
|
const handleGalleryPick = (0, react_1.useCallback)(async () => {
|
|
527
528
|
console.log('[FullDocScanner] handleGalleryPick called');
|
|
528
529
|
if (processing || isGalleryOpen) {
|
|
@@ -703,6 +704,11 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
703
704
|
clearTimeout(captureReadyTimeoutRef.current);
|
|
704
705
|
}
|
|
705
706
|
}, []);
|
|
707
|
+
(0, react_1.useEffect)(() => {
|
|
708
|
+
if (usesAndroidScannerActivity) {
|
|
709
|
+
setCaptureReady(true);
|
|
710
|
+
}
|
|
711
|
+
}, [usesAndroidScannerActivity]);
|
|
706
712
|
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
707
713
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
708
714
|
react_native_1.Platform.OS === 'android' && (react_1.default.createElement(react_native_1.StatusBar, { translucent: true, backgroundColor: "transparent" })),
|
|
@@ -783,11 +789,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
783
789
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
|
|
784
790
|
styles.shutterButton,
|
|
785
791
|
processing && styles.buttonDisabled,
|
|
786
|
-
react_native_1.Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
|
|
787
|
-
], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
|
|
792
|
+
react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
|
|
793
|
+
], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
|
|
788
794
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
789
795
|
styles.shutterInner,
|
|
790
|
-
(react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) &&
|
|
796
|
+
(react_native_1.Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
|
|
797
|
+
{ backgroundColor: overlayColor }
|
|
791
798
|
] })),
|
|
792
799
|
react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
|
|
793
800
|
processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
|
package/package.json
CHANGED
package/src/DocScanner.tsx
CHANGED
|
@@ -30,6 +30,7 @@ type PictureEvent = {
|
|
|
30
30
|
height?: number;
|
|
31
31
|
rectangleCoordinates?: NativeRectangle | null;
|
|
32
32
|
rectangleOnScreen?: NativeRectangle | null;
|
|
33
|
+
pages?: Array<{ path: string; width: number; height: number }> | null;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
export type RectangleDetectEvent = Omit<RectangleEventPayload, 'rectangleCoordinates' | 'rectangleOnScreen'> & {
|
|
@@ -53,6 +54,8 @@ const isFiniteNumber = (value: unknown): value is number =>
|
|
|
53
54
|
typeof value === 'number' && Number.isFinite(value);
|
|
54
55
|
|
|
55
56
|
const { RNPdfScannerManager } = NativeModules;
|
|
57
|
+
const isAndroidDocumentScanner =
|
|
58
|
+
Platform.OS === 'android' && typeof RNPdfScannerManager?.startDocumentScanner === 'function';
|
|
56
59
|
|
|
57
60
|
const safeRequire = (moduleName: string) => {
|
|
58
61
|
try {
|
|
@@ -137,6 +140,130 @@ const RectangleQuality = {
|
|
|
137
140
|
TOO_FAR: 2,
|
|
138
141
|
} as const;
|
|
139
142
|
|
|
143
|
+
const DEFAULT_PAGE_LIMIT = 2;
|
|
144
|
+
|
|
145
|
+
const ActivityScanner = forwardRef<DocScannerHandle, Props>(
|
|
146
|
+
(
|
|
147
|
+
{
|
|
148
|
+
onCapture,
|
|
149
|
+
children,
|
|
150
|
+
showManualCaptureButton = false,
|
|
151
|
+
},
|
|
152
|
+
ref,
|
|
153
|
+
) => {
|
|
154
|
+
const captureOriginRef = useRef<'auto' | 'manual'>('auto');
|
|
155
|
+
const captureResolvers = useRef<{
|
|
156
|
+
resolve: (value: PictureEvent) => void;
|
|
157
|
+
reject: (error: Error) => void;
|
|
158
|
+
} | null>(null);
|
|
159
|
+
|
|
160
|
+
const handlePictureTaken = useCallback(
|
|
161
|
+
(event: PictureEvent) => {
|
|
162
|
+
const captureError = (event as any)?.error;
|
|
163
|
+
if (captureError) {
|
|
164
|
+
captureOriginRef.current = 'auto';
|
|
165
|
+
|
|
166
|
+
if (captureResolvers.current) {
|
|
167
|
+
captureResolvers.current.reject(new Error(String(captureError)));
|
|
168
|
+
captureResolvers.current = null;
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const normalizedRectangle =
|
|
174
|
+
normalizeRectangle(event.rectangleCoordinates ?? null) ??
|
|
175
|
+
normalizeRectangle(event.rectangleOnScreen ?? null) ??
|
|
176
|
+
null;
|
|
177
|
+
const quad = normalizedRectangle ? rectangleToQuad(normalizedRectangle) : null;
|
|
178
|
+
const origin = captureOriginRef.current;
|
|
179
|
+
captureOriginRef.current = 'auto';
|
|
180
|
+
|
|
181
|
+
const initialPath = event.initialImage ?? null;
|
|
182
|
+
const croppedPath = event.croppedImage ?? null;
|
|
183
|
+
const editablePath = initialPath ?? croppedPath;
|
|
184
|
+
|
|
185
|
+
if (editablePath) {
|
|
186
|
+
onCapture?.({
|
|
187
|
+
path: editablePath,
|
|
188
|
+
initialPath,
|
|
189
|
+
croppedPath,
|
|
190
|
+
quad,
|
|
191
|
+
rectangle: normalizedRectangle,
|
|
192
|
+
width: event.width ?? 0,
|
|
193
|
+
height: event.height ?? 0,
|
|
194
|
+
origin,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (captureResolvers.current) {
|
|
199
|
+
captureResolvers.current.resolve(event);
|
|
200
|
+
captureResolvers.current = null;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
[onCapture],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const handleError = useCallback((error: Error) => {
|
|
207
|
+
if (captureResolvers.current) {
|
|
208
|
+
captureResolvers.current.reject(error);
|
|
209
|
+
captureResolvers.current = null;
|
|
210
|
+
}
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
const capture = useCallback((): Promise<PictureEvent> => {
|
|
214
|
+
captureOriginRef.current = 'manual';
|
|
215
|
+
|
|
216
|
+
if (!RNPdfScannerManager?.startDocumentScanner) {
|
|
217
|
+
captureOriginRef.current = 'auto';
|
|
218
|
+
return Promise.reject(new Error('document_scanner_not_available'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (captureResolvers.current) {
|
|
222
|
+
captureOriginRef.current = 'auto';
|
|
223
|
+
return Promise.reject(new Error('capture_in_progress'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return new Promise<PictureEvent>((resolve, reject) => {
|
|
227
|
+
captureResolvers.current = { resolve, reject };
|
|
228
|
+
RNPdfScannerManager.startDocumentScanner({ pageLimit: DEFAULT_PAGE_LIMIT })
|
|
229
|
+
.then(handlePictureTaken)
|
|
230
|
+
.catch((error: unknown) => {
|
|
231
|
+
captureOriginRef.current = 'auto';
|
|
232
|
+
handleError(error as Error);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}, [handleError, handlePictureTaken]);
|
|
236
|
+
|
|
237
|
+
useImperativeHandle(
|
|
238
|
+
ref,
|
|
239
|
+
() => ({
|
|
240
|
+
capture,
|
|
241
|
+
reset: () => {
|
|
242
|
+
if (captureResolvers.current) {
|
|
243
|
+
captureResolvers.current.reject(new Error('reset'));
|
|
244
|
+
captureResolvers.current = null;
|
|
245
|
+
}
|
|
246
|
+
captureOriginRef.current = 'auto';
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
[capture],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const handleManualCapture = useCallback(() => {
|
|
253
|
+
capture().catch(() => null);
|
|
254
|
+
}, [capture]);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<View style={styles.container}>
|
|
258
|
+
{showManualCaptureButton && (
|
|
259
|
+
<TouchableOpacity style={styles.button} onPress={handleManualCapture} />
|
|
260
|
+
)}
|
|
261
|
+
{children}
|
|
262
|
+
</View>
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
140
267
|
const evaluateRectangleQualityInView = (rectangle: Rectangle, viewWidth: number, viewHeight: number) => {
|
|
141
268
|
if (!viewWidth || !viewHeight) {
|
|
142
269
|
return RectangleQuality.TOO_FAR;
|
|
@@ -1035,10 +1162,17 @@ export const DocScanner = forwardRef<DocScannerHandle, Props>((props, ref) => {
|
|
|
1035
1162
|
if (Platform.OS !== 'android') {
|
|
1036
1163
|
return;
|
|
1037
1164
|
}
|
|
1038
|
-
|
|
1165
|
+
if (isAndroidDocumentScanner) {
|
|
1166
|
+
console.log('[DocScanner] Using ML Kit document scanner activity on Android');
|
|
1167
|
+
} else {
|
|
1168
|
+
console.log('[DocScanner] Using native CameraX pipeline on Android');
|
|
1169
|
+
}
|
|
1039
1170
|
}, []);
|
|
1040
1171
|
|
|
1041
1172
|
if (Platform.OS === 'android') {
|
|
1173
|
+
if (isAndroidDocumentScanner) {
|
|
1174
|
+
return <ActivityScanner ref={ref} {...props} />;
|
|
1175
|
+
}
|
|
1042
1176
|
return <NativeScanner ref={ref} {...props} />;
|
|
1043
1177
|
}
|
|
1044
1178
|
|
package/src/FullDocScanner.tsx
CHANGED
|
@@ -229,6 +229,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
229
229
|
const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
230
230
|
|
|
231
231
|
const isBusinessMode = type === 'business';
|
|
232
|
+
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
233
|
+
const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
|
|
234
|
+
const usesAndroidScannerActivity =
|
|
235
|
+
Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
|
|
232
236
|
|
|
233
237
|
const resetScannerView = useCallback(
|
|
234
238
|
(options?: { remount?: boolean }) => {
|
|
@@ -239,7 +243,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
239
243
|
setRotationDegrees(0);
|
|
240
244
|
setRectangleDetected(false);
|
|
241
245
|
setRectangleHint(false);
|
|
242
|
-
setCaptureReady(false);
|
|
246
|
+
setCaptureReady(usesAndroidScannerActivity ? true : false);
|
|
243
247
|
captureModeRef.current = null;
|
|
244
248
|
captureInProgressRef.current = false;
|
|
245
249
|
|
|
@@ -265,7 +269,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
265
269
|
setScannerSession((prev) => prev + 1);
|
|
266
270
|
}
|
|
267
271
|
},
|
|
268
|
-
[],
|
|
272
|
+
[usesAndroidScannerActivity],
|
|
269
273
|
);
|
|
270
274
|
|
|
271
275
|
const mergedStrings = useMemo(
|
|
@@ -287,9 +291,6 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
287
291
|
[strings],
|
|
288
292
|
);
|
|
289
293
|
|
|
290
|
-
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
291
|
-
const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
|
|
292
|
-
|
|
293
294
|
const autoEnhancementEnabled = useMemo(
|
|
294
295
|
() => typeof pdfScannerManager?.applyColorControls === 'function',
|
|
295
296
|
[pdfScannerManager],
|
|
@@ -647,7 +648,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
647
648
|
captureInProgress: captureInProgressRef.current,
|
|
648
649
|
});
|
|
649
650
|
|
|
650
|
-
if (Platform.OS === 'android' && !captureReady) {
|
|
651
|
+
if (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
|
|
651
652
|
console.log('[FullDocScanner] Capture not ready yet, skipping');
|
|
652
653
|
return;
|
|
653
654
|
}
|
|
@@ -669,7 +670,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
669
670
|
|
|
670
671
|
console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
|
|
671
672
|
|
|
672
|
-
const captureMode = rectangleDetected ? 'grid' : 'no-grid';
|
|
673
|
+
const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
|
|
673
674
|
captureModeRef.current = captureMode;
|
|
674
675
|
captureInProgressRef.current = true;
|
|
675
676
|
|
|
@@ -710,7 +711,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
710
711
|
);
|
|
711
712
|
}
|
|
712
713
|
});
|
|
713
|
-
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
|
|
714
|
+
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
|
|
714
715
|
|
|
715
716
|
const handleGalleryPick = useCallback(async () => {
|
|
716
717
|
console.log('[FullDocScanner] handleGalleryPick called');
|
|
@@ -934,6 +935,12 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
934
935
|
[],
|
|
935
936
|
);
|
|
936
937
|
|
|
938
|
+
useEffect(() => {
|
|
939
|
+
if (usesAndroidScannerActivity) {
|
|
940
|
+
setCaptureReady(true);
|
|
941
|
+
}
|
|
942
|
+
}, [usesAndroidScannerActivity]);
|
|
943
|
+
|
|
937
944
|
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
938
945
|
|
|
939
946
|
return (
|
|
@@ -1195,16 +1202,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
1195
1202
|
style={[
|
|
1196
1203
|
styles.shutterButton,
|
|
1197
1204
|
processing && styles.buttonDisabled,
|
|
1198
|
-
Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
|
|
1205
|
+
Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
|
|
1199
1206
|
]}
|
|
1200
1207
|
onPress={triggerManualCapture}
|
|
1201
|
-
disabled={processing || (Platform.OS === 'android' && !captureReady)}
|
|
1208
|
+
disabled={processing || (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity)}
|
|
1202
1209
|
accessibilityLabel={mergedStrings.manualHint}
|
|
1203
1210
|
accessibilityRole="button"
|
|
1204
1211
|
>
|
|
1205
1212
|
<View style={[
|
|
1206
1213
|
styles.shutterInner,
|
|
1207
|
-
(Platform.OS === 'android' ? captureReady : rectangleHint) &&
|
|
1214
|
+
(Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
|
|
1215
|
+
{ backgroundColor: overlayColor }
|
|
1208
1216
|
]} />
|
|
1209
1217
|
</TouchableOpacity>
|
|
1210
1218
|
<View style={styles.rightButtonsPlaceholder} />
|