react-native-rectangle-doc-scanner 13.11.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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()
@@ -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) {
@@ -157,6 +157,9 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
157
157
  const rectangleHintTimeoutRef = (0, react_1.useRef)(null);
158
158
  const captureReadyTimeoutRef = (0, react_1.useRef)(null);
159
159
  const isBusinessMode = type === 'business';
160
+ const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
161
+ const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
162
+ const usesAndroidScannerActivity = react_native_1.Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
160
163
  const resetScannerView = (0, react_1.useCallback)((options) => {
161
164
  setProcessing(false);
162
165
  setCroppedImageData(null);
@@ -165,7 +168,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
165
168
  setRotationDegrees(0);
166
169
  setRectangleDetected(false);
167
170
  setRectangleHint(false);
168
- setCaptureReady(false);
171
+ setCaptureReady(usesAndroidScannerActivity ? true : false);
169
172
  captureModeRef.current = null;
170
173
  captureInProgressRef.current = false;
171
174
  if (rectangleCaptureTimeoutRef.current) {
@@ -186,7 +189,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
186
189
  if (options?.remount) {
187
190
  setScannerSession((prev) => prev + 1);
188
191
  }
189
- }, []);
192
+ }, [usesAndroidScannerActivity]);
190
193
  const mergedStrings = (0, react_1.useMemo)(() => ({
191
194
  captureHint: strings?.captureHint,
192
195
  manualHint: strings?.manualHint,
@@ -202,8 +205,6 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
202
205
  secondPrompt: strings?.secondPrompt ?? strings?.secondBtn ?? 'Capture Back Side?',
203
206
  originalBtn: strings?.originalBtn ?? 'Use Original',
204
207
  }), [strings]);
205
- const pdfScannerManager = react_native_1.NativeModules?.RNPdfScannerManager;
206
- const isAndroidCropEditorAvailable = react_native_1.Platform.OS === 'android' && Boolean(CropEditor);
207
208
  const autoEnhancementEnabled = (0, react_1.useMemo)(() => typeof pdfScannerManager?.applyColorControls === 'function', [pdfScannerManager]);
208
209
  const ensureBase64ForImage = (0, react_1.useCallback)(async (image) => {
209
210
  if (image.base64) {
@@ -473,7 +474,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
473
474
  currentCaptureMode: captureModeRef.current,
474
475
  captureInProgress: captureInProgressRef.current,
475
476
  });
476
- if (react_native_1.Platform.OS === 'android' && !captureReady) {
477
+ if (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
477
478
  console.log('[FullDocScanner] Capture not ready yet, skipping');
478
479
  return;
479
480
  }
@@ -490,7 +491,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
490
491
  return;
491
492
  }
492
493
  console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
493
- const captureMode = rectangleDetected ? 'grid' : 'no-grid';
494
+ const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
494
495
  captureModeRef.current = captureMode;
495
496
  captureInProgressRef.current = true;
496
497
  // Add timeout to reset state if capture hangs
@@ -522,7 +523,7 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
522
523
  emitError(error, 'Failed to capture image. Please try again.');
523
524
  }
524
525
  });
525
- }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
526
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
526
527
  const handleGalleryPick = (0, react_1.useCallback)(async () => {
527
528
  console.log('[FullDocScanner] handleGalleryPick called');
528
529
  if (processing || isGalleryOpen) {
@@ -703,6 +704,11 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
703
704
  clearTimeout(captureReadyTimeoutRef.current);
704
705
  }
705
706
  }, []);
707
+ (0, react_1.useEffect)(() => {
708
+ if (usesAndroidScannerActivity) {
709
+ setCaptureReady(true);
710
+ }
711
+ }, [usesAndroidScannerActivity]);
706
712
  const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
707
713
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
708
714
  react_native_1.Platform.OS === 'android' && (react_1.default.createElement(react_native_1.StatusBar, { translucent: true, backgroundColor: "transparent" })),
@@ -783,11 +789,12 @@ const FullDocScanner = ({ onResult, onClose, detectionConfig, overlayColor = '#3
783
789
  react_1.default.createElement(react_native_1.TouchableOpacity, { style: [
784
790
  styles.shutterButton,
785
791
  processing && styles.buttonDisabled,
786
- react_native_1.Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
787
- ], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
792
+ react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
793
+ ], onPress: triggerManualCapture, disabled: processing || (react_native_1.Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity), accessibilityLabel: mergedStrings.manualHint, accessibilityRole: "button" },
788
794
  react_1.default.createElement(react_native_1.View, { style: [
789
795
  styles.shutterInner,
790
- (react_native_1.Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
796
+ (react_native_1.Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
797
+ { backgroundColor: overlayColor }
791
798
  ] })),
792
799
  react_1.default.createElement(react_native_1.View, { style: styles.rightButtonsPlaceholder }))))),
793
800
  processing && (react_1.default.createElement(react_native_1.View, { style: styles.processingOverlay },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "13.11.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",
@@ -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
 
@@ -229,6 +229,10 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
229
229
  const captureReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
230
230
 
231
231
  const isBusinessMode = type === 'business';
232
+ const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
233
+ const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
234
+ const usesAndroidScannerActivity =
235
+ Platform.OS === 'android' && typeof pdfScannerManager?.startDocumentScanner === 'function';
232
236
 
233
237
  const resetScannerView = useCallback(
234
238
  (options?: { remount?: boolean }) => {
@@ -239,7 +243,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
239
243
  setRotationDegrees(0);
240
244
  setRectangleDetected(false);
241
245
  setRectangleHint(false);
242
- setCaptureReady(false);
246
+ setCaptureReady(usesAndroidScannerActivity ? true : false);
243
247
  captureModeRef.current = null;
244
248
  captureInProgressRef.current = false;
245
249
 
@@ -265,7 +269,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
265
269
  setScannerSession((prev) => prev + 1);
266
270
  }
267
271
  },
268
- [],
272
+ [usesAndroidScannerActivity],
269
273
  );
270
274
 
271
275
  const mergedStrings = useMemo(
@@ -287,9 +291,6 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
287
291
  [strings],
288
292
  );
289
293
 
290
- const pdfScannerManager = (NativeModules as any)?.RNPdfScannerManager;
291
- const isAndroidCropEditorAvailable = Platform.OS === 'android' && Boolean(CropEditor);
292
-
293
294
  const autoEnhancementEnabled = useMemo(
294
295
  () => typeof pdfScannerManager?.applyColorControls === 'function',
295
296
  [pdfScannerManager],
@@ -647,7 +648,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
647
648
  captureInProgress: captureInProgressRef.current,
648
649
  });
649
650
 
650
- if (Platform.OS === 'android' && !captureReady) {
651
+ if (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity) {
651
652
  console.log('[FullDocScanner] Capture not ready yet, skipping');
652
653
  return;
653
654
  }
@@ -669,7 +670,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
669
670
 
670
671
  console.log('[FullDocScanner] Starting manual capture, grid detected:', rectangleDetected);
671
672
 
672
- const captureMode = rectangleDetected ? 'grid' : 'no-grid';
673
+ const captureMode = usesAndroidScannerActivity ? 'grid' : rectangleDetected ? 'grid' : 'no-grid';
673
674
  captureModeRef.current = captureMode;
674
675
  captureInProgressRef.current = true;
675
676
 
@@ -710,7 +711,7 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
710
711
  );
711
712
  }
712
713
  });
713
- }, [processing, rectangleDetected, rectangleHint, captureReady, emitError]);
714
+ }, [processing, rectangleDetected, rectangleHint, captureReady, emitError, usesAndroidScannerActivity]);
714
715
 
715
716
  const handleGalleryPick = useCallback(async () => {
716
717
  console.log('[FullDocScanner] handleGalleryPick called');
@@ -934,6 +935,12 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
934
935
  [],
935
936
  );
936
937
 
938
+ useEffect(() => {
939
+ if (usesAndroidScannerActivity) {
940
+ setCaptureReady(true);
941
+ }
942
+ }, [usesAndroidScannerActivity]);
943
+
937
944
  const activePreviewImage = croppedImageData ? getActivePreviewImage(croppedImageData) : null;
938
945
 
939
946
  return (
@@ -1195,16 +1202,17 @@ export const FullDocScanner: React.FC<FullDocScannerProps> = ({
1195
1202
  style={[
1196
1203
  styles.shutterButton,
1197
1204
  processing && styles.buttonDisabled,
1198
- Platform.OS === 'android' && !captureReady && styles.buttonDisabled,
1205
+ Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity && styles.buttonDisabled,
1199
1206
  ]}
1200
1207
  onPress={triggerManualCapture}
1201
- disabled={processing || (Platform.OS === 'android' && !captureReady)}
1208
+ disabled={processing || (Platform.OS === 'android' && !captureReady && !usesAndroidScannerActivity)}
1202
1209
  accessibilityLabel={mergedStrings.manualHint}
1203
1210
  accessibilityRole="button"
1204
1211
  >
1205
1212
  <View style={[
1206
1213
  styles.shutterInner,
1207
- (Platform.OS === 'android' ? captureReady : rectangleHint) && { backgroundColor: overlayColor }
1214
+ (Platform.OS === 'android' ? captureReady || usesAndroidScannerActivity : rectangleHint) &&
1215
+ { backgroundColor: overlayColor }
1208
1216
  ]} />
1209
1217
  </TouchableOpacity>
1210
1218
  <View style={styles.rightButtonsPlaceholder} />