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.
@@ -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()
@@ -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) {
@@ -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) && { backgroundColor: overlayColor }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "13.11.0",
3
+ "version": "15.1.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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
 
@@ -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) && { backgroundColor: overlayColor }
1240
+ (Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
1241
+ { backgroundColor: overlayColor }
1208
1242
  ]} />
1209
1243
  </TouchableOpacity>
1210
1244
  <View style={styles.rightButtonsPlaceholder} />