@unif/react-native-hms-scan 0.6.2 → 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 (156) hide show
  1. package/README.md +3 -2
  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/Scanner.js +11 -310
  35. package/lib/module/Scanner/Scanner.js.map +1 -1
  36. package/lib/module/Scanner/Scanner.web.js +29 -0
  37. package/lib/module/Scanner/Scanner.web.js.map +1 -0
  38. package/lib/module/Scanner/ScannerInner.js +310 -0
  39. package/lib/module/Scanner/ScannerInner.js.map +1 -0
  40. package/lib/module/Scanner/styles.js +11 -0
  41. package/lib/module/Scanner/styles.js.map +1 -0
  42. package/lib/module/Scanner/types.js +4 -0
  43. package/lib/module/Scanner/types.js.map +1 -0
  44. package/lib/module/constants.js +20 -0
  45. package/lib/module/constants.js.map +1 -0
  46. package/lib/module/decodeImage.js +29 -24
  47. package/lib/module/decodeImage.js.map +1 -1
  48. package/lib/module/decodeImage.web.js +7 -0
  49. package/lib/module/decodeImage.web.js.map +1 -0
  50. package/lib/module/errors.js +24 -0
  51. package/lib/module/errors.js.map +1 -0
  52. package/lib/module/format.js +53 -47
  53. package/lib/module/format.js.map +1 -1
  54. package/lib/module/index.js +5 -19
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/index.web.js +8 -0
  57. package/lib/module/index.web.js.map +1 -0
  58. package/lib/module/mock.js +2 -23
  59. package/lib/module/mock.js.map +1 -1
  60. package/lib/module/permissions.js +24 -20
  61. package/lib/module/permissions.js.map +1 -1
  62. package/lib/module/permissions.web.js +10 -0
  63. package/lib/module/permissions.web.js.map +1 -0
  64. package/lib/module/types.js +1 -45
  65. package/lib/module/types.js.map +1 -1
  66. package/lib/typescript/src/HmsScanView/HmsScanView.d.ts +3 -0
  67. package/lib/typescript/src/HmsScanView/HmsScanView.d.ts.map +1 -0
  68. package/lib/typescript/src/HmsScanView/HmsScanView.web.d.ts +3 -0
  69. package/lib/typescript/src/HmsScanView/HmsScanView.web.d.ts.map +1 -0
  70. package/lib/typescript/src/HmsScanView/types.d.ts +12 -0
  71. package/lib/typescript/src/HmsScanView/types.d.ts.map +1 -0
  72. package/lib/typescript/src/HmsScanViewNativeComponent.d.ts +6 -4
  73. package/lib/typescript/src/HmsScanViewNativeComponent.d.ts.map +1 -1
  74. package/lib/typescript/src/NativeHmsScan.d.ts +3 -5
  75. package/lib/typescript/src/NativeHmsScan.d.ts.map +1 -1
  76. package/lib/typescript/src/ScanError.d.ts +7 -0
  77. package/lib/typescript/src/ScanError.d.ts.map +1 -0
  78. package/lib/typescript/src/Scanner/DeniedOverlay.d.ts.map +1 -1
  79. package/lib/typescript/src/Scanner/ResultFail.d.ts +2 -1
  80. package/lib/typescript/src/Scanner/ResultFail.d.ts.map +1 -1
  81. package/lib/typescript/src/Scanner/ResultFocus/ResultFocus.d.ts +4 -0
  82. package/lib/typescript/src/Scanner/ResultFocus/ResultFocus.d.ts.map +1 -0
  83. package/lib/typescript/src/Scanner/ResultFocus/styles.d.ts +36 -0
  84. package/lib/typescript/src/Scanner/ResultFocus/styles.d.ts.map +1 -0
  85. package/lib/typescript/src/Scanner/ResultFocus/types.d.ts +8 -0
  86. package/lib/typescript/src/Scanner/ResultFocus/types.d.ts.map +1 -0
  87. package/lib/typescript/src/Scanner/Scanner.d.ts +2 -45
  88. package/lib/typescript/src/Scanner/Scanner.d.ts.map +1 -1
  89. package/lib/typescript/src/Scanner/Scanner.web.d.ts +3 -0
  90. package/lib/typescript/src/Scanner/Scanner.web.d.ts.map +1 -0
  91. package/lib/typescript/src/Scanner/ScannerInner.d.ts +3 -0
  92. package/lib/typescript/src/Scanner/ScannerInner.d.ts.map +1 -0
  93. package/lib/typescript/src/Scanner/styles.d.ts +7 -0
  94. package/lib/typescript/src/Scanner/styles.d.ts.map +1 -0
  95. package/lib/typescript/src/Scanner/types.d.ts +20 -0
  96. package/lib/typescript/src/Scanner/types.d.ts.map +1 -0
  97. package/lib/typescript/src/constants.d.ts +8 -0
  98. package/lib/typescript/src/constants.d.ts.map +1 -0
  99. package/lib/typescript/src/decodeImage.d.ts +2 -12
  100. package/lib/typescript/src/decodeImage.d.ts.map +1 -1
  101. package/lib/typescript/src/decodeImage.web.d.ts +3 -0
  102. package/lib/typescript/src/decodeImage.web.d.ts.map +1 -0
  103. package/lib/typescript/src/errors.d.ts +6 -0
  104. package/lib/typescript/src/errors.d.ts.map +1 -0
  105. package/lib/typescript/src/format.d.ts +15 -12
  106. package/lib/typescript/src/format.d.ts.map +1 -1
  107. package/lib/typescript/src/index.d.ts +6 -7
  108. package/lib/typescript/src/index.d.ts.map +1 -1
  109. package/lib/typescript/src/index.web.d.ts +9 -0
  110. package/lib/typescript/src/index.web.d.ts.map +1 -0
  111. package/lib/typescript/src/mock.d.ts +9 -8
  112. package/lib/typescript/src/mock.d.ts.map +1 -1
  113. package/lib/typescript/src/permissions.d.ts +4 -8
  114. package/lib/typescript/src/permissions.d.ts.map +1 -1
  115. package/lib/typescript/src/permissions.web.d.ts +4 -0
  116. package/lib/typescript/src/permissions.web.d.ts.map +1 -0
  117. package/lib/typescript/src/types.d.ts +31 -56
  118. package/lib/typescript/src/types.d.ts.map +1 -1
  119. package/package.json +8 -1
  120. package/src/HmsScanView/HmsScanView.tsx +67 -0
  121. package/src/HmsScanView/HmsScanView.web.tsx +22 -0
  122. package/src/HmsScanView/types.ts +17 -0
  123. package/src/HmsScanViewNativeComponent.ts +7 -5
  124. package/src/NativeHmsScan.ts +3 -5
  125. package/src/ScanError.ts +13 -0
  126. package/src/Scanner/DeniedOverlay.tsx +4 -2
  127. package/src/Scanner/ResultFail.tsx +23 -5
  128. package/src/Scanner/ResultFocus/ResultFocus.tsx +43 -0
  129. package/src/Scanner/ResultFocus/styles.ts +25 -0
  130. package/src/Scanner/ResultFocus/types.ts +8 -0
  131. package/src/Scanner/Scanner.tsx +14 -432
  132. package/src/Scanner/Scanner.web.tsx +26 -0
  133. package/src/Scanner/ScannerInner.tsx +391 -0
  134. package/src/Scanner/styles.ts +6 -0
  135. package/src/Scanner/types.ts +36 -0
  136. package/src/constants.ts +55 -0
  137. package/src/decodeImage.ts +35 -29
  138. package/src/decodeImage.web.ts +8 -0
  139. package/src/errors.ts +26 -0
  140. package/src/format.ts +77 -76
  141. package/src/index.tsx +17 -32
  142. package/src/index.web.tsx +22 -0
  143. package/src/mock.ts +21 -29
  144. package/src/permissions.ts +33 -20
  145. package/src/permissions.web.ts +9 -0
  146. package/src/types.ts +38 -126
  147. package/lib/module/HmsScanView.js +0 -43
  148. package/lib/module/HmsScanView.js.map +0 -1
  149. package/lib/module/Scanner/ResultFocus.js +0 -171
  150. package/lib/module/Scanner/ResultFocus.js.map +0 -1
  151. package/lib/typescript/src/HmsScanView.d.ts +0 -33
  152. package/lib/typescript/src/HmsScanView.d.ts.map +0 -1
  153. package/lib/typescript/src/Scanner/ResultFocus.d.ts +0 -14
  154. package/lib/typescript/src/Scanner/ResultFocus.d.ts.map +0 -1
  155. package/src/HmsScanView.tsx +0 -87
  156. package/src/Scanner/ResultFocus.tsx +0 -122
package/README.md CHANGED
@@ -27,12 +27,13 @@ Android 内置识别引擎,不要求华为手机或 HMS Core。两端均不需
27
27
  ```ts
28
28
  import { decodeImage } from '@unif/react-native-hms-scan';
29
29
 
30
- const results = await decodeImage('file:///path/to/photo.jpg', {
30
+ const results = await decodeImage({
31
+ uri: 'file:///path/to/photo.jpg',
31
32
  formats: ['QR_CODE'],
32
33
  });
33
34
  ```
34
35
 
35
- 正常识别但没有发现码时返回 `[]`;读取或解码失败抛出 `HmsScanError`。函数不下载网络图片,应用需先准备本地文件。
36
+ 正常识别但没有发现码时返回 `[]`;读取或解码失败抛出 `ScanError`。函数不下载网络图片,应用需先准备本地文件。
36
37
 
37
38
  ## 文档与开发
38
39
 
@@ -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
  }
@@ -15,22 +15,28 @@ import org.json.JSONObject
15
15
  * "cornerPoints":[{"x":10,"y":20}]}]
16
16
  * ```
17
17
  */
18
+ internal class InvalidScanResponse(
19
+ message: String,
20
+ ) : IllegalArgumentException(message)
21
+
18
22
  internal object HmsScanResultMapper {
19
23
  /**
20
24
  * Serialize an array of [HmsScan] into the ScanResult[] JSON string. The element
21
25
  * type is nullable because the HMS APIs (decodeWithBitmap / OnResultCallback)
22
26
  * hand back a Java array that can contain nulls.
23
27
  */
24
- fun toJson(scans: Array<out HmsScan?>?): String {
28
+ fun toJson(
29
+ scans: Array<out HmsScan?>?,
30
+ scaleX: Double = 1.0,
31
+ scaleY: Double = 1.0,
32
+ ): String {
25
33
  val array = JSONArray()
26
34
  if (scans != null) {
27
35
  for (scan in scans) {
28
36
  if (scan == null) continue
29
37
  val value = scan.getOriginalValue()
30
- // A hit with no value is meaningless to the JS layer (it would be dropped
31
- // by coerceResult anyway); skip it to keep the payload clean.
32
- if (value.isNullOrEmpty()) continue
33
- array.put(toJsonObject(scan, value))
38
+ if (value == null) throw InvalidScanResponse("ScanKit returned a result without text")
39
+ array.put(toJsonObject(scan, value, scaleX, scaleY))
34
40
  }
35
41
  }
36
42
  return array.toString()
@@ -39,6 +45,8 @@ internal object HmsScanResultMapper {
39
45
  private fun toJsonObject(
40
46
  scan: HmsScan,
41
47
  value: String,
48
+ scaleX: Double,
49
+ scaleY: Double,
42
50
  ): JSONObject {
43
51
  val obj = JSONObject()
44
52
  obj.put("value", value)
@@ -53,8 +61,8 @@ internal object HmsScanResultMapper {
53
61
  if (point == null) continue
54
62
  points.put(
55
63
  JSONObject().apply {
56
- put("x", point.x)
57
- put("y", point.y)
64
+ put("x", point.x * scaleX)
65
+ put("y", point.y * scaleY)
58
66
  },
59
67
  )
60
68
  }
@@ -66,7 +74,7 @@ internal object HmsScanResultMapper {
66
74
  return obj
67
75
  }
68
76
 
69
- /** HmsScan.getScanType() (barcode symbology) -> unified BarcodeFormat string. */
77
+ /** HmsScan.getScanType() (barcode symbology) -> unified ScanFormat string. */
70
78
  fun mapScanType(scanType: Int): String =
71
79
  when (scanType) {
72
80
  HmsScan.QRCODE_SCAN_TYPE -> "QR_CODE"
@@ -105,7 +113,7 @@ internal object HmsScanResultMapper {
105
113
  return if (types.isEmpty()) null else types.toIntArray()
106
114
  }
107
115
 
108
- /** Unified BarcodeFormat string -> HmsScan.*_SCAN_TYPE (null when unknown). */
116
+ /** Unified ScanFormat string -> HmsScan.*_SCAN_TYPE (null when unknown). */
109
117
  private fun mapFormatString(format: String): Int? =
110
118
  when (format) {
111
119
  "QR_CODE" -> HmsScan.QRCODE_SCAN_TYPE