@smileid/usesmileid_huawei_face 12.2.0 → 12.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -1
- package/README.md +17 -3
- package/android/build.gradle +23 -4
- package/android/consumer-rules.pro +0 -3
- package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceKameraAnalysisTick.kt +28 -26
- package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceModule.kt +19 -9
- package/app.plugin.js +3 -0
- package/package.json +15 -8
- package/plugin/build/index.d.ts +9 -0
- package/plugin/build/index.js +1 -0
- package/android/src/main/java/com/usesmileid/rn/analyzer/huaweiface/ResizingFrameStorage.kt +0 -329
- package/android/src/main/java/com/usesmileid/rn/analyzer/huaweiface/UseSmileIDAnalyzerExecutor.kt +0 -337
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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.3.0
|
|
4
|
+
|
|
5
|
+
See the repository CHANGELOG for the 12.3.0 release notes.
|
|
6
|
+
|
|
7
|
+
|
|
3
8
|
## 12.2.0
|
|
4
9
|
|
|
5
10
|
See the repository CHANGELOG for the 12.2.0 release notes.
|
|
6
11
|
|
|
7
|
-
|
|
8
12
|
## 12.1.1
|
|
9
13
|
|
|
10
14
|
See the repository CHANGELOG for the 12.1.1 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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -17,7 +17,7 @@ plugins {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
group = 'com.usesmileid.huawei.face'
|
|
20
|
-
version = '12.
|
|
20
|
+
version = '12.3.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.
|
|
59
|
+
versionName '12.3.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.
|
|
@@ -69,15 +69,34 @@ 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
71
|
// Maven Central; a local publishToMavenLocal build wins (see kamera.md).
|
|
72
|
-
implementation 'com.usesmileid:kamera:1.
|
|
72
|
+
implementation 'com.usesmileid:kamera:1.2.0'
|
|
73
|
+
|
|
74
|
+
// The analyzer executor and frame storage every provider module shares, from the bridge module.
|
|
75
|
+
implementation project(':smileid-usesmileid_bridge')
|
|
73
76
|
|
|
74
77
|
// Existing Smile ID native AAR — the entire ML pipeline lives here.
|
|
75
78
|
// Targets Huawei AppGallery / no-GMS devices via HMS ML kit. The
|
|
76
79
|
// Expo Module is a thin Kotlin shim that constructs the session
|
|
77
80
|
// and bridges its four methods to JS via Expo Modules.
|
|
78
|
-
implementation 'com.usesmileid:huawei-face:12.
|
|
81
|
+
implementation 'com.usesmileid:huawei-face:12.3.0'
|
|
82
|
+
// The bridge build the bridge module ships. The provider's SDK artifact names its bridge too, and a
|
|
83
|
+
// snapshot names the moving x.y.z-SNAPSHOT, so this keeps the provider compiling against the build
|
|
84
|
+
// the app runs.
|
|
85
|
+
implementation 'com.usesmileid:bridge:12.3.0'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
}
|
|
79
89
|
|
|
80
90
|
|
|
91
|
+
// The provider compiles against the executor the bridge module ships, so the two must be one release:
|
|
92
|
+
// a mixed install would build this provider against a bridge that lacks what it needs.
|
|
93
|
+
def smileIdPackageVersion = { File packageDir -> new groovy.json.JsonSlurper().parse(new File(packageDir, 'package.json')).version }
|
|
94
|
+
def bridgeVersion = smileIdPackageVersion(project(':smileid-usesmileid_bridge').projectDir.parentFile)
|
|
95
|
+
def providerVersion = smileIdPackageVersion(projectDir.parentFile)
|
|
96
|
+
if (bridgeVersion != providerVersion) {
|
|
97
|
+
throw new GradleException(
|
|
98
|
+
"@smileid/usesmileid_bridge is $bridgeVersion but ${project.name} is $providerVersion: install every @smileid package at one version."
|
|
99
|
+
)
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
// kamera resolves from Maven Central. mavenLocal stays injected — content-filtered to
|
package/android/src/main/java/com/usesmileid/huawei/face/UseSmileIDHuaweiFaceKameraAnalysisTick.kt
CHANGED
|
@@ -1,42 +1,44 @@
|
|
|
1
1
|
package com.usesmileid.huawei.face
|
|
2
2
|
|
|
3
|
-
import com.usesmileid.
|
|
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(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
+
return runBlocking {
|
|
26
|
+
withContext(UseSmileIDHuaweiFaceSessionHolder.sessionDispatcher) {
|
|
27
|
+
val activeSession = UseSmileIDHuaweiFaceSessionHolder.session
|
|
28
|
+
?: return@withContext mapOf("error" to "session_not_configured")
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 —
|
|
227
|
-
//
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smileid/usesmileid_huawei_face",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.3.0",
|
|
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.
|
|
48
|
-
"@smileid/usesmileid_platform_interface": "12.
|
|
50
|
+
"@smileid/usesmileid_bridge": "12.3.0",
|
|
51
|
+
"@smileid/usesmileid_platform_interface": "12.3.0"
|
|
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
|
-
"
|
|
59
|
+
"@types/node": "^26.6.4",
|
|
60
|
+
"expo": "~57.0.26",
|
|
61
|
+
"expo-modules-core": "^57.0.21",
|
|
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": "
|
|
65
|
-
"
|
|
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,329 +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.ConcurrentHashMap
|
|
21
|
-
import java.util.concurrent.ConcurrentLinkedDeque
|
|
22
|
-
import java.util.concurrent.atomic.AtomicReference
|
|
23
|
-
import kotlin.coroutines.cancellation.CancellationException
|
|
24
|
-
import kotlin.math.roundToInt
|
|
25
|
-
import kotlinx.coroutines.CoroutineExceptionHandler
|
|
26
|
-
import kotlinx.coroutines.CoroutineScope
|
|
27
|
-
import kotlinx.coroutines.Dispatchers
|
|
28
|
-
import kotlinx.coroutines.Job
|
|
29
|
-
import kotlinx.coroutines.SupervisorJob
|
|
30
|
-
import kotlinx.coroutines.launch
|
|
31
|
-
import kotlinx.coroutines.withContext
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* [FrameStorage] implementation that center-crops and scales every selfie and
|
|
35
|
-
* liveness JPEG to the configured target dimensions before writing.
|
|
36
|
-
*
|
|
37
|
-
* Target dimensions are resolved at configure-time from the token
|
|
38
|
-
* `sdk_config` claim; the executor falls back to 480 × 640 when the token
|
|
39
|
-
* carries no override, so the on-disk pixel size always matches the
|
|
40
|
-
* `image_width` / `image_height` values reported in job metadata.
|
|
41
|
-
*
|
|
42
|
-
* Document frames are decoded upright, then cropped to the guide rectangle when
|
|
43
|
-
* a crop region has been published via [setCropRegion] (the on-screen frame the
|
|
44
|
-
* user aligned the document into) so the uploaded image matches the capture
|
|
45
|
-
* preview. The crop runs at full resolution first; the resulting crop is then
|
|
46
|
-
* downscaled so its longest edge is bounded at [DOCUMENT_MAX_DIMENSION] (1600
|
|
47
|
-
* px), preserving aspect ratio and never upscaling, before JPEG-encoding at the
|
|
48
|
-
* specified [documentQuality]. Cropping before the downscale keeps maximum
|
|
49
|
-
* detail in the document region — the bound applies to the crop, not the whole
|
|
50
|
-
* frame, so a tightly framed card fills the saved pixels. Document dimensions
|
|
51
|
-
* are not part of the token `sdk_config` contract.
|
|
52
|
-
*
|
|
53
|
-
* Resize and encode run on a private `Dispatchers.IO` scope; [persist] is
|
|
54
|
-
* fire-and-forget so the analyzer keeps running at frame rate. [flush] awaits
|
|
55
|
-
* all in-flight writes before upload proceeds.
|
|
56
|
-
*/
|
|
57
|
-
internal class ResizingFrameStorage(
|
|
58
|
-
private val sessionDirectory: File,
|
|
59
|
-
private val captureType: CaptureType,
|
|
60
|
-
private val documentSide: DocumentSide = DocumentSide.Front,
|
|
61
|
-
private val selfieTargetWidth: Int = 480,
|
|
62
|
-
private val selfieTargetHeight: Int = 640,
|
|
63
|
-
private val livenessTargetWidth: Int = 480,
|
|
64
|
-
private val livenessTargetHeight: Int = 640,
|
|
65
|
-
private val selfieQuality: Int = 95,
|
|
66
|
-
private val livenessQuality: Int = 80,
|
|
67
|
-
private val documentQuality: Int = 85,
|
|
68
|
-
) : FrameStorage {
|
|
69
|
-
|
|
70
|
-
companion object {
|
|
71
|
-
/**
|
|
72
|
-
* Longest-edge bound, in pixels, for the saved document JPEG. The
|
|
73
|
-
* guide-rect crop is downscaled so its larger dimension never exceeds
|
|
74
|
-
* this value, preserving aspect ratio and never upscaling.
|
|
75
|
-
*/
|
|
76
|
-
private const val DOCUMENT_MAX_DIMENSION = 1600
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The hidden border kept around the guide rect: this fraction of the
|
|
80
|
-
* saved image's short side on every edge, the rule of the native
|
|
81
|
-
* bridges' `DocumentCropGeometry.DOCUMENT_BORDER_FRACTION` and the
|
|
82
|
-
* selfie's interior crop. The border is where presentation-attack cues
|
|
83
|
-
* live; the backend reads it, and the preview shows only the interior.
|
|
84
|
-
*/
|
|
85
|
-
internal const val DOCUMENT_BORDER_FRACTION = 0.15f
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The uniform border, in pixels, to grow a [left], [top], [right],
|
|
89
|
-
* [bottom] guide by: the b that is [DOCUMENT_BORDER_FRACTION] of the
|
|
90
|
-
* grown rect's own short side, b = f × s / (1 − 2f) for the guide's
|
|
91
|
-
* short side s. Shrinks to the most every edge of a [width] × [height]
|
|
92
|
-
* image can hold; 0 for an empty guide or one touching an edge.
|
|
93
|
-
* Mirrors the native bridges' `DocumentCropGeometry.borderAround`.
|
|
94
|
-
*/
|
|
95
|
-
internal fun borderAround(left: Float, top: Float, right: Float, bottom: Float, width: Int, height: Int): Float {
|
|
96
|
-
val shortSide = minOf(right - left, bottom - top)
|
|
97
|
-
if (shortSide <= 0f) return 0f
|
|
98
|
-
val wanted = DOCUMENT_BORDER_FRACTION * shortSide / (1f - 2f * DOCUMENT_BORDER_FRACTION)
|
|
99
|
-
val room = minOf(left, top, width - right, height - bottom)
|
|
100
|
-
return minOf(wanted, room).coerceAtLeast(0f)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* A [border] of pixels around a [width] × [height] crop as a fraction of the
|
|
105
|
-
* crop's short side, held to [DOCUMENT_BORDER_FRACTION] so the crop's pixel
|
|
106
|
-
* rounding never reports more border than was kept; 0 for an empty crop.
|
|
107
|
-
*/
|
|
108
|
-
internal fun borderFraction(border: Float, width: Int, height: Int): Float {
|
|
109
|
-
val shortSide = minOf(width, height)
|
|
110
|
-
if (shortSide <= 0) return 0f
|
|
111
|
-
return (border / shortSide).coerceIn(0f, DOCUMENT_BORDER_FRACTION)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** A backstop for anything that escapes the write's own catch; [flush] still surfaces it. */
|
|
116
|
-
private val scope = CoroutineScope(
|
|
117
|
-
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> firstError.compareAndSet(null, e) },
|
|
118
|
-
)
|
|
119
|
-
private val pending = ConcurrentLinkedDeque<Job>()
|
|
120
|
-
private val firstError = AtomicReference<Throwable?>(null)
|
|
121
|
-
|
|
122
|
-
/** Read back as [FrameStorage.cropRegion], which the document transitioner judges the detected document against. */
|
|
123
|
-
@Volatile
|
|
124
|
-
override var cropRegion: NativeViewportCropRegion? = null
|
|
125
|
-
private set
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* The hidden border of each document this storage saved with one, by absolute
|
|
129
|
-
* path, as a fraction of the document's short side. Written on the encode scope
|
|
130
|
-
* once the file is written, read after [flush].
|
|
131
|
-
*/
|
|
132
|
-
private val savedBorderFractions = ConcurrentHashMap<String, Float>()
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The hidden border the document at [file] was saved with, as a fraction of its
|
|
136
|
-
* short side on every edge: [DOCUMENT_BORDER_FRACTION] when the frame had room for
|
|
137
|
-
* all of it, less where it did not, and 0 for a document saved without a guide
|
|
138
|
-
* crop or a file this storage did not write. The screens that show the document
|
|
139
|
-
* hide exactly this much.
|
|
140
|
-
*/
|
|
141
|
-
fun borderFractionOf(file: File): Float = savedBorderFractions[file.absolutePath] ?: 0f
|
|
142
|
-
|
|
143
|
-
private fun recordBorder(file: File, borderFraction: Float) {
|
|
144
|
-
if (borderFraction > 0f) {
|
|
145
|
-
savedBorderFractions[file.absolutePath] = borderFraction
|
|
146
|
-
} else {
|
|
147
|
-
savedBorderFractions.remove(file.absolutePath)
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
override fun setCropRegion(region: NativeViewportCropRegion?) {
|
|
152
|
-
cropRegion = region
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
override fun fileFor(kind: FrameKind, captureIndex: Int): File = File(
|
|
156
|
-
sessionDirectory,
|
|
157
|
-
when (kind) {
|
|
158
|
-
FrameKind.Selfie -> "selfie.jpg"
|
|
159
|
-
FrameKind.Liveness -> "liveness_$captureIndex.jpg"
|
|
160
|
-
FrameKind.Document -> when (documentSide) {
|
|
161
|
-
DocumentSide.Front -> "document_front.jpg"
|
|
162
|
-
DocumentSide.Back -> "document_back.jpg"
|
|
163
|
-
DocumentSide.Visa -> "document_visa.jpg"
|
|
164
|
-
}
|
|
165
|
-
},
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
override fun persist(frame: YuvFrame, file: File) {
|
|
169
|
-
launchEncode(frame, file, documentCropRegion = null)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
override fun persistDocument(frame: YuvFrame, file: File, liveBoundingBox: NativeBoundingBox?) {
|
|
173
|
-
// Snapshot the crop region at dispatch time so a concurrent
|
|
174
|
-
// setCropRegion(null) between here and the coroutine body can't tear
|
|
175
|
-
// the state. liveBoundingBox is unused: the guide rectangle the user
|
|
176
|
-
// framed (cropRegion) takes precedence over the live detector box.
|
|
177
|
-
launchEncode(frame, file, documentCropRegion = cropRegion)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
private fun launchEncode(frame: YuvFrame, file: File, documentCropRegion: NativeViewportCropRegion?) {
|
|
181
|
-
val job = scope.launch {
|
|
182
|
-
try {
|
|
183
|
-
encodeAndWrite(frame, file, documentCropRegion)
|
|
184
|
-
} catch (e: CancellationException) {
|
|
185
|
-
runCatching { file.delete() }
|
|
186
|
-
recordBorder(file, 0f)
|
|
187
|
-
throw e
|
|
188
|
-
} catch (e: Throwable) {
|
|
189
|
-
runCatching { file.delete() }
|
|
190
|
-
recordBorder(file, 0f)
|
|
191
|
-
// Surfaced by flush; a rethrow here would reach the host's uncaught handler.
|
|
192
|
-
firstError.compareAndSet(null, e)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// Add synchronously so flush() cannot race past an in-flight job.
|
|
196
|
-
pending.add(job)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
override suspend fun flush() {
|
|
200
|
-
val jobs = mutableListOf<Job>()
|
|
201
|
-
while (true) jobs.add(pending.poll() ?: break)
|
|
202
|
-
jobs.forEach { it.join() }
|
|
203
|
-
firstError.getAndSet(null)?.let { throw it }
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
override fun clear() {
|
|
207
|
-
// Selfie and document captures share one session directory; delete
|
|
208
|
-
// only this role's own frames.
|
|
209
|
-
sessionDirectory.listFiles()?.forEach { file ->
|
|
210
|
-
if (ownsForClear(file)) {
|
|
211
|
-
file.delete()
|
|
212
|
-
recordBorder(file, 0f)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
private fun ownsForClear(file: File): Boolean = when (captureType) {
|
|
218
|
-
CaptureType.SELFIE -> {
|
|
219
|
-
val kind = frameKindOf(file)
|
|
220
|
-
kind == FrameKind.Selfie || kind == FrameKind.Liveness
|
|
221
|
-
}
|
|
222
|
-
CaptureType.DOCUMENT -> file.name == fileFor(FrameKind.Document, captureIndex = 0).name
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
private fun frameKindOf(file: File): FrameKind? = when {
|
|
226
|
-
file.name.startsWith("selfie") -> FrameKind.Selfie
|
|
227
|
-
file.name.startsWith("liveness") -> FrameKind.Liveness
|
|
228
|
-
file.name.startsWith("document") -> FrameKind.Document
|
|
229
|
-
else -> null
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private suspend fun encodeAndWrite(
|
|
233
|
-
frame: YuvFrame,
|
|
234
|
-
file: File,
|
|
235
|
-
documentCropRegion: NativeViewportCropRegion?,
|
|
236
|
-
) {
|
|
237
|
-
withContext(Dispatchers.IO) {
|
|
238
|
-
file.parentFile?.mkdirs()
|
|
239
|
-
val name = file.name
|
|
240
|
-
when {
|
|
241
|
-
name.startsWith("selfie") -> {
|
|
242
|
-
val bitmap = frame.toArgbBitmap(jpegQuality = selfieQuality)
|
|
243
|
-
?: throw IOException("toArgbBitmap returned null for ${file.name}")
|
|
244
|
-
try {
|
|
245
|
-
bitmap.writeJpegToFileExact(file, selfieTargetWidth, selfieTargetHeight, selfieQuality)
|
|
246
|
-
} finally {
|
|
247
|
-
bitmap.recycle()
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
name.startsWith("liveness") -> {
|
|
251
|
-
val bitmap = frame.toArgbBitmap(jpegQuality = livenessQuality)
|
|
252
|
-
?: throw IOException("toArgbBitmap returned null for ${file.name}")
|
|
253
|
-
try {
|
|
254
|
-
bitmap.writeJpegToFileExact(file, livenessTargetWidth, livenessTargetHeight, livenessQuality)
|
|
255
|
-
} finally {
|
|
256
|
-
bitmap.recycle()
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
else -> {
|
|
260
|
-
// Document frames: decode to the upright bitmap, crop to the guide
|
|
261
|
-
// rectangle (if a crop region is set) at full resolution, then
|
|
262
|
-
// downscale the crop so its longest edge is bounded at
|
|
263
|
-
// DOCUMENT_MAX_DIMENSION. Cropping before the downscale keeps maximum
|
|
264
|
-
// detail in the document region — the bound applies to the crop, not
|
|
265
|
-
// the whole frame, so a tightly framed card fills the saved pixels.
|
|
266
|
-
// toFrameBoundingBox returns a normalized 0–1 box in the upright
|
|
267
|
-
// frame's coordinate space, mapped to pixels by the bitmap dimensions.
|
|
268
|
-
// The box grows by the hidden border (DOCUMENT_BORDER_FRACTION of the
|
|
269
|
-
// saved image's short side, uniform on every edge), which the preview
|
|
270
|
-
// never shows, so it shows what the user framed.
|
|
271
|
-
val source = frame.toArgbBitmap(jpegQuality = documentQuality)
|
|
272
|
-
?: throw IOException("toArgbBitmap returned null for ${file.name}")
|
|
273
|
-
val (cropped, borderFraction) = if (documentCropRegion != null) {
|
|
274
|
-
val box = documentCropRegion.toFrameBoundingBox(source.width, source.height)
|
|
275
|
-
val border = borderAround(
|
|
276
|
-
box.left * source.width,
|
|
277
|
-
box.top * source.height,
|
|
278
|
-
(box.left + box.width) * source.width,
|
|
279
|
-
(box.top + box.height) * source.height,
|
|
280
|
-
source.width,
|
|
281
|
-
source.height,
|
|
282
|
-
)
|
|
283
|
-
val padX = border / source.width
|
|
284
|
-
val padY = border / source.height
|
|
285
|
-
// Clamp left/top to leave room for at least one pixel and derive
|
|
286
|
-
// right/bottom from the clamped left/top so the crop never runs past
|
|
287
|
-
// the bitmap edge. Bitmap.createBitmap throws IllegalArgumentException
|
|
288
|
-
// when x + width exceeds the source width, which is reachable if the
|
|
289
|
-
// padded box lands fully off an edge and both bounds collapse onto the
|
|
290
|
-
// same coordinate.
|
|
291
|
-
val pixelLeft = ((box.left - padX) * source.width).roundToInt().coerceIn(0, source.width - 1)
|
|
292
|
-
val pixelTop = ((box.top - padY) * source.height).roundToInt().coerceIn(0, source.height - 1)
|
|
293
|
-
val pixelRight = ((box.left + box.width + padX) * source.width).roundToInt().coerceIn(pixelLeft + 1, source.width)
|
|
294
|
-
val pixelBottom = ((box.top + box.height + padY) * source.height).roundToInt().coerceIn(pixelTop + 1, source.height)
|
|
295
|
-
val cropWidth = (pixelRight - pixelLeft).coerceAtLeast(1)
|
|
296
|
-
val cropHeight = (pixelBottom - pixelTop).coerceAtLeast(1)
|
|
297
|
-
android.graphics.Bitmap.createBitmap(source, pixelLeft, pixelTop, cropWidth, cropHeight) to
|
|
298
|
-
borderFraction(border, cropWidth, cropHeight)
|
|
299
|
-
} else {
|
|
300
|
-
source to 0f
|
|
301
|
-
}
|
|
302
|
-
// Downscale the crop so its longest edge is at most
|
|
303
|
-
// DOCUMENT_MAX_DIMENSION, preserving aspect ratio. Skip when the crop
|
|
304
|
-
// already fits so a small document is never upscaled.
|
|
305
|
-
val longest = maxOf(cropped.width, cropped.height)
|
|
306
|
-
val scaled = if (longest > DOCUMENT_MAX_DIMENSION) {
|
|
307
|
-
val scale = DOCUMENT_MAX_DIMENSION.toFloat() / longest
|
|
308
|
-
val scaledWidth = (cropped.width * scale).roundToInt().coerceAtLeast(1)
|
|
309
|
-
val scaledHeight = (cropped.height * scale).roundToInt().coerceAtLeast(1)
|
|
310
|
-
android.graphics.Bitmap.createScaledBitmap(cropped, scaledWidth, scaledHeight, true)
|
|
311
|
-
} else {
|
|
312
|
-
cropped
|
|
313
|
-
}
|
|
314
|
-
try {
|
|
315
|
-
val success = file.outputStream().use {
|
|
316
|
-
scaled.compress(android.graphics.Bitmap.CompressFormat.JPEG, documentQuality, it)
|
|
317
|
-
}
|
|
318
|
-
if (!success) throw IOException("Bitmap.compress(JPEG) failed for ${file.absolutePath}")
|
|
319
|
-
recordBorder(file, borderFraction)
|
|
320
|
-
} finally {
|
|
321
|
-
if (scaled !== cropped) scaled.recycle()
|
|
322
|
-
if (cropped !== source) cropped.recycle()
|
|
323
|
-
source.recycle()
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}
|
package/android/src/main/java/com/usesmileid/rn/analyzer/huaweiface/UseSmileIDAnalyzerExecutor.kt
DELETED
|
@@ -1,337 +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
|
-
* The latest session's storage, read by [handleDocumentBorderFraction] after `flush`. Written by
|
|
61
|
-
* `configure` on the executor's IO scope and read on the module's async-function thread.
|
|
62
|
-
*/
|
|
63
|
-
@Volatile
|
|
64
|
-
private var storage: ResizingFrameStorage? = null
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Scope used for all `analyze` / `flush` work. Bound to `Dispatchers.IO`
|
|
68
|
-
* + a `SupervisorJob` so a single failed frame analysis doesn't poison
|
|
69
|
-
* the rest of the session. Cancelled on module destroy.
|
|
70
|
-
*/
|
|
71
|
-
protected val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
72
|
-
|
|
73
|
-
// Subclass hooks --------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Build a native session from the JS-side config map. The
|
|
77
|
-
* implementation receives the prepared on-disk `frameStorage` so each
|
|
78
|
-
* `configure` starts with a fresh session directory.
|
|
79
|
-
*
|
|
80
|
-
* Subclasses are responsible for any registry interaction (face
|
|
81
|
-
* modules resolve the analyzer through `FaceAnalyzerFactoryRegistry`;
|
|
82
|
-
* document modules instantiate their analyzer directly).
|
|
83
|
-
*/
|
|
84
|
-
protected abstract fun createSession(config: PConfig, frameStorage: FrameStorage): Session
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The document side this session's [ResizingFrameStorage] writes, selecting
|
|
88
|
-
* the `document_front.jpg` vs `document_back.jpg` filename. Face modules
|
|
89
|
-
* keep the default ([DocumentSide.Front]); document modules override to
|
|
90
|
-
* read the side from their wire config so two-sided captures don't
|
|
91
|
-
* overwrite the front image with the back.
|
|
92
|
-
*/
|
|
93
|
-
protected open fun storageDocumentSide(config: PConfig): DocumentSide = DocumentSide.Front
|
|
94
|
-
|
|
95
|
-
/** Run one frame through the native session. */
|
|
96
|
-
protected abstract suspend fun scan(
|
|
97
|
-
session: Session,
|
|
98
|
-
frame: NativeYuvFrame,
|
|
99
|
-
rotation: Int,
|
|
100
|
-
): ScanResult
|
|
101
|
-
|
|
102
|
-
/** Drain pending capture-ready events from the session. */
|
|
103
|
-
protected abstract fun drainEvents(session: Session): List<NativeCaptureReadyEvent>
|
|
104
|
-
|
|
105
|
-
/** Flush the session (suspend — writes pending JPEGs to disk). */
|
|
106
|
-
protected abstract suspend fun flushSession(session: Session)
|
|
107
|
-
|
|
108
|
-
/** Reset the session state machine without tearing down the analyzer. */
|
|
109
|
-
protected abstract fun resetSession(session: Session)
|
|
110
|
-
|
|
111
|
-
/** Close the session and release any underlying analyzer resources. */
|
|
112
|
-
protected abstract fun closeSession(session: Session)
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Convert the native scan result + drained events into the JS-side
|
|
116
|
-
* wire result type (always a `Map<String, Any?>` for Expo). The map
|
|
117
|
-
* keys must match the JS-side schemas declared in `@usesmileid/ml`.
|
|
118
|
-
*/
|
|
119
|
-
protected abstract fun buildResult(
|
|
120
|
-
scanResult: ScanResult,
|
|
121
|
-
events: List<NativeCaptureReadyEvent>,
|
|
122
|
-
): PResult
|
|
123
|
-
|
|
124
|
-
// Lifecycle (driven by the Expo Module's OnCreate / OnDestroy) -------
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Called from the Expo Module's `OnCreate` block. Subclasses can
|
|
128
|
-
* override to register backend-specific analyzer factories with the
|
|
129
|
-
* shared process-wide registry. Default no-op.
|
|
130
|
-
*/
|
|
131
|
-
open fun onAttach(context: Context) {
|
|
132
|
-
val root = File(context.filesDir, "UseSmileID")
|
|
133
|
-
root.mkdirs()
|
|
134
|
-
File(root, ".nomedia").takeIf { !it.exists() }?.createNewFile()
|
|
135
|
-
storageRoot = root
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Called from the Expo Module's `OnDestroy` block. Cancels the
|
|
140
|
-
* coroutine scope, closes any live session, then invokes
|
|
141
|
-
* [onDetached] for subclass-specific teardown.
|
|
142
|
-
*/
|
|
143
|
-
fun onDetach() {
|
|
144
|
-
scope.cancel()
|
|
145
|
-
session?.let {
|
|
146
|
-
onSessionClosed()
|
|
147
|
-
closeSession(it)
|
|
148
|
-
}
|
|
149
|
-
session = null
|
|
150
|
-
onDetached()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Subclass hook invoked from [onDetach] after the session is closed
|
|
155
|
-
* and the scope is cancelled. Default no-op. Face modules use this
|
|
156
|
-
* to unregister their analyzer factory.
|
|
157
|
-
*/
|
|
158
|
-
protected open fun onDetached() {}
|
|
159
|
-
|
|
160
|
-
// Session lifecycle notifications (driven by holder publishing) ------
|
|
161
|
-
|
|
162
|
-
/** Called after a new session is created; subclasses publish it to the holder. Default no-op. */
|
|
163
|
-
protected open fun onSessionCreated(session: Session) {}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Called BEFORE [closeSession] when the session is about to be torn down.
|
|
167
|
-
* Subclasses null the process-wide holder here so any concurrent scan
|
|
168
|
-
* queued on the session dispatcher sees null instead of a closing session.
|
|
169
|
-
* Default no-op.
|
|
170
|
-
*/
|
|
171
|
-
protected open fun onSessionClosed() {}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* The single-thread [CoroutineDispatcher] that serialises all session
|
|
175
|
-
* operations for this module's holder, preventing concurrent state-machine
|
|
176
|
-
* mutations between the frame-processor thread and the Expo module thread.
|
|
177
|
-
* Returning `null` (default) runs inline on the calling thread.
|
|
178
|
-
*/
|
|
179
|
-
protected open fun holderSessionDispatcher(): CoroutineDispatcher? = null
|
|
180
|
-
|
|
181
|
-
// Shared implementation behind the Expo Module's async functions -----
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Implementation behind `configure`. Closes any prior session,
|
|
186
|
-
* allocates a fresh on-disk session directory, and delegates session
|
|
187
|
-
* construction to the subclass.
|
|
188
|
-
*/
|
|
189
|
-
fun handleConfigure(config: PConfig) {
|
|
190
|
-
session?.let {
|
|
191
|
-
// Null the holder before closing so a queued scan sees null,
|
|
192
|
-
// not a mid-close session.
|
|
193
|
-
onSessionClosed()
|
|
194
|
-
closeSession(it)
|
|
195
|
-
}
|
|
196
|
-
// Each `configure` starts a fresh session directory so retries
|
|
197
|
-
// after `close` / re-`configure` don't collide with the previous
|
|
198
|
-
// session's JPGs. Old directories are left in place; partner
|
|
199
|
-
// clean-up policies can prune them.
|
|
200
|
-
val sessionDir = freshSessionDirectory()
|
|
201
|
-
val storage = buildResizingFrameStorage(sessionDir, storageDocumentSide(config), config as? Map<*, *>)
|
|
202
|
-
this.storage = storage
|
|
203
|
-
val newSession = createSession(config, storage)
|
|
204
|
-
session = newSession
|
|
205
|
-
onSessionCreated(newSession)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Implementation behind `documentBorderFraction`: the hidden border the document at [url], a
|
|
210
|
-
* `file://` URL or a path, was saved with, as a fraction of its short side; 0 for a file the
|
|
211
|
-
* latest session's storage did not write with one.
|
|
212
|
-
*/
|
|
213
|
-
fun handleDocumentBorderFraction(url: String): Double {
|
|
214
|
-
val file = try {
|
|
215
|
-
if (url.startsWith("file:")) File(java.net.URI(url)) else File(url)
|
|
216
|
-
} catch (e: java.net.URISyntaxException) {
|
|
217
|
-
return 0.0
|
|
218
|
-
} catch (e: IllegalArgumentException) {
|
|
219
|
-
// Not a hierarchical file: URL.
|
|
220
|
-
return 0.0
|
|
221
|
-
}
|
|
222
|
-
return storage?.borderFractionOf(file)?.toDouble() ?: 0.0
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Implementation behind `flush`. Same dispatcher hopping discipline;
|
|
227
|
-
* a missing session is treated as a successful
|
|
228
|
-
* no-op (matches the prior per-module behaviour).
|
|
229
|
-
*/
|
|
230
|
-
fun handleFlush(callback: (Result<Unit>) -> Unit) {
|
|
231
|
-
scope.launch {
|
|
232
|
-
val result = runCatching { session?.let { flushSession(it) } ?: Unit }
|
|
233
|
-
withContext(Dispatchers.Main) { callback(result) }
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Implementation behind `reset`. Serialized through [holderSessionDispatcher]
|
|
239
|
-
* so it cannot interleave with a concurrent frame-processor scan.
|
|
240
|
-
*/
|
|
241
|
-
fun handleReset() {
|
|
242
|
-
val dispatcher = holderSessionDispatcher()
|
|
243
|
-
if (dispatcher != null) {
|
|
244
|
-
runBlocking {
|
|
245
|
-
withContext(dispatcher) {
|
|
246
|
-
session?.let { resetSession(it) }
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
} else {
|
|
250
|
-
session?.let { resetSession(it) }
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Implementation behind `close`. Nulls the holder BEFORE closing the
|
|
256
|
-
* session (see [onSessionClosed]), serialized through [holderSessionDispatcher].
|
|
257
|
-
*/
|
|
258
|
-
fun handleClose() {
|
|
259
|
-
val dispatcher = holderSessionDispatcher()
|
|
260
|
-
if (dispatcher != null) {
|
|
261
|
-
runBlocking {
|
|
262
|
-
withContext(dispatcher) {
|
|
263
|
-
session?.let {
|
|
264
|
-
onSessionClosed()
|
|
265
|
-
closeSession(it)
|
|
266
|
-
}
|
|
267
|
-
session = null
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
} else {
|
|
271
|
-
session?.let {
|
|
272
|
-
onSessionClosed()
|
|
273
|
-
closeSession(it)
|
|
274
|
-
}
|
|
275
|
-
session = null
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Helpers ---------------------------------------------------------------
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Build a [ResizingFrameStorage] from the wire config map, applying
|
|
284
|
-
* token-resolved dimensions and JPEG quality when present. All values
|
|
285
|
-
* fall back to SDK defaults when absent or not a [Number].
|
|
286
|
-
*/
|
|
287
|
-
private fun buildResizingFrameStorage(
|
|
288
|
-
sessionDir: File,
|
|
289
|
-
documentSide: DocumentSide,
|
|
290
|
-
config: Map<*, *>?,
|
|
291
|
-
): ResizingFrameStorage = ResizingFrameStorage(
|
|
292
|
-
sessionDirectory = sessionDir,
|
|
293
|
-
captureType = CaptureType.SELFIE,
|
|
294
|
-
documentSide = documentSide,
|
|
295
|
-
selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
|
|
296
|
-
selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
|
|
297
|
-
livenessTargetWidth = (config?.get("livenessImageWidth") as? Number)?.toInt() ?: 480,
|
|
298
|
-
livenessTargetHeight = (config?.get("livenessImageHeight") as? Number)?.toInt() ?: 640,
|
|
299
|
-
selfieQuality = (config?.get("selfieJpegQuality") as? Number)?.toInt() ?: 95,
|
|
300
|
-
livenessQuality = (config?.get("livenessJpegQuality") as? Number)?.toInt() ?: 80,
|
|
301
|
-
documentQuality = (config?.get("documentJpegQuality") as? Number)?.toInt() ?: 85,
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
private fun freshSessionDirectory(): File {
|
|
305
|
-
val root = checkNotNull(storageRoot) {
|
|
306
|
-
"Executor not attached — call onAttach(context) from the Expo Module's OnCreate block first"
|
|
307
|
-
}
|
|
308
|
-
val dir = File(root, System.currentTimeMillis().toString())
|
|
309
|
-
dir.mkdirs()
|
|
310
|
-
return dir
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Copies the YUV planes so the frame can close at once; rotation stays metadata for the detector. */
|
|
315
|
-
internal fun KameraFrame.toNativeYuvFrame(): NativeYuvFrame {
|
|
316
|
-
val yPlane = planes[0]
|
|
317
|
-
val uPlane = planes[1]
|
|
318
|
-
val vPlane = planes[2]
|
|
319
|
-
fun copyBytes(buffer: java.nio.ByteBuffer): ByteArray {
|
|
320
|
-
val source = buffer.duplicate()
|
|
321
|
-
source.rewind()
|
|
322
|
-
return ByteArray(source.remaining()).also { source.get(it) }
|
|
323
|
-
}
|
|
324
|
-
return NativeYuvFrame(
|
|
325
|
-
planeY = copyBytes(yPlane.buffer),
|
|
326
|
-
planeU = copyBytes(uPlane.buffer),
|
|
327
|
-
planeV = copyBytes(vPlane.buffer),
|
|
328
|
-
yRowStride = yPlane.rowStride,
|
|
329
|
-
uRowStride = uPlane.rowStride,
|
|
330
|
-
vRowStride = vPlane.rowStride,
|
|
331
|
-
uPixelStride = uPlane.pixelStride,
|
|
332
|
-
vPixelStride = vPlane.pixelStride,
|
|
333
|
-
width = width,
|
|
334
|
-
height = height,
|
|
335
|
-
rotation = rotationDegrees,
|
|
336
|
-
)
|
|
337
|
-
}
|