@smileid/usesmileid_huawei_face 12.1.1 → 12.2.1-SNAPSHOT.44

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 CHANGED
@@ -1,15 +1,17 @@
1
1
  Full release notes: [Smile ID docs](https://docs.usesmileid.com/developer-resources/sdks/mobile/release-notes/react-native-expo).
2
2
 
3
+ ## 12.2.0
4
+
5
+ See the repository CHANGELOG for the 12.2.0 release notes.
6
+
3
7
  ## 12.1.1
4
8
 
5
9
  See the repository CHANGELOG for the 12.1.1 release notes.
6
10
 
7
-
8
11
  ## 12.1.0
9
12
 
10
13
  See the repository CHANGELOG for the 12.1.0 release notes.
11
14
 
12
-
13
15
  ## 12.0.2
14
16
 
15
17
  See the repository CHANGELOG for the 12.0.2 release notes.
package/README.md CHANGED
@@ -24,9 +24,23 @@ pnpm add @smileid/usesmileid_huawei_face
24
24
  ```
25
25
 
26
26
  Expo's autolinking picks up `expo-module.config.json` at app build
27
- time. Partner apps shipping to Huawei AppGallery will additionally
28
- need the Huawei HMS Maven mirror in their root `build.gradle.kts` —
29
- see the Huawei HMS developer guides.
27
+ time, and the module adds Huawei's Maven repository to the build itself.
28
+
29
+ Add the config plugin to the app's `app.json`:
30
+
31
+ ```json
32
+ {
33
+ "expo": {
34
+ "plugins": ["@smileid/usesmileid_huawei_face"]
35
+ }
36
+ }
37
+ ```
38
+
39
+ HMS's `ml-computer-vision-base` declares `android:allowBackup="false"`, and
40
+ Expo writes the app's own `android.allowBackup` (`true` unless the app turns
41
+ it off), so the manifest merge fails without the plugin. The plugin marks the
42
+ app's value with `tools:replace="android:allowBackup"`, so the app's choice
43
+ stands. A bare React Native app adds that marker to its own manifest.
30
44
 
31
45
  ## Usage
32
46
 
@@ -17,7 +17,7 @@ plugins {
17
17
  }
18
18
 
19
19
  group = 'com.usesmileid.huawei.face'
20
- version = '12.1.1'
20
+ version = '12.2.0'
21
21
 
22
22
  // HMS ML is published only to Huawei's own Maven repository — none of the
23
23
  // com.huawei.hms:ml-computer-vision-* artifacts exist on Maven Central. Expo
@@ -56,7 +56,7 @@ android {
56
56
  // android/build.gradle and the rest of the @expo/* modules.
57
57
  defaultConfig {
58
58
  versionCode 1
59
- versionName '12.1.1'
59
+ versionName '12.2.0'
60
60
  // R8 keep-rules to bundle into partner apps that depend on this
61
61
  // module. Without these, R8 may rename the Expo module entry
62
62
  // point and the JS-bridged member functions, breaking autolinking.
@@ -68,16 +68,36 @@ android {
68
68
  dependencies {
69
69
  // kamera native core: the analyzer executor takes analysis frames from
70
70
  // kamera's KameraFrameHub so pixels never cross the JS bridge. Resolves from
71
- // Maven Central; a local publishToMavenLocal build wins (see kamera.md).
72
- implementation 'com.usesmileid:kamera:1.0.6'
71
+ // Maven Central, or its snapshots repository for a timestamped snapshot build like this one, the
72
+ // build the Android SDK pins (see kamera.md).
73
+ implementation 'com.usesmileid:kamera:1.1.1-20261011.222529-5'
74
+
75
+ // The analyzer executor and frame storage every provider module shares, from the bridge module.
76
+ implementation project(':smileid-usesmileid_bridge')
73
77
 
74
78
  // Existing Smile ID native AAR — the entire ML pipeline lives here.
75
79
  // Targets Huawei AppGallery / no-GMS devices via HMS ML kit. The
76
80
  // Expo Module is a thin Kotlin shim that constructs the session
77
81
  // and bridges its four methods to JS via Expo Modules.
78
- implementation 'com.usesmileid:huawei-face:12.1.1'
82
+ implementation 'com.usesmileid:huawei-face:12.2.0-20261012.004641-204'
83
+ // The bridge build the bridge module ships. The provider's SDK artifact names its bridge too, and a
84
+ // snapshot names the moving x.y.z-SNAPSHOT, so this keeps the provider compiling against the build
85
+ // the app runs.
86
+ implementation 'com.usesmileid:bridge:12.2.0-20261012.004641-204'
87
+
88
+
89
+ }
79
90
 
80
91
 
92
+ // The provider compiles against the executor the bridge module ships, so the two must be one release:
93
+ // a mixed install would build this provider against a bridge that lacks what it needs.
94
+ def smileIdPackageVersion = { File packageDir -> new groovy.json.JsonSlurper().parse(new File(packageDir, 'package.json')).version }
95
+ def bridgeVersion = smileIdPackageVersion(project(':smileid-usesmileid_bridge').projectDir.parentFile)
96
+ def providerVersion = smileIdPackageVersion(projectDir.parentFile)
97
+ if (bridgeVersion != providerVersion) {
98
+ throw new GradleException(
99
+ "@smileid/usesmileid_bridge is $bridgeVersion but ${project.name} is $providerVersion: install every @smileid package at one version."
100
+ )
81
101
  }
82
102
 
83
103
  // kamera resolves from Maven Central. mavenLocal stays injected — content-filtered to
@@ -17,6 +17,3 @@
17
17
  public static **[] values();
18
18
  public static ** valueOf(java.lang.String);
19
19
  }
20
-
21
- # ─── Shared RN analyzer-executor namespace ──────────────────────────────────
22
- -keep class com.usesmileid.rn.analyzer.huaweiface.** { *; }
@@ -1,42 +1,44 @@
1
1
  package com.usesmileid.huawei.face
2
2
 
3
- import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
3
+ import com.usesmileid.kamera.frame.KameraFrame
4
4
  import com.usesmileid.kamera.integration.KameraFrameHub
5
- import com.usesmileid.rn.analyzer.huaweiface.toNativeYuvFrame
6
5
  import kotlin.coroutines.cancellation.CancellationException
7
6
  import kotlinx.coroutines.runBlocking
8
7
  import kotlinx.coroutines.withContext
9
8
 
10
9
  /**
11
10
  * Scans the latest [KameraFrameHub] frame natively; no pending frame returns the `no_frame` wire error.
12
- * The frame's rotationDegrees already uprights the buffer to the locked interface axis.
11
+ * The frame's rotationDegrees already uprights the buffer to the locked interface axis; the frame
12
+ * stays open until its scan returns.
13
13
  */
14
- internal fun analyzeKameraFrameTick(arguments: Map<String, Any?>?): Any {
15
- val frame = KameraFrameHub.take() ?: return mapOf("error" to "no_frame")
16
- val yuvFrame = try {
17
- frame.toNativeYuvFrame()
18
- } finally {
19
- // Closing releases the frame to the pipeline; the converted copy owns its own bytes.
20
- frame.close()
21
- }
14
+ internal fun analyzeKameraFrameTick(
15
+ arguments: Map<String, Any?>?,
16
+ takeFrame: () -> KameraFrame? = KameraFrameHub::take,
17
+ ): Any {
18
+ val frame = takeFrame() ?: return mapOf("error" to "no_frame")
19
+ // The session reads the live frame in place, as the native capture screens do; closing it once
20
+ // the tick ends, on every path, releases it to the pipeline for the next one.
21
+ try {
22
+ // Device-tilt context (0 = upright portrait) for the validators.
23
+ val jsRotation = (arguments?.get("rotation") as? Number)?.toInt() ?: 0
22
24
 
23
- // Device-tilt context (0 = upright portrait) for the validators.
24
- val jsRotation = (arguments?.get("rotation") as? Number)?.toInt() ?: 0
25
+ return runBlocking {
26
+ withContext(UseSmileIDHuaweiFaceSessionHolder.sessionDispatcher) {
27
+ val activeSession = UseSmileIDHuaweiFaceSessionHolder.session
28
+ ?: return@withContext mapOf("error" to "session_not_configured")
25
29
 
26
- return runBlocking {
27
- withContext(UseSmileIDHuaweiFaceSessionHolder.sessionDispatcher) {
28
- val activeSession = UseSmileIDHuaweiFaceSessionHolder.session
29
- ?: return@withContext mapOf("error" to "session_not_configured")
30
-
31
- try {
32
- val scanResult = activeSession.scan(yuvFrame, jsRotation)
33
- val events = activeSession.drainCaptureReadyEvents()
34
- UseSmileIDHuaweiFaceModule.buildWireResult(scanResult, events)
35
- } catch (cancellation: CancellationException) {
36
- throw cancellation
37
- } catch (e: Exception) {
38
- mapOf("error" to ("scan_failed: " + (e.message ?: e.javaClass.simpleName)))
30
+ try {
31
+ val scanResult = activeSession.scan(frame, jsRotation)
32
+ val events = activeSession.drainCaptureReadyEvents()
33
+ UseSmileIDHuaweiFaceModule.buildWireResult(scanResult, events)
34
+ } catch (cancellation: CancellationException) {
35
+ throw cancellation
36
+ } catch (e: Exception) {
37
+ mapOf("error" to ("scan_failed: " + (e.message ?: e.javaClass.simpleName)))
38
+ }
39
39
  }
40
40
  }
41
+ } finally {
42
+ frame.close()
41
43
  }
42
44
  }
@@ -9,15 +9,18 @@ import com.usesmileid.bridge.model.HeadPoseResult
9
9
  import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
10
10
  import com.usesmileid.bridge.registry.FaceAnalyzerFactoryRegistry
11
11
  import com.usesmileid.bridge.session.FaceScanConfig
12
+ import com.usesmileid.bridge.session.UseSmileIDCaptureStep
12
13
  import com.usesmileid.bridge.session.UseSmileIDFaceScanResult
13
14
  import com.usesmileid.bridge.session.UseSmileIDFaceSession
14
15
  import com.usesmileid.bridge.session.UseSmileIDScanState
15
16
  import com.usesmileid.bridge.session.UseSmileIDScanStateType
16
17
  import com.usesmileid.bridge.storage.FrameStorage
17
- import com.usesmileid.rn.analyzer.huaweiface.UseSmileIDAnalyzerExecutor
18
+ import com.usesmileid.bridge.model.CaptureType
19
+ import com.usesmileid.rn.analyzer.UseSmileIDAnalyzerExecutor
18
20
  import expo.modules.kotlin.Promise
19
21
  import expo.modules.kotlin.modules.Module
20
22
  import expo.modules.kotlin.modules.ModuleDefinition
23
+ import com.usesmileid.bridge.config.ThresholdConfig
21
24
 
22
25
  /**
23
26
  * Expo Module bridging the existing native session
@@ -37,6 +40,9 @@ class UseSmileIDHuaweiFaceModule : Module() {
37
40
  UseSmileIDFaceScanResult,
38
41
  >() {
39
42
 
43
+ override val storageCaptureType: CaptureType = CaptureType.SELFIE
44
+
45
+
40
46
  override fun createSession(
41
47
  config: Map<String, Any?>,
42
48
  frameStorage: FrameStorage,
@@ -157,12 +163,10 @@ private fun Map<String, Any?>.toFaceScanConfig(): FaceScanConfig {
157
163
  // overlay any explicitly-set threshold field on top.
158
164
  val base = FaceDetectorMode.fromToken(this["faceDetectorMode"] as? String).config
159
165
  return FaceScanConfig(
160
- selfieCaptureTimeout = (this["selfieCaptureTimeout"] as? Number)?.toInt() ?: 100_000,
161
- // Default yields 1 selfie + 7 liveness frames — the server requires
162
- // `liveness_images >= 6`, so anything below 7 causes submission rejection.
163
- numSamples = (this["numSamples"] as? Number)?.toInt() ?: 8,
164
- sampleInterval = (this["sampleInterval"] as? Number)?.toInt() ?: 350,
165
- stayInFoundDuration = (this["stayInFoundDuration"] as? Number)?.toInt() ?: 3_000,
166
+ numSamples = (this["numSamples"] as? Number)?.toInt() ?: ThresholdConfig.FaceCapture.NUM_SAMPLES,
167
+ sampleInterval = (this["sampleInterval"] as? Number)?.toInt() ?: ThresholdConfig.FaceCapture.SAMPLE_INTERVAL_MS,
168
+ stayInFoundDuration = (this["stayInFoundDuration"] as? Number)?.toInt()
169
+ ?: ThresholdConfig.FaceCapture.DEFAULT_STAY_IN_FOUND_DURATION_MS,
166
170
  minFillThreshold = (this["minFillThreshold"] as? Number)?.toFloat() ?: base.minFillThreshold,
167
171
  maxFillThreshold = (this["maxFillThreshold"] as? Number)?.toFloat() ?: base.maxFillThreshold,
168
172
  faceDetectorMinScore = (this["faceDetectorMinScore"] as? Number)?.toFloat() ?: base.faceDetectorMinScore,
@@ -223,10 +227,16 @@ private fun UseSmileIDScanState.toWireMap(): Map<String, Any?> = buildMap {
223
227
  // Head-turn direction for Enhanced Smart Selfie guidance.
224
228
  currentDirection?.let { put("currentDirection", it.name.lowercase()) }
225
229
  captureProgress?.let { put("captureProgress", it.toDouble()) }
226
- // Latched pose-match flag for the ESS head turn — drives the grey→green
227
- // direction arrow. Emit only while matched (omit ⇒ false on the JS side),
230
+ // Latched pose-match flag for the ESS head turn — keeps the direction arrow
231
+ // green through a hold at the threshold. Emit only while matched (omit ⇒ false on the JS side),
228
232
  // matching the optional-field omission convention above.
229
233
  if (poseMatched) put("poseMatched", true)
234
+ when (captureStep) {
235
+ UseSmileIDCaptureStep.HOLD_STILL -> put("captureStep", "holdStill")
236
+ UseSmileIDCaptureStep.SMILE -> put("captureStep", "smile")
237
+ null -> Unit
238
+ }
239
+ turnProgress?.let { put("turnProgress", it.toDouble()) }
230
240
  }
231
241
 
232
242
  private fun NativeCaptureReadyEvent.toWireMap(): Map<String, Any?> = when (this) {
package/app.plugin.js ADDED
@@ -0,0 +1,3 @@
1
+ // Entry point Expo discovers when an app references this package in
2
+ // `expo.plugins`. The compiled plugin lives at `plugin/build/index.js`.
3
+ module.exports = require("./plugin/build");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/usesmileid_huawei_face",
3
- "version": "12.1.1",
3
+ "version": "12.2.1-SNAPSHOT.44",
4
4
  "description": "Huawei HMS face detection implementation for the UseSmileID React Native (Expo) SDK.",
5
5
  "homepage": "https://usesmileid.com",
6
6
  "repository": {
@@ -31,21 +31,24 @@
31
31
  "types": "./dist/index.d.ts",
32
32
  "import": "./dist/index.js",
33
33
  "require": "./dist/index.js"
34
- }
34
+ },
35
+ "./app.plugin.js": "./app.plugin.js"
35
36
  },
36
37
  "files": [
37
38
  "dist",
38
39
  "android/build.gradle",
39
40
  "android/consumer-rules.pro",
40
41
  "android/src/main",
42
+ "plugin/build",
43
+ "app.plugin.js",
41
44
  "expo-module.config.json",
42
45
  "README.md",
43
46
  "LICENSE",
44
47
  "CHANGELOG.md"
45
48
  ],
46
49
  "dependencies": {
47
- "@smileid/usesmileid_bridge": "12.1.1",
48
- "@smileid/usesmileid_platform_interface": "12.1.1"
50
+ "@smileid/usesmileid_bridge": "12.2.1-SNAPSHOT.44",
51
+ "@smileid/usesmileid_platform_interface": "12.2.1-SNAPSHOT.44"
49
52
  },
50
53
  "peerDependencies": {
51
54
  "expo": ">=56.0.0",
@@ -53,7 +56,9 @@
53
56
  "react-native": ">=0.83.0"
54
57
  },
55
58
  "devDependencies": {
56
- "expo-modules-core": "^57.0.16",
59
+ "@types/node": "^26.6.4",
60
+ "expo": "~57.0.26",
61
+ "expo-modules-core": "^57.0.20",
57
62
  "react-native": "^0.86.3",
58
63
  "typescript": "^6.0.3"
59
64
  },
@@ -61,10 +66,12 @@
61
66
  "access": "public"
62
67
  },
63
68
  "scripts": {
64
- "build": "tsc -p tsconfig.json && node ../Scripts/copy-assets.mjs",
65
- "typecheck": "tsc -b",
69
+ "build": "pnpm run build:lib && pnpm run build:plugin && node ../Scripts/copy-assets.mjs",
70
+ "build:lib": "tsc -p tsconfig.json",
71
+ "build:plugin": "tsc -p plugin/tsconfig.json",
72
+ "typecheck": "tsc -b && tsc -p plugin/tsconfig.json --noEmit",
66
73
  "test": "vitest run",
67
74
  "test:watch": "vitest",
68
- "clean": "rm -rf dist .turbo coverage tsconfig.tsbuildinfo"
75
+ "clean": "rm -rf dist plugin/build .turbo coverage tsconfig.tsbuildinfo"
69
76
  }
70
77
  }
@@ -0,0 +1,9 @@
1
+ import { AndroidConfig, ConfigPlugin } from "expo/config-plugins";
2
+ /**
3
+ * Marks the app's `android:allowBackup` with `tools:replace`, so HMS's `false` doesn't
4
+ * fail the merge. An app that doesn't declare the attribute is left alone: `tools:replace`
5
+ * with no value to replace is itself a merge error.
6
+ */
7
+ export declare function keepAppAllowBackup(manifest: AndroidConfig.Manifest.AndroidManifest): AndroidConfig.Manifest.AndroidManifest;
8
+ declare const withHuaweiAllowBackup: ConfigPlugin;
9
+ export default withHuaweiAllowBackup;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.keepAppAllowBackup=keepAppAllowBackup;const config_plugins_1=require("expo/config-plugins"),ALLOW_BACKUP="android:allowBackup",TOOLS_NAMESPACE="http://schemas.android.com/tools";function keepAppAllowBackup(e){const t=e.manifest.application?.[0];if(t?.$?.[ALLOW_BACKUP]===void 0)return e;e.manifest.$["xmlns:tools"]=TOOLS_NAMESPACE;const l=t.$,o=(l["tools:replace"]??"").split(",").map(s=>s.trim()).filter(s=>s.length>0);return o.includes(ALLOW_BACKUP)||o.push(ALLOW_BACKUP),l["tools:replace"]=o.join(","),e}const withHuaweiAllowBackup=e=>(0,config_plugins_1.withAndroidManifest)(e,t=>(t.modResults=keepAppAllowBackup(t.modResults),t));exports.default=withHuaweiAllowBackup;
@@ -1,250 +0,0 @@
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.BoundingBox as NativeBoundingBox
10
- import com.usesmileid.bridge.model.CaptureType
11
- import com.usesmileid.bridge.model.ViewportCropRegion as NativeViewportCropRegion
12
- import com.usesmileid.bridge.model.YuvFrame
13
- import com.usesmileid.bridge.storage.DocumentSide
14
- import com.usesmileid.bridge.storage.FrameKind
15
- import com.usesmileid.bridge.storage.FrameStorage
16
- import com.usesmileid.bridge.storage.writeJpegToFileExact
17
- import com.usesmileid.bridge.util.toArgbBitmap
18
- import java.io.File
19
- import java.io.IOException
20
- import java.util.concurrent.ConcurrentLinkedDeque
21
- import java.util.concurrent.atomic.AtomicReference
22
- import kotlin.math.roundToInt
23
- import kotlinx.coroutines.CoroutineScope
24
- import kotlinx.coroutines.Dispatchers
25
- import kotlinx.coroutines.Job
26
- import kotlinx.coroutines.SupervisorJob
27
- import kotlinx.coroutines.launch
28
- import kotlinx.coroutines.withContext
29
-
30
- /**
31
- * [FrameStorage] implementation that center-crops and scales every selfie and
32
- * liveness JPEG to the configured target dimensions before writing.
33
- *
34
- * Target dimensions are resolved at configure-time from the token
35
- * `sdk_config` claim; the executor falls back to 480 × 640 when the token
36
- * carries no override, so the on-disk pixel size always matches the
37
- * `image_width` / `image_height` values reported in job metadata.
38
- *
39
- * Document frames are decoded upright, then cropped to the guide rectangle when
40
- * a crop region has been published via [setCropRegion] (the on-screen frame the
41
- * user aligned the document into) so the uploaded image matches the capture
42
- * preview. The crop runs at full resolution first; the resulting crop is then
43
- * downscaled so its longest edge is bounded at [DOCUMENT_MAX_DIMENSION] (1600
44
- * px), preserving aspect ratio and never upscaling, before JPEG-encoding at the
45
- * specified [documentQuality]. Cropping before the downscale keeps maximum
46
- * detail in the document region — the bound applies to the crop, not the whole
47
- * frame, so a tightly framed card fills the saved pixels. Document dimensions
48
- * are not part of the token `sdk_config` contract.
49
- *
50
- * Resize and encode run on a private `Dispatchers.IO` scope; [persist] is
51
- * fire-and-forget so the analyzer keeps running at frame rate. [flush] awaits
52
- * all in-flight writes before upload proceeds.
53
- */
54
- internal class ResizingFrameStorage(
55
- private val sessionDirectory: File,
56
- private val captureType: CaptureType,
57
- private val documentSide: DocumentSide = DocumentSide.Front,
58
- private val selfieTargetWidth: Int = 480,
59
- private val selfieTargetHeight: Int = 640,
60
- private val livenessTargetWidth: Int = 480,
61
- private val livenessTargetHeight: Int = 640,
62
- private val selfieQuality: Int = 95,
63
- private val livenessQuality: Int = 80,
64
- private val documentQuality: Int = 85,
65
- ) : FrameStorage {
66
-
67
- companion object {
68
- /**
69
- * Longest-edge bound, in pixels, for the saved document JPEG. The
70
- * guide-rect crop is downscaled so its larger dimension never exceeds
71
- * this value, preserving aspect ratio and never upscaling.
72
- */
73
- private const val DOCUMENT_MAX_DIMENSION = 1600
74
-
75
- /**
76
- * Outward padding fraction applied to the guide-rect crop on each side
77
- * before cropping. Fifteen percent of the box's own width/height keeps
78
- * the document's corners and edges from being clipped when the card is
79
- * framed tight against the on-screen guide.
80
- */
81
- private const val DOCUMENT_CROP_PADDING_FRACTION = 0.15f
82
- }
83
-
84
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
85
- private val pending = ConcurrentLinkedDeque<Job>()
86
- private val firstError = AtomicReference<Throwable?>(null)
87
-
88
- @Volatile
89
- private var cropRegion: NativeViewportCropRegion? = null
90
-
91
- override fun setCropRegion(region: NativeViewportCropRegion?) {
92
- cropRegion = region
93
- }
94
-
95
- override fun fileFor(kind: FrameKind, captureIndex: Int): File = File(
96
- sessionDirectory,
97
- when (kind) {
98
- FrameKind.Selfie -> "selfie.jpg"
99
- FrameKind.Liveness -> "liveness_$captureIndex.jpg"
100
- FrameKind.Document -> when (documentSide) {
101
- DocumentSide.Front -> "document_front.jpg"
102
- DocumentSide.Back -> "document_back.jpg"
103
- }
104
- },
105
- )
106
-
107
- override fun persist(frame: YuvFrame, file: File) {
108
- launchEncode(frame, file, documentCropRegion = null)
109
- }
110
-
111
- override fun persistDocument(frame: YuvFrame, file: File, liveBoundingBox: NativeBoundingBox?) {
112
- // Snapshot the crop region at dispatch time so a concurrent
113
- // setCropRegion(null) between here and the coroutine body can't tear
114
- // the state. liveBoundingBox is unused: the guide rectangle the user
115
- // framed (cropRegion) takes precedence over the live detector box.
116
- launchEncode(frame, file, documentCropRegion = cropRegion)
117
- }
118
-
119
- private fun launchEncode(frame: YuvFrame, file: File, documentCropRegion: NativeViewportCropRegion?) {
120
- val job = scope.launch {
121
- try {
122
- encodeAndWrite(frame, file, documentCropRegion)
123
- } catch (throwable: Throwable) {
124
- firstError.compareAndSet(null, throwable)
125
- throw throwable
126
- }
127
- }
128
- // Add synchronously so flush() cannot race past an in-flight job.
129
- pending.add(job)
130
- }
131
-
132
- override suspend fun flush() {
133
- val jobs = mutableListOf<Job>()
134
- while (true) jobs.add(pending.poll() ?: break)
135
- jobs.forEach { it.join() }
136
- firstError.getAndSet(null)?.let { throw it }
137
- }
138
-
139
- override fun clear() {
140
- // Selfie and document captures share one session directory; delete
141
- // only this role's own frames.
142
- sessionDirectory.listFiles()?.forEach { file ->
143
- if (ownsForClear(file)) file.delete()
144
- }
145
- }
146
-
147
- private fun ownsForClear(file: File): Boolean = when (captureType) {
148
- CaptureType.SELFIE -> {
149
- val kind = frameKindOf(file)
150
- kind == FrameKind.Selfie || kind == FrameKind.Liveness
151
- }
152
- CaptureType.DOCUMENT -> file.name == fileFor(FrameKind.Document, captureIndex = 0).name
153
- }
154
-
155
- private fun frameKindOf(file: File): FrameKind? = when {
156
- file.name.startsWith("selfie") -> FrameKind.Selfie
157
- file.name.startsWith("liveness") -> FrameKind.Liveness
158
- file.name.startsWith("document") -> FrameKind.Document
159
- else -> null
160
- }
161
-
162
- private suspend fun encodeAndWrite(
163
- frame: YuvFrame,
164
- file: File,
165
- documentCropRegion: NativeViewportCropRegion?,
166
- ) {
167
- withContext(Dispatchers.IO) {
168
- file.parentFile?.mkdirs()
169
- val name = file.name
170
- when {
171
- name.startsWith("selfie") -> {
172
- val bitmap = frame.toArgbBitmap(jpegQuality = selfieQuality)
173
- ?: throw IOException("toArgbBitmap returned null for ${file.name}")
174
- try {
175
- bitmap.writeJpegToFileExact(file, selfieTargetWidth, selfieTargetHeight, selfieQuality)
176
- } finally {
177
- bitmap.recycle()
178
- }
179
- }
180
- name.startsWith("liveness") -> {
181
- val bitmap = frame.toArgbBitmap(jpegQuality = livenessQuality)
182
- ?: throw IOException("toArgbBitmap returned null for ${file.name}")
183
- try {
184
- bitmap.writeJpegToFileExact(file, livenessTargetWidth, livenessTargetHeight, livenessQuality)
185
- } finally {
186
- bitmap.recycle()
187
- }
188
- }
189
- else -> {
190
- // Document frames: decode to the upright bitmap, crop to the guide
191
- // rectangle (if a crop region is set) at full resolution, then
192
- // downscale the crop so its longest edge is bounded at
193
- // DOCUMENT_MAX_DIMENSION. Cropping before the downscale keeps maximum
194
- // detail in the document region — the bound applies to the crop, not
195
- // the whole frame, so a tightly framed card fills the saved pixels.
196
- // toFrameBoundingBox returns a normalized 0–1 box in the upright
197
- // frame's coordinate space, mapped to pixels by the bitmap dimensions.
198
- // The box is padded outward by DOCUMENT_CROP_PADDING_FRACTION of its
199
- // own size on each side so the document's corners and edges keep
200
- // breathing room and are never clipped when the card is framed tight
201
- // against the guide.
202
- val source = frame.toArgbBitmap(jpegQuality = documentQuality)
203
- ?: throw IOException("toArgbBitmap returned null for ${file.name}")
204
- val cropped = if (documentCropRegion != null) {
205
- val box = documentCropRegion.toFrameBoundingBox(source.width, source.height)
206
- val padX = box.width * DOCUMENT_CROP_PADDING_FRACTION
207
- val padY = box.height * DOCUMENT_CROP_PADDING_FRACTION
208
- // Clamp left/top to leave room for at least one pixel and derive
209
- // right/bottom from the clamped left/top so the crop never runs past
210
- // the bitmap edge. Bitmap.createBitmap throws IllegalArgumentException
211
- // when x + width exceeds the source width, which is reachable if the
212
- // padded box lands fully off an edge and both bounds collapse onto the
213
- // same coordinate.
214
- val pixelLeft = ((box.left - padX) * source.width).roundToInt().coerceIn(0, source.width - 1)
215
- val pixelTop = ((box.top - padY) * source.height).roundToInt().coerceIn(0, source.height - 1)
216
- val pixelRight = ((box.left + box.width + padX) * source.width).roundToInt().coerceIn(pixelLeft + 1, source.width)
217
- val pixelBottom = ((box.top + box.height + padY) * source.height).roundToInt().coerceIn(pixelTop + 1, source.height)
218
- val cropWidth = (pixelRight - pixelLeft).coerceAtLeast(1)
219
- val cropHeight = (pixelBottom - pixelTop).coerceAtLeast(1)
220
- android.graphics.Bitmap.createBitmap(source, pixelLeft, pixelTop, cropWidth, cropHeight)
221
- } else {
222
- source
223
- }
224
- // Downscale the crop so its longest edge is at most
225
- // DOCUMENT_MAX_DIMENSION, preserving aspect ratio. Skip when the crop
226
- // already fits so a small document is never upscaled.
227
- val longest = maxOf(cropped.width, cropped.height)
228
- val scaled = if (longest > DOCUMENT_MAX_DIMENSION) {
229
- val scale = DOCUMENT_MAX_DIMENSION.toFloat() / longest
230
- val scaledWidth = (cropped.width * scale).roundToInt().coerceAtLeast(1)
231
- val scaledHeight = (cropped.height * scale).roundToInt().coerceAtLeast(1)
232
- android.graphics.Bitmap.createScaledBitmap(cropped, scaledWidth, scaledHeight, true)
233
- } else {
234
- cropped
235
- }
236
- try {
237
- val success = file.outputStream().use {
238
- scaled.compress(android.graphics.Bitmap.CompressFormat.JPEG, documentQuality, it)
239
- }
240
- if (!success) throw IOException("Bitmap.compress(JPEG) failed for ${file.absolutePath}")
241
- } finally {
242
- if (scaled !== cropped) scaled.recycle()
243
- if (cropped !== source) cropped.recycle()
244
- source.recycle()
245
- }
246
- }
247
- }
248
- }
249
- }
250
- }
@@ -1,312 +0,0 @@
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.CaptureType
12
- import com.usesmileid.bridge.model.YuvFrame as NativeYuvFrame
13
- import com.usesmileid.bridge.storage.DocumentSide
14
- import com.usesmileid.bridge.storage.FrameStorage
15
- import com.usesmileid.kamera.frame.KameraFrame
16
- import com.usesmileid.kamera.integration.KameraFrameHub
17
- import java.io.File
18
- import kotlinx.coroutines.CoroutineDispatcher
19
- import kotlinx.coroutines.CoroutineScope
20
- import kotlinx.coroutines.Dispatchers
21
- import kotlinx.coroutines.SupervisorJob
22
- import kotlinx.coroutines.cancel
23
- import kotlinx.coroutines.launch
24
- import kotlinx.coroutines.runBlocking
25
- import kotlinx.coroutines.withContext
26
-
27
- /**
28
- * Generic base for the four RN Expo analyzer modules. Concrete
29
- * subclasses extend this base with their session construction (face vs
30
- * document, mlkit vs huawei) and result-to-wire conversion. The Expo
31
- * Module class owns one instance of a subclass and forwards its async
32
- * functions through it.
33
- *
34
- * Type parameters:
35
- * - [PConfig]: the JS-side config map (always `Map<String, Any?>` after
36
- * Expo bridge marshalling).
37
- * - [PResult]: the JS-side wire result (always `Map<String, Any?>`
38
- * after Expo bridge marshalling).
39
- * - [Session]: the native SDK session class
40
- * (`UseSmileIDFaceSession` / `UseSmileIDDocumentSession`).
41
- * - [ScanResult]: the native SDK scan-result type
42
- * (`UseSmileIDFaceScanResult` / `UseSmileIDDocumentScanResult`).
43
- *
44
- * The base owns the coroutine scope, the on-disk session-frame storage
45
- * root, and the current `session` reference. Subclasses must not
46
- * redeclare these — they would diverge from the lifecycle this base
47
- * manages.
48
- */
49
- abstract class UseSmileIDAnalyzerExecutor<
50
- PConfig : Any,
51
- PResult : Any,
52
- Session,
53
- ScanResult,
54
- > {
55
-
56
- protected var session: Session? = null
57
- private var storageRoot: File? = null
58
-
59
- /**
60
- * Scope used for all `analyze` / `flush` work. Bound to `Dispatchers.IO`
61
- * + a `SupervisorJob` so a single failed frame analysis doesn't poison
62
- * the rest of the session. Cancelled on module destroy.
63
- */
64
- protected val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
65
-
66
- // Subclass hooks --------------------------------------------------------
67
-
68
- /**
69
- * Build a native session from the JS-side config map. The
70
- * implementation receives the prepared on-disk `frameStorage` so each
71
- * `configure` starts with a fresh session directory.
72
- *
73
- * Subclasses are responsible for any registry interaction (face
74
- * modules resolve the analyzer through `FaceAnalyzerFactoryRegistry`;
75
- * document modules instantiate their analyzer directly).
76
- */
77
- protected abstract fun createSession(config: PConfig, frameStorage: FrameStorage): Session
78
-
79
- /**
80
- * The document side this session's [ResizingFrameStorage] writes, selecting
81
- * the `document_front.jpg` vs `document_back.jpg` filename. Face modules
82
- * keep the default ([DocumentSide.Front]); document modules override to
83
- * read the side from their wire config so two-sided captures don't
84
- * overwrite the front image with the back.
85
- */
86
- protected open fun storageDocumentSide(config: PConfig): DocumentSide = DocumentSide.Front
87
-
88
- /** Run one frame through the native session. */
89
- protected abstract suspend fun scan(
90
- session: Session,
91
- frame: NativeYuvFrame,
92
- rotation: Int,
93
- ): ScanResult
94
-
95
- /** Drain pending capture-ready events from the session. */
96
- protected abstract fun drainEvents(session: Session): List<NativeCaptureReadyEvent>
97
-
98
- /** Flush the session (suspend — writes pending JPEGs to disk). */
99
- protected abstract suspend fun flushSession(session: Session)
100
-
101
- /** Reset the session state machine without tearing down the analyzer. */
102
- protected abstract fun resetSession(session: Session)
103
-
104
- /** Close the session and release any underlying analyzer resources. */
105
- protected abstract fun closeSession(session: Session)
106
-
107
- /**
108
- * Convert the native scan result + drained events into the JS-side
109
- * wire result type (always a `Map<String, Any?>` for Expo). The map
110
- * keys must match the JS-side schemas declared in `@usesmileid/ml`.
111
- */
112
- protected abstract fun buildResult(
113
- scanResult: ScanResult,
114
- events: List<NativeCaptureReadyEvent>,
115
- ): PResult
116
-
117
- // Lifecycle (driven by the Expo Module's OnCreate / OnDestroy) -------
118
-
119
- /**
120
- * Called from the Expo Module's `OnCreate` block. Subclasses can
121
- * override to register backend-specific analyzer factories with the
122
- * shared process-wide registry. Default no-op.
123
- */
124
- open fun onAttach(context: Context) {
125
- val root = File(context.filesDir, "UseSmileID")
126
- root.mkdirs()
127
- File(root, ".nomedia").takeIf { !it.exists() }?.createNewFile()
128
- storageRoot = root
129
- }
130
-
131
- /**
132
- * Called from the Expo Module's `OnDestroy` block. Cancels the
133
- * coroutine scope, closes any live session, then invokes
134
- * [onDetached] for subclass-specific teardown.
135
- */
136
- fun onDetach() {
137
- scope.cancel()
138
- session?.let {
139
- onSessionClosed()
140
- closeSession(it)
141
- }
142
- session = null
143
- onDetached()
144
- }
145
-
146
- /**
147
- * Subclass hook invoked from [onDetach] after the session is closed
148
- * and the scope is cancelled. Default no-op. Face modules use this
149
- * to unregister their analyzer factory.
150
- */
151
- protected open fun onDetached() {}
152
-
153
- // Session lifecycle notifications (driven by holder publishing) ------
154
-
155
- /** Called after a new session is created; subclasses publish it to the holder. Default no-op. */
156
- protected open fun onSessionCreated(session: Session) {}
157
-
158
- /**
159
- * Called BEFORE [closeSession] when the session is about to be torn down.
160
- * Subclasses null the process-wide holder here so any concurrent scan
161
- * queued on the session dispatcher sees null instead of a closing session.
162
- * Default no-op.
163
- */
164
- protected open fun onSessionClosed() {}
165
-
166
- /**
167
- * The single-thread [CoroutineDispatcher] that serialises all session
168
- * operations for this module's holder, preventing concurrent state-machine
169
- * mutations between the frame-processor thread and the Expo module thread.
170
- * Returning `null` (default) runs inline on the calling thread.
171
- */
172
- protected open fun holderSessionDispatcher(): CoroutineDispatcher? = null
173
-
174
- // Shared implementation behind the Expo Module's async functions -----
175
-
176
-
177
- /**
178
- * Implementation behind `configure`. Closes any prior session,
179
- * allocates a fresh on-disk session directory, and delegates session
180
- * construction to the subclass.
181
- */
182
- fun handleConfigure(config: PConfig) {
183
- session?.let {
184
- // Null the holder before closing so a queued scan sees null,
185
- // not a mid-close session.
186
- onSessionClosed()
187
- closeSession(it)
188
- }
189
- // Each `configure` starts a fresh session directory so retries
190
- // after `close` / re-`configure` don't collide with the previous
191
- // session's JPGs. Old directories are left in place; partner
192
- // clean-up policies can prune them.
193
- val sessionDir = freshSessionDirectory()
194
- val storage = buildResizingFrameStorage(sessionDir, storageDocumentSide(config), config as? Map<*, *>)
195
- val newSession = createSession(config, storage)
196
- session = newSession
197
- onSessionCreated(newSession)
198
- }
199
-
200
- /**
201
- * Implementation behind `flush`. Same dispatcher hopping discipline;
202
- * a missing session is treated as a successful
203
- * no-op (matches the prior per-module behaviour).
204
- */
205
- fun handleFlush(callback: (Result<Unit>) -> Unit) {
206
- scope.launch {
207
- val result = runCatching { session?.let { flushSession(it) } ?: Unit }
208
- withContext(Dispatchers.Main) { callback(result) }
209
- }
210
- }
211
-
212
- /**
213
- * Implementation behind `reset`. Serialized through [holderSessionDispatcher]
214
- * so it cannot interleave with a concurrent frame-processor scan.
215
- */
216
- fun handleReset() {
217
- val dispatcher = holderSessionDispatcher()
218
- if (dispatcher != null) {
219
- runBlocking {
220
- withContext(dispatcher) {
221
- session?.let { resetSession(it) }
222
- }
223
- }
224
- } else {
225
- session?.let { resetSession(it) }
226
- }
227
- }
228
-
229
- /**
230
- * Implementation behind `close`. Nulls the holder BEFORE closing the
231
- * session (see [onSessionClosed]), serialized through [holderSessionDispatcher].
232
- */
233
- fun handleClose() {
234
- val dispatcher = holderSessionDispatcher()
235
- if (dispatcher != null) {
236
- runBlocking {
237
- withContext(dispatcher) {
238
- session?.let {
239
- onSessionClosed()
240
- closeSession(it)
241
- }
242
- session = null
243
- }
244
- }
245
- } else {
246
- session?.let {
247
- onSessionClosed()
248
- closeSession(it)
249
- }
250
- session = null
251
- }
252
- }
253
-
254
- // Helpers ---------------------------------------------------------------
255
-
256
-
257
- /**
258
- * Build a [ResizingFrameStorage] from the wire config map, applying
259
- * token-resolved dimensions and JPEG quality when present. All values
260
- * fall back to SDK defaults when absent or not a [Number].
261
- */
262
- private fun buildResizingFrameStorage(
263
- sessionDir: File,
264
- documentSide: DocumentSide,
265
- config: Map<*, *>?,
266
- ): ResizingFrameStorage = ResizingFrameStorage(
267
- sessionDirectory = sessionDir,
268
- captureType = CaptureType.SELFIE,
269
- documentSide = documentSide,
270
- selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
271
- selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
272
- livenessTargetWidth = (config?.get("livenessImageWidth") as? Number)?.toInt() ?: 480,
273
- livenessTargetHeight = (config?.get("livenessImageHeight") as? Number)?.toInt() ?: 640,
274
- selfieQuality = (config?.get("selfieJpegQuality") as? Number)?.toInt() ?: 95,
275
- livenessQuality = (config?.get("livenessJpegQuality") as? Number)?.toInt() ?: 80,
276
- documentQuality = (config?.get("documentJpegQuality") as? Number)?.toInt() ?: 85,
277
- )
278
-
279
- private fun freshSessionDirectory(): File {
280
- val root = checkNotNull(storageRoot) {
281
- "Executor not attached — call onAttach(context) from the Expo Module's OnCreate block first"
282
- }
283
- val dir = File(root, System.currentTimeMillis().toString())
284
- dir.mkdirs()
285
- return dir
286
- }
287
- }
288
-
289
- /** Copies the YUV planes so the frame can close at once; rotation stays metadata for the detector. */
290
- internal fun KameraFrame.toNativeYuvFrame(): NativeYuvFrame {
291
- val yPlane = planes[0]
292
- val uPlane = planes[1]
293
- val vPlane = planes[2]
294
- fun copyBytes(buffer: java.nio.ByteBuffer): ByteArray {
295
- val source = buffer.duplicate()
296
- source.rewind()
297
- return ByteArray(source.remaining()).also { source.get(it) }
298
- }
299
- return NativeYuvFrame(
300
- planeY = copyBytes(yPlane.buffer),
301
- planeU = copyBytes(uPlane.buffer),
302
- planeV = copyBytes(vPlane.buffer),
303
- yRowStride = yPlane.rowStride,
304
- uRowStride = uPlane.rowStride,
305
- vRowStride = vPlane.rowStride,
306
- uPixelStride = uPlane.pixelStride,
307
- vPixelStride = vPlane.pixelStride,
308
- width = width,
309
- height = height,
310
- rotation = rotationDegrees,
311
- )
312
- }