@smileid/usesmileid_mlkit_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.
@@ -17,7 +17,7 @@ plugins {
17
17
  }
18
18
 
19
19
  group = 'com.usesmileid.mlkit.face'
20
- version = '12.1.1'
20
+ version = '12.2.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.1.1'
32
+ versionName '12.2.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.
@@ -40,15 +40,35 @@ android {
40
40
  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
- // Maven Central; a local publishToMavenLocal build wins (see kamera.md).
44
- implementation 'com.usesmileid:kamera:1.0.6'
43
+ // Maven Central, or its snapshots repository for a timestamped snapshot build like this one, the
44
+ // build the Android SDK pins (see kamera.md).
45
+ implementation 'com.usesmileid:kamera:1.1.1-20261011.222529-5'
46
+
47
+ // The analyzer executor and frame storage every provider module shares, from the bridge module.
48
+ implementation project(':smileid-usesmileid_bridge')
45
49
 
46
50
  // Existing Smile ID native AAR — the entire ML pipeline lives here.
47
51
  // This Expo Module is a thin Kotlin shim that constructs the
48
52
  // session and bridges its four methods to JS via Expo Modules.
49
- implementation 'com.usesmileid:mlkit-face:12.1.1'
53
+ implementation 'com.usesmileid:mlkit-face:12.2.0-20261012.004641-201'
54
+ // The bridge build the bridge module ships. The provider's SDK artifact names its bridge too, and a
55
+ // snapshot names the moving x.y.z-SNAPSHOT, so this keeps the provider compiling against the build
56
+ // the app runs.
57
+ implementation 'com.usesmileid:bridge:12.2.0-20261012.004641-204'
58
+
59
+
60
+ }
50
61
 
51
62
 
63
+ // The provider compiles against the executor the bridge module ships, so the two must be one release:
64
+ // a mixed install would build this provider against a bridge that lacks what it needs.
65
+ def smileIdPackageVersion = { File packageDir -> new groovy.json.JsonSlurper().parse(new File(packageDir, 'package.json')).version }
66
+ def bridgeVersion = smileIdPackageVersion(project(':smileid-usesmileid_bridge').projectDir.parentFile)
67
+ def providerVersion = smileIdPackageVersion(projectDir.parentFile)
68
+ if (bridgeVersion != providerVersion) {
69
+ throw new GradleException(
70
+ "@smileid/usesmileid_bridge is $bridgeVersion but ${project.name} is $providerVersion: install every @smileid package at one version."
71
+ )
52
72
  }
53
73
 
54
74
  // 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.1.1",
3
+ "version": "12.2.1-SNAPSHOT.44",
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.1.1",
51
- "@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"
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.4.1",
60
- "expo": "~57.0.20",
61
- "expo-modules-core": "^57.0.16",
59
+ "@types/node": "^26.6.4",
60
+ "expo": "~57.0.26",
61
+ "expo-modules-core": "^57.0.20",
62
62
  "react-native": "^0.86.3",
63
63
  "typescript": "^6.0.3"
64
64
  },
@@ -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.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.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,311 +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
- * 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
- * Build a [ResizingFrameStorage] from the wire config map, applying
258
- * token-resolved dimensions and JPEG quality when present. All values
259
- * fall back to SDK defaults when absent or not a [Number].
260
- */
261
- private fun buildResizingFrameStorage(
262
- sessionDir: File,
263
- documentSide: DocumentSide,
264
- config: Map<*, *>?,
265
- ): ResizingFrameStorage = ResizingFrameStorage(
266
- sessionDirectory = sessionDir,
267
- captureType = CaptureType.SELFIE,
268
- documentSide = documentSide,
269
- selfieTargetWidth = (config?.get("selfieImageWidth") as? Number)?.toInt() ?: 480,
270
- selfieTargetHeight = (config?.get("selfieImageHeight") as? Number)?.toInt() ?: 640,
271
- livenessTargetWidth = (config?.get("livenessImageWidth") as? Number)?.toInt() ?: 480,
272
- livenessTargetHeight = (config?.get("livenessImageHeight") as? Number)?.toInt() ?: 640,
273
- selfieQuality = (config?.get("selfieJpegQuality") as? Number)?.toInt() ?: 95,
274
- livenessQuality = (config?.get("livenessJpegQuality") as? Number)?.toInt() ?: 80,
275
- documentQuality = (config?.get("documentJpegQuality") as? Number)?.toInt() ?: 85,
276
- )
277
-
278
- private fun freshSessionDirectory(): File {
279
- val root = checkNotNull(storageRoot) {
280
- "Executor not attached — call onAttach(context) from the Expo Module's OnCreate block first"
281
- }
282
- val dir = File(root, System.currentTimeMillis().toString())
283
- dir.mkdirs()
284
- return dir
285
- }
286
- }
287
-
288
- /** Copies the YUV planes so the frame can close at once; rotation stays metadata for the detector. */
289
- internal fun KameraFrame.toNativeYuvFrame(): NativeYuvFrame {
290
- val yPlane = planes[0]
291
- val uPlane = planes[1]
292
- val vPlane = planes[2]
293
- fun copyBytes(buffer: java.nio.ByteBuffer): ByteArray {
294
- val source = buffer.duplicate()
295
- source.rewind()
296
- return ByteArray(source.remaining()).also { source.get(it) }
297
- }
298
- return NativeYuvFrame(
299
- planeY = copyBytes(yPlane.buffer),
300
- planeU = copyBytes(uPlane.buffer),
301
- planeV = copyBytes(vPlane.buffer),
302
- yRowStride = yPlane.rowStride,
303
- uRowStride = uPlane.rowStride,
304
- vRowStride = vPlane.rowStride,
305
- uPixelStride = uPlane.pixelStride,
306
- vPixelStride = vPlane.pixelStride,
307
- width = width,
308
- height = height,
309
- rotation = rotationDegrees,
310
- )
311
- }