@unif/react-native-hms-scan 0.5.4 → 0.5.5

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.
Files changed (33) hide show
  1. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanView.kt +31 -15
  2. package/lib/module/NativeHmsScan.js.map +1 -1
  3. package/lib/module/Scanner/ScanErrorOverlay.js +55 -0
  4. package/lib/module/Scanner/ScanErrorOverlay.js.map +1 -0
  5. package/lib/module/Scanner/Scanner.js +176 -52
  6. package/lib/module/Scanner/Scanner.js.map +1 -1
  7. package/lib/module/decodeImage.js +3 -2
  8. package/lib/module/decodeImage.js.map +1 -1
  9. package/lib/module/index.js.map +1 -1
  10. package/lib/module/types.js +2 -0
  11. package/lib/module/types.js.map +1 -1
  12. package/lib/typescript/src/HmsScanView.d.ts +3 -6
  13. package/lib/typescript/src/HmsScanView.d.ts.map +1 -1
  14. package/lib/typescript/src/NativeHmsScan.d.ts +3 -1
  15. package/lib/typescript/src/NativeHmsScan.d.ts.map +1 -1
  16. package/lib/typescript/src/Scanner/ScanErrorOverlay.d.ts +7 -0
  17. package/lib/typescript/src/Scanner/ScanErrorOverlay.d.ts.map +1 -0
  18. package/lib/typescript/src/Scanner/Scanner.d.ts +9 -6
  19. package/lib/typescript/src/Scanner/Scanner.d.ts.map +1 -1
  20. package/lib/typescript/src/decodeImage.d.ts +3 -2
  21. package/lib/typescript/src/decodeImage.d.ts.map +1 -1
  22. package/lib/typescript/src/index.d.ts +1 -1
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/lib/typescript/src/types.d.ts +6 -1
  25. package/lib/typescript/src/types.d.ts.map +1 -1
  26. package/package.json +3 -2
  27. package/src/HmsScanView.tsx +3 -3
  28. package/src/NativeHmsScan.ts +3 -1
  29. package/src/Scanner/ScanErrorOverlay.tsx +51 -0
  30. package/src/Scanner/Scanner.tsx +236 -54
  31. package/src/decodeImage.ts +3 -2
  32. package/src/index.tsx +1 -0
  33. package/src/types.ts +7 -1
@@ -1,13 +1,18 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { Linking, StyleSheet, View } from 'react-native';
3
- import { ThemeProvider, ToastHost } from '@unif/react-native-design';
2
+ import { AppState, Linking, StyleSheet, View } from 'react-native';
3
+ import { ThemeProvider } from '@unif/react-native-design';
4
4
  import { HmsScanView } from '../HmsScanView';
5
5
  import { decodeImage } from '../decodeImage';
6
6
  import {
7
7
  getCameraPermissionStatus,
8
8
  requestCameraPermission,
9
9
  } from '../permissions';
10
- import type { BarcodeFormat, ScanProduct, ScanResult } from '../types';
10
+ import type {
11
+ BarcodeFormat,
12
+ ScanError,
13
+ ScanProduct,
14
+ ScanResult,
15
+ } from '../types';
11
16
  import { scanChrome } from './scanChrome';
12
17
  import { Viewfinder } from './Viewfinder';
13
18
  import { ScanTopBar } from './ScanTopBar';
@@ -15,18 +20,39 @@ import { ScanToolbar } from './ScanToolbar';
15
20
  import { ResultFocus } from './ResultFocus';
16
21
  import { ResultFail } from './ResultFail';
17
22
  import { DeniedOverlay } from './DeniedOverlay';
23
+ import { ScanErrorOverlay } from './ScanErrorOverlay';
18
24
 
19
25
  // 'done':autoConfirm 自动回调后的终态——相机暂停、不出卡片、不自动重扫(宿主通常已导航离开)。
20
- type Phase = 'init' | 'scan' | 'detecting' | 'success' | 'fail' | 'denied' | 'done';
26
+ type Phase =
27
+ | 'init'
28
+ | 'scan'
29
+ | 'detecting'
30
+ | 'success'
31
+ | 'fail'
32
+ | 'denied'
33
+ | 'error'
34
+ | 'done';
21
35
 
22
36
  const VF_SIZE = 256;
23
37
  const DEFAULT_HINT = '将条码 / 二维码放入框内,自动扫描';
24
38
 
39
+ function toScanError(error: unknown, fallbackMessage: string): ScanError {
40
+ if (error && typeof error === 'object') {
41
+ const native = error as { code?: unknown; message?: unknown };
42
+ return {
43
+ code: typeof native.code === 'string' ? native.code : 'E_UNKNOWN',
44
+ message:
45
+ typeof native.message === 'string' ? native.message : fallbackMessage,
46
+ };
47
+ }
48
+ return { code: 'E_UNKNOWN', message: fallbackMessage };
49
+ }
50
+
25
51
  export interface ScannerProps {
26
52
  /** 顶栏标题,默认 "扫一扫"。 */
27
53
  title?: string;
28
54
  /** 限定识别码制;不传 = 全部。 */
29
- formats?: BarcodeFormat[];
55
+ formats?: readonly BarcodeFormat[];
30
56
  /** 取景态提示文案。 */
31
57
  hintText?: string;
32
58
  /** 顶部安全区(默认 54)。用 react-native-safe-area-context 时可传 insets.top。 */
@@ -37,6 +63,8 @@ export interface ScannerProps {
37
63
  showTorch?: boolean;
38
64
  /** 返回回调(退出扫码页,回到上一级);按钮在底部工具栏,与手电筒并排。 */
39
65
  onClose?: () => void;
66
+ /** 扫码相机或权限检查出错时上报。 */
67
+ onScanError?: (error: ScanError) => void;
40
68
  /**
41
69
  * 扫到条码后由宿主解析商品信息(用于浮层确认卡)。
42
70
  * 返回 null/undefined 视为"未识别",抛错亦然。不传则默认用扫到的原文当商品名。
@@ -47,8 +75,9 @@ export interface ScannerProps {
47
75
  /** 用户点"确认":把结果带回上一级(宿主通常在此导航返回)。 */
48
76
  onConfirm?: (product: ScanProduct, result: ScanResult) => void;
49
77
  /**
50
- * 自动确认:扫到并解析成功后**不显示结果卡**,直接触发 `onConfirm(product, result)`。
51
- * 默认 `false`(显示结果卡,由用户点"确定")。
78
+ * 自动确认:传 `onConfirm` 时,扫到并解析成功后**不显示结果卡**,直接触发
79
+ * `onConfirm(product, result)`;未传回调时仍显示结果卡。默认 `false`(显示结果卡,
80
+ * 由用户点"确定")。
52
81
  * 适合"扫到即用、不需要二次确认"的场景。回调后相机暂停、不自动重扫——宿主通常在
53
82
  * `onConfirm` 里导航离开;若需再扫由宿主控制(如重新挂载 `<Scanner>`)。
54
83
  * 注:未识别(`resolveProduct` 返回 `null`/抛错)仍走失败态可重扫,不会误触发。
@@ -63,14 +92,13 @@ export interface ScannerProps {
63
92
 
64
93
  /**
65
94
  * 成品「扫一扫」界面(聚焦款)。底层 <HmsScanView> 出相机画面,取景框 / 工具栏 / 结果卡
66
- * 全用 @unif/react-native-design 的主题令牌与组件绘制(统一风格)。自带 ThemeProvider +
67
- * ToastHost,可直接整屏接入;放进宿主已有的 ThemeProvider 里也兼容。
95
+ * 全用 @unif/react-native-design 的主题令牌与组件绘制(统一风格)。自带 ThemeProvider,
96
+ * 可直接整屏接入;放进宿主已有的 ThemeProvider 里也兼容。
68
97
  */
69
98
  export function Scanner(props: ScannerProps) {
70
99
  return (
71
100
  <ThemeProvider>
72
101
  <ScannerInner {...props} />
73
- <ToastHost />
74
102
  </ThemeProvider>
75
103
  );
76
104
  }
@@ -87,59 +115,148 @@ function ScannerInner({
87
115
  onConfirm,
88
116
  autoConfirm = false,
89
117
  pickImage,
118
+ onScanError,
90
119
  }: ScannerProps) {
91
120
  const [phase, setPhase] = useState<Phase>('init');
92
121
  const [torch, setTorch] = useState(false);
93
122
  const [product, setProduct] = useState<ScanProduct | null>(null);
94
123
  const [detectMs, setDetectMs] = useState(0);
124
+ const [scanSession, setScanSession] = useState(0);
95
125
 
96
126
  const handlingRef = useRef(false);
127
+ const acceptingResultsRef = useRef(false);
128
+ const scanSessionRef = useRef(0);
97
129
  const detectStartRef = useRef(0);
98
130
  const lastResultRef = useRef<ScanResult | null>(null);
99
131
  const mountedRef = useRef(true);
132
+ const permissionRunRef = useRef(0);
133
+ const processingRunRef = useRef(0);
134
+ const waitingForSettingsRef = useRef(false);
135
+ const onScanErrorRef = useRef(onScanError);
136
+ onScanErrorRef.current = onScanError;
100
137
 
101
- // 权限流:已授权 → scan;否则请求;仍未授权 → denied。
102
- useEffect(() => {
103
- mountedRef.current = true;
104
- (async () => {
138
+ const invalidateProcessing = useCallback(() => {
139
+ ++processingRunRef.current;
140
+ }, []);
141
+
142
+ const invalidateScanSession = useCallback(() => {
143
+ ++scanSessionRef.current;
144
+ acceptingResultsRef.current = false;
145
+ }, []);
146
+
147
+ const enterScan = useCallback(() => {
148
+ invalidateProcessing();
149
+ handlingRef.current = false;
150
+ const sessionId = ++scanSessionRef.current;
151
+ acceptingResultsRef.current = true;
152
+ setScanSession(sessionId);
153
+ setProduct(null);
154
+ lastResultRef.current = null;
155
+ setPhase('scan');
156
+ }, [invalidateProcessing]);
157
+
158
+ const enterDenied = useCallback(() => {
159
+ ++permissionRunRef.current;
160
+ invalidateProcessing();
161
+ invalidateScanSession();
162
+ setProduct(null);
163
+ setPhase('denied');
164
+ }, [invalidateProcessing, invalidateScanSession]);
165
+
166
+ const reportFatalError = useCallback(
167
+ (error: ScanError) => {
168
+ // fatal 后不允许已在途的权限检查或扫码 / 相册处理链覆盖 error phase。
169
+ ++permissionRunRef.current;
170
+ invalidateProcessing();
171
+ invalidateScanSession();
172
+ setProduct(null);
173
+ setPhase('error');
174
+ onScanErrorRef.current?.(error);
175
+ },
176
+ [invalidateProcessing, invalidateScanSession]
177
+ );
178
+
179
+ const runPermissionFlow = useCallback(
180
+ async (requestIfNeeded: boolean) => {
181
+ const runId = ++permissionRunRef.current;
182
+ const isCurrent = () =>
183
+ mountedRef.current && permissionRunRef.current === runId;
105
184
  try {
106
185
  let status = await getCameraPermissionStatus();
107
- if (status === 'undetermined' || status === 'denied') {
186
+ if (!isCurrent()) return;
187
+ if (
188
+ requestIfNeeded &&
189
+ (status === 'undetermined' || status === 'denied')
190
+ ) {
108
191
  status = await requestCameraPermission();
192
+ if (!isCurrent()) return;
109
193
  }
110
- if (!mountedRef.current) return;
111
- setPhase(status === 'granted' ? 'scan' : 'denied');
112
- } catch {
113
- if (mountedRef.current) setPhase('scan');
194
+ if (status === 'granted') {
195
+ enterScan();
196
+ } else {
197
+ enterDenied();
198
+ }
199
+ } catch (error) {
200
+ if (!isCurrent()) return;
201
+ reportFatalError(toScanError(error, '检查相机权限失败'));
114
202
  }
115
- })();
203
+ },
204
+ [enterDenied, enterScan, reportFatalError]
205
+ );
206
+
207
+ // 权限流:已授权 → scan;否则请求;仍未授权 → denied。
208
+ useEffect(() => {
209
+ mountedRef.current = true;
210
+ void runPermissionFlow(true);
116
211
  return () => {
117
212
  mountedRef.current = false;
213
+ ++permissionRunRef.current;
214
+ invalidateProcessing();
215
+ invalidateScanSession();
118
216
  };
119
- }, []);
217
+ }, [invalidateProcessing, invalidateScanSession, runPermissionFlow]);
218
+
219
+ useEffect(() => {
220
+ const subscription = AppState.addEventListener('change', (nextState) => {
221
+ if (nextState === 'active' && waitingForSettingsRef.current) {
222
+ waitingForSettingsRef.current = false;
223
+ void runPermissionFlow(false);
224
+ }
225
+ });
226
+ return () => subscription.remove();
227
+ }, [runPermissionFlow]);
228
+
229
+ useEffect(() => {
230
+ if (!showTorch) setTorch(false);
231
+ }, [showTorch]);
120
232
 
121
233
  const reset = useCallback(() => {
122
- handlingRef.current = false;
123
- setProduct(null);
124
- setPhase('scan');
125
- }, []);
234
+ enterScan();
235
+ }, [enterScan]);
126
236
 
127
237
  const finalize = useCallback(
128
- async (result: ScanResult) => {
238
+ async (result: ScanResult, runId: number) => {
239
+ const isCurrent = () =>
240
+ mountedRef.current && processingRunRef.current === runId;
241
+ if (!isCurrent()) return;
129
242
  lastResultRef.current = result;
130
243
  try {
131
244
  const resolved = resolveProduct
132
245
  ? await resolveProduct(result)
133
246
  : { name: result.value };
134
- if (!mountedRef.current) return;
247
+ if (!isCurrent()) return;
135
248
  if (!resolved) {
136
249
  setPhase('fail');
137
250
  return;
138
251
  }
139
- const p: ScanProduct = { barcode: result.value, ...resolved };
140
- if (autoConfirm) {
252
+ const p: ScanProduct = {
253
+ ...resolved,
254
+ barcode: resolved.barcode ?? result.value,
255
+ };
256
+ if (autoConfirm && onConfirm) {
141
257
  // 跳过结果卡:直接回调,进 'done' 终态(相机暂停、不自动重扫)。
142
- onConfirm?.(p, result);
258
+ onConfirm(p, result);
259
+ if (!isCurrent()) return;
143
260
  setPhase('done');
144
261
  return;
145
262
  }
@@ -147,7 +264,7 @@ function ScannerInner({
147
264
  setDetectMs(Date.now() - detectStartRef.current);
148
265
  setPhase('success');
149
266
  } catch {
150
- if (mountedRef.current) setPhase('fail');
267
+ if (isCurrent()) setPhase('fail');
151
268
  }
152
269
  },
153
270
  [resolveProduct, autoConfirm, onConfirm]
@@ -155,44 +272,98 @@ function ScannerInner({
155
272
 
156
273
  const onCameraResult = useCallback(
157
274
  (results: ScanResult[]) => {
158
- if (phase !== 'scan' || handlingRef.current) return;
275
+ if (
276
+ scanSession !== scanSessionRef.current ||
277
+ phase !== 'scan' ||
278
+ !acceptingResultsRef.current ||
279
+ handlingRef.current
280
+ ) {
281
+ return;
282
+ }
159
283
  const first = results[0];
160
284
  if (!first) return;
285
+ acceptingResultsRef.current = false;
161
286
  handlingRef.current = true;
287
+ const runId = ++processingRunRef.current;
162
288
  detectStartRef.current = Date.now();
163
289
  setPhase('detecting');
164
- void finalize(first);
290
+ void finalize(first, runId);
165
291
  },
166
- [phase, finalize]
292
+ [phase, scanSession, finalize]
167
293
  );
168
294
 
169
- const onScanError = useCallback((error: { code: string; message: string }) => {
170
- if (error.code === 'E_NO_CAMERA_PERMISSION') setPhase('denied');
171
- }, []);
295
+ const handleViewScanError = useCallback(
296
+ (error: ScanError) => {
297
+ if (error.code === 'E_NO_CAMERA_PERMISSION') {
298
+ enterDenied();
299
+ } else if (error.code !== 'E_NO_RESULT') {
300
+ reportFatalError(error);
301
+ return;
302
+ }
303
+ onScanErrorRef.current?.(error);
304
+ },
305
+ [enterDenied, reportFatalError]
306
+ );
307
+
308
+ const onTorchStatus = useCallback(
309
+ (status: { available: boolean; on: boolean }) => {
310
+ setTorch(status.on);
311
+ },
312
+ []
313
+ );
314
+
315
+ const openSettings = useCallback(async () => {
316
+ waitingForSettingsRef.current = true;
317
+ try {
318
+ await Linking.openSettings();
319
+ } catch (error) {
320
+ waitingForSettingsRef.current = false;
321
+ if (mountedRef.current) {
322
+ reportFatalError(toScanError(error, '打开系统设置失败'));
323
+ }
324
+ }
325
+ }, [reportFatalError]);
326
+
327
+ const retryCamera = useCallback(() => {
328
+ invalidateProcessing();
329
+ invalidateScanSession();
330
+ handlingRef.current = false;
331
+ setProduct(null);
332
+ lastResultRef.current = null;
333
+ setPhase('init');
334
+ void runPermissionFlow(true);
335
+ }, [invalidateProcessing, invalidateScanSession, runPermissionFlow]);
172
336
 
173
337
  const onAlbum = useCallback(async () => {
174
- if (!pickImage || handlingRef.current) return;
338
+ if (!pickImage || !acceptingResultsRef.current || handlingRef.current) {
339
+ return;
340
+ }
341
+ acceptingResultsRef.current = false;
342
+ handlingRef.current = true;
343
+ const runId = ++processingRunRef.current;
344
+ const isCurrent = () =>
345
+ mountedRef.current && processingRunRef.current === runId;
346
+ setPhase('detecting');
175
347
  try {
176
348
  const uri = await pickImage();
177
- if (!uri || !mountedRef.current) return;
178
- handlingRef.current = true;
349
+ if (!isCurrent()) return;
350
+ if (!uri) {
351
+ reset();
352
+ return;
353
+ }
179
354
  detectStartRef.current = Date.now();
180
- setPhase('detecting');
181
355
  const results = await decodeImage(uri, formats ? { formats } : undefined);
182
- if (!mountedRef.current) return;
356
+ if (!isCurrent()) return;
183
357
  const first = results[0];
184
358
  if (!first) {
185
359
  setPhase('fail');
186
360
  return;
187
361
  }
188
- await finalize(first);
362
+ await finalize(first, runId);
189
363
  } catch {
190
- if (mountedRef.current) {
191
- handlingRef.current = true;
192
- setPhase('fail');
193
- }
364
+ if (isCurrent()) setPhase('fail');
194
365
  }
195
- }, [pickImage, formats, finalize]);
366
+ }, [pickImage, formats, finalize, reset]);
196
367
 
197
368
  const onConfirmPress = useCallback(() => {
198
369
  const r = lastResultRef.current;
@@ -207,22 +378,27 @@ function ScannerInner({
207
378
 
208
379
  return (
209
380
  <View style={styles.root}>
210
- {phase !== 'denied' && phase !== 'init' && (
381
+ {phase !== 'denied' && phase !== 'init' && phase !== 'error' && (
211
382
  <HmsScanView
212
383
  style={StyleSheet.absoluteFill}
213
384
  formats={formats}
214
385
  paused={phase !== 'scan'}
215
386
  torch={torch}
216
387
  onScanResult={onCameraResult}
217
- onScanError={onScanError}
388
+ onScanError={handleViewScanError}
389
+ onTorchStatus={onTorchStatus}
218
390
  />
219
391
  )}
220
392
 
221
393
  {showChrome && (
222
- <Viewfinder size={VF_SIZE} detecting={phase === 'detecting'} hintText={hintText} />
394
+ <Viewfinder
395
+ size={VF_SIZE}
396
+ detecting={phase === 'detecting'}
397
+ hintText={hintText}
398
+ />
223
399
  )}
224
400
 
225
- {phase !== 'denied' && phase !== 'init' && (
401
+ {phase !== 'denied' && phase !== 'init' && phase !== 'error' && (
226
402
  <ScanTopBar title={title} topInset={topInset} />
227
403
  )}
228
404
 
@@ -246,10 +422,16 @@ function ScannerInner({
246
422
  />
247
423
  )}
248
424
 
249
- {phase === 'fail' && <ResultFail bottomInset={bottomInset} onRescan={reset} />}
425
+ {phase === 'fail' && (
426
+ <ResultFail bottomInset={bottomInset} onRescan={reset} />
427
+ )}
428
+
429
+ {phase === 'error' && (
430
+ <ScanErrorOverlay onClose={onClose} onRetry={retryCamera} />
431
+ )}
250
432
 
251
433
  {phase === 'denied' && (
252
- <DeniedOverlay onClose={onClose} onSettings={() => Linking.openSettings()} />
434
+ <DeniedOverlay onClose={onClose} onSettings={openSettings} />
253
435
  )}
254
436
  </View>
255
437
  );
@@ -5,8 +5,9 @@ import { HmsScanError, type DecodeImageOptions, type ScanResult } from './types'
5
5
  /**
6
6
  * 从本地图片解码条码 / 二维码(华为 Bitmap 模式)。
7
7
  *
8
- * @param uri 本地图片:file:// / content:// / ph:// / 绝对路径。**不下载远程 URL**,
9
- * 如需识别网络图请宿主先下载到本地再传入。
8
+ * @param uri 本地图片。iOS 支持 file://、绝对路径、data:;Android 支持
9
+ * file://、绝对路径、content://、android.resource://。
10
+ * 两端都不下载远程 URL,也不支持 ph:// / assets-library://。
10
11
  * @param options 可选;formats 限定码制,不传 = 全部。
11
12
  * @returns 命中的结果数组(可能为空)。
12
13
  * @throws HmsScanError 图片加载失败 / 读权限缺失等。
package/src/index.tsx CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  BarcodeContentType,
13
13
  ScanCornerPoint,
14
14
  ScanResult,
15
+ ScanError,
15
16
  DecodeImageOptions,
16
17
  CameraPermissionStatus,
17
18
  HmsScanErrorCode,
package/src/types.ts CHANGED
@@ -82,7 +82,13 @@ export interface ScanResult {
82
82
  /** decodeImage 选项。 */
83
83
  export interface DecodeImageOptions {
84
84
  /** 限定识别码制;不传 = 全部。 */
85
- formats?: BarcodeFormat[];
85
+ formats?: readonly BarcodeFormat[];
86
+ }
87
+
88
+ /** `<HmsScanView>` / `<Scanner>` 通过回调上报的普通错误对象。 */
89
+ export interface ScanError {
90
+ code: string;
91
+ message: string;
86
92
  }
87
93
 
88
94
  /** 相机权限状态。 */