@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 +3 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/android/build.gradle +59 -0
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceFrameProcessorPlugin.kt +79 -0
- package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceModule.kt +270 -0
- package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceSessionHolder.kt +27 -0
- package/android/src/main/java/com/usesmileid/rn/analyzer/huaweiface/ResizingFrameStorage.kt +134 -0
- package/android/src/main/java/com/usesmileid/rn/analyzer/huaweiface/UseSmileIDAnalyzerExecutor.kt +282 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +70 -0
- package/src/__tests__/use_smile_id_huawei_face.test.ts +79 -0
- package/src/index.ts +42 -0
package/CHANGELOG.md
ADDED
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.** { *; }
|
package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceFrameProcessorPlugin.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/main/java/com/usesmileid/rn/analyzer/huaweiface/UseSmileIDAnalyzerExecutor.kt
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
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 };
|