react-native-rectangle-doc-scanner 13.11.0 → 15.1.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 +39 -10
- package/package.json +1 -1
- package/src/DocScanner.tsx +135 -1
- package/src/FullDocScanner.tsx +45 -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(true)
|
|
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
|
@@ -149,6 +149,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
149
149
|
const [scannerSession, setScannerSession] = (0, react_1.useState)(0);
|
|
150
150
|
const [cropEditorDocument, setCropEditorDocument] = (0, react_1.useState)(null);
|
|
151
151
|
const [cropEditorRectangle, setCropEditorRectangle] = (0, react_1.useState)(null);
|
|
152
|
+
const [androidScanAutoRequested, setAndroidScanAutoRequested] = (0, react_1.useState)(false);
|
|
152
153
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
153
154
|
const docScannerRef = (0, react_1.useRef)(null);
|
|
154
155
|
const captureModeRef = (0, react_1.useRef)(null);
|
|
@@ -157,15 +158,19 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
157
158
|
const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
|
|
158
159
|
const captureReadyTimeoutRef = (0, react_1.useRef)(null);
|
|
159
160
|
const isBusinessMode = type === 'business';
|
|
161
|
+
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
162
|
+
const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
|
|
163
|
+
const usesAndroidScannerActivity = react_native_1.Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
|
|
160
164
|
const resetScannerView = (0, react_1.useCallback)((options) => {
|
|
161
165
|
setProcessing(false);
|
|
162
166
|
setCroppedImageData(null);
|
|
163
167
|
setCropEditorDocument(null);
|
|
164
168
|
setCropEditorRectangle(null);
|
|
169
|
+
setAndroidScanAutoRequested(false);
|
|
165
170
|
setRotationDegrees(0);
|
|
166
171
|
setRectangleDetected(false);
|
|
167
172
|
setRectangleHint(false);
|
|
168
|
-
setCaptureReady(false);
|
|
173
|
+
setCaptureReady(usesAndroidScannerActivity ? true : false);
|
|
169
174
|
captureModeRef.current = null;
|
|
170
175
|
captureInProgressRef.current = false;
|
|
171
176
|
if (rectangleCaptureTimeoutRef.current) {
|
|
@@ -186,7 +191,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
186
191
|
if (options?.remount) {
|
|
187
192
|
setScannerSession((prev) => prev + 1);
|
|
188
193
|
}
|
|
189
|
-
}, []);
|
|
194
|
+
}, [usesAndroidScannerActivity]);
|
|
190
195
|
const mergedStrings = (0, react_1.useMemo)(() => ({
|
|
191
196
|
captureHint: strings?.captureHint,
|
|
192
197
|
manualHint: strings?.manualHint,
|
|
@@ -202,8 +207,6 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
202
207
|
secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
|
|
203
208
|
originalBtn: strings?.originalBtn ?? 'Use Original',
|
|
204
209
|
}), [strings]);
|
|
205
|
-
const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
|
|
206
|
-
const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
|
|
207
210
|
const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
|
|
208
211
|
const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
|
|
209
212
|
if (image.base64) {
|
|
@@ -473,7 +476,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
473
476
|
currentCaptureMode: captureModeRef.current,
|
|
474
477
|
captureInProgress: captureInProgressRef.current,
|
|
475
478
|
});
|
|
476
|
-
if (react_native_1.Platform.OS === 'android' && !captureReady) {
|
|
479
|
+
if (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
|
|
477
480
|
console.log('[FullDocScanner] Capture not ready yet, skipping');
|
|
478
481
|
return;
|
|
479
482
|
}
|
|
@@ -490,7 +493,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
490
493
|
return;
|
|
491
494
|
}
|
|
492
495
|
console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
|
|
493
|
-
const captureMode = rectangleDetected ? 'grid' : 'no-grid';
|
|
496
|
+
const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
|
|
494
497
|
captureModeRef.current = captureMode;
|
|
495
498
|
captureInProgressRef.current = true;
|
|
496
499
|
// Add timeout to reset state if capture hangs
|
|
@@ -518,11 +521,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
518
521
|
console.error('[FullDocScanner] Manual capture failed:', errorMessage, error);
|
|
519
522
|
captureModeRef.current = null;
|
|
520
523
|
captureInProgressRef.current = false;
|
|
524
|
+
if (errorMessage.includes('SCAN_CANCELLED')) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
521
527
|
if (error instanceof Error && error.message !== 'capture_in_progress') {
|
|
522
528
|
emitError(error, 'Failed to capture image. Please try again.');
|
|
523
529
|
}
|
|
524
530
|
});
|
|
525
|
-
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
|
|
531
|
+
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
|
|
526
532
|
const handleGalleryPick = (0, react_1.useCallback)(async () => {
|
|
527
533
|
console.log('[FullDocScanner] handleGalleryPick called');
|
|
528
534
|
if (processing || isGalleryOpen) {
|
|
@@ -703,6 +709,28 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
703
709
|
clearTimeout(captureReadyTimeoutRef.current);
|
|
704
710
|
}
|
|
705
711
|
}, []);
|
|
712
|
+
(0, react_1.useEffect)(() => {
|
|
713
|
+
if (usesAndroidScannerActivity) {
|
|
714
|
+
setCaptureReady(true);
|
|
715
|
+
}
|
|
716
|
+
}, [usesAndroidScannerActivity]);
|
|
717
|
+
(0, react_1.useEffect)(() => {
|
|
718
|
+
if (!usesAndroidScannerActivity) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (androidScanAutoRequested || croppedImageData || cropEditorDocument || processing) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
setAndroidScanAutoRequested(true);
|
|
725
|
+
triggerManualCapture();
|
|
726
|
+
}, [
|
|
727
|
+
androidScanAutoRequested,
|
|
728
|
+
cropEditorDocument,
|
|
729
|
+
croppedImageData,
|
|
730
|
+
processing,
|
|
731
|
+
triggerManualCapture,
|
|
732
|
+
usesAndroidScannerActivity,
|
|
733
|
+
]);
|
|
706
734
|
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
707
735
|
return (react_1.default.createElement(react_native_1.View, { style: styles.container },
|
|
708
736
|
react_native_1.Platform.OS === 'android' && (react_1.default.createElement(react_native_1.StatusBar, { translucent: true, backgroundColor: "transparent" })),
|
|
@@ -783,11 +811,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
|
|
|
783
811
|
react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
|
|
784
812
|
styles.shutterButton,
|
|
785
813
|
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" },
|
|
814
|
+
react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
|
|
815
|
+
], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
|
|
788
816
|
react_1.default.createElement(react_native_1.View, { style: [
|
|
789
817
|
styles.shutterInner,
|
|
790
|
-
(react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) &&
|
|
818
|
+
(react_native_1.Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
|
|
819
|
+
{ backgroundColor: overlayColor }
|
|
791
820
|
] })),
|
|
792
821
|
react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
|
|
793
822
|
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
|
@@ -220,6 +220,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
220
220
|
const [scannerSession, setScannerSession] = useState(0);
|
|
221
221
|
const [cropEditorDocument, setCropEditorDocument] = useState<CapturedDocument | null>(null);
|
|
222
222
|
const [cropEditorRectangle, setCropEditorRectangle] = useState<Rectangle | null>(null);
|
|
223
|
+
const [androidScanAutoRequested, setAndroidScanAutoRequested] = useState(false);
|
|
223
224
|
const resolvedGridColor = gridColor ?? overlayColor;
|
|
224
225
|
const docScannerRef = useRef<DocScannerHandle | null>(null);
|
|
225
226
|
const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
|
|
@@ -229,6 +230,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
229
230
|
const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
230
231
|
|
|
231
232
|
const isBusinessMode = type === 'business';
|
|
233
|
+
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
234
|
+
const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
|
|
235
|
+
const usesAndroidScannerActivity =
|
|
236
|
+
Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
|
|
232
237
|
|
|
233
238
|
const resetScannerView = useCallback(
|
|
234
239
|
(options?: { remount?: boolean }) => {
|
|
@@ -236,10 +241,11 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
236
241
|
setCroppedImageData(null);
|
|
237
242
|
setCropEditorDocument(null);
|
|
238
243
|
setCropEditorRectangle(null);
|
|
244
|
+
setAndroidScanAutoRequested(false);
|
|
239
245
|
setRotationDegrees(0);
|
|
240
246
|
setRectangleDetected(false);
|
|
241
247
|
setRectangleHint(false);
|
|
242
|
-
setCaptureReady(false);
|
|
248
|
+
setCaptureReady(usesAndroidScannerActivity ? true : false);
|
|
243
249
|
captureModeRef.current = null;
|
|
244
250
|
captureInProgressRef.current = false;
|
|
245
251
|
|
|
@@ -265,7 +271,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
265
271
|
setScannerSession((prev) => prev + 1);
|
|
266
272
|
}
|
|
267
273
|
},
|
|
268
|
-
[],
|
|
274
|
+
[usesAndroidScannerActivity],
|
|
269
275
|
);
|
|
270
276
|
|
|
271
277
|
const mergedStrings = useMemo(
|
|
@@ -287,9 +293,6 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
287
293
|
[strings],
|
|
288
294
|
);
|
|
289
295
|
|
|
290
|
-
const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
|
|
291
|
-
const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
|
|
292
|
-
|
|
293
296
|
const autoEnhancementEnabled = useMemo(
|
|
294
297
|
() => typeof pdfScannerManager?.applyColorControls === 'function',
|
|
295
298
|
[pdfScannerManager],
|
|
@@ -647,7 +650,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
647
650
|
captureInProgress: captureInProgressRef.current,
|
|
648
651
|
});
|
|
649
652
|
|
|
650
|
-
if (Platform.OS === 'android' && !captureReady) {
|
|
653
|
+
if (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
|
|
651
654
|
console.log('[FullDocScanner] Capture not ready yet, skipping');
|
|
652
655
|
return;
|
|
653
656
|
}
|
|
@@ -669,7 +672,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
669
672
|
|
|
670
673
|
console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
|
|
671
674
|
|
|
672
|
-
const captureMode = rectangleDetected ? 'grid' : 'no-grid';
|
|
675
|
+
const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
|
|
673
676
|
captureModeRef.current = captureMode;
|
|
674
677
|
captureInProgressRef.current = true;
|
|
675
678
|
|
|
@@ -703,6 +706,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
703
706
|
captureModeRef.current = null;
|
|
704
707
|
captureInProgressRef.current = false;
|
|
705
708
|
|
|
709
|
+
if (errorMessage.includes('SCAN_CANCELLED')) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
706
713
|
if (error instanceof Error && error.message !== 'capture_in_progress') {
|
|
707
714
|
emitError(
|
|
708
715
|
error,
|
|
@@ -710,7 +717,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
710
717
|
);
|
|
711
718
|
}
|
|
712
719
|
});
|
|
713
|
-
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
|
|
720
|
+
}, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
|
|
714
721
|
|
|
715
722
|
const handleGalleryPick = useCallback(async () => {
|
|
716
723
|
console.log('[FullDocScanner] handleGalleryPick called');
|
|
@@ -934,6 +941,32 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
934
941
|
[],
|
|
935
942
|
);
|
|
936
943
|
|
|
944
|
+
useEffect(() => {
|
|
945
|
+
if (usesAndroidScannerActivity) {
|
|
946
|
+
setCaptureReady(true);
|
|
947
|
+
}
|
|
948
|
+
}, [usesAndroidScannerActivity]);
|
|
949
|
+
|
|
950
|
+
useEffect(() => {
|
|
951
|
+
if (!usesAndroidScannerActivity) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (androidScanAutoRequested || croppedImageData || cropEditorDocument || processing) {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
setAndroidScanAutoRequested(true);
|
|
960
|
+
triggerManualCapture();
|
|
961
|
+
}, [
|
|
962
|
+
androidScanAutoRequested,
|
|
963
|
+
cropEditorDocument,
|
|
964
|
+
croppedImageData,
|
|
965
|
+
processing,
|
|
966
|
+
triggerManualCapture,
|
|
967
|
+
usesAndroidScannerActivity,
|
|
968
|
+
]);
|
|
969
|
+
|
|
937
970
|
const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
|
|
938
971
|
|
|
939
972
|
return (
|
|
@@ -1195,16 +1228,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
|
|
|
1195
1228
|
style={[
|
|
1196
1229
|
styles.shutterButton,
|
|
1197
1230
|
processing && styles.buttonDisabled,
|
|
1198
|
-
Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
|
|
1231
|
+
Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
|
|
1199
1232
|
]}
|
|
1200
1233
|
onPress={triggerManualCapture}
|
|
1201
|
-
disabled={processing || (Platform.OS === 'android' && !captureReady)}
|
|
1234
|
+
disabled={processing || (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity)}
|
|
1202
1235
|
accessibilityLabel={mergedStrings.manualHint}
|
|
1203
1236
|
accessibilityRole="button"
|
|
1204
1237
|
>
|
|
1205
1238
|
<View style={[
|
|
1206
1239
|
styles.shutterInner,
|
|
1207
|
-
(Platform.OS === 'android' ? captureReady : rectangleHint) &&
|
|
1240
|
+
(Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
|
|
1241
|
+
{ backgroundColor: overlayColor }
|
|
1208
1242
|
]} />
|
|
1209
1243
|
</TouchableOpacity>
|
|
1210
1244
|
<View style={styles.rightButtonsPlaceholder} />
|