react-native-document-processor 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -2
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/vn/money24h/documentprocessor/BarcodeImageScanner.java +81 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentCapture.java +163 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentProcessorModule.java +345 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentProcessorPackage.java +20 -0
- package/android/src/main/java/vn/money24h/documentprocessor/DocumentScanLog.java +27 -0
- package/android/src/main/java/vn/money24h/documentprocessor/JpegPdfWriter.java +93 -0
- package/ios/DPDocumentCapture.h +9 -0
- package/ios/DPDocumentCapture.m +128 -0
- package/ios/DocumentProcessor.m +356 -0
- package/package.json +31 -4
- package/react-native-document-processor.podspec +18 -0
- package/src/index.ts +192 -0
package/README.md
CHANGED
|
@@ -1,3 +1,208 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-native-document-processor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Phiên bản **0.2.0** — thư viện React Native chụp tài liệu, tạo PDF từ ảnh và đọc
|
|
4
|
+
QR/barcode bằng native trên iOS và Android.
|
|
5
|
+
|
|
6
|
+
- [Repository](https://github.com/quocvu12345/react-native-document-processor)
|
|
7
|
+
- [Package npm](https://www.npmjs.com/package/react-native-document-processor)
|
|
8
|
+
- Source của phiên bản này: tag `v0.2.0`.
|
|
9
|
+
|
|
10
|
+
## Cài đặt
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
yarn add react-native-document-processor@0.2.0 --exact
|
|
14
|
+
# Hoặc: npm install react-native-document-processor@0.2.0 --save-exact
|
|
15
|
+
pod install --project-directory=ios
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Yêu cầu Android API 28+, iOS 15.1+ và React Native có autolinking. Đã kiểm tra
|
|
19
|
+
trong ứng dụng host dùng React Native 0.83. Camera Android cần Google Play services;
|
|
20
|
+
model ML Kit đọc barcode được đóng gói sẵn. iOS cần khai báo
|
|
21
|
+
`NSCameraUsageDescription` trong `Info.plist` của ứng dụng.
|
|
22
|
+
|
|
23
|
+
Rebuild native app sau khi cài hoặc nâng cấp thư viện. Reload JavaScript hoặc
|
|
24
|
+
cập nhật OTA không thêm được native module vào binary đã cài. Thư viện cần
|
|
25
|
+
native build riêng, chẳng hạn Expo development build.
|
|
26
|
+
|
|
27
|
+
Metadata giấy phép hiện là `UNLICENSED`.
|
|
28
|
+
|
|
29
|
+
## Thay đổi trong 0.2.0
|
|
30
|
+
|
|
31
|
+
- Sửa lỗi không đọc được QR trên iOS Simulator khi Vision không tạo được
|
|
32
|
+
inference context: yêu cầu chỉ đọc QR sử dụng Vision revision 1 trên Simulator.
|
|
33
|
+
- Thiết bị iOS thật tiếp tục dùng revision mặc định. API JavaScript giữ nguyên.
|
|
34
|
+
- Bản sửa được đóng gói trực tiếp trong source native của npm.
|
|
35
|
+
|
|
36
|
+
## Chụp tài liệu và tạo PDF
|
|
37
|
+
|
|
38
|
+
`scanDocument` mở camera native: VisionKit trên iOS và ML Kit Document Scanner
|
|
39
|
+
trên Android. Người dùng có thể phát hiện biên, crop và xác nhận các trang.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import {
|
|
43
|
+
processDocumentImages,
|
|
44
|
+
scanDocument,
|
|
45
|
+
} from 'react-native-document-processor';
|
|
46
|
+
import RNFS from 'react-native-fs';
|
|
47
|
+
|
|
48
|
+
const capture = await scanDocument({ croppedImageQuality: 100 });
|
|
49
|
+
if (capture.status === 'cancel') return;
|
|
50
|
+
|
|
51
|
+
let output: Awaited<ReturnType<typeof processDocumentImages>> | undefined;
|
|
52
|
+
try {
|
|
53
|
+
output = await processDocumentImages(capture.scannedImages, {
|
|
54
|
+
onProgress: (completed, total) => updateProgress(completed, total),
|
|
55
|
+
isCancelled: () => hasLeftScreen,
|
|
56
|
+
});
|
|
57
|
+
// output.pdfUri: PDF để lưu hoặc upload
|
|
58
|
+
// output.pages: mã QR, trạng thái và preview của từng trang
|
|
59
|
+
// output.directoryUri: thư mục chứa PDF và preview, do caller sở hữu
|
|
60
|
+
await consumeDocument(output);
|
|
61
|
+
} finally {
|
|
62
|
+
// Chỉ dọn output sau khi mọi thao tác lưu/upload/hiển thị đã hoàn tất.
|
|
63
|
+
const temporaryPaths = [
|
|
64
|
+
...capture.scannedImages,
|
|
65
|
+
...(output ? [output.directoryUri] : []),
|
|
66
|
+
];
|
|
67
|
+
await Promise.all(
|
|
68
|
+
temporaryPaths.map(path => RNFS.unlink(path).catch(() => undefined)),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`updateProgress`, `hasLeftScreen` và `consumeDocument` là logic của ứng dụng host.
|
|
74
|
+
Ví dụ dùng `react-native-fs` để dọn file; thư viện này không phải dependency của
|
|
75
|
+
package và có thể được thay bằng API filesystem của ứng dụng.
|
|
76
|
+
|
|
77
|
+
`croppedImageQuality` nhận giá trị từ 0 đến 100; Android giữ JPEG gốc của scanner.
|
|
78
|
+
Mỗi lần chụp trả về `{ status: 'success' | 'cancel', scannedImages: string[] }`.
|
|
79
|
+
iOS trả tối đa 24 trang trong một lần chụp. Ứng dụng có thể gọi lại camera và gộp
|
|
80
|
+
các ảnh theo thứ tự trước khi tạo PDF.
|
|
81
|
+
|
|
82
|
+
`processDocumentImages` trả về các trang đánh số từ 1:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
{
|
|
86
|
+
pdfUri: 'file:///cache/session/document.pdf',
|
|
87
|
+
directoryUri: 'file:///cache/session',
|
|
88
|
+
pages: [
|
|
89
|
+
{
|
|
90
|
+
page: 1,
|
|
91
|
+
rawValues: ['doc|d|0030358'],
|
|
92
|
+
status: 'decoded', // 'decoded' | 'not-detected' | 'error'
|
|
93
|
+
previewUri: 'file:///cache/session/page-1.jpg', // tùy chọn
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Dùng `{ scanQr: false }` khi chỉ cần PDF. Khi đó native bỏ qua đọc QR và tạo
|
|
100
|
+
preview. Native không xóa ảnh đầu vào. Sau thành công, caller chịu trách nhiệm
|
|
101
|
+
xóa output và ảnh chụp khi không còn sử dụng. Nếu hủy hoặc có lỗi trước khi
|
|
102
|
+
chuyển quyền sở hữu, wrapper tự giải phóng output của session.
|
|
103
|
+
|
|
104
|
+
Hủy được kiểm tra giữa các trang và trước/sau khi đóng PDF. Trang đang được
|
|
105
|
+
Vision/ML Kit xử lý sẽ hoàn tất trước khi dọn tài nguyên. Lỗi đọc QR nằm trong
|
|
106
|
+
trạng thái từng trang; lỗi decode ảnh hoặc ghi PDF hủy cả session để tránh trả
|
|
107
|
+
PDF thiếu trang. Lỗi tạo preview không làm mất mã đã đọc được.
|
|
108
|
+
|
|
109
|
+
## Luồng xử lý native
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
Ảnh đã chụp
|
|
113
|
+
→ decode và chuẩn hóa hướng EXIF
|
|
114
|
+
→ đọc QR trên bitmap
|
|
115
|
+
→ tạo preview và ghi trang PDF
|
|
116
|
+
→ trả PDF URI, mã QR và trạng thái từng trang về JavaScript
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Các trang được xử lý tuần tự trên worker. Không truyền bitmap/base64 qua bridge
|
|
120
|
+
và không cần render lại PDF vừa tạo để đọc QR.
|
|
121
|
+
|
|
122
|
+
- iOS dùng ImageIO, Vision, Core Graphics và autorelease pool theo từng trang.
|
|
123
|
+
- Android dùng ImageDecoder, ML Kit barcode-scanning bundled 17.3.0 và PDF writer
|
|
124
|
+
ghi JPEG trực tiếp; camera dùng play-services-mlkit-document-scanner 16.0.0.
|
|
125
|
+
- Ảnh đầu vào được giới hạn cạnh dài 4096 px; PDF tối đa 3000 px, JPEG 92%;
|
|
126
|
+
preview tối đa 1000 px, JPEG 85%. Không phóng to ảnh nhỏ.
|
|
127
|
+
- Luồng xử lý tài liệu thử thêm bốn vùng chồng lấn 60% khi chưa thấy payload
|
|
128
|
+
`doc|...|<số>`. Kết quả vẫn chứa các chuỗi QR thô để ứng dụng tự parse, đối chiếu
|
|
129
|
+
và gộp mã theo nghiệp vụ.
|
|
130
|
+
|
|
131
|
+
## Đọc QR/barcode từ ảnh
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { scanBarcodes, scanQrImage } from 'react-native-document-processor';
|
|
135
|
+
|
|
136
|
+
const qr = await scanQrImage('/path/to/image.jpg');
|
|
137
|
+
const all = await scanBarcodes('file:///path/to/image.jpg');
|
|
138
|
+
const selected = await scanBarcodes('content://provider/image', {
|
|
139
|
+
formats: ['qr', 'ean-13'],
|
|
140
|
+
});
|
|
141
|
+
// { values: ['0030358'], codes: [{ value: '0030358', format: 'qr' }] }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
API chạy trên worker, giới hạn ảnh 4096 px và chuẩn hóa EXIF. Không mở camera,
|
|
145
|
+
tạo PDF hoặc xóa ảnh đầu vào. Hỗ trợ đường dẫn tuyệt đối, `file://` và thêm
|
|
146
|
+
`content://` trên Android; không nhận URL mạng hoặc base64.
|
|
147
|
+
|
|
148
|
+
Không phát hiện mã trả `{ values: [], codes: [] }`. Ảnh hỏng hoặc lỗi decoder
|
|
149
|
+
reject Promise. Kết quả hỗ trợ nhiều mã, loại mã trùng và giữ nguyên chuỗi,
|
|
150
|
+
kể cả số 0 đầu.
|
|
151
|
+
|
|
152
|
+
Các format: `qr`, `ean-13`, `ean-8`, `upc-a`, `upc-e`, `code-128`, `code-39`,
|
|
153
|
+
`code-93`, `codabar`, `itf`, `pdf-417`, `aztec`, `data-matrix`. iOS quy đổi UPC-A
|
|
154
|
+
từ EAN-13 có số 0 đầu khi yêu cầu UPC-A.
|
|
155
|
+
|
|
156
|
+
Package không render PDF có sẵn. Để đọc QR trong PDF local, ứng dụng cần render
|
|
157
|
+
lần lượt từng trang thành ảnh bằng một thư viện PDF, rồi gọi `scanQrImage` và
|
|
158
|
+
xóa ảnh tạm sau khi xử lý. Giữ tệp PDF gốc để lưu hoặc upload.
|
|
159
|
+
|
|
160
|
+
## Phát triển và kiểm tra
|
|
161
|
+
|
|
162
|
+
Clone repository và dùng dependency `file:` trong ứng dụng host để thử source
|
|
163
|
+
local. Chạy autolinking/pod install rồi rebuild native khi thay đổi iOS/Android.
|
|
164
|
+
|
|
165
|
+
Repository chứa:
|
|
166
|
+
|
|
167
|
+
- `src/__tests__/index.test.ts`: kiểm tra API, format, camera, thứ tự trang,
|
|
168
|
+
hủy, lỗi native và quyền sở hữu file. Chạy với Jest preset React Native của
|
|
169
|
+
ứng dụng host; cấu hình Jest để bao gồm thư mục source của thư viện.
|
|
170
|
+
- `tests/check-pdf-writer.sh`: tạo PDF 30 trang bằng Java rồi đọc/render bằng
|
|
171
|
+
PDFKit để kiểm tra số trang, thứ tự, kích thước và hướng ảnh.
|
|
172
|
+
- Android instrumentation tests cho ML Kit, PDF, EXIF, nhiều QR, barcode,
|
|
173
|
+
cleanup và xử lý camera khi không có foreground activity.
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
# Tại repository thư viện, cần macOS, Xcode và JDK
|
|
177
|
+
bash tests/check-pdf-writer.sh
|
|
178
|
+
|
|
179
|
+
# Tại thư mục android của ứng dụng host đã autolink, cần thiết bị ADB
|
|
180
|
+
./gradlew :react-native-document-processor:connectedReleaseAndroidTest
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Nếu JDK không nằm trong PATH, đặt `DOCUMENT_PROCESSOR_JAVAC` và
|
|
184
|
+
`DOCUMENT_PROCESSOR_JAVA` thành đường dẫn executable tương ứng.
|
|
185
|
+
|
|
186
|
+
Bản sửa iOS Simulator đã được kiểm tra với PDF mẫu 30 trang, đọc đủ 10 QR trong
|
|
187
|
+
ứng dụng host. Camera trên thiết bị thật vẫn cần kiểm tra với giấy nhỏ/mờ/xoay,
|
|
188
|
+
chụp nhiều đợt và thao tác hủy. Không công bố tỷ lệ tăng tốc hoặc mức RAM chung
|
|
189
|
+
cho mọi thiết bị.
|
|
190
|
+
|
|
191
|
+
## Log Android
|
|
192
|
+
|
|
193
|
+
Log native dùng tag `DocumentScan`, mặc định tắt. Bật trên thiết bị kiểm tra:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
adb shell setprop log.tag.DocumentScan DEBUG
|
|
197
|
+
adb logcat -v threadtime 'DocumentScan:D' 'AndroidRuntime:E' '*:S'
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Các sự kiện gồm `camera.*`, `session.*`, `page.*`, `pdf.complete` và `barcode.*`.
|
|
201
|
+
Ứng dụng có thể bổ sung sự kiện qua `logDocumentEvent(event, data)`. Log có thể
|
|
202
|
+
chứa mã tài liệu; chỉ bật khi cần chẩn đoán. Không ghi bitmap/base64.
|
|
203
|
+
|
|
204
|
+
Tắt khi hoàn tất hoặc khởi động lại thiết bị:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
adb shell setprop log.tag.DocumentScan INFO
|
|
208
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
android {
|
|
4
|
+
namespace 'vn.money24h.documentprocessor'
|
|
5
|
+
testBuildType 'release'
|
|
6
|
+
compileSdkVersion rootProject.ext.has('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
|
|
7
|
+
defaultConfig {
|
|
8
|
+
minSdkVersion 28
|
|
9
|
+
targetSdkVersion rootProject.ext.has('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
|
10
|
+
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
|
11
|
+
}
|
|
12
|
+
compileOptions {
|
|
13
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
14
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dependencies {
|
|
19
|
+
implementation 'com.facebook.react:react-android'
|
|
20
|
+
implementation 'com.google.mlkit:barcode-scanning:17.3.0'
|
|
21
|
+
implementation 'com.google.android.gms:play-services-mlkit-document-scanner:16.0.0'
|
|
22
|
+
androidTestImplementation 'androidx.test:runner:1.6.1'
|
|
23
|
+
androidTestImplementation 'com.google.zxing:core:3.5.2'
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package vn.money24h.documentprocessor;
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap;
|
|
4
|
+
import com.facebook.react.bridge.Arguments;
|
|
5
|
+
import com.facebook.react.bridge.ReadableArray;
|
|
6
|
+
import com.facebook.react.bridge.WritableArray;
|
|
7
|
+
import com.facebook.react.bridge.WritableMap;
|
|
8
|
+
import com.google.android.gms.tasks.Tasks;
|
|
9
|
+
import com.google.mlkit.vision.barcode.BarcodeScanner;
|
|
10
|
+
import com.google.mlkit.vision.barcode.BarcodeScannerOptions;
|
|
11
|
+
import com.google.mlkit.vision.barcode.BarcodeScanning;
|
|
12
|
+
import com.google.mlkit.vision.barcode.common.Barcode;
|
|
13
|
+
import com.google.mlkit.vision.common.InputImage;
|
|
14
|
+
import java.util.LinkedHashMap;
|
|
15
|
+
import java.util.LinkedHashSet;
|
|
16
|
+
import java.util.Map;
|
|
17
|
+
import java.util.Set;
|
|
18
|
+
|
|
19
|
+
/** Stateless image decoding on the processor worker; no document session/files. */
|
|
20
|
+
final class BarcodeImageScanner {
|
|
21
|
+
private static final Map<String, Integer> FORMATS = new LinkedHashMap<>();
|
|
22
|
+
static {
|
|
23
|
+
FORMATS.put("qr", Barcode.FORMAT_QR_CODE);
|
|
24
|
+
FORMATS.put("ean-13", Barcode.FORMAT_EAN_13);
|
|
25
|
+
FORMATS.put("ean-8", Barcode.FORMAT_EAN_8);
|
|
26
|
+
FORMATS.put("upc-a", Barcode.FORMAT_UPC_A);
|
|
27
|
+
FORMATS.put("upc-e", Barcode.FORMAT_UPC_E);
|
|
28
|
+
FORMATS.put("code-128", Barcode.FORMAT_CODE_128);
|
|
29
|
+
FORMATS.put("code-39", Barcode.FORMAT_CODE_39);
|
|
30
|
+
FORMATS.put("code-93", Barcode.FORMAT_CODE_93);
|
|
31
|
+
FORMATS.put("codabar", Barcode.FORMAT_CODABAR);
|
|
32
|
+
FORMATS.put("itf", Barcode.FORMAT_ITF);
|
|
33
|
+
FORMATS.put("pdf-417", Barcode.FORMAT_PDF417);
|
|
34
|
+
FORMATS.put("aztec", Barcode.FORMAT_AZTEC);
|
|
35
|
+
FORMATS.put("data-matrix", Barcode.FORMAT_DATA_MATRIX);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private BarcodeImageScanner() { }
|
|
39
|
+
|
|
40
|
+
static WritableMap scan(Bitmap bitmap, ReadableArray formats) throws Exception {
|
|
41
|
+
if (formats == null || formats.size() == 0) {
|
|
42
|
+
throw new IllegalArgumentException("At least one barcode format is required.");
|
|
43
|
+
}
|
|
44
|
+
int mask = 0;
|
|
45
|
+
for (int i = 0; i < formats.size(); i++) {
|
|
46
|
+
Integer format = FORMATS.get(formats.getString(i));
|
|
47
|
+
if (format == null) { throw new IllegalArgumentException("Unsupported barcode format."); }
|
|
48
|
+
mask |= format;
|
|
49
|
+
}
|
|
50
|
+
BarcodeScanner scanner = BarcodeScanning.getClient(new BarcodeScannerOptions.Builder()
|
|
51
|
+
.setBarcodeFormats(mask).build());
|
|
52
|
+
try {
|
|
53
|
+
WritableArray codes = Arguments.createArray();
|
|
54
|
+
Set<String> values = new LinkedHashSet<>();
|
|
55
|
+
Set<String> uniqueCodes = new LinkedHashSet<>();
|
|
56
|
+
for (Barcode barcode : Tasks.await(scanner.process(InputImage.fromBitmap(bitmap, 0)))) {
|
|
57
|
+
String value = barcode.getRawValue();
|
|
58
|
+
if (value == null || value.isEmpty()) { continue; }
|
|
59
|
+
for (Map.Entry<String, Integer> format : FORMATS.entrySet()) {
|
|
60
|
+
if (format.getValue() != barcode.getFormat() || (mask & format.getValue()) == 0) { continue; }
|
|
61
|
+
if (uniqueCodes.add(format.getKey() + "\n" + value)) {
|
|
62
|
+
WritableMap code = Arguments.createMap();
|
|
63
|
+
code.putString("value", value);
|
|
64
|
+
code.putString("format", format.getKey());
|
|
65
|
+
codes.pushMap(code);
|
|
66
|
+
values.add(value);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
WritableArray rawValues = Arguments.createArray();
|
|
72
|
+
for (String value : values) { rawValues.pushString(value); }
|
|
73
|
+
WritableMap result = Arguments.createMap();
|
|
74
|
+
result.putArray("values", rawValues);
|
|
75
|
+
result.putArray("codes", codes);
|
|
76
|
+
return result;
|
|
77
|
+
} finally {
|
|
78
|
+
scanner.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
package vn.money24h.documentprocessor;
|
|
2
|
+
|
|
3
|
+
import android.app.Activity;
|
|
4
|
+
import android.content.Intent;
|
|
5
|
+
import android.net.Uri;
|
|
6
|
+
import android.os.SystemClock;
|
|
7
|
+
import com.facebook.react.bridge.Arguments;
|
|
8
|
+
import com.facebook.react.bridge.BaseActivityEventListener;
|
|
9
|
+
import com.facebook.react.bridge.Promise;
|
|
10
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
11
|
+
import com.facebook.react.bridge.UiThreadUtil;
|
|
12
|
+
import com.facebook.react.bridge.WritableArray;
|
|
13
|
+
import com.facebook.react.bridge.WritableMap;
|
|
14
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScannerOptions;
|
|
15
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScanning;
|
|
16
|
+
import com.google.mlkit.vision.documentscanner.GmsDocumentScanningResult;
|
|
17
|
+
import java.io.File;
|
|
18
|
+
import java.io.FileOutputStream;
|
|
19
|
+
import java.io.IOException;
|
|
20
|
+
import java.io.InputStream;
|
|
21
|
+
import java.util.ArrayList;
|
|
22
|
+
import java.util.List;
|
|
23
|
+
import java.util.concurrent.ExecutorService;
|
|
24
|
+
import java.util.concurrent.Executors;
|
|
25
|
+
|
|
26
|
+
/** Owns camera lifecycle and copies scanner results into files owned by our caller. */
|
|
27
|
+
final class DocumentCapture extends BaseActivityEventListener {
|
|
28
|
+
private static final int REQUEST_SCAN = 24861;
|
|
29
|
+
private final ReactApplicationContext context;
|
|
30
|
+
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
|
31
|
+
private Promise pending;
|
|
32
|
+
private boolean closed;
|
|
33
|
+
private long startedAt;
|
|
34
|
+
|
|
35
|
+
DocumentCapture(ReactApplicationContext context) {
|
|
36
|
+
this.context = context;
|
|
37
|
+
context.addActivityEventListener(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
synchronized void scan(Promise promise) {
|
|
41
|
+
if (closed || pending != null) {
|
|
42
|
+
DocumentScanLog.event("camera.busy", "closed=" + closed);
|
|
43
|
+
promise.reject("DOCUMENT_CAPTURE_BUSY", "Document camera is busy or closed.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
pending = promise;
|
|
47
|
+
startedAt = SystemClock.elapsedRealtime();
|
|
48
|
+
DocumentScanLog.event("camera.start", "format=JPEG mode=FULL");
|
|
49
|
+
UiThreadUtil.runOnUiThread(() -> {
|
|
50
|
+
if (!isActive(promise)) { return; }
|
|
51
|
+
Activity activity = context.getCurrentActivity();
|
|
52
|
+
if (activity == null || activity.isFinishing() || activity.isDestroyed()) {
|
|
53
|
+
fail(promise, new IOException("No foreground activity for document camera."));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
GmsDocumentScannerOptions options = new GmsDocumentScannerOptions.Builder()
|
|
58
|
+
.setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG)
|
|
59
|
+
.setScannerMode(GmsDocumentScannerOptions.SCANNER_MODE_FULL)
|
|
60
|
+
.build();
|
|
61
|
+
GmsDocumentScanning.getClient(options).getStartScanIntent(activity)
|
|
62
|
+
.addOnSuccessListener(sender -> {
|
|
63
|
+
if (!isActive(promise)) { return; }
|
|
64
|
+
try {
|
|
65
|
+
DocumentScanLog.event("camera.ready", "elapsedMs=" + (SystemClock.elapsedRealtime() - startedAt));
|
|
66
|
+
activity.startIntentSenderForResult(sender, REQUEST_SCAN, null, 0, 0, 0);
|
|
67
|
+
} catch (Exception error) {
|
|
68
|
+
fail(promise, error);
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.addOnFailureListener(error -> fail(promise, error));
|
|
72
|
+
} catch (Exception error) {
|
|
73
|
+
fail(promise, error);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private synchronized boolean isActive(Promise promise) {
|
|
79
|
+
return !closed && pending == promise;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private synchronized void fail(Promise promise, Exception error) {
|
|
83
|
+
if (pending == promise) {
|
|
84
|
+
pending = null;
|
|
85
|
+
DocumentScanLog.error("camera.error", error);
|
|
86
|
+
promise.reject("DOCUMENT_CAPTURE_ERROR", error.getMessage(), error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private synchronized boolean complete(Promise promise, String status, List<File> files) {
|
|
91
|
+
if (!isActive(promise)) { return false; }
|
|
92
|
+
WritableArray images = Arguments.createArray();
|
|
93
|
+
for (File file : files) { images.pushString(Uri.fromFile(file).toString()); }
|
|
94
|
+
WritableMap result = Arguments.createMap();
|
|
95
|
+
result.putString("status", status);
|
|
96
|
+
result.putArray("scannedImages", images);
|
|
97
|
+
pending = null;
|
|
98
|
+
DocumentScanLog.event("camera.complete", "status=" + status + " pages=" + files.size()
|
|
99
|
+
+ " elapsedMs=" + (SystemClock.elapsedRealtime() - startedAt));
|
|
100
|
+
promise.resolve(result);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@Override
|
|
105
|
+
public synchronized void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
|
|
106
|
+
if (requestCode != REQUEST_SCAN || pending == null || closed) { return; }
|
|
107
|
+
Promise promise = pending;
|
|
108
|
+
DocumentScanLog.event("camera.result", "resultCode=" + resultCode);
|
|
109
|
+
if (resultCode == Activity.RESULT_CANCELED) {
|
|
110
|
+
complete(promise, "cancel", new ArrayList<>());
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
GmsDocumentScanningResult result = GmsDocumentScanningResult.fromActivityResultIntent(data);
|
|
114
|
+
if (resultCode != Activity.RESULT_OK || result == null || result.getPages() == null
|
|
115
|
+
|| result.getPages().isEmpty()) {
|
|
116
|
+
fail(promise, new IOException("Document camera returned no pages."));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
List<GmsDocumentScanningResult.Page> pages = new ArrayList<>(result.getPages());
|
|
120
|
+
DocumentScanLog.event("camera.copy.start", "pages=" + pages.size());
|
|
121
|
+
worker.execute(() -> {
|
|
122
|
+
long copyStartedAt = SystemClock.elapsedRealtime();
|
|
123
|
+
List<File> files = new ArrayList<>();
|
|
124
|
+
boolean transferred = false;
|
|
125
|
+
try {
|
|
126
|
+
byte[] buffer = new byte[64 * 1024];
|
|
127
|
+
for (GmsDocumentScanningResult.Page page : pages) {
|
|
128
|
+
if (!isActive(promise)) { return; }
|
|
129
|
+
File file = File.createTempFile("document-capture-", ".jpg", context.getCacheDir());
|
|
130
|
+
files.add(file);
|
|
131
|
+
try (InputStream input = context.getContentResolver().openInputStream(page.getImageUri());
|
|
132
|
+
FileOutputStream output = new FileOutputStream(file)) {
|
|
133
|
+
if (input == null) { throw new IOException("Cannot read scanned page."); }
|
|
134
|
+
int count;
|
|
135
|
+
while ((count = input.read(buffer)) != -1) { output.write(buffer, 0, count); }
|
|
136
|
+
}
|
|
137
|
+
if (file.length() == 0) { throw new IOException("Scanned page is empty."); }
|
|
138
|
+
DocumentScanLog.event("camera.copy.page", "page=" + files.size() + " bytes=" + file.length());
|
|
139
|
+
}
|
|
140
|
+
DocumentScanLog.event("camera.copy.complete", "pages=" + files.size()
|
|
141
|
+
+ " elapsedMs=" + (SystemClock.elapsedRealtime() - copyStartedAt));
|
|
142
|
+
transferred = complete(promise, "success", files);
|
|
143
|
+
} catch (Exception error) {
|
|
144
|
+
fail(promise, error);
|
|
145
|
+
} finally {
|
|
146
|
+
if (!transferred) {
|
|
147
|
+
for (File file : files) { file.delete(); }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
synchronized void invalidate() {
|
|
154
|
+
if (closed) { return; }
|
|
155
|
+
closed = true;
|
|
156
|
+
if (pending != null) {
|
|
157
|
+
pending.reject("DOCUMENT_CAPTURE_CANCELLED", "Document camera was closed.");
|
|
158
|
+
pending = null;
|
|
159
|
+
}
|
|
160
|
+
context.removeActivityEventListener(this);
|
|
161
|
+
worker.shutdown();
|
|
162
|
+
}
|
|
163
|
+
}
|