react-native-rectangle-doc-scanner 13.9.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.
@@ -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()
@@ -6,6 +6,8 @@ interface CropEditorProps {
6
6
  overlayStrokeColor?: string;
7
7
  handlerColor?: string;
8
8
  enablePanStrict?: boolean;
9
+ enableEditor?: boolean;
10
+ autoCrop?: boolean;
9
11
  onCropChange?: (rectangle: Rectangle) => void;
10
12
  }
11
13
  /**
@@ -32,10 +32,14 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.CropEditor = void 0;
37
40
  const react_1 = __importStar(require("react"));
38
41
  const react_native_1 = require("react-native");
42
+ const react_native_perspective_image_cropper_1 = __importDefault(require("react-native-perspective-image-cropper"));
39
43
  const coordinate_1 = require("./utils/coordinate");
40
44
  /**
41
45
  * CropEditor Component
@@ -50,7 +54,7 @@ const coordinate_1 = require("./utils/coordinate");
50
54
  * @param enablePanStrict - Enable strict panning behavior
51
55
  * @param onCropChange - Callback when user adjusts crop corners
52
56
  */
53
- const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeColor = '#e7a649', handlerColor = '#e7a649', enablePanStrict = false, onCropChange, }) => {
57
+ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeColor = '#e7a649', handlerColor = '#e7a649', enablePanStrict = false, enableEditor = false, autoCrop = true, onCropChange, }) => {
54
58
  const [imageSize, setImageSize] = (0, react_1.useState)(null);
55
59
  const [displaySize, setDisplaySize] = (0, react_1.useState)({
56
60
  width: react_native_1.Dimensions.get('window').width,
@@ -59,32 +63,6 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
59
63
  const [isImageLoading, setIsImageLoading] = (0, react_1.useState)(true);
60
64
  const [loadError, setLoadError] = (0, react_1.useState)(null);
61
65
  const [croppedImageUri, setCroppedImageUri] = (0, react_1.useState)(null);
62
- (0, react_1.useEffect)(() => {
63
- console.log('[CropEditor] Document path:', document.path);
64
- console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
65
- console.log('[CropEditor] Document quad:', document.quad);
66
- console.log('[CropEditor] Document rectangle:', document.rectangle);
67
- // Load image size using Image.getSize
68
- const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
69
- react_native_1.Image.getSize(imageUri, (width, height) => {
70
- console.log('[CropEditor] Image.getSize success:', { width, height });
71
- setImageSize({ width, height });
72
- // If we have a rectangle (from auto-capture), crop the image
73
- if (document.rectangle || document.quad) {
74
- cropImageToRectangle(imageUri, width, height);
75
- }
76
- else {
77
- setIsImageLoading(false);
78
- setLoadError(null);
79
- }
80
- }, (error) => {
81
- console.error('[CropEditor] Image.getSize error:', error);
82
- // Fallback to document dimensions
83
- console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
84
- setImageSize({ width: document.width, height: document.height });
85
- setIsImageLoading(false);
86
- });
87
- }, [document]);
88
66
  const cropImageToRectangle = (0, react_1.useCallback)((imageUri, width, height) => {
89
67
  const cropManager = react_native_1.NativeModules.CustomCropManager;
90
68
  if (!cropManager?.crop) {
@@ -127,6 +105,33 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
127
105
  setLoadError(null);
128
106
  });
129
107
  }, [document]);
108
+ (0, react_1.useEffect)(() => {
109
+ console.log('[CropEditor] Document path:', document.path);
110
+ console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
111
+ console.log('[CropEditor] Document quad:', document.quad);
112
+ console.log('[CropEditor] Document rectangle:', document.rectangle);
113
+ const shouldAutoCrop = autoCrop && !enableEditor;
114
+ // Load image size using Image.getSize
115
+ const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
116
+ react_native_1.Image.getSize(imageUri, (width, height) => {
117
+ console.log('[CropEditor] Image.getSize success:', { width, height });
118
+ setImageSize({ width, height });
119
+ // If we have a rectangle (from auto-capture), crop the image
120
+ if (shouldAutoCrop && (document.rectangle || document.quad)) {
121
+ cropImageToRectangle(imageUri, width, height);
122
+ }
123
+ else {
124
+ setIsImageLoading(false);
125
+ setLoadError(null);
126
+ }
127
+ }, (error) => {
128
+ console.error('[CropEditor] Image.getSize error:', error);
129
+ // Fallback to document dimensions
130
+ console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
131
+ setImageSize({ width: document.width, height: document.height });
132
+ setIsImageLoading(false);
133
+ });
134
+ }, [autoCrop, cropImageToRectangle, document, enableEditor]);
130
135
  // Get initial rectangle from detected quad or use default
131
136
  const getInitialRectangle = (0, react_1.useCallback)(() => {
132
137
  if (!imageSize) {
@@ -180,7 +185,7 @@ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeC
180
185
  react_1.default.createElement(react_native_1.Text, { style: styles.errorText }, "Failed to load image"),
181
186
  react_1.default.createElement(react_native_1.Text, { style: styles.errorPath }, imageUri))) : !imageSize || isImageLoading ? (react_1.default.createElement(react_native_1.View, { style: styles.loadingContainer },
182
187
  react_1.default.createElement(react_native_1.ActivityIndicator, { size: "large", color: handlerColor }),
183
- react_1.default.createElement(react_native_1.Text, { style: styles.loadingText }, "Loading image..."))) : (react_1.default.createElement(react_1.default.Fragment, null,
188
+ react_1.default.createElement(react_native_1.Text, { style: styles.loadingText }, "Loading image..."))) : enableEditor ? (react_1.default.createElement(react_native_perspective_image_cropper_1.default, { height: displaySize.height, width: displaySize.width, image: imageUri, rectangleCoordinates: initialRect, overlayColor: overlayColor, overlayStrokeColor: overlayStrokeColor, handlerColor: handlerColor, enablePanStrict: enablePanStrict, onDragEnd: handleDragEnd })) : (react_1.default.createElement(react_1.default.Fragment, null,
184
189
  react_1.default.createElement(react_native_1.Image, { source: { uri: croppedImageUri || imageUri }, style: styles.fullImage, resizeMode: "contain", onLoad: () => console.log('[CropEditor] Image loaded successfully', croppedImageUri ? 'cropped' : 'original'), onError: (e) => console.error('[CropEditor] Image load error:', e.nativeEvent.error) })))));
185
190
  };
186
191
  exports.CropEditor = CropEditor;
@@ -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;
@@ -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
- console.log('[DocScanner] Using native CameraX pipeline on Android');
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) {
@@ -43,6 +43,7 @@ const react_native_image_picker_1 = require("react-native-image-picker");
43
43
  const react_native_image_crop_picker_1 = __importDefault(require("react-native-image-crop-picker"));
44
44
  const react_native_fs_1 = __importDefault(require("react-native-fs"));
45
45
  const DocScanner_1 = require("./DocScanner");
46
+ const coordinate_1 = require("./utils/coordinate");
46
47
  // 회전은 항상 지원됨 (회전 각도를 반환하고 tdb 앱에서 처리)
47
48
  const isImageRotationSupported = () => true;
48
49
  const stripFileUri = (value) => value.replace(/^file:\/\//, '');
@@ -58,6 +59,17 @@ const ensureFileUri = (value) => {
58
59
  }
59
60
  return value;
60
61
  };
62
+ const safeRequire = (moduleName) => {
63
+ try {
64
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
65
+ return require(moduleName);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ };
71
+ const CropEditorModule = safeRequire('./CropEditor');
72
+ const CropEditor = CropEditorModule?.CropEditor;
61
73
  const CROPPER_TIMEOUT_MS = 8000;
62
74
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
63
75
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -135,6 +147,8 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
135
147
  const [capturedPhotos, setCapturedPhotos] = (0, react_1.useState)([]);
136
148
  const [currentPhotoIndex, setCurrentPhotoIndex] = (0, react_1.useState)(0);
137
149
  const [scannerSession, setScannerSession] = (0, react_1.useState)(0);
150
+ const [cropEditorDocument, setCropEditorDocument] = (0, react_1.useState)(null);
151
+ const [cropEditorRectangle, setCropEditorRectangle] = (0, react_1.useState)(null);
138
152
  const resolvedGridColor = gridColor ?? overlayColor;
139
153
  const docScannerRef = (0, react_1.useRef)(null);
140
154
  const captureModeRef = (0, react_1.useRef)(null);
@@ -143,13 +157,18 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
143
157
  const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
144
158
  const captureReadyTimeoutRef = (0, react_1.useRef)(null);
145
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';
146
163
  const resetScannerView = (0, react_1.useCallback)((options) => {
147
164
  setProcessing(false);
148
165
  setCroppedImageData(null);
166
+ setCropEditorDocument(null);
167
+ setCropEditorRectangle(null);
149
168
  setRotationDegrees(0);
150
169
  setRectangleDetected(false);
151
170
  setRectangleHint(false);
152
- setCaptureReady(false);
171
+ setCaptureReady(usesAndroidScannerActivity ? true : false);
153
172
  captureModeRef.current = null;
154
173
  captureInProgressRef.current = false;
155
174
  if (rectangleCaptureTimeoutRef.current) {
@@ -170,7 +189,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
170
189
  if (options?.remount) {
171
190
  setScannerSession((prev) => prev + 1);
172
191
  }
173
- }, []);
192
+ }, [usesAndroidScannerActivity]);
174
193
  const mergedStrings = (0, react_1.useMemo)(() => ({
175
194
  captureHint: strings?.captureHint,
176
195
  manualHint: strings?.manualHint,
@@ -186,7 +205,6 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
186
205
  secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
187
206
  originalBtn: strings?.originalBtn ?? 'Use Original',
188
207
  }), [strings]);
189
- const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
190
208
  const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
191
209
  const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
192
210
  if (image.base64) {
@@ -243,6 +261,17 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
243
261
  react_native_1.Alert.alert('Document Scanner', fallbackMessage);
244
262
  }
245
263
  }, [onError]);
264
+ const openAndroidCropEditor = (0, react_1.useCallback)((document) => {
265
+ const rectangle = document.rectangle ??
266
+ (document.quad && document.quad.length === 4 ? (0, coordinate_1.quadToRectangle)(document.quad) : null);
267
+ const documentForEditor = rectangle ? { ...document, rectangle } : document;
268
+ setCropEditorDocument(documentForEditor);
269
+ setCropEditorRectangle(rectangle);
270
+ }, []);
271
+ const closeAndroidCropEditor = (0, react_1.useCallback)(() => {
272
+ setCropEditorDocument(null);
273
+ setCropEditorRectangle(null);
274
+ }, []);
246
275
  const openCropper = (0, react_1.useCallback)(async (imagePath, options) => {
247
276
  try {
248
277
  console.log('[FullDocScanner] openCropper called with path:', imagePath);
@@ -314,6 +343,64 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
314
343
  }
315
344
  }
316
345
  }, [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView]);
346
+ const handleCropEditorConfirm = (0, react_1.useCallback)(async () => {
347
+ if (!cropEditorDocument) {
348
+ return;
349
+ }
350
+ const documentPath = cropEditorDocument.path;
351
+ const rectangle = cropEditorRectangle ??
352
+ cropEditorDocument.rectangle ??
353
+ (cropEditorDocument.quad && cropEditorDocument.quad.length === 4
354
+ ? (0, coordinate_1.quadToRectangle)(cropEditorDocument.quad)
355
+ : null);
356
+ if (!rectangle || !pdfScannerManager?.processImage) {
357
+ closeAndroidCropEditor();
358
+ await openCropper(documentPath, { waitForPickerDismissal: false });
359
+ return;
360
+ }
361
+ setProcessing(true);
362
+ try {
363
+ const payload = await pdfScannerManager.processImage({
364
+ imagePath: documentPath,
365
+ rectangleCoordinates: rectangle,
366
+ rectangleWidth: cropEditorDocument.width ?? 0,
367
+ rectangleHeight: cropEditorDocument.height ?? 0,
368
+ useBase64: false,
369
+ quality: 90,
370
+ brightness: 0,
371
+ contrast: 1,
372
+ saturation: 1,
373
+ saveInAppDocument: false,
374
+ });
375
+ const croppedPath = typeof payload?.croppedImage === 'string' ? stripFileUri(payload.croppedImage) : null;
376
+ if (!croppedPath) {
377
+ throw new Error('missing_cropped_image');
378
+ }
379
+ const preview = await preparePreviewImage({ path: croppedPath });
380
+ setCroppedImageData(preview);
381
+ }
382
+ catch (error) {
383
+ console.error('[FullDocScanner] Crop editor processing failed:', error);
384
+ resetScannerView({ remount: true });
385
+ emitError(error instanceof Error ? error : new Error(String(error)), 'Failed to crop image. Please try again.');
386
+ }
387
+ finally {
388
+ setProcessing(false);
389
+ closeAndroidCropEditor();
390
+ }
391
+ }, [
392
+ closeAndroidCropEditor,
393
+ cropEditorDocument,
394
+ cropEditorRectangle,
395
+ emitError,
396
+ openCropper,
397
+ pdfScannerManager,
398
+ preparePreviewImage,
399
+ resetScannerView,
400
+ ]);
401
+ const handleCropEditorCancel = (0, react_1.useCallback)(() => {
402
+ resetScannerView({ remount: true });
403
+ }, [resetScannerView]);
317
404
  const handleCapture = (0, react_1.useCallback)(async (document) => {
318
405
  console.log('[FullDocScanner] handleCapture called:', {
319
406
  origin: document.origin,
@@ -332,6 +419,15 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
332
419
  return;
333
420
  }
334
421
  const normalizedDoc = normalizeCapturedDocument(document);
422
+ const shouldOpenAndroidCropEditor = isAndroidCropEditorAvailable &&
423
+ captureMode === 'grid' &&
424
+ Boolean(normalizedDoc.rectangle ||
425
+ (normalizedDoc.quad && normalizedDoc.quad.length === 4));
426
+ if (shouldOpenAndroidCropEditor) {
427
+ console.log('[FullDocScanner] Opening Android crop editor with detected rectangle');
428
+ openAndroidCropEditor(normalizedDoc);
429
+ return;
430
+ }
335
431
  if (captureMode === 'no-grid') {
336
432
  console.log('[FullDocScanner] No grid at capture button press: opening cropper for manual selection');
337
433
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
@@ -358,7 +454,14 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
358
454
  }
359
455
  console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
360
456
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
361
- }, [emitError, openCropper, preparePreviewImage, resetScannerView]);
457
+ }, [
458
+ emitError,
459
+ isAndroidCropEditorAvailable,
460
+ openAndroidCropEditor,
461
+ openCropper,
462
+ preparePreviewImage,
463
+ resetScannerView,
464
+ ]);
362
465
  const triggerManualCapture = (0, react_1.useCallback)(() => {
363
466
  const scannerInstance = docScannerRef.current;
364
467
  const hasScanner = !!scannerInstance;
@@ -371,7 +474,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
371
474
  currentCaptureMode: captureModeRef.current,
372
475
  captureInProgress: captureInProgressRef.current,
373
476
  });
374
- if (react_native_1.Platform.OS === 'android' && !captureReady) {
477
+ if (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
375
478
  console.log('[FullDocScanner] Capture not ready yet, skipping');
376
479
  return;
377
480
  }
@@ -388,7 +491,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
388
491
  return;
389
492
  }
390
493
  console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
391
- const captureMode = rectangleDetected ? 'grid' : 'no-grid';
494
+ const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
392
495
  captureModeRef.current = captureMode;
393
496
  captureInProgressRef.current = true;
394
497
  // Add timeout to reset state if capture hangs
@@ -420,7 +523,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
420
523
  emitError(error, 'Failed to capture image. Please try again.');
421
524
  }
422
525
  });
423
- }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
526
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
424
527
  const handleGalleryPick = (0, react_1.useCallback)(async () => {
425
528
  console.log('[FullDocScanner] handleGalleryPick called');
426
529
  if (processing || isGalleryOpen) {
@@ -601,6 +704,11 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
601
704
  clearTimeout(captureReadyTimeoutRef.current);
602
705
  }
603
706
  }, []);
707
+ (0, react_1.useEffect)(() => {
708
+ if (usesAndroidScannerActivity) {
709
+ setCaptureReady(true);
710
+ }
711
+ }, [usesAndroidScannerActivity]);
604
712
  const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
605
713
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
606
714
  react_native_1.Platform.OS === 'android' && (react_1.default.createElement(react_native_1.StatusBar, { translucent: true, backgroundColor: "transparent" })),
@@ -649,6 +757,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
649
757
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleRetake, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button" },
650
758
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
651
759
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button" },
760
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm))))) : cropEditorDocument && CropEditor ? (react_1.default.createElement(react_native_1.View, { style: styles.flex },
761
+ react_1.default.createElement(CropEditor, { document: cropEditorDocument, enableEditor: true, autoCrop: false, onCropChange: setCropEditorRectangle }),
762
+ react_1.default.createElement(react_native_1.View, { style: styles.confirmationButtons },
763
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.retakeButton], onPress: handleCropEditorCancel, accessibilityLabel: mergedStrings.retake, accessibilityRole: "button", disabled: processing },
764
+ react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.retake)),
765
+ react_1.default.createElement(react_native_1.TouchableOpacity, { style: [styles.confirmButton, styles.confirmButtonPrimary], onPress: handleCropEditorConfirm, accessibilityLabel: mergedStrings.confirm, accessibilityRole: "button", disabled: processing },
652
766
  react_1.default.createElement(react_native_1.Text, { style: styles.confirmButtonText }, mergedStrings.confirm))))) : (react_1.default.createElement(react_native_1.View, { style: styles.flex },
653
767
  react_1.default.createElement(DocScanner_1.DocScanner, { key: scannerSession, ref: docScannerRef, autoCapture: false, overlayColor: overlayColor, showGrid: showGrid, gridColor: resolvedGridColor, gridLineWidth: gridLineWidth, minStableFrames: minStableFrames ?? 6, detectionConfig: detectionConfig, onCapture: handleCapture, onRectangleDetect: handleRectangleDetect, showManualCaptureButton: false, enableTorch: flashEnabled },
654
768
  react_1.default.createElement(react_native_1.View, { style: styles.overlayTop, pointerEvents: "box-none" },
@@ -675,11 +789,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
675
789
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
676
790
  styles.shutterButton,
677
791
  processing && styles.buttonDisabled,
678
- react_native_1.Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
679
- ], 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" },
680
794
  react_1.default.createElement(react_native_1.View, { style: [
681
795
  styles.shutterInner,
682
- (react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
796
+ (react_native_1.Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
797
+ { backgroundColor: overlayColor }
683
798
  ] })),
684
799
  react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
685
800
  processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "13.9.0",
3
+ "version": "15.0.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -26,6 +26,8 @@ interface CropEditorProps {
26
26
  overlayStrokeColor?: string;
27
27
  handlerColor?: string;
28
28
  enablePanStrict?: boolean;
29
+ enableEditor?: boolean;
30
+ autoCrop?: boolean;
29
31
  onCropChange?: (rectangle: Rectangle) => void;
30
32
  }
31
33
 
@@ -48,6 +50,8 @@ export const CropEditor: React.FC<CropEditorProps> = ({
48
50
  overlayStrokeColor = '#e7a649',
49
51
  handlerColor = '#e7a649',
50
52
  enablePanStrict = false,
53
+ enableEditor = false,
54
+ autoCrop = true,
51
55
  onCropChange,
52
56
  }) => {
53
57
  const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
@@ -59,38 +63,6 @@ export const CropEditor: React.FC<CropEditorProps> = ({
59
63
  const [loadError, setLoadError] = useState<string | null>(null);
60
64
  const [croppedImageUri, setCroppedImageUri] = useState<string | null>(null);
61
65
 
62
- useEffect(() => {
63
- console.log('[CropEditor] Document path:', document.path);
64
- console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
65
- console.log('[CropEditor] Document quad:', document.quad);
66
- console.log('[CropEditor] Document rectangle:', document.rectangle);
67
-
68
- // Load image size using Image.getSize
69
- const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
70
- Image.getSize(
71
- imageUri,
72
- (width, height) => {
73
- console.log('[CropEditor] Image.getSize success:', { width, height });
74
- setImageSize({ width, height });
75
-
76
- // If we have a rectangle (from auto-capture), crop the image
77
- if (document.rectangle || document.quad) {
78
- cropImageToRectangle(imageUri, width, height);
79
- } else {
80
- setIsImageLoading(false);
81
- setLoadError(null);
82
- }
83
- },
84
- (error) => {
85
- console.error('[CropEditor] Image.getSize error:', error);
86
- // Fallback to document dimensions
87
- console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
88
- setImageSize({ width: document.width, height: document.height });
89
- setIsImageLoading(false);
90
- }
91
- );
92
- }, [document]);
93
-
94
66
  const cropImageToRectangle = useCallback((imageUri: string, width: number, height: number) => {
95
67
  const cropManager = NativeModules.CustomCropManager as CustomCropManagerType | undefined;
96
68
 
@@ -145,6 +117,40 @@ export const CropEditor: React.FC<CropEditorProps> = ({
145
117
  );
146
118
  }, [document]);
147
119
 
120
+ useEffect(() => {
121
+ console.log('[CropEditor] Document path:', document.path);
122
+ console.log('[CropEditor] Document dimensions:', document.width, 'x', document.height);
123
+ console.log('[CropEditor] Document quad:', document.quad);
124
+ console.log('[CropEditor] Document rectangle:', document.rectangle);
125
+
126
+ const shouldAutoCrop = autoCrop && !enableEditor;
127
+
128
+ // Load image size using Image.getSize
129
+ const imageUri = document.path.startsWith('file://') ? document.path : `file://${document.path}`;
130
+ Image.getSize(
131
+ imageUri,
132
+ (width, height) => {
133
+ console.log('[CropEditor] Image.getSize success:', { width, height });
134
+ setImageSize({ width, height });
135
+
136
+ // If we have a rectangle (from auto-capture), crop the image
137
+ if (shouldAutoCrop && (document.rectangle || document.quad)) {
138
+ cropImageToRectangle(imageUri, width, height);
139
+ } else {
140
+ setIsImageLoading(false);
141
+ setLoadError(null);
142
+ }
143
+ },
144
+ (error) => {
145
+ console.error('[CropEditor] Image.getSize error:', error);
146
+ // Fallback to document dimensions
147
+ console.log('[CropEditor] Using fallback dimensions:', document.width, 'x', document.height);
148
+ setImageSize({ width: document.width, height: document.height });
149
+ setIsImageLoading(false);
150
+ }
151
+ );
152
+ }, [autoCrop, cropImageToRectangle, document, enableEditor]);
153
+
148
154
  // Get initial rectangle from detected quad or use default
149
155
  const getInitialRectangle = useCallback((): CropperRectangle | undefined => {
150
156
  if (!imageSize) {
@@ -226,6 +232,18 @@ export const CropEditor: React.FC<CropEditorProps> = ({
226
232
  <ActivityIndicator size="large" color={handlerColor} />
227
233
  <Text style={styles.loadingText}>Loading image...</Text>
228
234
  </View>
235
+ ) : enableEditor ? (
236
+ <CustomImageCropper
237
+ height={displaySize.height}
238
+ width={displaySize.width}
239
+ image={imageUri}
240
+ rectangleCoordinates={initialRect}
241
+ overlayColor={overlayColor}
242
+ overlayStrokeColor={overlayStrokeColor}
243
+ handlerColor={handlerColor}
244
+ enablePanStrict={enablePanStrict}
245
+ onDragEnd={handleDragEnd}
246
+ />
229
247
  ) : (
230
248
  <>
231
249
  {/* Show cropped image if available, otherwise show original */}
@@ -236,18 +254,6 @@ export const CropEditor: React.FC<CropEditorProps> = ({
236
254
  onLoad={() => console.log('[CropEditor] Image loaded successfully', croppedImageUri ? 'cropped' : 'original')}
237
255
  onError={(e) => console.error('[CropEditor] Image load error:', e.nativeEvent.error)}
238
256
  />
239
- {/* Temporarily disabled CustomImageCropper - showing image only */}
240
- {/* <CustomImageCropper
241
- height={displaySize.height}
242
- width={displaySize.width}
243
- image={imageUri}
244
- rectangleCoordinates={initialRect}
245
- overlayColor={overlayColor}
246
- overlayStrokeColor={overlayStrokeColor}
247
- handlerColor={handlerColor}
248
- enablePanStrict={enablePanStrict}
249
- onDragEnd={handleDragEnd}
250
- /> */}
251
257
  </>
252
258
  )}
253
259
  </View>
@@ -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
- console.log('[DocScanner] Using native CameraX pipeline on Android');
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
 
@@ -16,7 +16,8 @@ import { launchImageLibrary } from 'react-native-image-picker';
16
16
  import ImageCropPicker from 'react-native-image-crop-picker';
17
17
  import RNFS from 'react-native-fs';
18
18
  import { DocScanner } from './DocScanner';
19
- import type { CapturedDocument } from './types';
19
+ import type { CapturedDocument, Rectangle } from './types';
20
+ import { quadToRectangle } from './utils/coordinate';
20
21
  import type {
21
22
  DetectionConfig,
22
23
  DocScannerHandle,
@@ -41,6 +42,25 @@ const ensureFileUri = (value?: string | null) => {
41
42
  return value;
42
43
  };
43
44
 
45
+ const safeRequire = (moduleName: string) => {
46
+ try {
47
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
48
+ return require(moduleName);
49
+ } catch {
50
+ return null;
51
+ }
52
+ };
53
+
54
+ const CropEditorModule = safeRequire('./CropEditor');
55
+ const CropEditor = CropEditorModule?.CropEditor as
56
+ | React.ComponentType<{
57
+ document: CapturedDocument;
58
+ enableEditor?: boolean;
59
+ autoCrop?: boolean;
60
+ onCropChange?: (rectangle: Rectangle) => void;
61
+ }>
62
+ | undefined;
63
+
44
64
  const CROPPER_TIMEOUT_MS = 8000;
45
65
  const CROPPER_TIMEOUT_CODE = 'cropper_timeout';
46
66
 
@@ -198,6 +218,8 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
198
218
  const [capturedPhotos, setCapturedPhotos] = useState<FullDocScannerResult[]>([]);
199
219
  const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
200
220
  const [scannerSession, setScannerSession] = useState(0);
221
+ const [cropEditorDocument, setCropEditorDocument] = useState<CapturedDocument | null>(null);
222
+ const [cropEditorRectangle, setCropEditorRectangle] = useState<Rectangle | null>(null);
201
223
  const resolvedGridColor = gridColor ?? overlayColor;
202
224
  const docScannerRef = useRef<DocScannerHandle | null>(null);
203
225
  const captureModeRef = useRef<'grid' | 'no-grid' | null>(null);
@@ -207,15 +229,21 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
207
229
  const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
208
230
 
209
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';
210
236
 
211
237
  const resetScannerView = useCallback(
212
238
  (options?: { remount?: boolean }) => {
213
239
  setProcessing(false);
214
240
  setCroppedImageData(null);
241
+ setCropEditorDocument(null);
242
+ setCropEditorRectangle(null);
215
243
  setRotationDegrees(0);
216
244
  setRectangleDetected(false);
217
245
  setRectangleHint(false);
218
- setCaptureReady(false);
246
+ setCaptureReady(usesAndroidScannerActivity ? true : false);
219
247
  captureModeRef.current = null;
220
248
  captureInProgressRef.current = false;
221
249
 
@@ -241,7 +269,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
241
269
  setScannerSession((prev) => prev + 1);
242
270
  }
243
271
  },
244
- [],
272
+ [usesAndroidScannerActivity],
245
273
  );
246
274
 
247
275
  const mergedStrings = useMemo(
@@ -263,8 +291,6 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
263
291
  [strings],
264
292
  );
265
293
 
266
- const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
267
-
268
294
  const autoEnhancementEnabled = useMemo(
269
295
  () => typeof pdfScannerManager?.applyColorControls === 'function',
270
296
  [pdfScannerManager],
@@ -353,6 +379,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
353
379
  [onError],
354
380
  );
355
381
 
382
+ const openAndroidCropEditor = useCallback((document: CapturedDocument) => {
383
+ const rectangle =
384
+ document.rectangle ??
385
+ (document.quad && document.quad.length === 4 ? quadToRectangle(document.quad) : null);
386
+ const documentForEditor = rectangle ? { ...document, rectangle } : document;
387
+ setCropEditorDocument(documentForEditor);
388
+ setCropEditorRectangle(rectangle);
389
+ }, []);
390
+
391
+ const closeAndroidCropEditor = useCallback(() => {
392
+ setCropEditorDocument(null);
393
+ setCropEditorRectangle(null);
394
+ }, []);
395
+
356
396
  const openCropper = useCallback(
357
397
  async (imagePath: string, options?: OpenCropperOptions) => {
358
398
  try {
@@ -446,6 +486,76 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
446
486
  [cropWidth, cropHeight, emitError, preparePreviewImage, resetScannerView],
447
487
  );
448
488
 
489
+ const handleCropEditorConfirm = useCallback(async () => {
490
+ if (!cropEditorDocument) {
491
+ return;
492
+ }
493
+
494
+ const documentPath = cropEditorDocument.path;
495
+
496
+ const rectangle =
497
+ cropEditorRectangle ??
498
+ cropEditorDocument.rectangle ??
499
+ (cropEditorDocument.quad && cropEditorDocument.quad.length === 4
500
+ ? quadToRectangle(cropEditorDocument.quad)
501
+ : null);
502
+
503
+ if (!rectangle || !pdfScannerManager?.processImage) {
504
+ closeAndroidCropEditor();
505
+ await openCropper(documentPath, { waitForPickerDismissal: false });
506
+ return;
507
+ }
508
+
509
+ setProcessing(true);
510
+ try {
511
+ const payload = await pdfScannerManager.processImage({
512
+ imagePath: documentPath,
513
+ rectangleCoordinates: rectangle,
514
+ rectangleWidth: cropEditorDocument.width ?? 0,
515
+ rectangleHeight: cropEditorDocument.height ?? 0,
516
+ useBase64: false,
517
+ quality: 90,
518
+ brightness: 0,
519
+ contrast: 1,
520
+ saturation: 1,
521
+ saveInAppDocument: false,
522
+ });
523
+
524
+ const croppedPath =
525
+ typeof payload?.croppedImage === 'string' ? stripFileUri(payload.croppedImage) : null;
526
+
527
+ if (!croppedPath) {
528
+ throw new Error('missing_cropped_image');
529
+ }
530
+
531
+ const preview = await preparePreviewImage({ path: croppedPath });
532
+ setCroppedImageData(preview);
533
+ } catch (error) {
534
+ console.error('[FullDocScanner] Crop editor processing failed:', error);
535
+ resetScannerView({ remount: true });
536
+ emitError(
537
+ error instanceof Error ? error : new Error(String(error)),
538
+ 'Failed to crop image. Please try again.',
539
+ );
540
+ } finally {
541
+ setProcessing(false);
542
+ closeAndroidCropEditor();
543
+ }
544
+ }, [
545
+ closeAndroidCropEditor,
546
+ cropEditorDocument,
547
+ cropEditorRectangle,
548
+ emitError,
549
+ openCropper,
550
+ pdfScannerManager,
551
+ preparePreviewImage,
552
+ resetScannerView,
553
+ ]);
554
+
555
+ const handleCropEditorCancel = useCallback(() => {
556
+ resetScannerView({ remount: true });
557
+ }, [resetScannerView]);
558
+
449
559
  const handleCapture = useCallback(
450
560
  async (document: DocScannerCapture) => {
451
561
  console.log('[FullDocScanner] handleCapture called:', {
@@ -470,6 +580,20 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
470
580
 
471
581
  const normalizedDoc = normalizeCapturedDocument(document);
472
582
 
583
+ const shouldOpenAndroidCropEditor =
584
+ isAndroidCropEditorAvailable &&
585
+ captureMode === 'grid' &&
586
+ Boolean(
587
+ normalizedDoc.rectangle ||
588
+ (normalizedDoc.quad && normalizedDoc.quad.length === 4),
589
+ );
590
+
591
+ if (shouldOpenAndroidCropEditor) {
592
+ console.log('[FullDocScanner] Opening Android crop editor with detected rectangle');
593
+ openAndroidCropEditor(normalizedDoc);
594
+ return;
595
+ }
596
+
473
597
  if (captureMode === 'no-grid') {
474
598
  console.log('[FullDocScanner] No grid at capture button press: opening cropper for manual selection');
475
599
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
@@ -501,7 +625,14 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
501
625
  console.log('[FullDocScanner] Fallback to manual crop (no croppedPath available)');
502
626
  await openCropper(normalizedDoc.path, { waitForPickerDismissal: false });
503
627
  },
504
- [emitError, openCropper, preparePreviewImage, resetScannerView],
628
+ [
629
+ emitError,
630
+ isAndroidCropEditorAvailable,
631
+ openAndroidCropEditor,
632
+ openCropper,
633
+ preparePreviewImage,
634
+ resetScannerView,
635
+ ],
505
636
  );
506
637
 
507
638
  const triggerManualCapture = useCallback(() => {
@@ -517,7 +648,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
517
648
  captureInProgress: captureInProgressRef.current,
518
649
  });
519
650
 
520
- if (Platform.OS === 'android' && !captureReady) {
651
+ if (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
521
652
  console.log('[FullDocScanner] Capture not ready yet, skipping');
522
653
  return;
523
654
  }
@@ -539,7 +670,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
539
670
 
540
671
  console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
541
672
 
542
- const captureMode = rectangleDetected ? 'grid' : 'no-grid';
673
+ const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
543
674
  captureModeRef.current = captureMode;
544
675
  captureInProgressRef.current = true;
545
676
 
@@ -580,7 +711,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
580
711
  );
581
712
  }
582
713
  });
583
- }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
714
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
584
715
 
585
716
  const handleGalleryPick = useCallback(async () => {
586
717
  console.log('[FullDocScanner] handleGalleryPick called');
@@ -804,6 +935,12 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
804
935
  [],
805
936
  );
806
937
 
938
+ useEffect(() => {
939
+ if (usesAndroidScannerActivity) {
940
+ setCaptureReady(true);
941
+ }
942
+ }, [usesAndroidScannerActivity]);
943
+
807
944
  const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
808
945
 
809
946
  return (
@@ -949,6 +1086,36 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
949
1086
  </TouchableOpacity>
950
1087
  </View>
951
1088
  </View>
1089
+ ) : cropEditorDocument && CropEditor ? (
1090
+ <View style={styles.flex}>
1091
+ <CropEditor
1092
+ document={cropEditorDocument}
1093
+ enableEditor
1094
+ autoCrop={false}
1095
+ onCropChange={setCropEditorRectangle}
1096
+ />
1097
+ <View style={styles.confirmationButtons}>
1098
+ <TouchableOpacity
1099
+ style={[styles.confirmButton, styles.retakeButton]}
1100
+ onPress={handleCropEditorCancel}
1101
+ accessibilityLabel={mergedStrings.retake}
1102
+ accessibilityRole="button"
1103
+ disabled={processing}
1104
+ >
1105
+ <Text style={styles.confirmButtonText}>{mergedStrings.retake}</Text>
1106
+ </TouchableOpacity>
1107
+
1108
+ <TouchableOpacity
1109
+ style={[styles.confirmButton, styles.confirmButtonPrimary]}
1110
+ onPress={handleCropEditorConfirm}
1111
+ accessibilityLabel={mergedStrings.confirm}
1112
+ accessibilityRole="button"
1113
+ disabled={processing}
1114
+ >
1115
+ <Text style={styles.confirmButtonText}>{mergedStrings.confirm}</Text>
1116
+ </TouchableOpacity>
1117
+ </View>
1118
+ </View>
952
1119
  ) : (
953
1120
  <View style={styles.flex}>
954
1121
  <DocScanner
@@ -1035,16 +1202,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
1035
1202
  style={[
1036
1203
  styles.shutterButton,
1037
1204
  processing && styles.buttonDisabled,
1038
- Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
1205
+ Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
1039
1206
  ]}
1040
1207
  onPress={triggerManualCapture}
1041
- disabled={processing || (Platform.OS === 'android' && !captureReady)}
1208
+ disabled={processing || (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity)}
1042
1209
  accessibilityLabel={mergedStrings.manualHint}
1043
1210
  accessibilityRole="button"
1044
1211
  >
1045
1212
  <View style={[
1046
1213
  styles.shutterInner,
1047
- (Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
1214
+ (Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
1215
+ { backgroundColor: overlayColor }
1048
1216
  ]} />
1049
1217
  </TouchableOpacity>
1050
1218
  <View style={styles.rightButtonsPlaceholder} />