@unif/react-native-hms-scan 0.6.1 → 1.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.
Files changed (168) hide show
  1. package/README.md +24 -72
  2. package/android/src/main/AndroidManifest.xml +0 -9
  3. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanImage.kt +135 -0
  4. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanModule.kt +163 -147
  5. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanResultMapper.kt +17 -9
  6. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanView.kt +57 -19
  7. package/android/src/main/java/com/unif/reactnativehmsscan/HmsScanViewManager.kt +1 -1
  8. package/ios/HmsScanModule.mm +13 -37
  9. package/ios/HmsScanResultMapper.h +4 -3
  10. package/ios/HmsScanResultMapper.mm +31 -19
  11. package/ios/HmsScanView.h +1 -1
  12. package/ios/HmsScanView.mm +140 -25
  13. package/lib/module/HmsScanView/HmsScanView.js +66 -0
  14. package/lib/module/HmsScanView/HmsScanView.js.map +1 -0
  15. package/lib/module/HmsScanView/HmsScanView.web.js +26 -0
  16. package/lib/module/HmsScanView/HmsScanView.web.js.map +1 -0
  17. package/lib/module/HmsScanView/types.js +4 -0
  18. package/lib/module/HmsScanView/types.js.map +1 -0
  19. package/lib/module/HmsScanViewNativeComponent.ts +7 -5
  20. package/lib/module/NativeHmsScan.js +1 -1
  21. package/lib/module/NativeHmsScan.js.map +1 -1
  22. package/lib/module/ScanError.js +12 -0
  23. package/lib/module/ScanError.js.map +1 -0
  24. package/lib/module/Scanner/DeniedOverlay.js +1 -1
  25. package/lib/module/Scanner/DeniedOverlay.js.map +1 -1
  26. package/lib/module/Scanner/ResultFail.js +3 -2
  27. package/lib/module/Scanner/ResultFail.js.map +1 -1
  28. package/lib/module/Scanner/ResultFocus/ResultFocus.js +56 -0
  29. package/lib/module/Scanner/ResultFocus/ResultFocus.js.map +1 -0
  30. package/lib/module/Scanner/ResultFocus/styles.js +39 -0
  31. package/lib/module/Scanner/ResultFocus/styles.js.map +1 -0
  32. package/lib/module/Scanner/ResultFocus/types.js +4 -0
  33. package/lib/module/Scanner/ResultFocus/types.js.map +1 -0
  34. package/lib/module/Scanner/ScanHint.js +6 -3
  35. package/lib/module/Scanner/ScanHint.js.map +1 -1
  36. package/lib/module/Scanner/ScanToolbar.js +6 -3
  37. package/lib/module/Scanner/ScanToolbar.js.map +1 -1
  38. package/lib/module/Scanner/ScanTopBar.js +6 -3
  39. package/lib/module/Scanner/ScanTopBar.js.map +1 -1
  40. package/lib/module/Scanner/Scanner.js +9 -311
  41. package/lib/module/Scanner/Scanner.js.map +1 -1
  42. package/lib/module/Scanner/Scanner.web.js +29 -0
  43. package/lib/module/Scanner/Scanner.web.js.map +1 -0
  44. package/lib/module/Scanner/ScannerInner.js +310 -0
  45. package/lib/module/Scanner/ScannerInner.js.map +1 -0
  46. package/lib/module/Scanner/styles.js +11 -0
  47. package/lib/module/Scanner/styles.js.map +1 -0
  48. package/lib/module/Scanner/types.js +4 -0
  49. package/lib/module/Scanner/types.js.map +1 -0
  50. package/lib/module/constants.js +20 -0
  51. package/lib/module/constants.js.map +1 -0
  52. package/lib/module/decodeImage.js +29 -24
  53. package/lib/module/decodeImage.js.map +1 -1
  54. package/lib/module/decodeImage.web.js +7 -0
  55. package/lib/module/decodeImage.web.js.map +1 -0
  56. package/lib/module/errors.js +24 -0
  57. package/lib/module/errors.js.map +1 -0
  58. package/lib/module/format.js +53 -47
  59. package/lib/module/format.js.map +1 -1
  60. package/lib/module/index.js +5 -19
  61. package/lib/module/index.js.map +1 -1
  62. package/lib/module/index.web.js +8 -0
  63. package/lib/module/index.web.js.map +1 -0
  64. package/lib/module/mock.js +2 -23
  65. package/lib/module/mock.js.map +1 -1
  66. package/lib/module/permissions.js +24 -20
  67. package/lib/module/permissions.js.map +1 -1
  68. package/lib/module/permissions.web.js +10 -0
  69. package/lib/module/permissions.web.js.map +1 -0
  70. package/lib/module/types.js +1 -45
  71. package/lib/module/types.js.map +1 -1
  72. package/lib/typescript/src/HmsScanView/HmsScanView.d.ts +3 -0
  73. package/lib/typescript/src/HmsScanView/HmsScanView.d.ts.map +1 -0
  74. package/lib/typescript/src/HmsScanView/HmsScanView.web.d.ts +3 -0
  75. package/lib/typescript/src/HmsScanView/HmsScanView.web.d.ts.map +1 -0
  76. package/lib/typescript/src/HmsScanView/types.d.ts +12 -0
  77. package/lib/typescript/src/HmsScanView/types.d.ts.map +1 -0
  78. package/lib/typescript/src/HmsScanViewNativeComponent.d.ts +6 -4
  79. package/lib/typescript/src/HmsScanViewNativeComponent.d.ts.map +1 -1
  80. package/lib/typescript/src/NativeHmsScan.d.ts +3 -5
  81. package/lib/typescript/src/NativeHmsScan.d.ts.map +1 -1
  82. package/lib/typescript/src/ScanError.d.ts +7 -0
  83. package/lib/typescript/src/ScanError.d.ts.map +1 -0
  84. package/lib/typescript/src/Scanner/DeniedOverlay.d.ts.map +1 -1
  85. package/lib/typescript/src/Scanner/ResultFail.d.ts +2 -1
  86. package/lib/typescript/src/Scanner/ResultFail.d.ts.map +1 -1
  87. package/lib/typescript/src/Scanner/ResultFocus/ResultFocus.d.ts +4 -0
  88. package/lib/typescript/src/Scanner/ResultFocus/ResultFocus.d.ts.map +1 -0
  89. package/lib/typescript/src/Scanner/ResultFocus/styles.d.ts +36 -0
  90. package/lib/typescript/src/Scanner/ResultFocus/styles.d.ts.map +1 -0
  91. package/lib/typescript/src/Scanner/ResultFocus/types.d.ts +8 -0
  92. package/lib/typescript/src/Scanner/ResultFocus/types.d.ts.map +1 -0
  93. package/lib/typescript/src/Scanner/ScanHint.d.ts.map +1 -1
  94. package/lib/typescript/src/Scanner/ScanToolbar.d.ts.map +1 -1
  95. package/lib/typescript/src/Scanner/ScanTopBar.d.ts.map +1 -1
  96. package/lib/typescript/src/Scanner/Scanner.d.ts +2 -45
  97. package/lib/typescript/src/Scanner/Scanner.d.ts.map +1 -1
  98. package/lib/typescript/src/Scanner/Scanner.web.d.ts +3 -0
  99. package/lib/typescript/src/Scanner/Scanner.web.d.ts.map +1 -0
  100. package/lib/typescript/src/Scanner/ScannerInner.d.ts +3 -0
  101. package/lib/typescript/src/Scanner/ScannerInner.d.ts.map +1 -0
  102. package/lib/typescript/src/Scanner/styles.d.ts +7 -0
  103. package/lib/typescript/src/Scanner/styles.d.ts.map +1 -0
  104. package/lib/typescript/src/Scanner/types.d.ts +20 -0
  105. package/lib/typescript/src/Scanner/types.d.ts.map +1 -0
  106. package/lib/typescript/src/constants.d.ts +8 -0
  107. package/lib/typescript/src/constants.d.ts.map +1 -0
  108. package/lib/typescript/src/decodeImage.d.ts +2 -12
  109. package/lib/typescript/src/decodeImage.d.ts.map +1 -1
  110. package/lib/typescript/src/decodeImage.web.d.ts +3 -0
  111. package/lib/typescript/src/decodeImage.web.d.ts.map +1 -0
  112. package/lib/typescript/src/errors.d.ts +6 -0
  113. package/lib/typescript/src/errors.d.ts.map +1 -0
  114. package/lib/typescript/src/format.d.ts +15 -12
  115. package/lib/typescript/src/format.d.ts.map +1 -1
  116. package/lib/typescript/src/index.d.ts +6 -7
  117. package/lib/typescript/src/index.d.ts.map +1 -1
  118. package/lib/typescript/src/index.web.d.ts +9 -0
  119. package/lib/typescript/src/index.web.d.ts.map +1 -0
  120. package/lib/typescript/src/mock.d.ts +9 -8
  121. package/lib/typescript/src/mock.d.ts.map +1 -1
  122. package/lib/typescript/src/permissions.d.ts +4 -8
  123. package/lib/typescript/src/permissions.d.ts.map +1 -1
  124. package/lib/typescript/src/permissions.web.d.ts +4 -0
  125. package/lib/typescript/src/permissions.web.d.ts.map +1 -0
  126. package/lib/typescript/src/types.d.ts +31 -56
  127. package/lib/typescript/src/types.d.ts.map +1 -1
  128. package/package.json +9 -2
  129. package/src/HmsScanView/HmsScanView.tsx +67 -0
  130. package/src/HmsScanView/HmsScanView.web.tsx +22 -0
  131. package/src/HmsScanView/types.ts +17 -0
  132. package/src/HmsScanViewNativeComponent.ts +7 -5
  133. package/src/NativeHmsScan.ts +3 -5
  134. package/src/ScanError.ts +13 -0
  135. package/src/Scanner/DeniedOverlay.tsx +4 -2
  136. package/src/Scanner/ResultFail.tsx +23 -5
  137. package/src/Scanner/ResultFocus/ResultFocus.tsx +43 -0
  138. package/src/Scanner/ResultFocus/styles.ts +25 -0
  139. package/src/Scanner/ResultFocus/types.ts +8 -0
  140. package/src/Scanner/ScanHint.tsx +16 -11
  141. package/src/Scanner/ScanToolbar.tsx +35 -13
  142. package/src/Scanner/ScanTopBar.tsx +20 -12
  143. package/src/Scanner/Scanner.tsx +13 -436
  144. package/src/Scanner/Scanner.web.tsx +26 -0
  145. package/src/Scanner/ScannerInner.tsx +391 -0
  146. package/src/Scanner/styles.ts +6 -0
  147. package/src/Scanner/types.ts +36 -0
  148. package/src/constants.ts +55 -0
  149. package/src/decodeImage.ts +35 -29
  150. package/src/decodeImage.web.ts +8 -0
  151. package/src/errors.ts +26 -0
  152. package/src/format.ts +77 -76
  153. package/src/index.tsx +17 -32
  154. package/src/index.web.tsx +22 -0
  155. package/src/mock.ts +21 -29
  156. package/src/permissions.ts +33 -20
  157. package/src/permissions.web.ts +9 -0
  158. package/src/types.ts +38 -126
  159. package/lib/module/HmsScanView.js +0 -43
  160. package/lib/module/HmsScanView.js.map +0 -1
  161. package/lib/module/Scanner/ResultFocus.js +0 -171
  162. package/lib/module/Scanner/ResultFocus.js.map +0 -1
  163. package/lib/typescript/src/HmsScanView.d.ts +0 -33
  164. package/lib/typescript/src/HmsScanView.d.ts.map +0 -1
  165. package/lib/typescript/src/Scanner/ResultFocus.d.ts +0 -14
  166. package/lib/typescript/src/Scanner/ResultFocus.d.ts.map +0 -1
  167. package/src/HmsScanView.tsx +0 -87
  168. package/src/Scanner/ResultFocus.tsx +0 -122
package/README.md CHANGED
@@ -1,91 +1,43 @@
1
1
  # @unif/react-native-hms-scan
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@unif/react-native-hms-scan.svg?color=cb3837&logo=npm)](https://www.npmjs.com/package/@unif/react-native-hms-scan)
4
- [![CI](https://github.com/unif-design/react-native-hms-scan/actions/workflows/ci.yml/badge.svg)](https://github.com/unif-design/react-native-hms-scan/actions/workflows/ci.yml)
5
- [![License](https://img.shields.io/npm/l/@unif/react-native-hms-scan.svg?color=blue)](LICENSE)
6
- [![Docs](https://img.shields.io/badge/docs-unif--design.github.io-orange.svg)](https://unif-design.github.io/react-native-hms-scan/)
3
+ 基于 HUAWEI Scan Kit 的 React Native 扫码库,支持实时扫码和本地图片识别。
7
4
 
8
- 华为 **HMS 统一扫码(HUAWEI Scan Kit)** 的 React Native 封装,面向 RN 0.86.3 新架构(Fabric + TurboModule):成品扫一扫页、headless 自定义扫码 UI、从图片识别条码 / 二维码。
5
+ [文档站](https://unif-design.github.io/react-native-hms-scan/) · [npm](https://www.npmjs.com/package/@unif/react-native-hms-scan) · [示例](example/README.md)
9
6
 
10
- ## 特性
7
+ ## 提供什么
11
8
 
12
- - **三种用法** — 成品 `<Scanner>` 扫一扫页(自带权限流 / 状态机 / 主题)、headless `<HmsScanView>` 相机组件(自定义 UI)、`decodeImage(uri)` 从本地图片识别。
13
- - **Android 内置引擎** — 用 Scan SDK-Plus,**非华为机型可用**,不依赖设备装 HMS Core APK;`scanplus` 由本库声明,宿主需把 Huawei Maven 加到实际参与依赖解析的 `repositories`。
14
- - **iOS 官方 CocoaPod** — CocoaPods 安装官方 `ScanKitFrameWork 1.1.2.305`,当前只支持真机构建和运行。
15
- - **零云端配置** — 两端**都不需要 AppGallery Connect / agconnect / API Key**。
16
- - **14 种码制** — `QR_CODE` / `EAN_13` / `CODE_128` / `PDF417` / `DATA_MATRIX` 等,默认识别全部。
17
- - 仅支持 **RN 新架构**;`@unif/react-native-design` 风格统一。
9
+ | 入口 | 用途 |
10
+ | ------------- | -------------------------------------- |
11
+ | `Scanner` | 带权限流程、取景和结果确认的扫一扫页面 |
12
+ | `HmsScanView` | 仅提供预览与事件,由应用组合界面 |
13
+ | `decodeImage` | 识别本地图片中的二维码或条码 |
18
14
 
19
15
  ## 安装
20
16
 
21
17
  ```sh
22
- yarn add @unif/react-native-hms-scan react-native-svg
23
- cd ios && pod install
18
+ yarn add @unif/react-native-hms-scan
24
19
  ```
25
20
 
26
- CocoaPods 会直接安装官方 `ScanKitFrameWork 1.1.2.305`;`pod install` 不会在 `node_modules` 中生成 XCFramework。iOS Simulator 不属于支持目标。
21
+ 按[安装指南](website/docs/getting-started/installation.md)补齐 peer 依赖、相机权限和原生配置:Android 需要 Huawei Maven;iOS 使用官方 ScanKit CocoaPod。
27
22
 
28
- Android 宿主还必须在实际参与依赖解析的仓库列表中加入 Huawei Maven;库模块自己的 `repositories` 不会传播给 consumer:
23
+ Android 内置识别引擎,不要求华为手机或 HMS Core。两端均不需要 AppGallery Connect 或 API Key。当前 iOS SDK 仅支持真机,React Native 需开启新架构。
29
24
 
30
- ```gradle
31
- allprojects {
32
- repositories {
33
- maven { url 'https://developer.huawei.com/repo/' }
34
- }
35
- }
36
- ```
37
-
38
- `react-native-svg` 是 peer 依赖(`<Scanner>` 图标用它绘制);`<Scanner>` 还依赖 peer `@unif/react-native-design`。宿主需开启新架构。完整 peer dependencies、Android 权限、iOS `NSCameraUsageDescription` 等见[文档站 · 安装](https://unif-design.github.io/react-native-hms-scan/docs/getting-started/installation)。
39
-
40
- ## 快速开始
25
+ ## 最小用法
41
26
 
42
- 成品「扫一扫」页 —— 扫到条码后由宿主解析商品、确认带回:
27
+ ```ts
28
+ import { decodeImage } from '@unif/react-native-hms-scan';
43
29
 
44
- ```tsx
45
- import { Scanner, type ScanResult, type ScanProduct } from '@unif/react-native-hms-scan';
46
-
47
- function ScanScreen({ navigation }) {
48
- return (
49
- <Scanner
50
- title="扫一扫"
51
- onClose={() => navigation.goBack()}
52
- resolveProduct={async (r: ScanResult): Promise<ScanProduct | null> => {
53
- const p = await api.lookupByBarcode(r.value); // 你来解析;返回 null = 未识别
54
- return p ? { name: p.name, brand: p.brand, price: `¥${p.price}`, barcode: r.value } : null;
55
- }}
56
- onConfirm={(product, result) => navigation.navigate('Order', { product, barcode: result.value })}
57
- />
58
- );
59
- }
30
+ const results = await decodeImage({
31
+ uri: 'file:///path/to/photo.jpg',
32
+ formats: ['QR_CODE'],
33
+ });
60
34
  ```
61
35
 
62
- headless `<HmsScanView>`(完全自定义 UI)、图片识别 `decodeImage`、权限、平台差异 —— 见[文档站](https://unif-design.github.io/react-native-hms-scan/)。
63
-
64
- ## Example 能力展厅
65
-
66
- 仓库内的 [`example/`](example/) 是可直接运行的三能力展示应用,而非 library API 的一部分:
67
-
68
- - **Scanner 成品页** — 配置码制、`autoConfirm`、相册选图与商品确认流。
69
- - **HmsScanView 自定义页** — 展示由宿主管理的权限、`paused` / `continuous` / `torch`、结果和错误状态。
70
- - **decodeImage 图片识别** — 从设备上的本地图片选择并离线解码,展示成功、空数组和 `HmsScanError`。
71
-
72
- 运行、原生配置、平台边界和测试矩阵见 [example/README.md](example/README.md)。示例的 image-picker 与本地商品表仅用于演示,接入应用应替换为自己的选择器和业务数据。
73
-
74
- ## 文档
75
-
76
- - **完整文档**(安装 · 平台配置 · API · 平台差异 · 故障排查):https://unif-design.github.io/react-native-hms-scan/
77
- - **AI / Agent**(按需 fetch,别凭记忆猜 API):[llms.txt](https://unif-design.github.io/react-native-hms-scan/llms.txt) · [llms-full.txt](https://unif-design.github.io/react-native-hms-scan/llms-full.txt)
78
- - **Agent Skill** `hms-scan`(`unif` plugin):`/plugin marketplace add unif-design/skills` → `/plugin install unif@skills`
79
-
80
- ## 兼容性
81
-
82
- | 平台 | 支持 |
83
- | --- | --- |
84
- | React Native | 新架构(Fabric + TurboModule)**必须开启**;在 RN 0.86.3 / React 19.2.3 上开发与验证 |
85
- | Android | ✅ minSdkVersion ≥ 24(Android 7.0) |
86
- | iOS | ✅ 官方 CocoaPod `ScanKitFrameWork 1.1.2.305` + 真机 |
87
- | iOS Simulator | ❌ 不支持;无硬件逻辑测试使用随包 Jest mock |
36
+ 正常识别但没有发现码时返回 `[]`;读取或解码失败抛出 `ScanError`。函数不下载网络图片,应用需先准备本地文件。
88
37
 
89
- ## 许可
38
+ ## 文档与开发
90
39
 
91
- MIT © unif-design
40
+ - [Scanner](website/docs/api/scanner.md) · [HmsScanView](website/docs/api/hms-scan-view.md) · [函数 API](website/docs/api/functions.md)
41
+ - [运行示例](example/README.md) · [开发资料与新版本契约](docs/DEVELOPMENT.md)
42
+ - [AI 文档索引](https://unif-design.github.io/react-native-hms-scan/llms.txt)
43
+ - [研发技能](https://github.com/unif-skill/unif-portal-dev-skills) · [MIT 许可](LICENSE)
@@ -3,15 +3,6 @@
3
3
  <!-- Camera preview / scanning (RemoteView). -->
4
4
  <uses-permission android:name="android.permission.CAMERA" />
5
5
 
6
- <!-- Reading a local image for decodeImage().
7
- Android 13+ (API 33) uses the scoped media permission; older devices fall
8
- back to READ_EXTERNAL_STORAGE (capped at API 32 so the merger doesn't
9
- force it on newer OS versions). -->
10
- <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
11
- <uses-permission
12
- android:name="android.permission.READ_EXTERNAL_STORAGE"
13
- android:maxSdkVersion="32" />
14
-
15
6
  <!-- Camera is used but not strictly required (gallery decode works without it). -->
16
7
  <uses-feature
17
8
  android:name="android.hardware.camera"
@@ -0,0 +1,135 @@
1
+ package com.unif.reactnativehmsscan
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Matrix
6
+ import android.media.ExifInterface
7
+ import android.net.Uri
8
+ import java.io.Closeable
9
+ import java.io.File
10
+ import java.io.FileInputStream
11
+ import java.io.IOException
12
+
13
+ internal class ScanImageFailure(
14
+ val code: String,
15
+ message: String,
16
+ ) : IOException(message)
17
+
18
+ /** Owns one upright bitmap; points are returned in the upright original image. */
19
+ internal class HmsScanImage private constructor(
20
+ val bitmap: Bitmap,
21
+ val scaleX: Double,
22
+ val scaleY: Double,
23
+ ) : Closeable {
24
+ override fun close() {
25
+ if (!bitmap.isRecycled) bitmap.recycle()
26
+ }
27
+
28
+ companion object {
29
+ // At most 32 MiB per ARGB bitmap, temporarily 64 MiB during EXIF rotation.
30
+ // The vendor has additional working memory. Only one image job runs at once.
31
+ private const val MAX_PIXELS = 8_388_608L
32
+ private const val MAX_SOURCE_PIXELS = 268_435_456L
33
+ private const val MAX_SOURCE_EDGE = 32_768
34
+ private const val MAX_FILE_BYTES = 128L * 1024 * 1024
35
+
36
+ fun read(uri: String): HmsScanImage {
37
+ val parsed = Uri.parse(uri)
38
+ val path = parsed.path
39
+ if (!parsed.scheme.equals("file", ignoreCase = true) || path.isNullOrEmpty()) {
40
+ throw ScanImageFailure("E_INVALID_INPUT", "需要可读的 file URI")
41
+ }
42
+ val file = File(path)
43
+ if (!file.isFile || !file.canRead()) {
44
+ throw ScanImageFailure("E_IMAGE_LOAD_FAILED", "无法读取本地图片")
45
+ }
46
+ if (file.length() > MAX_FILE_BYTES) {
47
+ throw ScanImageFailure("E_INVALID_INPUT", "图片文件超过 128 MiB")
48
+ }
49
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
50
+ BitmapFactory.decodeFile(path, bounds)
51
+ val width = bounds.outWidth
52
+ val height = bounds.outHeight
53
+ if (width <= 0 || height <= 0) {
54
+ throw ScanImageFailure("E_IMAGE_LOAD_FAILED", "无法读取图片尺寸")
55
+ }
56
+ if (width > MAX_SOURCE_EDGE || height > MAX_SOURCE_EDGE || width.toLong() * height > MAX_SOURCE_PIXELS) {
57
+ throw ScanImageFailure("E_INVALID_INPUT", "图片尺寸超过安全解码范围")
58
+ }
59
+ var sample = 1
60
+ while (((width + sample - 1) / sample).toLong() * ((height + sample - 1) / sample) > MAX_PIXELS) {
61
+ sample *= 2
62
+ }
63
+ val options =
64
+ BitmapFactory.Options().apply {
65
+ inSampleSize = sample
66
+ inPreferredConfig = Bitmap.Config.ARGB_8888
67
+ }
68
+ var bitmap =
69
+ BitmapFactory.decodeFile(path, options)
70
+ ?: throw ScanImageFailure("E_IMAGE_LOAD_FAILED", "无法解码本地图片")
71
+ try {
72
+ if (bitmap.width.toLong() * bitmap.height > MAX_PIXELS || bitmap.allocationByteCount.toLong() > MAX_PIXELS * 4) {
73
+ throw ScanImageFailure("E_INVALID_INPUT", "图片解码超过内存预算")
74
+ }
75
+ // Unsupported/missing optional EXIF does not discard an otherwise valid image.
76
+ val orientation =
77
+ try {
78
+ FileInputStream(file).use {
79
+ ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
80
+ }
81
+ } catch (_: IOException) {
82
+ ExifInterface.ORIENTATION_NORMAL
83
+ }
84
+ val matrix = Matrix()
85
+ when (orientation) {
86
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
87
+ matrix.setScale(-1f, 1f)
88
+ }
89
+
90
+ ExifInterface.ORIENTATION_ROTATE_180 -> {
91
+ matrix.setRotate(180f)
92
+ }
93
+
94
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
95
+ matrix.setScale(1f, -1f)
96
+ }
97
+
98
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
99
+ matrix.setRotate(90f)
100
+ matrix.postScale(-1f, 1f)
101
+ }
102
+
103
+ ExifInterface.ORIENTATION_ROTATE_90 -> {
104
+ matrix.setRotate(90f)
105
+ }
106
+
107
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
108
+ matrix.setRotate(270f)
109
+ matrix.postScale(-1f, 1f)
110
+ }
111
+
112
+ ExifInterface.ORIENTATION_ROTATE_270 -> {
113
+ matrix.setRotate(270f)
114
+ }
115
+ }
116
+ if (orientation in ExifInterface.ORIENTATION_FLIP_HORIZONTAL..ExifInterface.ORIENTATION_ROTATE_270) {
117
+ val upright = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
118
+ if (upright !== bitmap) {
119
+ bitmap.recycle()
120
+ bitmap = upright
121
+ }
122
+ }
123
+ val swapsAxes = orientation in ExifInterface.ORIENTATION_TRANSPOSE..ExifInterface.ORIENTATION_ROTATE_270
124
+ return HmsScanImage(
125
+ bitmap,
126
+ (if (swapsAxes) height else width).toDouble() / bitmap.width,
127
+ (if (swapsAxes) width else height).toDouble() / bitmap.height,
128
+ )
129
+ } catch (error: Throwable) {
130
+ if (!bitmap.isRecycled) bitmap.recycle()
131
+ throw error
132
+ }
133
+ }
134
+ }
135
+ }
@@ -3,200 +3,216 @@ package com.unif.reactnativehmsscan
3
3
  import android.Manifest
4
4
  import android.app.Activity
5
5
  import android.content.pm.PackageManager
6
- import android.graphics.Bitmap
7
- import android.graphics.BitmapFactory
8
- import android.net.Uri
9
6
  import androidx.core.app.ActivityCompat
10
7
  import androidx.core.content.ContextCompat
8
+ import com.facebook.react.bridge.LifecycleEventListener
11
9
  import com.facebook.react.bridge.Promise
12
10
  import com.facebook.react.bridge.ReactApplicationContext
11
+ import com.facebook.react.bridge.UiThreadUtil
13
12
  import com.facebook.react.modules.core.PermissionAwareActivity
14
13
  import com.facebook.react.modules.core.PermissionListener
15
14
  import com.huawei.hms.hmsscankit.ScanUtil
16
15
  import com.huawei.hms.ml.scan.HmsScan
17
16
  import com.huawei.hms.ml.scan.HmsScanAnalyzerOptions
18
- import java.io.File
19
- import java.io.InputStream
20
-
21
- /**
22
- * TurboModule "HmsScan". Implements the codegen-generated [NativeHmsScanSpec]:
23
- * - decodeImage(): decode a local image into a ScanResult[] JSON string.
24
- * - getCameraPermissionStatus() / requestCameraPermission(): camera permission.
25
- */
17
+ import java.util.concurrent.ArrayBlockingQueue
18
+ import java.util.concurrent.RejectedExecutionException
19
+ import java.util.concurrent.ThreadPoolExecutor
20
+ import java.util.concurrent.TimeUnit
21
+
22
+ /** Independent image decoding and camera permission boundaries. */
26
23
  class HmsScanModule(
27
24
  reactContext: ReactApplicationContext,
28
- ) : NativeHmsScanSpec(reactContext) {
29
- override fun getName(): String = NAME
25
+ ) : NativeHmsScanSpec(reactContext),
26
+ LifecycleEventListener {
27
+ // One allocated image at a time, with at most four waiting file borrowers.
28
+ private val imageQueue =
29
+ ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, ArrayBlockingQueue(4)) { work ->
30
+ Thread(work, "HmsScan-image").apply { isDaemon = true }
31
+ }
30
32
 
31
- // ── decodeImage ──────────────────────────────────────────────────────────
33
+ @Volatile private var invalidated = false
34
+
35
+ // Permission requests and host callbacks are serialized on the UI thread.
36
+ private class PermissionBatch(
37
+ val activity: Activity,
38
+ val promises: MutableList<Promise>,
39
+ )
40
+
41
+ private var permissionBatch: PermissionBatch? = null
42
+
43
+ override fun getName(): String = NAME
32
44
 
33
- /**
34
- * Decode barcodes/QR codes from a local image. Loads the bitmap from a
35
- * file:// / content:// URI or an absolute path, runs ScanUtil.decodeWithBitmap
36
- * (photo mode), and resolves the ScanResult[] JSON. Never rejects on "no code
37
- * found" — it resolves an empty array, matching the JS contract.
38
- */
39
45
  override fun decodeImage(
40
46
  uri: String,
41
47
  formatsCsv: String,
42
48
  promise: Promise,
43
49
  ) {
44
- val bitmap = loadBitmap(uri)
45
- if (bitmap == null) {
46
- promise.reject(E_IMAGE_LOAD_FAILED, "Failed to load image from uri: $uri")
47
- return
50
+ val work = ImageWork(uri, formatsCsv, promise)
51
+ synchronized(imageQueue) {
52
+ if (invalidated) {
53
+ work.rejectUnavailable()
54
+ return
55
+ }
56
+ try {
57
+ imageQueue.execute(work)
58
+ } catch (_: RejectedExecutionException) {
59
+ work.rejectUnavailable()
60
+ }
48
61
  }
62
+ }
49
63
 
50
- try {
51
- val types = HmsScanResultMapper.parseFormatsCsv(formatsCsv)
52
- val optionsCreator = HmsScanAnalyzerOptions.Creator().setPhotoMode(true)
53
- if (types != null && types.isNotEmpty()) {
54
- val first = types.first()
55
- val rest = if (types.size > 1) types.copyOfRange(1, types.size) else IntArray(0)
56
- optionsCreator.setHmsScanTypes(first, *rest)
57
- } else {
58
- optionsCreator.setHmsScanTypes(HmsScan.ALL_SCAN_TYPE)
64
+ private inner class ImageWork(
65
+ val uri: String,
66
+ val formatsCsv: String,
67
+ val promise: Promise,
68
+ ) : Runnable {
69
+ fun rejectUnavailable() {
70
+ promise.reject("E_UNAVAILABLE", "图片识别不可用或等待队列已满")
71
+ }
72
+
73
+ override fun run() {
74
+ if (invalidated) {
75
+ rejectUnavailable()
76
+ return
59
77
  }
60
- val options = optionsCreator.create()
61
-
62
- val scans = ScanUtil.decodeWithBitmap(reactApplicationContext, bitmap, options)
63
- promise.resolve(HmsScanResultMapper.toJson(scans))
64
- } catch (e: Throwable) {
65
- promise.reject(E_DECODE_FAILED, e.message ?: "Failed to decode image", e)
66
- } finally {
67
- if (!bitmap.isRecycled) {
68
- bitmap.recycle()
78
+ try {
79
+ // use closes the bitmap before the promise settles and the caller releases its file.
80
+ val json =
81
+ HmsScanImage.read(uri).use { image ->
82
+ val types = HmsScanResultMapper.parseFormatsCsv(formatsCsv)
83
+ val creator = HmsScanAnalyzerOptions.Creator().setPhotoMode(true)
84
+ if (types != null && types.isNotEmpty()) {
85
+ creator.setHmsScanTypes(types.first(), *types.drop(1).toIntArray())
86
+ } else {
87
+ creator.setHmsScanTypes(HmsScan.ALL_SCAN_TYPE)
88
+ }
89
+ val scans = ScanUtil.decodeWithBitmap(reactApplicationContext, image.bitmap, creator.create())
90
+ HmsScanResultMapper.toJson(scans, image.scaleX, image.scaleY)
91
+ }
92
+ promise.resolve(json)
93
+ } catch (error: ScanImageFailure) {
94
+ promise.reject(error.code, error.message, error)
95
+ } catch (error: InvalidScanResponse) {
96
+ promise.reject("E_INVALID_RESPONSE", error.message, error)
97
+ } catch (error: Exception) {
98
+ promise.reject("E_DECODE_FAILED", error.message ?: "图片识别失败", error)
99
+ } catch (error: OutOfMemoryError) {
100
+ promise.reject("E_DECODE_FAILED", "图片识别内存不足", error)
69
101
  }
70
102
  }
71
103
  }
72
104
 
73
- /** Resolve a uri/path to a decoded [Bitmap], or null on any failure. */
74
- private fun loadBitmap(uri: String): Bitmap? =
105
+ /** Android does not distinguish never asked from blocked in a read-only query. */
106
+ override fun getCameraPermissionStatus(promise: Promise) {
75
107
  try {
76
- val parsed = Uri.parse(uri)
77
- val scheme = parsed.scheme?.lowercase()
78
- when (scheme) {
79
- "content", "file", "android.resource" -> {
80
- val resolver = reactApplicationContext.contentResolver
81
- var stream: InputStream? = null
82
- try {
83
- stream = resolver.openInputStream(parsed)
84
- if (stream == null) null else BitmapFactory.decodeStream(stream)
85
- } finally {
86
- stream?.close()
87
- }
88
- }
108
+ promise.resolve(if (hasCameraPermission()) GRANTED else DENIED)
109
+ } catch (error: Exception) {
110
+ promise.reject("E_UNAVAILABLE", "读取相机权限失败", error)
111
+ }
112
+ }
89
113
 
90
- null -> {
91
- // No scheme -> treat as an absolute filesystem path.
92
- val path = parsed.path ?: uri
93
- decodeFile(path)
114
+ override fun requestCameraPermission(promise: Promise) {
115
+ UiThreadUtil.runOnUiThread {
116
+ if (invalidated) {
117
+ promise.reject("E_UNAVAILABLE", "扫码模块已释放")
118
+ return@runOnUiThread
119
+ }
120
+ try {
121
+ if (hasCameraPermission()) {
122
+ promise.resolve(GRANTED)
123
+ return@runOnUiThread
94
124
  }
95
-
96
- else -> {
97
- // Unsupported scheme (e.g. http/https): JS contract forbids remote URLs.
98
- null
125
+ val activity = reactApplicationContext.currentActivity
126
+ if (activity !is PermissionAwareActivity) {
127
+ promise.reject("E_NO_ACTIVITY", "没有可申请相机权限的界面")
128
+ return@runOnUiThread
129
+ }
130
+ permissionBatch?.let { pending ->
131
+ if (pending.activity === activity) {
132
+ pending.promises.add(promise)
133
+ return@runOnUiThread
134
+ }
135
+ rejectPermissions(pending, "E_NO_ACTIVITY", "相机权限所属界面已结束")
136
+ }
137
+ val batch = PermissionBatch(activity, mutableListOf(promise))
138
+ permissionBatch = batch
139
+ reactApplicationContext.addLifecycleEventListener(this)
140
+ val listener =
141
+ PermissionListener { requestCode, _, grantResults ->
142
+ if (requestCode != CAMERA_PERMISSION_REQUEST_CODE) {
143
+ return@PermissionListener false
144
+ }
145
+ if (permissionBatch !== batch) return@PermissionListener true
146
+ if (grantResults.isEmpty()) {
147
+ rejectPermissions(batch, "E_UNAVAILABLE", "相机权限申请未完成")
148
+ } else {
149
+ val status =
150
+ when {
151
+ grantResults[0] == PackageManager.PERMISSION_GRANTED -> GRANTED
152
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA) -> DENIED
153
+ else -> BLOCKED
154
+ }
155
+ finishPermissions(batch).forEach { it.resolve(status) }
156
+ }
157
+ true
158
+ }
159
+ try {
160
+ activity.requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE, listener)
161
+ } catch (error: Exception) {
162
+ rejectPermissions(batch, "E_UNAVAILABLE", error.message ?: "申请相机权限失败")
99
163
  }
164
+ } catch (error: Exception) {
165
+ promise.reject("E_UNAVAILABLE", "申请相机权限失败", error)
100
166
  }
101
- } catch (e: Throwable) {
102
- null
103
167
  }
168
+ }
104
169
 
105
- private fun decodeFile(path: String): Bitmap? {
106
- val file = File(path)
107
- if (!file.exists() || !file.canRead()) return null
108
- return BitmapFactory.decodeFile(file.absolutePath)
170
+ private fun finishPermissions(batch: PermissionBatch): List<Promise> {
171
+ if (permissionBatch !== batch) return emptyList()
172
+ permissionBatch = null
173
+ reactApplicationContext.removeLifecycleEventListener(this)
174
+ return batch.promises.toList().also { batch.promises.clear() }
109
175
  }
110
176
 
111
- // ── Camera permission ────────────────────────────────────────────────────
112
-
113
- /**
114
- * Current camera permission without prompting.
115
- *
116
- * Note: at query time Android exposes no reliable signal to distinguish
117
- * "blocked" / "undetermined" from a plain denial (shouldShowRequestPermissionRationale
118
- * is false for both the never-asked and permanently-denied states), so a
119
- * not-granted result is reported as "denied". The blocked/undetermined nuance is
120
- * resolved by requestCameraPermission() via the post-request rationale check.
121
- */
122
- override fun getCameraPermissionStatus(promise: Promise) {
123
- promise.resolve(if (hasCameraPermission()) GRANTED else DENIED)
177
+ private fun rejectPermissions(
178
+ batch: PermissionBatch,
179
+ code: String,
180
+ message: String,
181
+ ) {
182
+ finishPermissions(batch).forEach { it.reject(code, message) }
124
183
  }
125
184
 
126
- /**
127
- * Request the camera permission (system dialog if needed) and resolve the
128
- * resulting status:
129
- * - granted: permission held.
130
- * - denied: rejected but the app may ask again (rationale should be shown).
131
- * - blocked: rejected with "don't ask again" (rationale will not be shown).
132
- */
133
- override fun requestCameraPermission(promise: Promise) {
134
- if (hasCameraPermission()) {
135
- promise.resolve(GRANTED)
136
- return
137
- }
185
+ override fun onHostResume() = Unit
138
186
 
139
- val activity = currentActivity
140
- if (activity == null || activity !is PermissionAwareActivity) {
141
- // Can't surface a system dialog without a PermissionAwareActivity; report the
142
- // current (not-granted) state rather than hanging the promise.
143
- promise.reject(
144
- E_NO_ACTIVITY,
145
- "Cannot request camera permission: no current PermissionAwareActivity",
146
- )
147
- return
148
- }
187
+ override fun onHostPause() = Unit
149
188
 
150
- val permissionAwareActivity = activity as PermissionAwareActivity
151
- val listener =
152
- PermissionListener { requestCode, _, grantResults ->
153
- if (requestCode != CAMERA_PERMISSION_REQUEST_CODE) {
154
- return@PermissionListener false
155
- }
156
- val granted =
157
- grantResults.isNotEmpty() &&
158
- grantResults[0] == PackageManager.PERMISSION_GRANTED
159
- val status =
160
- when {
161
- granted -> GRANTED
162
-
163
- // After a denial: rationale==true means the user can be asked again;
164
- // rationale==false means "don't ask again" (blocked).
165
- ActivityCompat.shouldShowRequestPermissionRationale(
166
- activity,
167
- Manifest.permission.CAMERA,
168
- ) -> DENIED
169
-
170
- else -> BLOCKED
171
- }
172
- promise.resolve(status)
173
- true
174
- }
189
+ override fun onHostDestroy() {
190
+ permissionBatch?.let { rejectPermissions(it, "E_NO_ACTIVITY", "相机权限所属界面已结束") }
191
+ }
175
192
 
176
- permissionAwareActivity.requestPermissions(
177
- arrayOf(Manifest.permission.CAMERA),
178
- CAMERA_PERMISSION_REQUEST_CODE,
179
- listener,
180
- )
193
+ override fun invalidate() {
194
+ val waiting = mutableListOf<Runnable>()
195
+ synchronized(imageQueue) {
196
+ invalidated = true
197
+ imageQueue.queue.drainTo(waiting)
198
+ // Do not interrupt an active vendor read or return its file before it finishes.
199
+ imageQueue.shutdown()
200
+ }
201
+ waiting.forEach { (it as ImageWork).rejectUnavailable() }
202
+ UiThreadUtil.runOnUiThread {
203
+ permissionBatch?.let { rejectPermissions(it, "E_UNAVAILABLE", "扫码模块已释放") }
204
+ }
205
+ super.invalidate()
181
206
  }
182
207
 
183
208
  private fun hasCameraPermission(): Boolean =
184
- ContextCompat.checkSelfPermission(
185
- reactApplicationContext,
186
- Manifest.permission.CAMERA,
187
- ) == PackageManager.PERMISSION_GRANTED
209
+ ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
188
210
 
189
211
  companion object {
190
212
  const val NAME = NativeHmsScanSpec.NAME
191
-
192
213
  private const val GRANTED = "granted"
193
214
  private const val DENIED = "denied"
194
215
  private const val BLOCKED = "blocked"
195
-
196
- private const val E_IMAGE_LOAD_FAILED = "E_IMAGE_LOAD_FAILED"
197
- private const val E_DECODE_FAILED = "E_DECODE_FAILED"
198
- private const val E_NO_ACTIVITY = "E_NO_ACTIVITY"
199
-
200
- private const val CAMERA_PERMISSION_REQUEST_CODE = 0x48_4D // "HM"
216
+ private const val CAMERA_PERMISSION_REQUEST_CODE = 0x48_4D
201
217
  }
202
218
  }