@smileid/usesmileid_huawei_face 12.0.0-beta01

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 12.0.0-beta01
2
+
3
+ First public beta release. Huawei HMS face detection for the Smile ID React Native (Expo) SDK v12.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Smile ID
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # `@smileid/usesmileid_huawei_face`
2
+
3
+ UseSmileID v12 — Huawei HMS face provider for React Native (Expo Modules).
4
+ **Android-only.** Thin shim around the existing native AAR
5
+ `com.usesmileid:huawei-face`. Use this provider on devices without
6
+ Google Mobile Services (Huawei post-2019 hardware, etc.); use
7
+ `@smileid/usesmileid_mlkit_face` everywhere else.
8
+
9
+ ## Architecture
10
+
11
+ The native side runs the entire face-scan pipeline. This package
12
+ contributes a Kotlin Expo Module that constructs
13
+ `UseSmileIDFaceSession(FaceDetectorAnalyzer())` against the Huawei
14
+ HMS-backed analyzer in the `com.usesmileid:huawei-face` AAR, and a
15
+ TS wrapper that bridges `configure / analyze / reset / close` with
16
+ Zod-validated wire payloads.
17
+
18
+ No ML logic is reimplemented in this package. See
19
+ `react-native-expo/docs/Architecture.md`.
20
+
21
+ ## Installation
22
+
23
+ ```sh
24
+ pnpm add @smileid/usesmileid_huawei_face
25
+ ```
26
+
27
+ Expo's autolinking picks up `expo-module.config.json` at app build
28
+ time. Partner apps shipping to Huawei AppGallery will additionally
29
+ need the Huawei HMS Maven mirror in their root `build.gradle.kts` —
30
+ see the Huawei HMS developer guides.
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { useSmileIDHuaweiFace } from "@smileid/usesmileid_huawei_face";
36
+
37
+ await useSmileIDHuaweiFace.configure({ selfieCaptureTimeoutMs: 60_000 });
38
+ const result = await useSmileIDHuaweiFace.analyze(bytes, rotation);
39
+ await useSmileIDHuaweiFace.close();
40
+ ```
41
+
42
+ ## Cross-platform peers
43
+
44
+ | Platform | Provider package | Native dep |
45
+ | ---------------- | --------------------------------- | ---------------------------- |
46
+ | Android (no GMS) | `@smileid/usesmileid_huawei_face` | `com.usesmileid:huawei-face` |
47
+ | Android | `@smileid/usesmileid_mlkit_face` | `com.usesmileid:mlkit-face` |
48
+ | iOS | `@smileid/usesmileid_vision_face` | `UseSmileIDVisionFace` (SPM) |
49
+
50
+ ## Testing
51
+
52
+ ```sh
53
+ pnpm --filter @smileid/usesmileid_huawei_face test
54
+ ```
@@ -0,0 +1,59 @@
1
+ // Expo Module — Huawei face analyzer (HMS ML kit). Uses the
2
+ // canonical Expo Module Groovy template so the script-compilation
3
+ // classpath inherits AGP from the host app's pluginManagement, the
4
+ // same way every other Expo module on the workspace's autolinking
5
+ // graph does (see node_modules/expo-camera/android/build.gradle for
6
+ // the upstream reference).
7
+ //
8
+ // `id 'expo-module-gradle-plugin'` provides:
9
+ // • compileSdk / minSdk / Java + Kotlin target levels (from the host)
10
+ // • Standard Expo Module dependency wiring + publishing config
11
+ // So we only declare what's specific to THIS module: namespace +
12
+ // the native AAR that ships the actual ML pipeline.
13
+
14
+ plugins {
15
+ id 'com.android.library'
16
+ id 'expo-module-gradle-plugin'
17
+ }
18
+
19
+ group = 'com.usesmileid.huawei.face'
20
+ version = '12.0.0'
21
+
22
+ android {
23
+ namespace 'com.usesmileid.huawei.face'
24
+
25
+ // `expo-module-gradle-plugin` requires versionName + versionCode on
26
+ // the library's defaultConfig — concrete failure without these:
27
+ // "android.defaultConfig.versionName is not defined" at autolinking
28
+ // time. Matches the canonical pattern in node_modules/expo-camera/
29
+ // android/build.gradle and the rest of the @expo/* modules.
30
+ defaultConfig {
31
+ versionCode 1
32
+ versionName '12.0.0'
33
+ // R8 keep-rules to bundle into partner apps that depend on this
34
+ // module. Without these, R8 may rename the Expo module entry
35
+ // point and the JS-bridged member functions, breaking autolinking.
36
+ consumerProguardFiles 'consumer-rules.pro'
37
+ }
38
+ }
39
+
40
+ dependencies {
41
+ // Existing Smile ID native AAR — the entire ML pipeline lives here.
42
+ // Targets Huawei AppGallery / no-GMS devices via HMS ML kit. The
43
+ // Expo Module is a thin Kotlin shim that constructs the session
44
+ // and bridges its four methods to JS via Expo Modules.
45
+ implementation 'com.usesmileid:huawei-face:12.0.0-beta01'
46
+
47
+ // VisionCamera frame-processor plugin classes. The host app provides
48
+ // this dependency via RN autolinking — react-native-vision-camera >= 4.7.3
49
+ // must be present in the partner app's package.json.
50
+ implementation project(':react-native-vision-camera')
51
+
52
+ // CameraX types (`ImageProxy`, `ExperimentalGetImage`) referenced by the
53
+ // frame processor plugin. VisionCamera declares camera-core as
54
+ // `implementation`, so it is NOT on this module's compile classpath
55
+ // transitively — compileOnly pins the compile-time API while the host
56
+ // app's VisionCamera provides the runtime classes (its camera-core is
57
+ // 1.5.0-alpha03; the `ImageProxy` surface used here is stable since 1.0).
58
+ compileOnly 'androidx.camera:camera-core:1.5.0-alpha03'
59
+ }
@@ -0,0 +1,22 @@
1
+ # ═════════════════════════════════════════════════════════════════════════════
2
+ # R8 consumer rules — usesmileid_huawei_face (React Native / Expo module)
3
+ #
4
+ # Bundled into partner apps that depend on this Expo module. Tells R8 what
5
+ # NOT to rename / strip during the partner's release build.
6
+ # ═════════════════════════════════════════════════════════════════════════════
7
+
8
+ # ─── Expo Module entry-point + bridge surface ───────────────────────────────
9
+ # Expo's module registry instantiates `UseSmileIDHuaweiFaceModule` by FQN at
10
+ # app launch — renaming breaks autolinking. The same package hosts the
11
+ # `ModuleDefinition` function names referenced by JS via string keys, plus
12
+ # the shared `UseSmileIDAnalyzerExecutor` subclass; both must survive R8.
13
+ -keep class com.usesmileid.huawei.face.** { *; }
14
+
15
+ # ─── Enums (kotlinx-serialization / JS bridge valueOf reflection) ───────────
16
+ -keepclassmembers enum com.usesmileid.huawei.face.** {
17
+ public static **[] values();
18
+ public static ** valueOf(java.lang.String);
19
+ }
20
+
21
+ # ─── Shared RN analyzer-executor namespace ──────────────────────────────────
22
+ -keep class com.usesmileid.rn.analyzer.huaweiface.** { *; }
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,79 @@
1
+ package com.usesmileid.huawei.face
2
+
3
+ import androidx.camera.core.ExperimentalGetImage
4
+ import com.mrousavy.camera.core.FrameInvalidError
5
+ import com.mrousavy.camera.frameprocessors.Frame
6
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
7
+ import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
8
+ import kotlin.coroutines.cancellation.CancellationException
9
+ import kotlinx.coroutines.runBlocking
10
+ import kotlinx.coroutines.withContext
11
+
12
+ /**
13
+ * VisionCamera Frame Processor Plugin for the Huawei HMS ML Kit face backend.
14
+ * Registered under [UseSmileIDHuaweiFaceModule.FRAME_PROCESSOR_PLUGIN_NAME].
15
+ * Reads raw [ImageProxy] planes directly (no JPEG encoding) and runs the
16
+ * native session pipeline. Blocks the FP thread via [runBlocking] —
17
+ * VisionCamera drops frames that arrive while a scan is in flight.
18
+ */
19
+ @OptIn(ExperimentalGetImage::class)
20
+ internal class UseSmileIDHuaweiFaceFrameProcessorPlugin : FrameProcessorPlugin() {
21
+
22
+ override fun callback(frame: Frame, arguments: Map<String, Any>?): Any? {
23
+ val imageProxy = try {
24
+ frame.imageProxy
25
+ } catch (e: FrameInvalidError) {
26
+ return mapOf("error" to "frame_conversion_failed")
27
+ }
28
+
29
+ val image = imageProxy.image
30
+ ?: return mapOf("error" to "frame_conversion_failed")
31
+
32
+ // Pixel-orientation for ML Kit — always from ImageProxy metadata, never from JS.
33
+ val sensorRotation = imageProxy.imageInfo.rotationDegrees
34
+
35
+ // Device-tilt context (0 = upright portrait) for head-pose math.
36
+ // Must not fall back to sensorRotation — sensor angle != device tilt.
37
+ val jsRotation = (arguments?.get("rotation") as? Number)?.toInt() ?: 0
38
+
39
+ val planes = image.planes
40
+ if (planes.size < 3) return mapOf("error" to "frame_conversion_failed")
41
+
42
+ fun extractPlane(index: Int): ByteArray {
43
+ val buffer = planes[index].buffer
44
+ buffer.rewind()
45
+ return ByteArray(buffer.remaining()).also { buffer.get(it) }
46
+ }
47
+
48
+ val yuvFrame = NativeYuvFrame(
49
+ planeY = extractPlane(0),
50
+ planeU = extractPlane(1),
51
+ planeV = extractPlane(2),
52
+ yRowStride = planes[0].rowStride,
53
+ uRowStride = planes[1].rowStride,
54
+ vRowStride = planes[2].rowStride,
55
+ uPixelStride = planes[1].pixelStride,
56
+ vPixelStride = planes[2].pixelStride,
57
+ width = image.width,
58
+ height = image.height,
59
+ rotation = sensorRotation,
60
+ )
61
+
62
+ return runBlocking {
63
+ withContext(UseSmileIDHuaweiFaceSessionHolder.sessionDispatcher) {
64
+ val activeSession = UseSmileIDHuaweiFaceSessionHolder.session
65
+ ?: return@withContext mapOf("error" to "session_not_configured")
66
+
67
+ try {
68
+ val scanResult = activeSession.scan(yuvFrame, jsRotation)
69
+ val events = activeSession.drainCaptureReadyEvents()
70
+ UseSmileIDHuaweiFaceModule.buildWireResult(scanResult, events)
71
+ } catch (cancellation: CancellationException) {
72
+ throw cancellation
73
+ } catch (e: Exception) {
74
+ mapOf("error" to ("scan_failed: " + (e.message ?: e.javaClass.simpleName)))
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,270 @@
1
+ package com.usesmileid.huawei.face
2
+
3
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPluginRegistry
4
+ import com.usesmileid.bridge.dsl.builder.FaceDetectorMode
5
+ import com.usesmileid.bridge.huawei.face.FaceDetectorAnalyzer
6
+ import com.usesmileid.bridge.model.BoundingBox as NativeBoundingBox
7
+ import com.usesmileid.bridge.model.CaptureReadyEvent as NativeCaptureReadyEvent
8
+ import com.usesmileid.bridge.model.FaceDetectorOutput as NativeFaceOutput
9
+ import com.usesmileid.bridge.model.HeadPoseResult
10
+ import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
11
+ import com.usesmileid.bridge.registry.FaceAnalyzerFactoryRegistry
12
+ import com.usesmileid.bridge.session.FaceScanConfig
13
+ import com.usesmileid.bridge.session.UseSmileIDFaceScanResult
14
+ import com.usesmileid.bridge.session.UseSmileIDFaceSession
15
+ import com.usesmileid.bridge.session.UseSmileIDScanState
16
+ import com.usesmileid.bridge.session.UseSmileIDScanStateType
17
+ import com.usesmileid.bridge.storage.FrameStorage
18
+ import com.usesmileid.rn.analyzer.huaweiface.UseSmileIDAnalyzerExecutor
19
+ import expo.modules.kotlin.Promise
20
+ import expo.modules.kotlin.modules.Module
21
+ import expo.modules.kotlin.modules.ModuleDefinition
22
+
23
+ /**
24
+ * Expo Module bridging the existing native session
25
+ * `UseSmileIDFaceSession(FaceDetectorAnalyzer())` (shipped in the
26
+ * `com.smileidentity:huawei-face` AAR) to the JS side. The Huawei
27
+ * variant targets devices without Google Mobile Services and uses
28
+ * Huawei HMS ML kit for face detection.
29
+ *
30
+ * Per-frame work is handled by [UseSmileIDHuaweiFaceFrameProcessorPlugin],
31
+ * which is registered with VisionCamera's frame-processor runtime in
32
+ * [OnCreate]. Partners call `VisionCameraProxy.initFrameProcessorPlugin`
33
+ * from their JS worklet instead of the old `AsyncFunction("analyze")`.
34
+ */
35
+ class UseSmileIDHuaweiFaceModule : Module() {
36
+
37
+ private val executor = object : UseSmileIDAnalyzerExecutor<
38
+ Map<String, Any?>,
39
+ Map<String, Any?>,
40
+ UseSmileIDFaceSession,
41
+ UseSmileIDFaceScanResult,
42
+ >() {
43
+
44
+ override fun createSession(
45
+ config: Map<String, Any?>,
46
+ frameStorage: FrameStorage,
47
+ ): UseSmileIDFaceSession {
48
+ val key = (config["analyzerFactoryKey"] as? String) ?: KEY
49
+ val analyzer = FaceAnalyzerFactoryRegistry.newAnalyzer(key)
50
+ return UseSmileIDFaceSession(
51
+ analyzer = analyzer,
52
+ config = config.toFaceScanConfig(),
53
+ frameStorage = frameStorage,
54
+ )
55
+ }
56
+
57
+ override suspend fun scan(
58
+ session: UseSmileIDFaceSession,
59
+ frame: NativeYuvFrame,
60
+ rotation: Int,
61
+ ): UseSmileIDFaceScanResult = session.scan(frame, rotation)
62
+
63
+ override fun drainEvents(session: UseSmileIDFaceSession): List<NativeCaptureReadyEvent> =
64
+ session.drainCaptureReadyEvents()
65
+
66
+ override suspend fun flushSession(session: UseSmileIDFaceSession) {
67
+ session.flush()
68
+ }
69
+
70
+ override fun resetSession(session: UseSmileIDFaceSession) {
71
+ session.reset()
72
+ }
73
+
74
+ override fun closeSession(session: UseSmileIDFaceSession) {
75
+ session.close()
76
+ }
77
+
78
+ override fun buildResult(
79
+ scanResult: UseSmileIDFaceScanResult,
80
+ events: List<NativeCaptureReadyEvent>,
81
+ ): Map<String, Any?> = buildWireResult(scanResult, events)
82
+
83
+ override fun onDetached() {
84
+ FaceAnalyzerFactoryRegistry.unregister(KEY)
85
+ }
86
+
87
+ override fun onSessionCreated(session: UseSmileIDFaceSession) {
88
+ UseSmileIDHuaweiFaceSessionHolder.session = session
89
+ }
90
+
91
+ override fun onSessionClosed() {
92
+ UseSmileIDHuaweiFaceSessionHolder.session = null
93
+ }
94
+
95
+ override fun holderSessionDispatcher() = UseSmileIDHuaweiFaceSessionHolder.sessionDispatcher
96
+ }
97
+
98
+ override fun definition() = ModuleDefinition {
99
+ // Must match the JS-side `requireNativeModule('UseSmileIDHuaweiFace')`.
100
+ Name("UseSmileIDHuaweiFace")
101
+
102
+ OnCreate {
103
+ FaceAnalyzerFactoryRegistry.register(KEY) { FaceDetectorAnalyzer() }
104
+ val ctx = appContext.reactContext
105
+ ?: throw IllegalStateException("Module not attached to a React context")
106
+ executor.onAttach(ctx)
107
+
108
+ // Guard against duplicate registration on dev-client hot reloads
109
+ // where OnCreate can fire more than once in the same process.
110
+ if (!pluginRegistered) {
111
+ pluginRegistered = true
112
+ FrameProcessorPluginRegistry.addFrameProcessorPlugin(
113
+ FRAME_PROCESSOR_PLUGIN_NAME,
114
+ ) { _, _ -> UseSmileIDHuaweiFaceFrameProcessorPlugin() }
115
+ }
116
+ }
117
+
118
+ AsyncFunction("configure") { config: Map<String, Any?> ->
119
+ executor.handleConfigure(config)
120
+ }
121
+
122
+ AsyncFunction("reset") {
123
+ executor.handleReset()
124
+ }
125
+
126
+ AsyncFunction("flush") { promise: Promise ->
127
+ executor.handleFlush { result ->
128
+ result.fold(
129
+ onSuccess = { promise.resolve(null) },
130
+ onFailure = { promise.reject("FLUSH_FAILED", it.message, it) },
131
+ )
132
+ }
133
+ }
134
+
135
+ AsyncFunction("close") {
136
+ executor.handleClose()
137
+ }
138
+
139
+ OnDestroy {
140
+ executor.onDetach()
141
+ }
142
+ }
143
+
144
+ companion object {
145
+ /** Wire-format key partners pass via `FaceConfig.analyzerFactoryKey`. */
146
+ const val KEY: String = "huawei-face"
147
+
148
+ /**
149
+ * Must match the JS worklet's `VisionCameraProxy.initFrameProcessorPlugin(...)`
150
+ * call exactly — drift is a runtime "plugin not found" error.
151
+ */
152
+ const val FRAME_PROCESSOR_PLUGIN_NAME: String = "UseSmileIDHuaweiFaceFrameProcessor"
153
+
154
+ /**
155
+ * [FrameProcessorPluginRegistry.addFrameProcessorPlugin] asserts uniqueness
156
+ * and crashes on duplicate registration; this guard prevents that on dev-client
157
+ * hot reloads where [OnCreate] fires more than once per process.
158
+ */
159
+ @Volatile
160
+ private var pluginRegistered: Boolean = false
161
+
162
+ /** Wire-result builder shared with [UseSmileIDHuaweiFaceFrameProcessorPlugin]. */
163
+ internal fun buildWireResult(
164
+ scanResult: UseSmileIDFaceScanResult,
165
+ events: List<NativeCaptureReadyEvent>,
166
+ ): Map<String, Any?> = mapOf(
167
+ "output" to (
168
+ (scanResult.output as? NativeFaceOutput)?.toWireMap()
169
+ ?: mapOf("faces" to emptyList<Any>(), "brightness" to 0.0)
170
+ ),
171
+ "state" to scanResult.state.toWireMap(),
172
+ "capturedEvents" to events.map { it.toWireMap() },
173
+ )
174
+ }
175
+ }
176
+
177
+ private fun Map<String, Any?>.toFaceScanConfig(): FaceScanConfig {
178
+ // Resolve the preset token to this backend's engine-calibrated base, then
179
+ // overlay any explicitly-set threshold field on top.
180
+ val base = FaceDetectorMode.fromToken(this["faceDetectorMode"] as? String).config
181
+ return FaceScanConfig(
182
+ selfieCaptureTimeout = (this["selfieCaptureTimeout"] as? Number)?.toInt() ?: 100_000,
183
+ // Default yields 1 selfie + 7 liveness frames — the server requires
184
+ // `liveness_images >= 6`, so anything below 7 causes submission rejection.
185
+ numSamples = (this["numSamples"] as? Number)?.toInt() ?: 8,
186
+ sampleInterval = (this["sampleInterval"] as? Number)?.toInt() ?: 350,
187
+ stayInFoundDuration = (this["stayInFoundDuration"] as? Number)?.toInt() ?: 3_000,
188
+ minFillThreshold = (this["minFillThreshold"] as? Number)?.toFloat() ?: base.minFillThreshold,
189
+ maxFillThreshold = (this["maxFillThreshold"] as? Number)?.toFloat() ?: base.maxFillThreshold,
190
+ faceDetectorMinScore = (this["faceDetectorMinScore"] as? Number)?.toFloat() ?: base.faceDetectorMinScore,
191
+ centerToleranceX = (this["centerToleranceX"] as? Number)?.toFloat() ?: base.centerToleranceX,
192
+ centerToleranceY = (this["centerToleranceY"] as? Number)?.toFloat() ?: base.centerToleranceY,
193
+ motionBlurMinIou = (this["motionBlurMinIou"] as? Number)?.toFloat() ?: base.motionBlurMinIou,
194
+ motionBlurMinDuration = (this["motionBlurMinDuration"] as? Number)?.toLong() ?: base.motionBlurMinDuration,
195
+ minTopPadding = (this["minTopPadding"] as? Number)?.toFloat() ?: base.minTopPadding,
196
+ minFaceBoundsThreshold = (this["minFaceBoundsThreshold"] as? Number)?.toFloat() ?: base.minFaceBoundsThreshold,
197
+ maxFaceBoundsThreshold = (this["maxFaceBoundsThreshold"] as? Number)?.toFloat() ?: base.maxFaceBoundsThreshold,
198
+ minSmileProbability = (this["minSmileProbability"] as? Number)?.toFloat() ?: base.minSmileProbability,
199
+ enableEnhancedLiveness = (this["enableEnhancedLiveness"] as? Boolean) ?: false,
200
+ )
201
+ }
202
+
203
+ private fun NativeFaceOutput.toWireMap(): Map<String, Any?> {
204
+ val primaryFace = faces.maxByOrNull { it.boundingBox.area }
205
+ return buildMap {
206
+ put("faces", faces.map { it.boundingBox.toWireMap() })
207
+ // Omit absent optional fields; never put null (Zod .optional() rejects explicit null).
208
+ primaryFace?.headPose?.let { put("headPose", it.toWireMap()) }
209
+ primaryFace?.smilingProbability?.let { put("smilingProbability", it.toDouble()) }
210
+ put("brightness", (primaryFace?.brightness ?: 0f).toDouble())
211
+ }
212
+ }
213
+
214
+ private fun NativeBoundingBox.toWireMap(): Map<String, Any?> = mapOf(
215
+ "left" to left.toDouble(),
216
+ "top" to top.toDouble(),
217
+ "width" to width.toDouble(),
218
+ "height" to height.toDouble(),
219
+ "score" to score.toDouble(),
220
+ )
221
+
222
+ private fun HeadPoseResult.toWireMap(): Map<String, Any?> = mapOf(
223
+ "yaw" to yaw.toDouble(),
224
+ "pitch" to pitch.toDouble(),
225
+ "roll" to roll.toDouble(),
226
+ )
227
+
228
+ private fun UseSmileIDScanState.toWireMap(): Map<String, Any?> = buildMap {
229
+ put(
230
+ "type",
231
+ when (type) {
232
+ UseSmileIDScanStateType.INITIAL -> "initial"
233
+ UseSmileIDScanStateType.FOUND -> "found"
234
+ UseSmileIDScanStateType.SATISFIED -> "satisfied"
235
+ UseSmileIDScanStateType.UNSATISFIED -> "unsatisfied"
236
+ UseSmileIDScanStateType.FINISHED -> "finished"
237
+ UseSmileIDScanStateType.TIMEOUT -> "timeout"
238
+ UseSmileIDScanStateType.HEAD_TURN -> "headTurn"
239
+ },
240
+ )
241
+ // Absent optional fields are OMITTED, never set to null:
242
+ // Zod .optional() rejects explicit null; the bridge maps Kotlin null → JS null.
243
+ feedbackName?.let { put("feedback", it) }
244
+ unsatisfiedReason?.let { put("reason", it) }
245
+ // Head-turn direction for Enhanced Smart Selfie guidance.
246
+ currentDirection?.let { put("currentDirection", it.name.lowercase()) }
247
+ captureProgress?.let { put("captureProgress", it.toDouble()) }
248
+ // Latched pose-match flag for the ESS head turn — drives the grey→green
249
+ // direction arrow. Emit only while matched (omit ⇒ false on the JS side),
250
+ // matching the optional-field omission convention above.
251
+ if (poseMatched) put("poseMatched", true)
252
+ }
253
+
254
+ private fun NativeCaptureReadyEvent.toWireMap(): Map<String, Any?> = when (this) {
255
+ is NativeCaptureReadyEvent.SelfieFrame -> mapOf(
256
+ "kind" to "selfie",
257
+ "captureIndex" to captureIndex,
258
+ "url" to file.toURI().toString(),
259
+ )
260
+ is NativeCaptureReadyEvent.LivenessFrame -> mapOf(
261
+ "kind" to "liveness",
262
+ "captureIndex" to captureIndex,
263
+ "url" to file.toURI().toString(),
264
+ )
265
+ is NativeCaptureReadyEvent.DocumentFrame -> mapOf(
266
+ "kind" to "document",
267
+ "captureIndex" to captureIndex,
268
+ "url" to file.toURI().toString(),
269
+ )
270
+ }
@@ -0,0 +1,27 @@
1
+ package com.usesmileid.huawei.face
2
+
3
+ import com.usesmileid.bridge.session.UseSmileIDFaceSession
4
+ import java.util.concurrent.Executors
5
+ import kotlinx.coroutines.CoroutineDispatcher
6
+ import kotlinx.coroutines.asCoroutineDispatcher
7
+
8
+ /**
9
+ * Process-wide holder for the active [UseSmileIDFaceSession] (Huawei HMS ML Kit face backend).
10
+ *
11
+ * The session's state machine is not thread-safe; all operations —
12
+ * scan+drain as one atomic unit, flush, reset, close — are serialized
13
+ * through [sessionDispatcher] (a single-thread executor). The FP plugin
14
+ * blocks its thread via [runBlocking]; VisionCamera drops frames that
15
+ * arrive while a scan is in flight rather than queuing them.
16
+ */
17
+ internal object UseSmileIDHuaweiFaceSessionHolder {
18
+
19
+ /** Single-thread dispatcher; serializes all session operations without explicit locking. */
20
+ val sessionDispatcher: CoroutineDispatcher =
21
+ Executors.newSingleThreadExecutor { runnable ->
22
+ Thread(runnable, "usesmileid-huawei-face-session")
23
+ }.asCoroutineDispatcher()
24
+
25
+ @Volatile
26
+ var session: UseSmileIDFaceSession? = null
27
+ }
@@ -0,0 +1,134 @@
1
+ // MIRRORED FILE — duplicated into all four RN Expo Android analyzer modules
2
+ // (mlkitface / mlkitdoc / huaweiface / huaweidoc). The per-module sub-package
3
+ // is required so a partner shipping multiple backends doesn't get a dex-merge
4
+ // FQN collision. Only the `package` declaration differs across copies; any
5
+ // edit here MUST be applied to the other three locations in the same commit.
6
+
7
+ package com.usesmileid.rn.analyzer.huaweiface
8
+
9
+ import com.usesmileid.bridge.model.YuvFrame
10
+ import com.usesmileid.bridge.storage.DocumentSide
11
+ import com.usesmileid.bridge.storage.FrameKind
12
+ import com.usesmileid.bridge.storage.FrameStorage
13
+ import com.usesmileid.bridge.storage.writeJpegToFileExact
14
+ import com.usesmileid.bridge.util.toArgbBitmap
15
+ import java.io.File
16
+ import java.io.IOException
17
+ import java.util.concurrent.ConcurrentLinkedDeque
18
+ import java.util.concurrent.atomic.AtomicReference
19
+ import kotlinx.coroutines.CoroutineScope
20
+ import kotlinx.coroutines.Dispatchers
21
+ import kotlinx.coroutines.Job
22
+ import kotlinx.coroutines.SupervisorJob
23
+ import kotlinx.coroutines.launch
24
+ import kotlinx.coroutines.withContext
25
+
26
+ /**
27
+ * [FrameStorage] implementation that center-crops and scales every selfie and
28
+ * liveness JPEG to the configured target dimensions before writing.
29
+ *
30
+ * Target dimensions are resolved at configure-time from the token
31
+ * `sdk_config` claim; the executor falls back to 480 × 640 when the token
32
+ * carries no override, so the on-disk pixel size always matches the
33
+ * `image_width` / `image_height` values reported in job metadata.
34
+ *
35
+ * Document frames are not resized — they are written at the analyzer's native
36
+ * frame resolution using the specified [documentQuality]. Document dimensions
37
+ * are not part of the token `sdk_config` contract.
38
+ *
39
+ * Resize and encode run on a private `Dispatchers.IO` scope; [persist] is
40
+ * fire-and-forget so the analyzer keeps running at frame rate. [flush] awaits
41
+ * all in-flight writes before upload proceeds.
42
+ */
43
+ internal class ResizingFrameStorage(
44
+ private val sessionDirectory: File,
45
+ private val documentSide: DocumentSide = DocumentSide.Front,
46
+ private val selfieTargetWidth: Int = 480,
47
+ private val selfieTargetHeight: Int = 640,
48
+ private val livenessTargetWidth: Int = 480,
49
+ private val livenessTargetHeight: Int = 640,
50
+ private val selfieQuality: Int = 95,
51
+ private val livenessQuality: Int = 80,
52
+ private val documentQuality: Int = 85,
53
+ ) : FrameStorage {
54
+
55
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
56
+ private val pending = ConcurrentLinkedDeque<Job>()
57
+ private val firstError = AtomicReference<Throwable?>(null)
58
+
59
+ override fun fileFor(kind: FrameKind, captureIndex: Int): File = File(
60
+ sessionDirectory,
61
+ when (kind) {
62
+ FrameKind.Selfie -> "selfie.jpg"
63
+ FrameKind.Liveness -> "liveness_$captureIndex.jpg"
64
+ FrameKind.Document -> when (documentSide) {
65
+ DocumentSide.Front -> "document_front.jpg"
66
+ DocumentSide.Back -> "document_back.jpg"
67
+ }
68
+ },
69
+ )
70
+
71
+ override fun persist(frame: YuvFrame, file: File) {
72
+ val job = scope.launch {
73
+ try {
74
+ encodeAndWrite(frame, file)
75
+ } catch (throwable: Throwable) {
76
+ firstError.compareAndSet(null, throwable)
77
+ throw throwable
78
+ }
79
+ }
80
+ // Add synchronously so flush() cannot race past an in-flight job.
81
+ pending.add(job)
82
+ }
83
+
84
+ override suspend fun flush() {
85
+ val jobs = mutableListOf<Job>()
86
+ while (true) jobs.add(pending.poll() ?: break)
87
+ jobs.forEach { it.join() }
88
+ firstError.getAndSet(null)?.let { throw it }
89
+ }
90
+
91
+ override fun clear() {
92
+ sessionDirectory.listFiles()?.forEach { it.delete() }
93
+ }
94
+
95
+ private suspend fun encodeAndWrite(frame: YuvFrame, file: File) {
96
+ withContext(Dispatchers.IO) {
97
+ file.parentFile?.mkdirs()
98
+ val name = file.name
99
+ when {
100
+ name.startsWith("selfie") -> {
101
+ val bitmap = frame.toArgbBitmap(jpegQuality = selfieQuality)
102
+ ?: throw IOException("toArgbBitmap returned null for ${file.name}")
103
+ try {
104
+ bitmap.writeJpegToFileExact(file, selfieTargetWidth, selfieTargetHeight, selfieQuality)
105
+ } finally {
106
+ bitmap.recycle()
107
+ }
108
+ }
109
+ name.startsWith("liveness") -> {
110
+ val bitmap = frame.toArgbBitmap(jpegQuality = livenessQuality)
111
+ ?: throw IOException("toArgbBitmap returned null for ${file.name}")
112
+ try {
113
+ bitmap.writeJpegToFileExact(file, livenessTargetWidth, livenessTargetHeight, livenessQuality)
114
+ } finally {
115
+ bitmap.recycle()
116
+ }
117
+ }
118
+ else -> {
119
+ // Document frames: encode at native resolution, quality only.
120
+ val bitmap = frame.toArgbBitmap(jpegQuality = documentQuality)
121
+ ?: throw IOException("toArgbBitmap returned null for ${file.name}")
122
+ try {
123
+ val success = file.outputStream().use {
124
+ bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, documentQuality, it)
125
+ }
126
+ if (!success) throw IOException("Bitmap.compress(JPEG) failed for ${file.absolutePath}")
127
+ } finally {
128
+ bitmap.recycle()
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,282 @@
1
+ // MIRRORED FILE — duplicated into all four RN Expo Android analyzer modules
2
+ // (mlkitface / mlkitdoc / huaweiface / huaweidoc). The per-module sub-package
3
+ // suffix is required so a partner shipping multiple backends doesn't get a
4
+ // dex-merge FQN collision. Only the `package` declaration differs across copies;
5
+ // any edit here MUST be applied to the other three locations in the same commit.
6
+
7
+ package com.usesmileid.rn.analyzer.huaweiface
8
+
9
+ import android.content.Context
10
+ import com.usesmileid.bridge.model.CaptureReadyEvent as NativeCaptureReadyEvent
11
+ import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
12
+ import com.usesmileid.bridge.storage.DocumentSide
13
+ import com.usesmileid.bridge.storage.FrameStorage
14
+ import java.io.File
15
+ import kotlinx.coroutines.CoroutineDispatcher
16
+ import kotlinx.coroutines.CoroutineScope
17
+ import kotlinx.coroutines.Dispatchers
18
+ import kotlinx.coroutines.SupervisorJob
19
+ import kotlinx.coroutines.cancel
20
+ import kotlinx.coroutines.launch
21
+ import kotlinx.coroutines.runBlocking
22
+ import kotlinx.coroutines.withContext
23
+
24
+ /**
25
+ * Generic base for the four RN Expo analyzer modules. Concrete
26
+ * subclasses extend this base with their session construction (face vs
27
+ * document, mlkit vs huawei) and result-to-wire conversion. The Expo
28
+ * Module class owns one instance of a subclass and forwards its async
29
+ * functions through it.
30
+ *
31
+ * Type parameters:
32
+ * - [PConfig]: the JS-side config map (always `Map<String, Any?>` after
33
+ * Expo bridge marshalling).
34
+ * - [PResult]: the JS-side wire result (always `Map<String, Any?>`
35
+ * after Expo bridge marshalling).
36
+ * - [Session]: the native SDK session class
37
+ * (`UseSmileIDFaceSession` / `UseSmileIDDocumentSession`).
38
+ * - [ScanResult]: the native SDK scan-result type
39
+ * (`UseSmileIDFaceScanResult` / `UseSmileIDDocumentScanResult`).
40
+ *
41
+ * The base owns the coroutine scope, the on-disk session-frame storage
42
+ * root, and the current `session` reference. Subclasses must not
43
+ * redeclare these — they would diverge from the lifecycle this base
44
+ * manages.
45
+ */
46
+ abstract class UseSmileIDAnalyzerExecutor<
47
+ PConfig : Any,
48
+ PResult : Any,
49
+ Session,
50
+ ScanResult,
51
+ > {
52
+
53
+ protected var session: Session? = null
54
+ private var storageRoot: File? = null
55
+
56
+ /**
57
+ * Scope used for all `analyze` / `flush` work. Bound to `Dispatchers.IO`
58
+ * + a `SupervisorJob` so a single failed frame analysis doesn't poison
59
+ * the rest of the session. Cancelled on module destroy.
60
+ */
61
+ protected val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
62
+
63
+ // Subclass hooks --------------------------------------------------------
64
+
65
+ /**
66
+ * Build a native session from the JS-side config map. The
67
+ * implementation receives the prepared on-disk `frameStorage` so each
68
+ * `configure` starts with a fresh session directory.
69
+ *
70
+ * Subclasses are responsible for any registry interaction (face
71
+ * modules resolve the analyzer through `FaceAnalyzerFactoryRegistry`;
72
+ * document modules instantiate their analyzer directly).
73
+ */
74
+ protected abstract fun createSession(config: PConfig, frameStorage: FrameStorage): Session
75
+
76
+ /**
77
+ * The document side this session's [JpegFrameStorage] writes, selecting
78
+ * the `document_front.jpg` vs `document_back.jpg` filename. Face modules
79
+ * keep the default ([DocumentSide.Front]); document modules override to
80
+ * read the side from their wire config so two-sided captures don't
81
+ * overwrite the front image with the back.
82
+ */
83
+ protected open fun storageDocumentSide(config: PConfig): DocumentSide = DocumentSide.Front
84
+
85
+ /** Run one frame through the native session. */
86
+ protected abstract suspend fun scan(
87
+ session: Session,
88
+ frame: NativeYuvFrame,
89
+ rotation: Int,
90
+ ): ScanResult
91
+
92
+ /** Drain pending capture-ready events from the session. */
93
+ protected abstract fun drainEvents(session: Session): List<NativeCaptureReadyEvent>
94
+
95
+ /** Flush the session (suspend — writes pending JPEGs to disk). */
96
+ protected abstract suspend fun flushSession(session: Session)
97
+
98
+ /** Reset the session state machine without tearing down the analyzer. */
99
+ protected abstract fun resetSession(session: Session)
100
+
101
+ /** Close the session and release any underlying analyzer resources. */
102
+ protected abstract fun closeSession(session: Session)
103
+
104
+ /**
105
+ * Convert the native scan result + drained events into the JS-side
106
+ * wire result type (always a `Map<String, Any?>` for Expo). The map
107
+ * keys must match the JS-side schemas declared in `@usesmileid/ml`.
108
+ */
109
+ protected abstract fun buildResult(
110
+ scanResult: ScanResult,
111
+ events: List<NativeCaptureReadyEvent>,
112
+ ): PResult
113
+
114
+ // Lifecycle (driven by the Expo Module's OnCreate / OnDestroy) -------
115
+
116
+ /**
117
+ * Called from the Expo Module's `OnCreate` block. Subclasses can
118
+ * override to register backend-specific analyzer factories with the
119
+ * shared process-wide registry. Default no-op.
120
+ */
121
+ open fun onAttach(context: Context) {
122
+ val root = File(context.filesDir, "UseSmileID")
123
+ root.mkdirs()
124
+ File(root, ".nomedia").takeIf { !it.exists() }?.createNewFile()
125
+ storageRoot = root
126
+ }
127
+
128
+ /**
129
+ * Called from the Expo Module's `OnDestroy` block. Cancels the
130
+ * coroutine scope, closes any live session, then invokes
131
+ * [onDetached] for subclass-specific teardown.
132
+ */
133
+ fun onDetach() {
134
+ scope.cancel()
135
+ session?.let {
136
+ onSessionClosed()
137
+ closeSession(it)
138
+ }
139
+ session = null
140
+ onDetached()
141
+ }
142
+
143
+ /**
144
+ * Subclass hook invoked from [onDetach] after the session is closed
145
+ * and the scope is cancelled. Default no-op. Face modules use this
146
+ * to unregister their analyzer factory.
147
+ */
148
+ protected open fun onDetached() {}
149
+
150
+ // Session lifecycle notifications (driven by holder publishing) ------
151
+
152
+ /** Called after a new session is created; subclasses publish it to the holder. Default no-op. */
153
+ protected open fun onSessionCreated(session: Session) {}
154
+
155
+ /**
156
+ * Called BEFORE [closeSession] when the session is about to be torn down.
157
+ * Subclasses null the process-wide holder here so any concurrent scan
158
+ * queued on the session dispatcher sees null instead of a closing session.
159
+ * Default no-op.
160
+ */
161
+ protected open fun onSessionClosed() {}
162
+
163
+ /**
164
+ * The single-thread [CoroutineDispatcher] that serialises all session
165
+ * operations for this module's holder, preventing concurrent state-machine
166
+ * mutations between the frame-processor thread and the Expo module thread.
167
+ * Returning `null` (default) runs inline on the calling thread.
168
+ */
169
+ protected open fun holderSessionDispatcher(): CoroutineDispatcher? = null
170
+
171
+ // Shared implementation behind the Expo Module's async functions -----
172
+
173
+ /**
174
+ * Implementation behind `configure`. Closes any prior session,
175
+ * allocates a fresh on-disk session directory, and delegates session
176
+ * construction to the subclass.
177
+ */
178
+ fun handleConfigure(config: PConfig) {
179
+ session?.let {
180
+ // Null the holder before closing so a queued scan sees null,
181
+ // not a mid-close session.
182
+ onSessionClosed()
183
+ closeSession(it)
184
+ }
185
+ // Each `configure` starts a fresh session directory so retries
186
+ // after `close` / re-`configure` don't collide with the previous
187
+ // session's JPGs. Old directories are left in place; partner
188
+ // clean-up policies can prune them.
189
+ val sessionDir = freshSessionDirectory()
190
+ val storage = buildResizingFrameStorage(sessionDir, storageDocumentSide(config), config as? Map<*, *>)
191
+ val newSession = createSession(config, storage)
192
+ session = newSession
193
+ onSessionCreated(newSession)
194
+ }
195
+
196
+ /**
197
+ * Implementation behind `flush`. Same dispatcher hopping discipline;
198
+ * a missing session is treated as a successful
199
+ * no-op (matches the prior per-module behaviour).
200
+ */
201
+ fun handleFlush(callback: (Result<Unit>) -> Unit) {
202
+ scope.launch {
203
+ val result = runCatching { session?.let { flushSession(it) } ?: Unit }
204
+ withContext(Dispatchers.Main) { callback(result) }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Implementation behind `reset`. Serialized through [holderSessionDispatcher]
210
+ * so it cannot interleave with a concurrent frame-processor scan.
211
+ */
212
+ fun handleReset() {
213
+ val dispatcher = holderSessionDispatcher()
214
+ if (dispatcher != null) {
215
+ runBlocking {
216
+ withContext(dispatcher) {
217
+ session?.let { resetSession(it) }
218
+ }
219
+ }
220
+ } else {
221
+ session?.let { resetSession(it) }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Implementation behind `close`. Nulls the holder BEFORE closing the
227
+ * session (see [onSessionClosed]), serialized through [holderSessionDispatcher].
228
+ */
229
+ fun handleClose() {
230
+ val dispatcher = holderSessionDispatcher()
231
+ if (dispatcher != null) {
232
+ runBlocking {
233
+ withContext(dispatcher) {
234
+ session?.let {
235
+ onSessionClosed()
236
+ closeSession(it)
237
+ }
238
+ session = null
239
+ }
240
+ }
241
+ } else {
242
+ session?.let {
243
+ onSessionClosed()
244
+ closeSession(it)
245
+ }
246
+ session = null
247
+ }
248
+ }
249
+
250
+ // Helpers ---------------------------------------------------------------
251
+
252
+
253
+ /**
254
+ * Build a [ResizingFrameStorage] from the wire config map, applying
255
+ * token-resolved dimensions and JPEG quality when present. All values
256
+ * fall back to SDK defaults when absent or not a [Number].
257
+ */
258
+ private fun buildResizingFrameStorage(
259
+ sessionDir: File,
260
+ documentSide: DocumentSide,
261
+ config: Map<*, *>?,
262
+ ): ResizingFrameStorage = ResizingFrameStorage(
263
+ sessionDirectory = sessionDir,
264
+ documentSide = documentSide,
265
+ selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
266
+ selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
267
+ livenessTargetWidth = (config?.get("livenessImageWidth") as? Number)?.toInt() ?: 480,
268
+ livenessTargetHeight = (config?.get("livenessImageHeight") as? Number)?.toInt() ?: 640,
269
+ selfieQuality = (config?.get("selfieJpegQuality") as? Number)?.toInt() ?: 95,
270
+ livenessQuality = (config?.get("livenessJpegQuality") as? Number)?.toInt() ?: 80,
271
+ documentQuality = (config?.get("documentJpegQuality") as? Number)?.toInt() ?: 85,
272
+ )
273
+
274
+ private fun freshSessionDirectory(): File {
275
+ val root = checkNotNull(storageRoot) {
276
+ "Executor not attached — call onAttach(context) from the Expo Module's OnCreate block first"
277
+ }
278
+ val dir = File(root, System.currentTimeMillis().toString())
279
+ dir.mkdirs()
280
+ return dir
281
+ }
282
+ }
@@ -0,0 +1,32 @@
1
+ import type { FaceConfig, FaceScanResult } from "@smileid/usesmileid_bridge";
2
+ /**
3
+ * Huawei HMS face analyzer (Android-only, for devices without
4
+ * Google Mobile Services).
5
+ *
6
+ * Thin shim around the existing native session
7
+ * `com.usesmileid.bridge.session.UseSmileIDFaceSession(FaceDetectorAnalyzer())`
8
+ * shipped in the `com.smileidentity:huawei-face` AAR. The native side
9
+ * runs the entire pipeline (analyzer + validator + transitioner +
10
+ * state machine); this TS layer routes calls and validates wire
11
+ * payloads at the channel boundary.
12
+ *
13
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
14
+ * `usesmileid_platform_interface`.
15
+ *
16
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
17
+ * processor plugin → `reset()`? → `close()`. See
18
+ * `usesmileid_platform_interface` `FaceAnalyzer` for the canonical
19
+ * contract.
20
+ */
21
+ /**
22
+ * Must match the Kotlin Expo Module's `Name("UseSmileIDHuaweiFace")` and its
23
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
24
+ * "module/plugin not found" error, not a compile error.
25
+ */
26
+ export declare const UseSmileIDHuaweiFaceIdentifiers: {
27
+ readonly nativeModuleName: "UseSmileIDHuaweiFace";
28
+ readonly frameProcessorPluginName: "UseSmileIDHuaweiFaceFrameProcessor";
29
+ };
30
+ export declare const useSmileIDHuaweiFace: import("@smileid/usesmileid_platform_interface").FaceAnalyzer;
31
+ export type { FaceConfig, FaceScanResult };
32
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG7E;;;;;;;;;;;;;;;;;;GAkBG;AACH;;;;GAIG;AACH,eAAO,MAAM,+BAA+B;;;CAGlC,CAAC;AAEX,eAAO,MAAM,oBAAoB,+DAM/B,CAAC;AAEH,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,37 @@
1
+ import { AnalyzerFactoryKey } from "@smileid/usesmileid_bridge";
2
+ import { useSmileIDAnalyzerExecutor } from "@smileid/usesmileid_platform_interface";
3
+ /**
4
+ * Huawei HMS face analyzer (Android-only, for devices without
5
+ * Google Mobile Services).
6
+ *
7
+ * Thin shim around the existing native session
8
+ * `com.usesmileid.bridge.session.UseSmileIDFaceSession(FaceDetectorAnalyzer())`
9
+ * shipped in the `com.smileidentity:huawei-face` AAR. The native side
10
+ * runs the entire pipeline (analyzer + validator + transitioner +
11
+ * state machine); this TS layer routes calls and validates wire
12
+ * payloads at the channel boundary.
13
+ *
14
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
15
+ * `usesmileid_platform_interface`.
16
+ *
17
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
18
+ * processor plugin → `reset()`? → `close()`. See
19
+ * `usesmileid_platform_interface` `FaceAnalyzer` for the canonical
20
+ * contract.
21
+ */
22
+ /**
23
+ * Must match the Kotlin Expo Module's `Name("UseSmileIDHuaweiFace")` and its
24
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
25
+ * "module/plugin not found" error, not a compile error.
26
+ */
27
+ export const UseSmileIDHuaweiFaceIdentifiers = {
28
+ nativeModuleName: "UseSmileIDHuaweiFace",
29
+ frameProcessorPluginName: "UseSmileIDHuaweiFaceFrameProcessor",
30
+ };
31
+ export const useSmileIDHuaweiFace = useSmileIDAnalyzerExecutor({
32
+ nativeModuleName: UseSmileIDHuaweiFaceIdentifiers.nativeModuleName,
33
+ frameProcessorPluginName: UseSmileIDHuaweiFaceIdentifiers.frameProcessorPluginName,
34
+ kind: "face",
35
+ key: AnalyzerFactoryKey.HuaweiFace,
36
+ });
37
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEhE,OAAO,EAAE,0BAA0B,EAAE,MAAM,wCAAwC,CAAC;AAEpF;;;;;;;;;;;;;;;;;;GAkBG;AACH;;;;GAIG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG;IAC7C,gBAAgB,EAAE,sBAAsB;IACxC,wBAAwB,EAAE,oCAAoC;CACtD,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,0BAA0B,CAAC;IAC7D,gBAAgB,EAAE,+BAA+B,CAAC,gBAAgB;IAClE,wBAAwB,EACtB,+BAA+B,CAAC,wBAAwB;IAC1D,IAAI,EAAE,MAAM;IACZ,GAAG,EAAE,kBAAkB,CAAC,UAAU;CACnC,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["com.usesmileid.huawei.face.UseSmileIDHuaweiFaceModule"]
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@smileid/usesmileid_huawei_face",
3
+ "version": "12.0.0-beta01",
4
+ "description": "UseSmileID v12 — Huawei HMS face provider (Android-only, no-GMS devices). Thin Expo Module shim around the existing native AAR `com.smileidentity:huawei-face`.",
5
+ "homepage": "https://usesmileid.com",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/smileidentity/react-native-expo.git",
9
+ "directory": "usesmileid_huawei_face"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/smileidentity/react-native-expo/issues"
13
+ },
14
+ "license": "MIT",
15
+ "author": "Smile Identity <hello@usesmileid.com>",
16
+ "keywords": [
17
+ "react-native",
18
+ "expo",
19
+ "expo-module",
20
+ "biometric",
21
+ "kyc",
22
+ "identity-verification",
23
+ "face-detection",
24
+ "document-capture",
25
+ "smile-id"
26
+ ],
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "require": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "android",
40
+ "expo-module.config.json",
41
+ "README.md",
42
+ "LICENSE",
43
+ "CHANGELOG.md"
44
+ ],
45
+ "dependencies": {
46
+ "@smileid/usesmileid_bridge": "12.0.0-beta01",
47
+ "@smileid/usesmileid_platform_interface": "12.0.0-beta01"
48
+ },
49
+ "peerDependencies": {
50
+ "expo": ">=56.0.0",
51
+ "expo-modules-core": ">=56.0.0",
52
+ "react-native": ">=0.83.0"
53
+ },
54
+ "devDependencies": {
55
+ "expo-modules-core": "^56.0.15",
56
+ "react-native": "^0.85.3",
57
+ "typescript": "^6.0.3"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "provenance": false
62
+ },
63
+ "scripts": {
64
+ "build": "tsc -p tsconfig.json",
65
+ "typecheck": "tsc -b",
66
+ "test": "vitest run",
67
+ "test:watch": "vitest",
68
+ "clean": "rm -rf dist .turbo coverage"
69
+ }
70
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it, vi, beforeEach, type Mock } from "vitest";
2
+
3
+ const native = vi.hoisted(() => ({
4
+ configure: vi.fn(),
5
+ reset: vi.fn(),
6
+ close: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("expo-modules-core", () => ({
10
+ requireNativeModule: vi.fn(() => native),
11
+ }));
12
+
13
+ import { requireNativeModule } from "expo-modules-core";
14
+ import {
15
+ useSmileIDHuaweiFace,
16
+ UseSmileIDHuaweiFaceIdentifiers,
17
+ } from "../index";
18
+
19
+ describe("useSmileIDHuaweiFace — TS wrapper", () => {
20
+ beforeEach(() => {
21
+ Object.values(native).forEach((m) => (m as Mock).mockReset());
22
+ });
23
+
24
+ it("looks up the native module under the contract-stable name", () => {
25
+ expect(requireNativeModule).toHaveBeenCalledWith("UseSmileIDHuaweiFace");
26
+ });
27
+
28
+ it("exposes the lifecycle surface — no per-frame analyze method", () => {
29
+ expect(typeof useSmileIDHuaweiFace.configure).toBe("function");
30
+ expect(typeof useSmileIDHuaweiFace.reset).toBe("function");
31
+ expect(typeof useSmileIDHuaweiFace.close).toBe("function");
32
+ // Per-frame analysis lives in the native VisionCamera frame
33
+ // processor plugin — raw pixels never cross the JS bridge.
34
+ expect("analyze" in useSmileIDHuaweiFace).toBe(false);
35
+ });
36
+
37
+ it("exposes the kind discriminator and frame processor plugin name", () => {
38
+ expect(useSmileIDHuaweiFace.kind).toBe("face");
39
+ expect(useSmileIDHuaweiFace.frameProcessorPluginName).toBe(
40
+ UseSmileIDHuaweiFaceIdentifiers.frameProcessorPluginName,
41
+ );
42
+ });
43
+
44
+ describe("configure", () => {
45
+ it("passes the validated config through to native", async () => {
46
+ native.configure.mockResolvedValueOnce(undefined);
47
+ await useSmileIDHuaweiFace.configure({ selfieCaptureTimeout: 60_000 });
48
+ expect(native.configure).toHaveBeenCalledTimes(1);
49
+ expect(native.configure).toHaveBeenCalledWith({
50
+ selfieCaptureTimeout: 60_000,
51
+ });
52
+ });
53
+
54
+ it("rejects malformed config at the JS boundary before crossing the bridge", async () => {
55
+ await expect(
56
+ useSmileIDHuaweiFace.configure({
57
+ // @ts-expect-error — int field with float value
58
+ selfieCaptureTimeout: 60_000.5,
59
+ }),
60
+ ).rejects.toThrow();
61
+ expect(native.configure).not.toHaveBeenCalled();
62
+ });
63
+ });
64
+
65
+ describe("reset / close", () => {
66
+ it("routes reset through to native", async () => {
67
+ native.reset.mockResolvedValueOnce(undefined);
68
+ await useSmileIDHuaweiFace.reset();
69
+ expect(native.reset).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it("routes close through to native and is idempotent", async () => {
73
+ native.close.mockResolvedValue(undefined);
74
+ await useSmileIDHuaweiFace.close();
75
+ await useSmileIDHuaweiFace.close();
76
+ expect(native.close).toHaveBeenCalledTimes(2);
77
+ });
78
+ });
79
+ });
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { AnalyzerFactoryKey } from "@smileid/usesmileid_bridge";
2
+ import type { FaceConfig, FaceScanResult } from "@smileid/usesmileid_bridge";
3
+ import { useSmileIDAnalyzerExecutor } from "@smileid/usesmileid_platform_interface";
4
+
5
+ /**
6
+ * Huawei HMS face analyzer (Android-only, for devices without
7
+ * Google Mobile Services).
8
+ *
9
+ * Thin shim around the existing native session
10
+ * `com.usesmileid.bridge.session.UseSmileIDFaceSession(FaceDetectorAnalyzer())`
11
+ * shipped in the `com.smileidentity:huawei-face` AAR. The native side
12
+ * runs the entire pipeline (analyzer + validator + transitioner +
13
+ * state machine); this TS layer routes calls and validates wire
14
+ * payloads at the channel boundary.
15
+ *
16
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
17
+ * `usesmileid_platform_interface`.
18
+ *
19
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
20
+ * processor plugin → `reset()`? → `close()`. See
21
+ * `usesmileid_platform_interface` `FaceAnalyzer` for the canonical
22
+ * contract.
23
+ */
24
+ /**
25
+ * Must match the Kotlin Expo Module's `Name("UseSmileIDHuaweiFace")` and its
26
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
27
+ * "module/plugin not found" error, not a compile error.
28
+ */
29
+ export const UseSmileIDHuaweiFaceIdentifiers = {
30
+ nativeModuleName: "UseSmileIDHuaweiFace",
31
+ frameProcessorPluginName: "UseSmileIDHuaweiFaceFrameProcessor",
32
+ } as const;
33
+
34
+ export const useSmileIDHuaweiFace = useSmileIDAnalyzerExecutor({
35
+ nativeModuleName: UseSmileIDHuaweiFaceIdentifiers.nativeModuleName,
36
+ frameProcessorPluginName:
37
+ UseSmileIDHuaweiFaceIdentifiers.frameProcessorPluginName,
38
+ kind: "face",
39
+ key: AnalyzerFactoryKey.HuaweiFace,
40
+ });
41
+
42
+ export type { FaceConfig, FaceScanResult };