@unif/react-native-hms-scan 0.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/ReactNativeHmsScan.podspec +30 -0
  4. package/android/build.gradle +79 -0
  5. package/android/consumer-rules.pro +13 -0
  6. package/android/src/main/AndroidManifest.xml +23 -0
  7. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanModule.kt +192 -0
  8. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanResultMapper.kt +145 -0
  9. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanView.kt +257 -0
  10. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanViewManager.kt +66 -0
  11. package/android/src/main/java/com/unif/reactnativehmsscan/ReactNativeHmsScanPackage.kt +49 -0
  12. package/ios/HmsScanModule.h +20 -0
  13. package/ios/HmsScanModule.mm +161 -0
  14. package/ios/HmsScanResultMapper.h +45 -0
  15. package/ios/HmsScanResultMapper.mm +297 -0
  16. package/ios/HmsScanView.h +24 -0
  17. package/ios/HmsScanView.mm +322 -0
  18. package/lib/module/HmsScanView.js +43 -0
  19. package/lib/module/HmsScanView.js.map +1 -0
  20. package/lib/module/HmsScanViewNativeComponent.ts +44 -0
  21. package/lib/module/NativeHmsScan.js +11 -0
  22. package/lib/module/NativeHmsScan.js.map +1 -0
  23. package/lib/module/Scanner/DeniedOverlay.js +60 -0
  24. package/lib/module/Scanner/DeniedOverlay.js.map +1 -0
  25. package/lib/module/Scanner/ResultFail.js +79 -0
  26. package/lib/module/Scanner/ResultFail.js.map +1 -0
  27. package/lib/module/Scanner/ResultFocus.js +174 -0
  28. package/lib/module/Scanner/ResultFocus.js.map +1 -0
  29. package/lib/module/Scanner/ScanHint.js +44 -0
  30. package/lib/module/Scanner/ScanHint.js.map +1 -0
  31. package/lib/module/Scanner/ScanToolbar.js +103 -0
  32. package/lib/module/Scanner/ScanToolbar.js.map +1 -0
  33. package/lib/module/Scanner/ScanTopBar.js +87 -0
  34. package/lib/module/Scanner/ScanTopBar.js.map +1 -0
  35. package/lib/module/Scanner/Scanner.js +184 -0
  36. package/lib/module/Scanner/Scanner.js.map +1 -0
  37. package/lib/module/Scanner/Viewfinder.js +159 -0
  38. package/lib/module/Scanner/Viewfinder.js.map +1 -0
  39. package/lib/module/Scanner/scanChrome.js +23 -0
  40. package/lib/module/Scanner/scanChrome.js.map +1 -0
  41. package/lib/module/decodeImage.js +32 -0
  42. package/lib/module/decodeImage.js.map +1 -0
  43. package/lib/module/format.js +66 -0
  44. package/lib/module/format.js.map +1 -0
  45. package/lib/module/index.js +21 -0
  46. package/lib/module/index.js.map +1 -0
  47. package/lib/module/mock.js +30 -0
  48. package/lib/module/mock.js.map +1 -0
  49. package/lib/module/package.json +1 -0
  50. package/lib/module/permissions.js +28 -0
  51. package/lib/module/permissions.js.map +1 -0
  52. package/lib/module/types.js +46 -0
  53. package/lib/module/types.js.map +1 -0
  54. package/lib/typescript/jest.setup.d.ts +1 -0
  55. package/lib/typescript/jest.setup.d.ts.map +1 -0
  56. package/lib/typescript/package.json +1 -0
  57. package/lib/typescript/src/HmsScanView.d.ts +36 -0
  58. package/lib/typescript/src/HmsScanView.d.ts.map +1 -0
  59. package/lib/typescript/src/HmsScanViewNativeComponent.d.ts +32 -0
  60. package/lib/typescript/src/HmsScanViewNativeComponent.d.ts.map +1 -0
  61. package/lib/typescript/src/NativeHmsScan.d.ts +17 -0
  62. package/lib/typescript/src/NativeHmsScan.d.ts.map +1 -0
  63. package/lib/typescript/src/Scanner/DeniedOverlay.d.ts +7 -0
  64. package/lib/typescript/src/Scanner/DeniedOverlay.d.ts.map +1 -0
  65. package/lib/typescript/src/Scanner/ResultFail.d.ts +7 -0
  66. package/lib/typescript/src/Scanner/ResultFail.d.ts.map +1 -0
  67. package/lib/typescript/src/Scanner/ResultFocus.d.ts +12 -0
  68. package/lib/typescript/src/Scanner/ResultFocus.d.ts.map +1 -0
  69. package/lib/typescript/src/Scanner/ScanHint.d.ts +8 -0
  70. package/lib/typescript/src/Scanner/ScanHint.d.ts.map +1 -0
  71. package/lib/typescript/src/Scanner/ScanToolbar.d.ts +9 -0
  72. package/lib/typescript/src/Scanner/ScanToolbar.d.ts.map +1 -0
  73. package/lib/typescript/src/Scanner/ScanTopBar.d.ts +10 -0
  74. package/lib/typescript/src/Scanner/ScanTopBar.d.ts.map +1 -0
  75. package/lib/typescript/src/Scanner/Scanner.d.ts +34 -0
  76. package/lib/typescript/src/Scanner/Scanner.d.ts.map +1 -0
  77. package/lib/typescript/src/Scanner/Viewfinder.d.ts +11 -0
  78. package/lib/typescript/src/Scanner/Viewfinder.d.ts.map +1 -0
  79. package/lib/typescript/src/Scanner/scanChrome.d.ts +19 -0
  80. package/lib/typescript/src/Scanner/scanChrome.d.ts.map +1 -0
  81. package/lib/typescript/src/decodeImage.d.ts +12 -0
  82. package/lib/typescript/src/decodeImage.d.ts.map +1 -0
  83. package/lib/typescript/src/format.d.ts +13 -0
  84. package/lib/typescript/src/format.d.ts.map +1 -0
  85. package/lib/typescript/src/index.d.ts +10 -0
  86. package/lib/typescript/src/index.d.ts.map +1 -0
  87. package/lib/typescript/src/mock.d.ts +12 -0
  88. package/lib/typescript/src/mock.d.ts.map +1 -0
  89. package/lib/typescript/src/permissions.d.ts +9 -0
  90. package/lib/typescript/src/permissions.d.ts.map +1 -0
  91. package/lib/typescript/src/types.d.ts +62 -0
  92. package/lib/typescript/src/types.d.ts.map +1 -0
  93. package/package.json +174 -0
  94. package/src/HmsScanView.tsx +87 -0
  95. package/src/HmsScanViewNativeComponent.ts +44 -0
  96. package/src/NativeHmsScan.ts +24 -0
  97. package/src/Scanner/DeniedOverlay.tsx +59 -0
  98. package/src/Scanner/ResultFail.tsx +71 -0
  99. package/src/Scanner/ResultFocus.tsx +118 -0
  100. package/src/Scanner/ScanHint.tsx +38 -0
  101. package/src/Scanner/ScanToolbar.tsx +93 -0
  102. package/src/Scanner/ScanTopBar.tsx +74 -0
  103. package/src/Scanner/Scanner.tsx +238 -0
  104. package/src/Scanner/Viewfinder.tsx +115 -0
  105. package/src/Scanner/scanChrome.ts +20 -0
  106. package/src/decodeImage.ts +36 -0
  107. package/src/format.ts +93 -0
  108. package/src/index.tsx +35 -0
  109. package/src/mock.ts +43 -0
  110. package/src/permissions.ts +27 -0
  111. package/src/types.ts +138 -0
@@ -0,0 +1,238 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Linking, StyleSheet, View } from 'react-native';
3
+ import { ThemeProvider, ToastHost, toast } from '@unif/react-native-design';
4
+ import { HmsScanView } from '../HmsScanView';
5
+ import { decodeImage } from '../decodeImage';
6
+ import {
7
+ getCameraPermissionStatus,
8
+ requestCameraPermission,
9
+ } from '../permissions';
10
+ import type { BarcodeFormat, ScanProduct, ScanResult } from '../types';
11
+ import { scanChrome } from './scanChrome';
12
+ import { Viewfinder } from './Viewfinder';
13
+ import { ScanTopBar } from './ScanTopBar';
14
+ import { ScanToolbar } from './ScanToolbar';
15
+ import { ResultFocus } from './ResultFocus';
16
+ import { ResultFail } from './ResultFail';
17
+ import { DeniedOverlay } from './DeniedOverlay';
18
+
19
+ type Phase = 'init' | 'scan' | 'detecting' | 'success' | 'fail' | 'denied';
20
+
21
+ const VF_SIZE = 256;
22
+ const DEFAULT_HINT = '将条码 / 二维码放入框内,自动扫描';
23
+
24
+ export interface ScannerProps {
25
+ /** 顶栏标题,默认 "扫一扫"。 */
26
+ title?: string;
27
+ /** 限定识别码制;不传 = 全部。 */
28
+ formats?: BarcodeFormat[];
29
+ /** 取景态提示文案。 */
30
+ hintText?: string;
31
+ /** 顶部安全区(默认 54)。用 react-native-safe-area-context 时可传 insets.top。 */
32
+ topInset?: number;
33
+ /** 底部安全区(默认 34)。 */
34
+ bottomInset?: number;
35
+ /** 左上角关闭。 */
36
+ onClose?: () => void;
37
+ /**
38
+ * 扫到条码后由宿主解析商品信息(用于浮层确认卡)。
39
+ * 返回 null/undefined 视为"未识别",抛错亦然。不传则默认用扫到的原文当商品名。
40
+ */
41
+ resolveProduct?: (
42
+ result: ScanResult
43
+ ) => ScanProduct | null | undefined | Promise<ScanProduct | null | undefined>;
44
+ /** 用户点"确认":把结果带回上一级(宿主通常在此导航返回)。 */
45
+ onConfirm?: (product: ScanProduct, result: ScanResult) => void;
46
+ /**
47
+ * 点"相册":宿主用自己的图片选择器选图并返回本地 uri(取消则返回 null)。
48
+ * 不传则相册按钮不可用。
49
+ */
50
+ pickImage?: () => Promise<string | null>;
51
+ }
52
+
53
+ /**
54
+ * 成品「扫一扫」界面(聚焦款)。底层 <HmsScanView> 出相机画面,取景框 / 工具栏 / 结果卡
55
+ * 全用 @unif/react-native-design 的主题令牌与组件绘制(统一风格)。自带 ThemeProvider +
56
+ * ToastHost,可直接整屏接入;放进宿主已有的 ThemeProvider 里也兼容。
57
+ */
58
+ export function Scanner(props: ScannerProps) {
59
+ return (
60
+ <ThemeProvider>
61
+ <ScannerInner {...props} />
62
+ <ToastHost />
63
+ </ThemeProvider>
64
+ );
65
+ }
66
+
67
+ function ScannerInner({
68
+ title = '扫一扫',
69
+ formats,
70
+ hintText = DEFAULT_HINT,
71
+ topInset = 54,
72
+ bottomInset = 34,
73
+ onClose,
74
+ resolveProduct,
75
+ onConfirm,
76
+ pickImage,
77
+ }: ScannerProps) {
78
+ const [phase, setPhase] = useState<Phase>('init');
79
+ const [torch, setTorch] = useState(false);
80
+ const [product, setProduct] = useState<ScanProduct | null>(null);
81
+ const [detectMs, setDetectMs] = useState(0);
82
+
83
+ const handlingRef = useRef(false);
84
+ const detectStartRef = useRef(0);
85
+ const lastResultRef = useRef<ScanResult | null>(null);
86
+ const mountedRef = useRef(true);
87
+
88
+ // 权限流:已授权 → scan;否则请求;仍未授权 → denied。
89
+ useEffect(() => {
90
+ mountedRef.current = true;
91
+ (async () => {
92
+ try {
93
+ let status = await getCameraPermissionStatus();
94
+ if (status === 'undetermined' || status === 'denied') {
95
+ status = await requestCameraPermission();
96
+ }
97
+ if (!mountedRef.current) return;
98
+ setPhase(status === 'granted' ? 'scan' : 'denied');
99
+ } catch {
100
+ if (mountedRef.current) setPhase('scan');
101
+ }
102
+ })();
103
+ return () => {
104
+ mountedRef.current = false;
105
+ };
106
+ }, []);
107
+
108
+ const reset = useCallback(() => {
109
+ handlingRef.current = false;
110
+ setProduct(null);
111
+ setPhase('scan');
112
+ }, []);
113
+
114
+ const finalize = useCallback(
115
+ async (result: ScanResult) => {
116
+ lastResultRef.current = result;
117
+ try {
118
+ const resolved = resolveProduct
119
+ ? await resolveProduct(result)
120
+ : { name: result.value };
121
+ if (!mountedRef.current) return;
122
+ if (!resolved) {
123
+ setPhase('fail');
124
+ return;
125
+ }
126
+ setProduct({ barcode: result.value, ...resolved });
127
+ setDetectMs(Date.now() - detectStartRef.current);
128
+ setPhase('success');
129
+ } catch {
130
+ if (mountedRef.current) setPhase('fail');
131
+ }
132
+ },
133
+ [resolveProduct]
134
+ );
135
+
136
+ const onCameraResult = useCallback(
137
+ (results: ScanResult[]) => {
138
+ if (phase !== 'scan' || handlingRef.current) return;
139
+ const first = results[0];
140
+ if (!first) return;
141
+ handlingRef.current = true;
142
+ detectStartRef.current = Date.now();
143
+ setPhase('detecting');
144
+ void finalize(first);
145
+ },
146
+ [phase, finalize]
147
+ );
148
+
149
+ const onScanError = useCallback((error: { code: string; message: string }) => {
150
+ if (error.code === 'E_NO_CAMERA_PERMISSION') setPhase('denied');
151
+ }, []);
152
+
153
+ const onAlbum = useCallback(async () => {
154
+ if (!pickImage || handlingRef.current) return;
155
+ try {
156
+ const uri = await pickImage();
157
+ if (!uri || !mountedRef.current) return;
158
+ handlingRef.current = true;
159
+ detectStartRef.current = Date.now();
160
+ setPhase('detecting');
161
+ const results = await decodeImage(uri, formats ? { formats } : undefined);
162
+ if (!mountedRef.current) return;
163
+ const first = results[0];
164
+ if (!first) {
165
+ setPhase('fail');
166
+ return;
167
+ }
168
+ await finalize(first);
169
+ } catch {
170
+ if (mountedRef.current) {
171
+ handlingRef.current = true;
172
+ setPhase('fail');
173
+ }
174
+ }
175
+ }, [pickImage, formats, finalize]);
176
+
177
+ const onConfirmPress = useCallback(() => {
178
+ const r = lastResultRef.current;
179
+ const p = product;
180
+ if (p && r) onConfirm?.(p, r);
181
+ toast.success('已确认 · 扫描结果已填入上一级');
182
+ reset();
183
+ }, [product, onConfirm, reset]);
184
+
185
+ const showChrome = phase === 'scan' || phase === 'detecting';
186
+
187
+ return (
188
+ <View style={styles.root}>
189
+ {phase !== 'denied' && phase !== 'init' && (
190
+ <HmsScanView
191
+ style={StyleSheet.absoluteFill}
192
+ formats={formats}
193
+ paused={phase !== 'scan'}
194
+ torch={torch}
195
+ onScanResult={onCameraResult}
196
+ onScanError={onScanError}
197
+ />
198
+ )}
199
+
200
+ {showChrome && (
201
+ <Viewfinder size={VF_SIZE} detecting={phase === 'detecting'} hintText={hintText} />
202
+ )}
203
+
204
+ {phase !== 'denied' && phase !== 'init' && (
205
+ <ScanTopBar title={title} topInset={topInset} onClose={onClose} />
206
+ )}
207
+
208
+ {showChrome && (
209
+ <ScanToolbar
210
+ flash={torch}
211
+ bottomInset={bottomInset}
212
+ onFlash={() => setTorch((t) => !t)}
213
+ onAlbum={onAlbum}
214
+ />
215
+ )}
216
+
217
+ {phase === 'success' && product && (
218
+ <ResultFocus
219
+ product={product}
220
+ detectMs={detectMs}
221
+ bottomInset={bottomInset}
222
+ onContinue={reset}
223
+ onConfirm={onConfirmPress}
224
+ />
225
+ )}
226
+
227
+ {phase === 'fail' && <ResultFail bottomInset={bottomInset} onRetry={reset} />}
228
+
229
+ {phase === 'denied' && (
230
+ <DeniedOverlay onClose={onClose} onSettings={() => Linking.openSettings()} />
231
+ )}
232
+ </View>
233
+ );
234
+ }
235
+
236
+ const styles = StyleSheet.create({
237
+ root: { flex: 1, backgroundColor: scanChrome.cameraBg },
238
+ });
@@ -0,0 +1,115 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Animated, Easing, StyleSheet, View } from 'react-native';
3
+ import { Icon, useColors, r } from '@unif/react-native-design';
4
+ import { scanChrome } from './scanChrome';
5
+ import { ScanHint } from './ScanHint';
6
+
7
+ interface ViewfinderProps {
8
+ /** 取景窗边长(px)。窗高 = size × 0.82。 */
9
+ size: number;
10
+ /** 识别中(停止呼吸/脉冲动画,准星与边框变淡)。 */
11
+ detecting: boolean;
12
+ /** 取景态提示文案。 */
13
+ hintText: string;
14
+ }
15
+
16
+ // 聚焦款取景:圆角窗 + 白描边 + 主题主色脉冲准星环 + 中心十字(design 的 scan 图标)。
17
+ // 窗外暗化用「上 / 中(左+窗+右) / 下」三段布局还原(等价于设计稿 box-shadow 挖洞)。
18
+ export function Viewfinder({ size, detecting, hintText }: ViewfinderProps) {
19
+ const c = useColors();
20
+ const windowH = Math.round(size * 0.82);
21
+ const scale = useRef(new Animated.Value(1)).current;
22
+ const pulse = useRef(new Animated.Value(1)).current;
23
+
24
+ useEffect(() => {
25
+ if (detecting) {
26
+ scale.setValue(1);
27
+ pulse.setValue(0.25);
28
+ return;
29
+ }
30
+ const ease = Easing.inOut(Easing.ease);
31
+ const breathe = Animated.loop(
32
+ Animated.sequence([
33
+ Animated.timing(scale, { toValue: 1.012, duration: 1300, easing: ease, useNativeDriver: true }),
34
+ Animated.timing(scale, { toValue: 1, duration: 1300, easing: ease, useNativeDriver: true }),
35
+ ])
36
+ );
37
+ const reticle = Animated.loop(
38
+ Animated.sequence([
39
+ Animated.timing(pulse, { toValue: 0.55, duration: 900, easing: ease, useNativeDriver: true }),
40
+ Animated.timing(pulse, { toValue: 1, duration: 900, easing: ease, useNativeDriver: true }),
41
+ ])
42
+ );
43
+ breathe.start();
44
+ reticle.start();
45
+ return () => {
46
+ breathe.stop();
47
+ reticle.stop();
48
+ };
49
+ }, [detecting, scale, pulse]);
50
+
51
+ const scrim = { backgroundColor: c.scrim };
52
+
53
+ return (
54
+ <View style={styles.overlay} pointerEvents="none">
55
+ <View style={[styles.scrimTop, scrim]} />
56
+
57
+ <View style={[styles.midRow, { height: windowH }]}>
58
+ <View style={[styles.scrimSide, scrim]} />
59
+ <Animated.View style={{ width: size, height: windowH, transform: [{ scale }] }}>
60
+ <View style={styles.windowBorder} />
61
+ <Animated.View
62
+ style={[styles.reticle, { borderColor: c.primary, opacity: pulse }]}
63
+ />
64
+ <View style={styles.crosshair}>
65
+ <Icon name="scan" size={r(30)} color={scanChrome.crosshair} />
66
+ </View>
67
+ </Animated.View>
68
+ <View style={[styles.scrimSide, scrim]} />
69
+ </View>
70
+
71
+ <View style={[styles.scrimBottom, scrim]}>
72
+ <ScanHint detecting={detecting} text={hintText} />
73
+ </View>
74
+ </View>
75
+ );
76
+ }
77
+
78
+ const styles = StyleSheet.create({
79
+ overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 15 },
80
+ // 上:下 = 1:1.35,使取景窗略高于屏幕中线
81
+ scrimTop: { flex: 1 },
82
+ scrimBottom: { flex: 1.35, alignItems: 'center' },
83
+ midRow: { flexDirection: 'row' },
84
+ scrimSide: { flex: 1 },
85
+ windowBorder: {
86
+ position: 'absolute',
87
+ top: 0,
88
+ left: 0,
89
+ right: 0,
90
+ bottom: 0,
91
+ borderRadius: r(22),
92
+ borderWidth: 1.5,
93
+ borderColor: scanChrome.windowBorder,
94
+ },
95
+ reticle: {
96
+ position: 'absolute',
97
+ top: 0,
98
+ left: 0,
99
+ right: 0,
100
+ bottom: 0,
101
+ borderRadius: r(22),
102
+ borderWidth: 2,
103
+ },
104
+ crosshair: {
105
+ position: 'absolute',
106
+ top: '50%',
107
+ left: '50%',
108
+ width: r(30),
109
+ height: r(30),
110
+ marginTop: r(-15),
111
+ marginLeft: r(-15),
112
+ alignItems: 'center',
113
+ justifyContent: 'center',
114
+ },
115
+ });
@@ -0,0 +1,20 @@
1
+ // 相机暗景之上的 chrome 专属常量——取景区恒为深色实景,这些不随主题变化。
2
+ // 其余颜色一律走 @unif/react-native-design 主题令牌(useColors / useThemedStyles)。
3
+ export const scanChrome = {
4
+ /** 相机未就绪时的底色(深色实景占位)。 */
5
+ cameraBg: '#0c0b0a',
6
+ white: '#FFFFFF',
7
+ /** 顶栏关闭键玻璃底。 */
8
+ glassDisc: 'rgba(20,18,16,0.42)',
9
+ /** 工具栏未点亮玻璃底。 */
10
+ glassDisc2: 'rgba(28,26,24,0.5)',
11
+ glassBorder: 'rgba(255,255,255,0.16)',
12
+ glassBorderActive: 'rgba(255,255,255,0.25)',
13
+ /** 取景提示文字。 */
14
+ hintText: 'rgba(255,255,255,0.86)',
15
+ toolbarLabel: 'rgba(255,255,255,0.82)',
16
+ /** 取景窗白色描边。 */
17
+ windowBorder: 'rgba(255,255,255,0.55)',
18
+ crosshair: 'rgba(255,255,255,0.9)',
19
+ textShadow: 'rgba(0,0,0,0.55)',
20
+ } as const;
@@ -0,0 +1,36 @@
1
+ import NativeHmsScan from './NativeHmsScan';
2
+ import { formatsToCsv, parseResultsJson } from './format';
3
+ import { HmsScanError, type DecodeImageOptions, type ScanResult } from './types';
4
+
5
+ /**
6
+ * 从本地图片解码条码 / 二维码(华为 Bitmap 模式)。
7
+ *
8
+ * @param uri 本地图片:file:// / content:// / ph:// / 绝对路径。**不下载远程 URL**,
9
+ * 如需识别网络图请宿主先下载到本地再传入。
10
+ * @param options 可选;formats 限定码制,不传 = 全部。
11
+ * @returns 命中的结果数组(可能为空)。
12
+ * @throws HmsScanError 图片加载失败 / 读权限缺失等。
13
+ */
14
+ export async function decodeImage(
15
+ uri: string,
16
+ options?: DecodeImageOptions
17
+ ): Promise<ScanResult[]> {
18
+ if (!uri) {
19
+ throw new HmsScanError('E_IMAGE_LOAD_FAILED', 'decodeImage: uri 不能为空');
20
+ }
21
+ try {
22
+ const json = await NativeHmsScan.decodeImage(uri, formatsToCsv(options?.formats));
23
+ return parseResultsJson(json);
24
+ } catch (e) {
25
+ // 原生 reject 的 code 透传为 HmsScanError
26
+ const err = e as { code?: string; message?: string };
27
+ if (
28
+ err.code === 'E_NO_READ_PERMISSION' ||
29
+ err.code === 'E_IMAGE_LOAD_FAILED' ||
30
+ err.code === 'E_DECODE_FAILED'
31
+ ) {
32
+ throw new HmsScanError(err.code, err.message);
33
+ }
34
+ throw new HmsScanError('E_UNKNOWN', err.message ?? 'decodeImage failed');
35
+ }
36
+ }
package/src/format.ts ADDED
@@ -0,0 +1,93 @@
1
+ import {
2
+ ALL_BARCODE_FORMATS,
3
+ type BarcodeContentType,
4
+ type BarcodeFormat,
5
+ type ScanCornerPoint,
6
+ type ScanResult,
7
+ } from './types';
8
+
9
+ const FORMAT_SET = new Set<string>([...ALL_BARCODE_FORMATS, 'UNKNOWN']);
10
+
11
+ const CONTENT_TYPES: readonly BarcodeContentType[] = [
12
+ 'TEXT',
13
+ 'URL',
14
+ 'EMAIL',
15
+ 'PHONE',
16
+ 'SMS',
17
+ 'WIFI',
18
+ 'CONTACT',
19
+ 'EVENT',
20
+ 'LOCATION',
21
+ 'DRIVER',
22
+ 'ISBN',
23
+ 'ARTICLE',
24
+ 'OTHER',
25
+ ];
26
+ const CONTENT_SET = new Set<string>(CONTENT_TYPES);
27
+
28
+ /** 把任意字符串安全收敛成 BarcodeFormat(未知归 UNKNOWN)。 */
29
+ export function coerceFormat(value: unknown): BarcodeFormat {
30
+ return typeof value === 'string' && FORMAT_SET.has(value)
31
+ ? (value as BarcodeFormat)
32
+ : 'UNKNOWN';
33
+ }
34
+
35
+ /** 把任意字符串安全收敛成 BarcodeContentType(未知/缺省 → undefined)。 */
36
+ export function coerceContentType(value: unknown): BarcodeContentType | undefined {
37
+ return typeof value === 'string' && CONTENT_SET.has(value)
38
+ ? (value as BarcodeContentType)
39
+ : undefined;
40
+ }
41
+
42
+ /** formats[] → 逗号分隔 CSV(传给原生);空/未传 → ''(= 全部码制)。 */
43
+ export function formatsToCsv(formats?: readonly BarcodeFormat[]): string {
44
+ return formats && formats.length > 0 ? formats.join(',') : '';
45
+ }
46
+
47
+ function coerceCornerPoints(value: unknown): ScanCornerPoint[] | undefined {
48
+ if (!Array.isArray(value)) return undefined;
49
+ const points = value
50
+ .filter(
51
+ (p): p is { x: number; y: number } =>
52
+ !!p && typeof p.x === 'number' && typeof p.y === 'number'
53
+ )
54
+ .map((p) => ({ x: p.x, y: p.y }));
55
+ return points.length > 0 ? points : undefined;
56
+ }
57
+
58
+ function coerceResult(raw: unknown): ScanResult | null {
59
+ if (!raw || typeof raw !== 'object') return null;
60
+ const r = raw as Record<string, unknown>;
61
+ // 没有有效 value 的命中无意义,丢弃
62
+ if (typeof r.value !== 'string' || r.value.length === 0) return null;
63
+ const result: ScanResult = {
64
+ value: r.value,
65
+ format: coerceFormat(r.format),
66
+ };
67
+ const contentType = coerceContentType(r.contentType);
68
+ if (contentType) result.contentType = contentType;
69
+ const cornerPoints = coerceCornerPoints(r.cornerPoints);
70
+ if (cornerPoints) result.cornerPoints = cornerPoints;
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * 解析原生回传的 JSON 字符串 → 强类型 ScanResult[]。
76
+ * 任何脏数据都被安全过滤;解析失败返回空数组而非抛错。
77
+ */
78
+ export function parseResultsJson(json: string): ScanResult[] {
79
+ if (!json) return [];
80
+ let parsed: unknown;
81
+ try {
82
+ parsed = JSON.parse(json);
83
+ } catch {
84
+ return [];
85
+ }
86
+ if (!Array.isArray(parsed)) return [];
87
+ const out: ScanResult[] = [];
88
+ for (const item of parsed) {
89
+ const r = coerceResult(item);
90
+ if (r) out.push(r);
91
+ }
92
+ return out;
93
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,35 @@
1
+ // @unif/react-native-hms-scan —— 华为 HMS 统一扫码服务 RN bridge
2
+ // 能力:定制视图扫码(底层 <HmsScanView> + 成品 <Scanner> 扫一扫页)+ 图片识别(decodeImage)。
3
+
4
+ // ── 类型与常量 ──
5
+ export {
6
+ ALL_BARCODE_FORMATS,
7
+ HmsScanError,
8
+ } from './types';
9
+ export type {
10
+ BarcodeFormat,
11
+ BarcodeContentType,
12
+ ScanCornerPoint,
13
+ ScanResult,
14
+ DecodeImageOptions,
15
+ CameraPermissionStatus,
16
+ HmsScanErrorCode,
17
+ ScanProduct,
18
+ } from './types';
19
+
20
+ // ── 码制工具 ──
21
+ export { coerceFormat, coerceContentType, formatsToCsv } from './format';
22
+
23
+ // ── 底层 headless 相机组件 ──
24
+ export { HmsScanView } from './HmsScanView';
25
+ export type { HmsScanViewProps, TorchStatus } from './HmsScanView';
26
+
27
+ // ── 图片识别 ──
28
+ export { decodeImage } from './decodeImage';
29
+
30
+ // ── 相机权限 ──
31
+ export { getCameraPermissionStatus, requestCameraPermission } from './permissions';
32
+
33
+ // ── 成品扫一扫页(聚焦款,浅色) ──
34
+ export { Scanner } from './Scanner/Scanner';
35
+ export type { ScannerProps } from './Scanner/Scanner';
package/src/mock.ts ADDED
@@ -0,0 +1,43 @@
1
+ // Jest mock for @unif/react-native-hms-scan —— 供消费者在测试中替换本库,
2
+ // 避免 jest 环境加载 HmsScan TurboModule / HmsScanView Fabric 组件而崩溃。
3
+ //
4
+ // 用法(消费者 jest setup 或单个测试文件):
5
+ //
6
+ // jest.mock('@unif/react-native-hms-scan', () =>
7
+ // require('@unif/react-native-hms-scan/mock')
8
+ // );
9
+ //
10
+ // 替换后 decodeImage / 权限方法都是 jest.fn;<HmsScanView> / <Scanner> 渲染为 null。
11
+ // 按需覆盖单次返回:
12
+ // (decodeImage as jest.Mock).mockResolvedValueOnce([{ value: '690...', format: 'EAN_13' }]);
13
+
14
+ import type { FC } from 'react';
15
+ import type {
16
+ CameraPermissionStatus,
17
+ DecodeImageOptions,
18
+ ScanResult,
19
+ } from './types';
20
+ import type { HmsScanViewProps } from './HmsScanView';
21
+ import type { ScannerProps } from './Scanner/Scanner';
22
+
23
+ // 纯 JS 类型 / 常量 / Error / 码制工具:保留真实实现(不碰原生)。
24
+ export * from './types';
25
+ export { coerceFormat, coerceContentType, formatsToCsv } from './format';
26
+
27
+ // ── 图片识别 ──
28
+ export const decodeImage = jest.fn(
29
+ (_uri: string, _options?: DecodeImageOptions): Promise<ScanResult[]> =>
30
+ Promise.resolve([])
31
+ );
32
+
33
+ // ── 相机权限 ──(默认已授权)
34
+ export const getCameraPermissionStatus = jest.fn(
35
+ (): Promise<CameraPermissionStatus> => Promise.resolve('granted')
36
+ );
37
+ export const requestCameraPermission = jest.fn(
38
+ (): Promise<CameraPermissionStatus> => Promise.resolve('granted')
39
+ );
40
+
41
+ // ── 组件 ──(渲染 null,避免触碰原生)
42
+ export const HmsScanView: FC<HmsScanViewProps> = () => null;
43
+ export const Scanner: FC<ScannerProps> = () => null;
@@ -0,0 +1,27 @@
1
+ import NativeHmsScan from './NativeHmsScan';
2
+ import type { CameraPermissionStatus } from './types';
3
+
4
+ function normalize(status: string): CameraPermissionStatus {
5
+ switch (status) {
6
+ case 'granted':
7
+ case 'denied':
8
+ case 'blocked':
9
+ case 'undetermined':
10
+ return status;
11
+ default:
12
+ return 'undetermined';
13
+ }
14
+ }
15
+
16
+ /** 查询当前相机权限状态(不弹窗)。 */
17
+ export async function getCameraPermissionStatus(): Promise<CameraPermissionStatus> {
18
+ return normalize(await NativeHmsScan.getCameraPermissionStatus());
19
+ }
20
+
21
+ /**
22
+ * 发起相机权限请求(必要时弹系统授权框),返回请求后的状态。
23
+ * 若用户已永久拒绝(blocked),需引导去系统设置开启。
24
+ */
25
+ export async function requestCameraPermission(): Promise<CameraPermissionStatus> {
26
+ return normalize(await NativeHmsScan.requestCameraPermission());
27
+ }