@smileid/usesmileid_mlkit_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 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.
@@ -17,7 +17,7 @@ plugins {
17
17
  }
18
18
 
19
19
  group = 'com.usesmileid.mlkit.face'
20
- version = '12.2.0'
20
+ version = '12.3.0'
21
21
 
22
22
  android {
23
23
  namespace 'com.usesmileid.mlkit.face'
@@ -29,7 +29,7 @@ android {
29
29
  // android/build.gradle and the rest of the @expo/* modules.
30
30
  defaultConfig {
31
31
  versionCode 1
32
- versionName '12.2.0'
32
+ versionName '12.3.0'
33
33
  // R8 keep-rules to bundle into partner apps that depend on this
34
34
  // module. Without these, R8 may rename the Expo module entry
35
35
  // point and the JS-bridged member functions, breaking autolinking.
@@ -41,14 +41,33 @@ dependencies {
41
41
  // kamera native core: the analyzer executor takes analysis frames from
42
42
  // kamera's KameraFrameHub so pixels never cross the JS bridge. Resolves from
43
43
  // Maven Central; a local publishToMavenLocal build wins (see kamera.md).
44
- implementation 'com.usesmileid:kamera:1.1.0'
44
+ implementation 'com.usesmileid:kamera:1.2.0'
45
+
46
+ // The analyzer executor and frame storage every provider module shares, from the bridge module.
47
+ implementation project(':smileid-usesmileid_bridge')
45
48
 
46
49
  // Existing Smile ID native AAR — the entire ML pipeline lives here.
47
50
  // This Expo Module is a thin Kotlin shim that constructs the
48
51
  // session and bridges its four methods to JS via Expo Modules.
49
- implementation 'com.usesmileid:mlkit-face:12.2.0'
52
+ implementation 'com.usesmileid:mlkit-face:12.3.0'
53
+ // The bridge build the bridge module ships. The provider's SDK artifact names its bridge too, and a
54
+ // snapshot names the moving x.y.z-SNAPSHOT, so this keeps the provider compiling against the build
55
+ // the app runs.
56
+ implementation 'com.usesmileid:bridge:12.3.0'
57
+
58
+
59
+ }
50
60
 
51
61
 
62
+ // The provider compiles against the executor the bridge module ships, so the two must be one release:
63
+ // a mixed install would build this provider against a bridge that lacks what it needs.
64
+ def smileIdPackageVersion = { File packageDir -> new groovy.json.JsonSlurper().parse(new File(packageDir, 'package.json')).version }
65
+ def bridgeVersion = smileIdPackageVersion(project(':smileid-usesmileid_bridge').projectDir.parentFile)
66
+ def providerVersion = smileIdPackageVersion(projectDir.parentFile)
67
+ if (bridgeVersion != providerVersion) {
68
+ throw new GradleException(
69
+ "@smileid/usesmileid_bridge is $bridgeVersion but ${project.name} is $providerVersion: install every @smileid package at one version."
70
+ )
52
71
  }
53
72
 
54
73
  // kamera resolves from Maven Central. mavenLocal stays injected — content-filtered to
@@ -17,10 +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
- # 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.** { *; }
@@ -1,42 +1,44 @@
1
1
  package com.usesmileid.mlkit.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.mlkitface.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(UseSmileIDMlkitFaceSessionHolder.sessionDispatcher) {
27
+ val activeSession = UseSmileIDMlkitFaceSessionHolder.session
28
+ ?: return@withContext mapOf("error" to "session_not_configured")
25
29
 
26
- return runBlocking {
27
- withContext(UseSmileIDMlkitFaceSessionHolder.sessionDispatcher) {
28
- val activeSession = UseSmileIDMlkitFaceSessionHolder.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
- UseSmileIDMlkitFaceModule.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
+ UseSmileIDMlkitFaceModule.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.mlkitface.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
@@ -44,6 +47,9 @@ class UseSmileIDMlkitFaceModule : Module() {
44
47
  UseSmileIDFaceScanResult,
45
48
  >() {
46
49
 
50
+ override val storageCaptureType: CaptureType = CaptureType.SELFIE
51
+
52
+
47
53
  override fun createSession(
48
54
  config: Map<String, Any?>,
49
55
  frameStorage: FrameStorage,
@@ -177,12 +183,10 @@ private fun Map<String, Any?>.toFaceScanConfig(): FaceScanConfig {
177
183
  // overlay any explicitly-set threshold field on top.
178
184
  val base = FaceDetectorMode.fromToken(this["faceDetectorMode"] as? String).config
179
185
  return FaceScanConfig(
180
- selfieCaptureTimeout = (this["selfieCaptureTimeout"] as? Number)?.toInt() ?: 100_000,
181
- // Default yields 1 selfie + 7 liveness frames — the server requires
182
- // `liveness_images >= 6`, so anything below 7 causes submission rejection.
183
- numSamples = (this["numSamples"] as? Number)?.toInt() ?: 8,
184
- sampleInterval = (this["sampleInterval"] as? Number)?.toInt() ?: 350,
185
- stayInFoundDuration = (this["stayInFoundDuration"] as? Number)?.toInt() ?: 3_000,
186
+ numSamples = (this["numSamples"] as? Number)?.toInt() ?: ThresholdConfig.FaceCapture.NUM_SAMPLES,
187
+ sampleInterval = (this["sampleInterval"] as? Number)?.toInt() ?: ThresholdConfig.FaceCapture.SAMPLE_INTERVAL_MS,
188
+ stayInFoundDuration = (this["stayInFoundDuration"] as? Number)?.toInt()
189
+ ?: ThresholdConfig.FaceCapture.DEFAULT_STAY_IN_FOUND_DURATION_MS,
186
190
  minFillThreshold = (this["minFillThreshold"] as? Number)?.toFloat() ?: base.minFillThreshold,
187
191
  maxFillThreshold = (this["maxFillThreshold"] as? Number)?.toFloat() ?: base.maxFillThreshold,
188
192
  faceDetectorMinScore = (this["faceDetectorMinScore"] as? Number)?.toFloat() ?: base.faceDetectorMinScore,
@@ -255,10 +259,16 @@ private fun UseSmileIDScanState.toWireMap(): Map<String, Any?> = buildMap {
255
259
  // Head-turn direction for Enhanced Smart Selfie guidance.
256
260
  currentDirection?.let { put("currentDirection", it.name.lowercase()) }
257
261
  captureProgress?.let { put("captureProgress", it.toDouble()) }
258
- // Latched pose-match flag for the ESS head turn — drives the grey→green
259
- // direction arrow. Emit only while matched (omit ⇒ false on the JS side),
262
+ // Latched pose-match flag for the ESS head turn — keeps the direction arrow
263
+ // green through a hold at the threshold. Emit only while matched (omit ⇒ false on the JS side),
260
264
  // matching the optional-field omission convention above.
261
265
  if (poseMatched) put("poseMatched", true)
266
+ when (captureStep) {
267
+ UseSmileIDCaptureStep.HOLD_STILL -> put("captureStep", "holdStill")
268
+ UseSmileIDCaptureStep.SMILE -> put("captureStep", "smile")
269
+ null -> Unit
270
+ }
271
+ turnProgress?.let { put("turnProgress", it.toDouble()) }
262
272
  }
263
273
 
264
274
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/usesmileid_mlkit_face",
3
- "version": "12.2.0",
3
+ "version": "12.3.0",
4
4
  "description": "Android MLKit face detection implementation for the UseSmileID React Native (Expo) SDK.",
5
5
  "homepage": "https://usesmileid.com",
6
6
  "repository": {
@@ -47,8 +47,8 @@
47
47
  "CHANGELOG.md"
48
48
  ],
49
49
  "dependencies": {
50
- "@smileid/usesmileid_bridge": "12.2.0",
51
- "@smileid/usesmileid_platform_interface": "12.2.0"
50
+ "@smileid/usesmileid_platform_interface": "12.3.0",
51
+ "@smileid/usesmileid_bridge": "12.3.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "expo": ">=56.0.0",
@@ -56,9 +56,9 @@
56
56
  "react-native": ">=0.83.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^26.6.2",
60
- "expo": "~57.0.25",
61
- "expo-modules-core": "^57.0.19",
59
+ "@types/node": "^26.6.4",
60
+ "expo": "~57.0.26",
61
+ "expo-modules-core": "^57.0.21",
62
62
  "react-native": "^0.86.3",
63
63
  "typescript": "^6.0.3"
64
64
  },
@@ -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.mlkitface
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
- }
@@ -1,336 +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.mlkitface
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
- * Build a [ResizingFrameStorage] from the wire config map, applying
283
- * token-resolved dimensions and JPEG quality when present. All values
284
- * fall back to SDK defaults when absent or not a [Number].
285
- */
286
- private fun buildResizingFrameStorage(
287
- sessionDir: File,
288
- documentSide: DocumentSide,
289
- config: Map<*, *>?,
290
- ): ResizingFrameStorage = ResizingFrameStorage(
291
- sessionDirectory = sessionDir,
292
- captureType = CaptureType.SELFIE,
293
- documentSide = documentSide,
294
- selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
295
- selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
296
- livenessTargetWidth = (config?.get("livenessImageWidth") as? Number)?.toInt() ?: 480,
297
- livenessTargetHeight = (config?.get("livenessImageHeight") as? Number)?.toInt() ?: 640,
298
- selfieQuality = (config?.get("selfieJpegQuality") as? Number)?.toInt() ?: 95,
299
- livenessQuality = (config?.get("livenessJpegQuality") as? Number)?.toInt() ?: 80,
300
- documentQuality = (config?.get("documentJpegQuality") as? Number)?.toInt() ?: 85,
301
- )
302
-
303
- private fun freshSessionDirectory(): File {
304
- val root = checkNotNull(storageRoot) {
305
- "Executor not attached — call onAttach(context) from the Expo Module's OnCreate block first"
306
- }
307
- val dir = File(root, System.currentTimeMillis().toString())
308
- dir.mkdirs()
309
- return dir
310
- }
311
- }
312
-
313
- /** Copies the YUV planes so the frame can close at once; rotation stays metadata for the detector. */
314
- internal fun KameraFrame.toNativeYuvFrame(): NativeYuvFrame {
315
- val yPlane = planes[0]
316
- val uPlane = planes[1]
317
- val vPlane = planes[2]
318
- fun copyBytes(buffer: java.nio.ByteBuffer): ByteArray {
319
- val source = buffer.duplicate()
320
- source.rewind()
321
- return ByteArray(source.remaining()).also { source.get(it) }
322
- }
323
- return NativeYuvFrame(
324
- planeY = copyBytes(yPlane.buffer),
325
- planeU = copyBytes(uPlane.buffer),
326
- planeV = copyBytes(vPlane.buffer),
327
- yRowStride = yPlane.rowStride,
328
- uRowStride = uPlane.rowStride,
329
- vRowStride = vPlane.rowStride,
330
- uPixelStride = uPlane.pixelStride,
331
- vPixelStride = vPlane.pixelStride,
332
- width = width,
333
- height = height,
334
- rotation = rotationDegrees,
335
- )
336
- }