@smileid/usesmileid_mlkit_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. Android ML Kit 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,66 @@
1
+ # `@smileid/usesmileid_mlkit_face`
2
+
3
+ UseSmileID v12 — Google ML Kit face provider for React Native (Expo Modules).
4
+ **Android-only.** Thin shim around the existing native AAR
5
+ `com.usesmileid:mlkit-face`.
6
+
7
+ ## Architecture
8
+
9
+ The native side runs the entire face-scan pipeline (analyzer + validator
10
+
11
+ - transitioner + state machine). This package contributes:
12
+
13
+ * A Kotlin Expo Module that constructs `UseSmileIDFaceSession(FaceDetectorAnalyzer())`
14
+ from the existing native session.
15
+ * A TypeScript wrapper that bridges the four contract methods
16
+ (`configure / analyze / reset / close`) and validates the wire payloads
17
+ at the channel boundary via Zod.
18
+
19
+ No ML logic is reimplemented in this package — it's a pure passthrough.
20
+ See `improvements.md` and `react-native-expo/docs/Architecture.md` for
21
+ the design rationale.
22
+
23
+ ## Installation
24
+
25
+ ```sh
26
+ pnpm add @smileid/usesmileid_mlkit_face
27
+ ```
28
+
29
+ Expo's autolinking picks up `expo-module.config.json` at app build time;
30
+ no manual native registration required.
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { useSmileIDMlkitFace } from "@smileid/usesmileid_mlkit_face";
36
+
37
+ await useSmileIDMlkitFace.configure({
38
+ selfieCaptureTimeoutMs: 60_000,
39
+ });
40
+
41
+ const result = await useSmileIDMlkitFace.analyze(bytes, rotation);
42
+ // result.state.type === 'found' | 'satisfied' | 'unsatisfied' | …
43
+ // result.output.faces[0].score === 0.9
44
+
45
+ await useSmileIDMlkitFace.close();
46
+ ```
47
+
48
+ ## Cross-platform peers
49
+
50
+ | Platform | Provider package | Native dep |
51
+ | ---------------- | --------------------------------- | ---------------------------- |
52
+ | Android | `@smileid/usesmileid_mlkit_face` | `com.usesmileid:mlkit-face` |
53
+ | Android (no GMS) | `@smileid/usesmileid_huawei_face` | `com.usesmileid:huawei-face` |
54
+ | iOS | `@smileid/usesmileid_vision_face` | `UseSmileIDVisionFace` (SPM) |
55
+
56
+ ## Testing
57
+
58
+ ```sh
59
+ pnpm --filter @smileid/usesmileid_mlkit_face test
60
+ ```
61
+
62
+ JS-side tests mock `requireNativeModule` and verify the wrapper's
63
+ contract: method shape, boundary validation, error propagation,
64
+ idempotency. Native-side tests live under
65
+ `android/src/test/kotlin/...` (TODO when the Kotlin module is wired
66
+ to the real AAR).
@@ -0,0 +1,58 @@
1
+ // Expo Module — ML Kit face analyzer (GMS-backed). 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.mlkit.face'
20
+ version = '12.0.0'
21
+
22
+ android {
23
+ namespace 'com.usesmileid.mlkit.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
+ // This Expo Module is a thin Kotlin shim that constructs the
43
+ // session and bridges its four methods to JS via Expo Modules.
44
+ implementation 'com.usesmileid:mlkit-face:12.0.0-beta01'
45
+
46
+ // VisionCamera frame-processor plugin classes. The host app provides
47
+ // this dependency via RN autolinking — react-native-vision-camera >= 4.7.3
48
+ // must be present in the partner app's package.json.
49
+ implementation project(':react-native-vision-camera')
50
+
51
+ // CameraX types (`ImageProxy`, `ExperimentalGetImage`) referenced by the
52
+ // frame processor plugin. VisionCamera declares camera-core as
53
+ // `implementation`, so it is NOT on this module's compile classpath
54
+ // transitively — compileOnly pins the compile-time API while the host
55
+ // app's VisionCamera provides the runtime classes (its camera-core is
56
+ // 1.5.0-alpha03; the `ImageProxy` surface used here is stable since 1.0).
57
+ compileOnly 'androidx.camera:camera-core:1.5.0-alpha03'
58
+ }
@@ -0,0 +1,26 @@
1
+ # ═════════════════════════════════════════════════════════════════════════════
2
+ # R8 consumer rules — usesmileid_mlkit_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 `UseSmileIDMlkitFaceModule` 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.mlkit.face.** { *; }
14
+
15
+ # ─── Enums (kotlinx-serialization / JS bridge valueOf reflection) ───────────
16
+ -keepclassmembers enum com.usesmileid.mlkit.face.** {
17
+ public static **[] values();
18
+ public static ** valueOf(java.lang.String);
19
+ }
20
+
21
+ # ─── Shared RN analyzer-executor namespace ──────────────────────────────────
22
+ # RN providers reach into `com.usesmileid.rn.analyzer.<provider>` for their
23
+ # local `UseSmileIDAnalyzerExecutor` subclass (the pattern this workspace
24
+ # uses before the Flutter executor was de-duplicated). Keep that namespace
25
+ # intact so JS-bridged calls resolve.
26
+ -keep class com.usesmileid.rn.analyzer.mlkitface.** { *; }
@@ -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.mlkit.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 ML Kit face backend.
14
+ * Registered under [UseSmileIDMlkitFaceModule.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 UseSmileIDMlkitFaceFrameProcessorPlugin : 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(UseSmileIDMlkitFaceSessionHolder.sessionDispatcher) {
64
+ val activeSession = UseSmileIDMlkitFaceSessionHolder.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
+ UseSmileIDMlkitFaceModule.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,309 @@
1
+ package com.usesmileid.mlkit.face
2
+
3
+ import com.usesmileid.bridge.dsl.builder.FaceDetectorMode
4
+ import com.usesmileid.bridge.mlkit.face.FaceDetectorAnalyzer
5
+ import com.usesmileid.bridge.model.BoundingBox as NativeBoundingBox
6
+ import com.usesmileid.bridge.model.CaptureReadyEvent as NativeCaptureReadyEvent
7
+ import com.usesmileid.bridge.model.FaceDetectorOutput as NativeFaceOutput
8
+ import com.usesmileid.bridge.model.HeadPoseResult
9
+ import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
10
+ import com.usesmileid.bridge.registry.FaceAnalyzerFactoryRegistry
11
+ import com.usesmileid.bridge.session.FaceScanConfig
12
+ import com.usesmileid.bridge.session.UseSmileIDFaceScanResult
13
+ import com.usesmileid.bridge.session.UseSmileIDFaceSession
14
+ import com.usesmileid.bridge.session.UseSmileIDScanState
15
+ import com.usesmileid.bridge.session.UseSmileIDScanStateType
16
+ import com.usesmileid.bridge.storage.FrameStorage
17
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPluginRegistry
18
+ import com.usesmileid.rn.analyzer.mlkitface.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:mlkit-face` AAR) to the JS side.
27
+ *
28
+ * No ML logic lives here — the native session owns the entire
29
+ * pipeline. The configure / flush / reset / close plumbing
30
+ * lives in [UseSmileIDAnalyzerExecutor]; this module supplies the
31
+ * subclass hooks (session construction, wire conversion) and forwards
32
+ * the async functions through it.
33
+ *
34
+ * Per-frame work is handled by [UseSmileIDMlkitFaceFrameProcessorPlugin],
35
+ * which is registered with VisionCamera's frame-processor runtime in
36
+ * [OnCreate]. Partners call `VisionCameraProxy.initFrameProcessorPlugin`
37
+ * from their JS worklet instead of the old `AsyncFunction("analyze")`.
38
+ *
39
+ * Lifecycle: Expo instantiates exactly one `Module` per app process;
40
+ * the executor is owned for the module's lifetime.
41
+ */
42
+ class UseSmileIDMlkitFaceModule : Module() {
43
+
44
+ private val executor = object : UseSmileIDAnalyzerExecutor<
45
+ Map<String, Any?>,
46
+ Map<String, Any?>,
47
+ UseSmileIDFaceSession,
48
+ UseSmileIDFaceScanResult,
49
+ >() {
50
+
51
+ override fun createSession(
52
+ config: Map<String, Any?>,
53
+ frameStorage: FrameStorage,
54
+ ): UseSmileIDFaceSession {
55
+ val key = (config["analyzerFactoryKey"] as? String) ?: KEY
56
+ val analyzer = FaceAnalyzerFactoryRegistry.newAnalyzer(key)
57
+ return UseSmileIDFaceSession(
58
+ analyzer = analyzer,
59
+ config = config.toFaceScanConfig(),
60
+ frameStorage = frameStorage,
61
+ )
62
+ }
63
+
64
+ override suspend fun scan(
65
+ session: UseSmileIDFaceSession,
66
+ frame: NativeYuvFrame,
67
+ rotation: Int,
68
+ ): UseSmileIDFaceScanResult = session.scan(frame, rotation)
69
+
70
+ override fun drainEvents(session: UseSmileIDFaceSession): List<NativeCaptureReadyEvent> =
71
+ session.drainCaptureReadyEvents()
72
+
73
+ override suspend fun flushSession(session: UseSmileIDFaceSession) {
74
+ session.flush()
75
+ }
76
+
77
+ override fun resetSession(session: UseSmileIDFaceSession) {
78
+ session.reset()
79
+ }
80
+
81
+ override fun closeSession(session: UseSmileIDFaceSession) {
82
+ session.close()
83
+ }
84
+
85
+ override fun buildResult(
86
+ scanResult: UseSmileIDFaceScanResult,
87
+ events: List<NativeCaptureReadyEvent>,
88
+ ): Map<String, Any?> = buildWireResult(scanResult, events)
89
+
90
+ override fun onDetached() {
91
+ FaceAnalyzerFactoryRegistry.unregister(KEY)
92
+ }
93
+
94
+ override fun onSessionCreated(session: UseSmileIDFaceSession) {
95
+ UseSmileIDMlkitFaceSessionHolder.session = session
96
+ }
97
+
98
+ override fun onSessionClosed() {
99
+ UseSmileIDMlkitFaceSessionHolder.session = null
100
+ }
101
+
102
+ override fun holderSessionDispatcher() = UseSmileIDMlkitFaceSessionHolder.sessionDispatcher
103
+ }
104
+
105
+ override fun definition() = ModuleDefinition {
106
+ // Must match the JS-side `requireNativeModule('UseSmileIDMlkitFace')`
107
+ // call in `src/index.ts`. Drift here = "Cannot find native
108
+ // module" runtime error in partner apps.
109
+ Name("UseSmileIDMlkitFace")
110
+
111
+ OnCreate {
112
+ // Register this backend's factory with the shared process-wide
113
+ // registry so partners who ship multiple backends (e.g.
114
+ // mlkit-face + huawei-face) can select among them at runtime via
115
+ // the `analyzerFactoryKey` DSL field.
116
+ FaceAnalyzerFactoryRegistry.register(KEY) { FaceDetectorAnalyzer() }
117
+ val ctx = appContext.reactContext
118
+ ?: throw IllegalStateException("Module not attached to a React context")
119
+ executor.onAttach(ctx)
120
+
121
+ // Guard against duplicate registration on dev-client hot reloads
122
+ // where OnCreate can fire more than once in the same process.
123
+ if (!pluginRegistered) {
124
+ pluginRegistered = true
125
+ FrameProcessorPluginRegistry.addFrameProcessorPlugin(
126
+ FRAME_PROCESSOR_PLUGIN_NAME,
127
+ ) { _, _ -> UseSmileIDMlkitFaceFrameProcessorPlugin() }
128
+ }
129
+ }
130
+
131
+ AsyncFunction("configure") { config: Map<String, Any?> ->
132
+ executor.handleConfigure(config)
133
+ }
134
+
135
+ AsyncFunction("reset") {
136
+ executor.handleReset()
137
+ }
138
+
139
+ AsyncFunction("flush") { promise: Promise ->
140
+ executor.handleFlush { result ->
141
+ result.fold(
142
+ onSuccess = { promise.resolve(null) },
143
+ onFailure = { promise.reject("FLUSH_FAILED", it.message, it) },
144
+ )
145
+ }
146
+ }
147
+
148
+ AsyncFunction("close") {
149
+ executor.handleClose()
150
+ }
151
+
152
+ OnDestroy {
153
+ executor.onDetach()
154
+ }
155
+ }
156
+
157
+ companion object {
158
+ /** Wire-format key partners pass via `FaceConfig.analyzerFactoryKey`. */
159
+ const val KEY: String = "mlkit-face"
160
+
161
+ /**
162
+ * Must match the JS worklet's `VisionCameraProxy.initFrameProcessorPlugin(...)`
163
+ * call exactly — drift is a runtime "plugin not found" error.
164
+ */
165
+ const val FRAME_PROCESSOR_PLUGIN_NAME: String = "UseSmileIDMlkitFaceFrameProcessor"
166
+
167
+ /**
168
+ * [FrameProcessorPluginRegistry.addFrameProcessorPlugin] asserts uniqueness
169
+ * and crashes on duplicate registration; this guard prevents that on dev-client
170
+ * hot reloads where [OnCreate] fires more than once per process.
171
+ */
172
+ @Volatile
173
+ private var pluginRegistered: Boolean = false
174
+
175
+ /** Wire-result builder shared with [UseSmileIDMlkitFaceFrameProcessorPlugin]. */
176
+ internal fun buildWireResult(
177
+ scanResult: UseSmileIDFaceScanResult,
178
+ events: List<NativeCaptureReadyEvent>,
179
+ ): Map<String, Any?> = mapOf(
180
+ "output" to (
181
+ (scanResult.output as? NativeFaceOutput)?.toWireMap()
182
+ ?: mapOf("faces" to emptyList<Any>(), "brightness" to 0.0)
183
+ ),
184
+ "state" to scanResult.state.toWireMap(),
185
+ "capturedEvents" to events.map { it.toWireMap() },
186
+ )
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Adapt the partner-supplied JS object (a `Map<String, Any?>` after
192
+ * Expo Modules' bridge marshalling) into the strongly-typed
193
+ * `FaceScanConfig` the native session expects. Every field is
194
+ * nullable on the wire; the fallbacks below are the production
195
+ * defaults.
196
+ */
197
+ private fun Map<String, Any?>.toFaceScanConfig(): FaceScanConfig {
198
+ // Resolve the preset token to this backend's engine-calibrated base, then
199
+ // overlay any explicitly-set threshold field on top.
200
+ val base = FaceDetectorMode.fromToken(this["faceDetectorMode"] as? String).config
201
+ return FaceScanConfig(
202
+ selfieCaptureTimeout = (this["selfieCaptureTimeout"] as? Number)?.toInt() ?: 100_000,
203
+ // Default yields 1 selfie + 7 liveness frames — the server requires
204
+ // `liveness_images >= 6`, so anything below 7 causes submission rejection.
205
+ numSamples = (this["numSamples"] as? Number)?.toInt() ?: 8,
206
+ sampleInterval = (this["sampleInterval"] as? Number)?.toInt() ?: 350,
207
+ stayInFoundDuration = (this["stayInFoundDuration"] as? Number)?.toInt() ?: 3_000,
208
+ minFillThreshold = (this["minFillThreshold"] as? Number)?.toFloat() ?: base.minFillThreshold,
209
+ maxFillThreshold = (this["maxFillThreshold"] as? Number)?.toFloat() ?: base.maxFillThreshold,
210
+ faceDetectorMinScore = (this["faceDetectorMinScore"] as? Number)?.toFloat() ?: base.faceDetectorMinScore,
211
+ centerToleranceX = (this["centerToleranceX"] as? Number)?.toFloat() ?: base.centerToleranceX,
212
+ centerToleranceY = (this["centerToleranceY"] as? Number)?.toFloat() ?: base.centerToleranceY,
213
+ motionBlurMinIou = (this["motionBlurMinIou"] as? Number)?.toFloat() ?: base.motionBlurMinIou,
214
+ motionBlurMinDuration = (this["motionBlurMinDuration"] as? Number)?.toLong() ?: base.motionBlurMinDuration,
215
+ minTopPadding = (this["minTopPadding"] as? Number)?.toFloat() ?: base.minTopPadding,
216
+ minFaceBoundsThreshold = (this["minFaceBoundsThreshold"] as? Number)?.toFloat() ?: base.minFaceBoundsThreshold,
217
+ maxFaceBoundsThreshold = (this["maxFaceBoundsThreshold"] as? Number)?.toFloat() ?: base.maxFaceBoundsThreshold,
218
+ minSmileProbability = (this["minSmileProbability"] as? Number)?.toFloat() ?: base.minSmileProbability,
219
+ enableEnhancedLiveness = (this["enableEnhancedLiveness"] as? Boolean) ?: false,
220
+ )
221
+ }
222
+
223
+ /**
224
+ * Convert the native session's typed result into the JS-side wire
225
+ * shape declared in `@usesmileid/ml`. Keys must match
226
+ * `faceDetectorOutputSchema` field-for-field — the JS-side
227
+ * `parseFaceScanResult` parse fails loud on any drift.
228
+ */
229
+ private fun NativeFaceOutput.toWireMap(): Map<String, Any?> {
230
+ val primaryFace = faces.maxByOrNull { it.boundingBox.area }
231
+ return buildMap {
232
+ put("faces", faces.map { it.boundingBox.toWireMap() })
233
+ // Omit absent optional fields; never put null (Zod .optional() rejects explicit null).
234
+ primaryFace?.headPose?.let { put("headPose", it.toWireMap()) }
235
+ primaryFace?.smilingProbability?.let { put("smilingProbability", it.toDouble()) }
236
+ put("brightness", (primaryFace?.brightness ?: 0f).toDouble())
237
+ }
238
+ }
239
+
240
+ private fun NativeBoundingBox.toWireMap(): Map<String, Any?> = mapOf(
241
+ "left" to left.toDouble(),
242
+ "top" to top.toDouble(),
243
+ "width" to width.toDouble(),
244
+ "height" to height.toDouble(),
245
+ "score" to score.toDouble(),
246
+ )
247
+
248
+ private fun HeadPoseResult.toWireMap(): Map<String, Any?> = mapOf(
249
+ "yaw" to yaw.toDouble(),
250
+ "pitch" to pitch.toDouble(),
251
+ "roll" to roll.toDouble(),
252
+ )
253
+
254
+ /**
255
+ * Map the native state-machine type into the wire-string the JS-side
256
+ * `scanStateTypeSchema` enum accepts. Keep in lock-step with the
257
+ * `ScanStateType` enum order so cross-platform analytics/dashboards
258
+ * see consistent values.
259
+ */
260
+ private fun UseSmileIDScanState.toWireMap(): Map<String, Any?> = buildMap {
261
+ put(
262
+ "type",
263
+ when (type) {
264
+ UseSmileIDScanStateType.INITIAL -> "initial"
265
+ UseSmileIDScanStateType.FOUND -> "found"
266
+ UseSmileIDScanStateType.SATISFIED -> "satisfied"
267
+ UseSmileIDScanStateType.UNSATISFIED -> "unsatisfied"
268
+ UseSmileIDScanStateType.FINISHED -> "finished"
269
+ UseSmileIDScanStateType.TIMEOUT -> "timeout"
270
+ UseSmileIDScanStateType.HEAD_TURN -> "headTurn"
271
+ },
272
+ )
273
+ // Absent optional fields are OMITTED, never set to null:
274
+ // Zod .optional() rejects explicit null; the bridge maps Kotlin null → JS null.
275
+ feedbackName?.let { put("feedback", it) }
276
+ unsatisfiedReason?.let { put("reason", it) }
277
+ // Head-turn direction for Enhanced Smart Selfie guidance.
278
+ currentDirection?.let { put("currentDirection", it.name.lowercase()) }
279
+ captureProgress?.let { put("captureProgress", it.toDouble()) }
280
+ // Latched pose-match flag for the ESS head turn — drives the grey→green
281
+ // direction arrow. Emit only while matched (omit ⇒ false on the JS side),
282
+ // matching the optional-field omission convention above.
283
+ if (poseMatched) put("poseMatched", true)
284
+ }
285
+
286
+ /**
287
+ * Wire-shape for [CaptureReadyEvent] — keys match the JS-side
288
+ * `captureReadyEventSchema` field-for-field. The native event carries a
289
+ * `java.io.File`; we serialise as a `file://` URL so the JS partner can
290
+ * hand the string straight to `Image` / upload pipelines without further
291
+ * processing.
292
+ */
293
+ private fun NativeCaptureReadyEvent.toWireMap(): Map<String, Any?> = when (this) {
294
+ is NativeCaptureReadyEvent.SelfieFrame -> mapOf(
295
+ "kind" to "selfie",
296
+ "captureIndex" to captureIndex,
297
+ "url" to file.toURI().toString(),
298
+ )
299
+ is NativeCaptureReadyEvent.LivenessFrame -> mapOf(
300
+ "kind" to "liveness",
301
+ "captureIndex" to captureIndex,
302
+ "url" to file.toURI().toString(),
303
+ )
304
+ is NativeCaptureReadyEvent.DocumentFrame -> mapOf(
305
+ "kind" to "document",
306
+ "captureIndex" to captureIndex,
307
+ "url" to file.toURI().toString(),
308
+ )
309
+ }
@@ -0,0 +1,27 @@
1
+ package com.usesmileid.mlkit.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] (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 UseSmileIDMlkitFaceSessionHolder {
18
+
19
+ /** Single-thread dispatcher; serializes all session operations without explicit locking. */
20
+ val sessionDispatcher: CoroutineDispatcher =
21
+ Executors.newSingleThreadExecutor { runnable ->
22
+ Thread(runnable, "usesmileid-mlkit-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.mlkitface
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,281 @@
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.mlkitface
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
+ * Build a [ResizingFrameStorage] from the wire config map, applying
254
+ * token-resolved dimensions and JPEG quality when present. All values
255
+ * fall back to SDK defaults when absent or not a [Number].
256
+ */
257
+ private fun buildResizingFrameStorage(
258
+ sessionDir: File,
259
+ documentSide: DocumentSide,
260
+ config: Map<*, *>?,
261
+ ): ResizingFrameStorage = ResizingFrameStorage(
262
+ sessionDirectory = sessionDir,
263
+ documentSide = documentSide,
264
+ selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
265
+ selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
266
+ livenessTargetWidth = (config?.get("livenessImageWidth") as? Number)?.toInt() ?: 480,
267
+ livenessTargetHeight = (config?.get("livenessImageHeight") as? Number)?.toInt() ?: 640,
268
+ selfieQuality = (config?.get("selfieJpegQuality") as? Number)?.toInt() ?: 95,
269
+ livenessQuality = (config?.get("livenessJpegQuality") as? Number)?.toInt() ?: 80,
270
+ documentQuality = (config?.get("documentJpegQuality") as? Number)?.toInt() ?: 85,
271
+ )
272
+
273
+ private fun freshSessionDirectory(): File {
274
+ val root = checkNotNull(storageRoot) {
275
+ "Executor not attached — call onAttach(context) from the Expo Module's OnCreate block first"
276
+ }
277
+ val dir = File(root, System.currentTimeMillis().toString())
278
+ dir.mkdirs()
279
+ return dir
280
+ }
281
+ }
@@ -0,0 +1,33 @@
1
+ import type { FaceConfig, FaceScanResult } from "@smileid/usesmileid_bridge";
2
+ /**
3
+ * Google ML Kit face analyzer (Android-only).
4
+ *
5
+ * Thin shim around the existing native session
6
+ * `com.usesmileid.bridge.session.UseSmileIDFaceSession(FaceDetectorAnalyzer())`
7
+ * shipped in the `com.smileidentity:mlkit-face` AAR. The native side
8
+ * runs the entire pipeline (analyzer + validator + transitioner +
9
+ * state machine); this TS layer only routes calls and validates wire
10
+ * payloads at the channel boundary.
11
+ *
12
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
13
+ * `usesmileid_platform_interface`. This file only supplies the
14
+ * provider-specific identity so all six provider modules share one set
15
+ * of correctness properties by construction.
16
+ *
17
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
18
+ * processor plugin → `reset()`? → `close()`. Calling
19
+ * `close()` twice is idempotent. See `usesmileid_platform_interface`
20
+ * `FaceAnalyzer` for the canonical contract.
21
+ */
22
+ /**
23
+ * Must match the Kotlin Expo Module's `Name("UseSmileIDMlkitFace")` 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 declare const UseSmileIDMlkitFaceIdentifiers: {
28
+ readonly nativeModuleName: "UseSmileIDMlkitFace";
29
+ readonly frameProcessorPluginName: "UseSmileIDMlkitFaceFrameProcessor";
30
+ };
31
+ export declare const useSmileIDMlkitFace: import("@smileid/usesmileid_platform_interface").FaceAnalyzer;
32
+ export type { FaceConfig, FaceScanResult };
33
+ //# 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;;;;;;;;;;;;;;;;;;;GAmBG;AACH;;;;GAIG;AACH,eAAO,MAAM,8BAA8B;;;CAGjC,CAAC;AAEX,eAAO,MAAM,mBAAmB,+DAM9B,CAAC;AAKH,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { AnalyzerFactoryKey } from "@smileid/usesmileid_bridge";
2
+ import { useSmileIDAnalyzerExecutor } from "@smileid/usesmileid_platform_interface";
3
+ /**
4
+ * Google ML Kit face analyzer (Android-only).
5
+ *
6
+ * Thin shim around the existing native session
7
+ * `com.usesmileid.bridge.session.UseSmileIDFaceSession(FaceDetectorAnalyzer())`
8
+ * shipped in the `com.smileidentity:mlkit-face` AAR. The native side
9
+ * runs the entire pipeline (analyzer + validator + transitioner +
10
+ * state machine); this TS layer only routes calls and validates wire
11
+ * payloads at the channel boundary.
12
+ *
13
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
14
+ * `usesmileid_platform_interface`. This file only supplies the
15
+ * provider-specific identity so all six provider modules share one set
16
+ * of correctness properties by construction.
17
+ *
18
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
19
+ * processor plugin → `reset()`? → `close()`. Calling
20
+ * `close()` twice is idempotent. See `usesmileid_platform_interface`
21
+ * `FaceAnalyzer` for the canonical contract.
22
+ */
23
+ /**
24
+ * Must match the Kotlin Expo Module's `Name("UseSmileIDMlkitFace")` and its
25
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
26
+ * "module/plugin not found" error, not a compile error.
27
+ */
28
+ export const UseSmileIDMlkitFaceIdentifiers = {
29
+ nativeModuleName: "UseSmileIDMlkitFace",
30
+ frameProcessorPluginName: "UseSmileIDMlkitFaceFrameProcessor",
31
+ };
32
+ export const useSmileIDMlkitFace = useSmileIDAnalyzerExecutor({
33
+ nativeModuleName: UseSmileIDMlkitFaceIdentifiers.nativeModuleName,
34
+ frameProcessorPluginName: UseSmileIDMlkitFaceIdentifiers.frameProcessorPluginName,
35
+ kind: "face",
36
+ key: AnalyzerFactoryKey.MLKitFace,
37
+ });
38
+ //# 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;;;;;;;;;;;;;;;;;;;GAmBG;AACH;;;;GAIG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG;IAC5C,gBAAgB,EAAE,qBAAqB;IACvC,wBAAwB,EAAE,mCAAmC;CACrD,CAAC;AAEX,MAAM,CAAC,MAAM,mBAAmB,GAAG,0BAA0B,CAAC;IAC5D,gBAAgB,EAAE,8BAA8B,CAAC,gBAAgB;IACjE,wBAAwB,EACtB,8BAA8B,CAAC,wBAAwB;IACzD,IAAI,EAAE,MAAM;IACZ,GAAG,EAAE,kBAAkB,CAAC,SAAS;CAClC,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": ["com.usesmileid.mlkit.face.UseSmileIDMlkitFaceModule"]
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@smileid/usesmileid_mlkit_face",
3
+ "version": "12.0.0-beta01",
4
+ "description": "UseSmileID v12 — Google ML Kit face provider (Android-only). Thin Expo Module shim around the existing native AAR `com.smileidentity:mlkit-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_mlkit_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,101 @@
1
+ import { describe, expect, it, vi, beforeEach, type Mock } from "vitest";
2
+
3
+ // Hoist the mock so `requireNativeModule` resolves to a stub before
4
+ // the index module's top-level `requireNativeModule(...)` call runs.
5
+ // Without `vi.hoisted(...)`, Vitest evaluates the import statement
6
+ // before the mock is in scope and the wrapper grabs the real native
7
+ // module — which doesn't exist in a Node test environment.
8
+ const native = vi.hoisted(() => ({
9
+ configure: vi.fn(),
10
+ reset: vi.fn(),
11
+ close: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("expo-modules-core", () => ({
15
+ requireNativeModule: vi.fn(() => native),
16
+ }));
17
+
18
+ // Imports happen *after* the mock is registered.
19
+ import { requireNativeModule } from "expo-modules-core";
20
+ import { useSmileIDMlkitFace, UseSmileIDMlkitFaceIdentifiers } from "../index";
21
+
22
+ describe("useSmileIDMlkitFace — TS wrapper", () => {
23
+ beforeEach(() => {
24
+ Object.values(native).forEach((m) => (m as Mock).mockReset());
25
+ });
26
+
27
+ it("looks up the native module under the contract-stable name", () => {
28
+ // The Kotlin Expo Module declares `Name("UseSmileIDMlkitFace")`.
29
+ // If the JS wrapper passes a different string, partner apps fail
30
+ // at runtime with "Cannot find native module 'X'".
31
+ expect(requireNativeModule).toHaveBeenCalledWith("UseSmileIDMlkitFace");
32
+ });
33
+
34
+ it("exposes the lifecycle surface — no per-frame analyze method", () => {
35
+ expect(typeof useSmileIDMlkitFace.configure).toBe("function");
36
+ expect(typeof useSmileIDMlkitFace.reset).toBe("function");
37
+ expect(typeof useSmileIDMlkitFace.close).toBe("function");
38
+ // Per-frame analysis lives in the native VisionCamera frame
39
+ // processor plugin — raw pixels never cross the JS bridge.
40
+ expect("analyze" in useSmileIDMlkitFace).toBe(false);
41
+ });
42
+
43
+ it("exposes the kind discriminator and frame processor plugin name", () => {
44
+ expect(useSmileIDMlkitFace.kind).toBe("face");
45
+ expect(useSmileIDMlkitFace.frameProcessorPluginName).toBe(
46
+ UseSmileIDMlkitFaceIdentifiers.frameProcessorPluginName,
47
+ );
48
+ });
49
+
50
+ describe("configure", () => {
51
+ it("passes the validated config through to native", async () => {
52
+ native.configure.mockResolvedValueOnce(undefined);
53
+ await useSmileIDMlkitFace.configure({ selfieCaptureTimeout: 60_000 });
54
+ expect(native.configure).toHaveBeenCalledTimes(1);
55
+ expect(native.configure).toHaveBeenCalledWith({
56
+ selfieCaptureTimeout: 60_000,
57
+ });
58
+ });
59
+
60
+ it("rejects malformed config at the JS boundary before crossing the bridge", async () => {
61
+ // Concrete scenario: a partner builds a config from float math
62
+ // and passes `selfieCaptureTimeout: 60_000.5`. Without the
63
+ // boundary parse, the value would marshal to a Kotlin `Int`
64
+ // with silent truncation. With it, the JS layer throws here
65
+ // and `native.configure` is never called.
66
+ await expect(
67
+ useSmileIDMlkitFace.configure({
68
+ // @ts-expect-error — deliberately wrong: int field with float value
69
+ selfieCaptureTimeout: 60_000.5,
70
+ }),
71
+ ).rejects.toThrow();
72
+ expect(native.configure).not.toHaveBeenCalled();
73
+ });
74
+ });
75
+
76
+ describe("reset", () => {
77
+ it("routes to native reset", async () => {
78
+ native.reset.mockResolvedValueOnce(undefined);
79
+ await useSmileIDMlkitFace.reset();
80
+ expect(native.reset).toHaveBeenCalledTimes(1);
81
+ });
82
+ });
83
+
84
+ describe("close", () => {
85
+ it("routes to native close", async () => {
86
+ native.close.mockResolvedValueOnce(undefined);
87
+ await useSmileIDMlkitFace.close();
88
+ expect(native.close).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it("is idempotent — partner can call it twice safely", async () => {
92
+ // Idempotency contract from FaceAnalyzer doc-comment. The native
93
+ // side handles the second close as a no-op; the wrapper just
94
+ // needs to not throw on re-entry.
95
+ native.close.mockResolvedValue(undefined);
96
+ await useSmileIDMlkitFace.close();
97
+ await useSmileIDMlkitFace.close();
98
+ expect(native.close).toHaveBeenCalledTimes(2);
99
+ });
100
+ });
101
+ });
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
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
+ * Google ML Kit face analyzer (Android-only).
7
+ *
8
+ * Thin shim around the existing native session
9
+ * `com.usesmileid.bridge.session.UseSmileIDFaceSession(FaceDetectorAnalyzer())`
10
+ * shipped in the `com.smileidentity:mlkit-face` AAR. The native side
11
+ * runs the entire pipeline (analyzer + validator + transitioner +
12
+ * state machine); this TS layer only routes calls and validates wire
13
+ * payloads at the channel boundary.
14
+ *
15
+ * The canonical wiring lives in [useSmileIDAnalyzerExecutor] in
16
+ * `usesmileid_platform_interface`. This file only supplies the
17
+ * provider-specific identity so all six provider modules share one set
18
+ * of correctness properties by construction.
19
+ *
20
+ * Lifecycle: `configure()` → per-frame analysis via the native frame
21
+ * processor plugin → `reset()`? → `close()`. Calling
22
+ * `close()` twice is idempotent. See `usesmileid_platform_interface`
23
+ * `FaceAnalyzer` for the canonical contract.
24
+ */
25
+ /**
26
+ * Must match the Kotlin Expo Module's `Name("UseSmileIDMlkitFace")` and its
27
+ * VisionCamera frame-processor-plugin registration — drift is a runtime
28
+ * "module/plugin not found" error, not a compile error.
29
+ */
30
+ export const UseSmileIDMlkitFaceIdentifiers = {
31
+ nativeModuleName: "UseSmileIDMlkitFace",
32
+ frameProcessorPluginName: "UseSmileIDMlkitFaceFrameProcessor",
33
+ } as const;
34
+
35
+ export const useSmileIDMlkitFace = useSmileIDAnalyzerExecutor({
36
+ nativeModuleName: UseSmileIDMlkitFaceIdentifiers.nativeModuleName,
37
+ frameProcessorPluginName:
38
+ UseSmileIDMlkitFaceIdentifiers.frameProcessorPluginName,
39
+ kind: "face",
40
+ key: AnalyzerFactoryKey.MLKitFace,
41
+ });
42
+
43
+ // Re-export the contract types so consumers don't need a separate
44
+ // import line for `FaceConfig` / `FaceScanResult` when they're using
45
+ // this provider.
46
+ export type { FaceConfig, FaceScanResult };