@smileid/usesmileid_huawei_face 12.1.1 → 12.2.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,15 +1,18 @@
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
+
7
+
3
8
  ## 12.1.1
4
9
 
5
10
  See the repository CHANGELOG for the 12.1.1 release notes.
6
11
 
7
-
8
12
  ## 12.1.0
9
13
 
10
14
  See the repository CHANGELOG for the 12.1.0 release notes.
11
15
 
12
-
13
16
  ## 12.0.2
14
17
 
15
18
  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.huawei.face'
20
- version = '12.1.1'
20
+ version = '12.2.0'
21
21
 
22
22
  // HMS ML is published only to Huawei's own Maven repository — none of the
23
23
  // com.huawei.hms:ml-computer-vision-* artifacts exist on Maven Central. Expo
@@ -56,7 +56,7 @@ android {
56
56
  // android/build.gradle and the rest of the @expo/* modules.
57
57
  defaultConfig {
58
58
  versionCode 1
59
- versionName '12.1.1'
59
+ versionName '12.2.0'
60
60
  // R8 keep-rules to bundle into partner apps that depend on this
61
61
  // module. Without these, R8 may rename the Expo module entry
62
62
  // point and the JS-bridged member functions, breaking autolinking.
@@ -69,13 +69,13 @@ 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.0.6'
72
+ implementation 'com.usesmileid:kamera:1.1.0'
73
73
 
74
74
  // Existing Smile ID native AAR — the entire ML pipeline lives here.
75
75
  // Targets Huawei AppGallery / no-GMS devices via HMS ML kit. The
76
76
  // Expo Module is a thin Kotlin shim that constructs the session
77
77
  // and bridges its four methods to JS via Expo Modules.
78
- implementation 'com.usesmileid:huawei-face:12.1.1'
78
+ implementation 'com.usesmileid:huawei-face:12.2.0'
79
79
 
80
80
 
81
81
  }
@@ -17,9 +17,12 @@ import com.usesmileid.bridge.storage.writeJpegToFileExact
17
17
  import com.usesmileid.bridge.util.toArgbBitmap
18
18
  import java.io.File
19
19
  import java.io.IOException
20
+ import java.util.concurrent.ConcurrentHashMap
20
21
  import java.util.concurrent.ConcurrentLinkedDeque
21
22
  import java.util.concurrent.atomic.AtomicReference
23
+ import kotlin.coroutines.cancellation.CancellationException
22
24
  import kotlin.math.roundToInt
25
+ import kotlinx.coroutines.CoroutineExceptionHandler
23
26
  import kotlinx.coroutines.CoroutineScope
24
27
  import kotlinx.coroutines.Dispatchers
25
28
  import kotlinx.coroutines.Job
@@ -73,20 +76,77 @@ internal class ResizingFrameStorage(
73
76
  private const val DOCUMENT_MAX_DIMENSION = 1600
74
77
 
75
78
  /**
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.
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.
80
84
  */
81
- private const val DOCUMENT_CROP_PADDING_FRACTION = 0.15f
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
+ }
82
113
  }
83
114
 
84
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
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
+ )
85
119
  private val pending = ConcurrentLinkedDeque<Job>()
86
120
  private val firstError = AtomicReference<Throwable?>(null)
87
121
 
122
+ /** Read back as [FrameStorage.cropRegion], which the document transitioner judges the detected document against. */
88
123
  @Volatile
89
- private var cropRegion: NativeViewportCropRegion? = null
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
+ }
90
150
 
91
151
  override fun setCropRegion(region: NativeViewportCropRegion?) {
92
152
  cropRegion = region
@@ -100,6 +160,7 @@ internal class ResizingFrameStorage(
100
160
  FrameKind.Document -> when (documentSide) {
101
161
  DocumentSide.Front -> "document_front.jpg"
102
162
  DocumentSide.Back -> "document_back.jpg"
163
+ DocumentSide.Visa -> "document_visa.jpg"
103
164
  }
104
165
  },
105
166
  )
@@ -120,9 +181,15 @@ internal class ResizingFrameStorage(
120
181
  val job = scope.launch {
121
182
  try {
122
183
  encodeAndWrite(frame, file, documentCropRegion)
123
- } catch (throwable: Throwable) {
124
- firstError.compareAndSet(null, throwable)
125
- throw throwable
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)
126
193
  }
127
194
  }
128
195
  // Add synchronously so flush() cannot race past an in-flight job.
@@ -140,7 +207,10 @@ internal class ResizingFrameStorage(
140
207
  // Selfie and document captures share one session directory; delete
141
208
  // only this role's own frames.
142
209
  sessionDirectory.listFiles()?.forEach { file ->
143
- if (ownsForClear(file)) file.delete()
210
+ if (ownsForClear(file)) {
211
+ file.delete()
212
+ recordBorder(file, 0f)
213
+ }
144
214
  }
145
215
  }
146
216
 
@@ -195,16 +265,23 @@ internal class ResizingFrameStorage(
195
265
  // the whole frame, so a tightly framed card fills the saved pixels.
196
266
  // toFrameBoundingBox returns a normalized 0–1 box in the upright
197
267
  // 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.
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.
202
271
  val source = frame.toArgbBitmap(jpegQuality = documentQuality)
203
272
  ?: throw IOException("toArgbBitmap returned null for ${file.name}")
204
- val cropped = if (documentCropRegion != null) {
273
+ val (cropped, borderFraction) = if (documentCropRegion != null) {
205
274
  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
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
208
285
  // Clamp left/top to leave room for at least one pixel and derive
209
286
  // right/bottom from the clamped left/top so the crop never runs past
210
287
  // the bitmap edge. Bitmap.createBitmap throws IllegalArgumentException
@@ -217,9 +294,10 @@ internal class ResizingFrameStorage(
217
294
  val pixelBottom = ((box.top + box.height + padY) * source.height).roundToInt().coerceIn(pixelTop + 1, source.height)
218
295
  val cropWidth = (pixelRight - pixelLeft).coerceAtLeast(1)
219
296
  val cropHeight = (pixelBottom - pixelTop).coerceAtLeast(1)
220
- android.graphics.Bitmap.createBitmap(source, pixelLeft, pixelTop, cropWidth, cropHeight)
297
+ android.graphics.Bitmap.createBitmap(source, pixelLeft, pixelTop, cropWidth, cropHeight) to
298
+ borderFraction(border, cropWidth, cropHeight)
221
299
  } else {
222
- source
300
+ source to 0f
223
301
  }
224
302
  // Downscale the crop so its longest edge is at most
225
303
  // DOCUMENT_MAX_DIMENSION, preserving aspect ratio. Skip when the crop
@@ -238,6 +316,7 @@ internal class ResizingFrameStorage(
238
316
  scaled.compress(android.graphics.Bitmap.CompressFormat.JPEG, documentQuality, it)
239
317
  }
240
318
  if (!success) throw IOException("Bitmap.compress(JPEG) failed for ${file.absolutePath}")
319
+ recordBorder(file, borderFraction)
241
320
  } finally {
242
321
  if (scaled !== cropped) scaled.recycle()
243
322
  if (cropped !== source) cropped.recycle()
@@ -56,6 +56,13 @@ abstract class UseSmileIDAnalyzerExecutor<
56
56
  protected var session: Session? = null
57
57
  private var storageRoot: File? = null
58
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
+
59
66
  /**
60
67
  * Scope used for all `analyze` / `flush` work. Bound to `Dispatchers.IO`
61
68
  * + a `SupervisorJob` so a single failed frame analysis doesn't poison
@@ -192,11 +199,29 @@ abstract class UseSmileIDAnalyzerExecutor<
192
199
  // clean-up policies can prune them.
193
200
  val sessionDir = freshSessionDirectory()
194
201
  val storage = buildResizingFrameStorage(sessionDir, storageDocumentSide(config), config as? Map<*, *>)
202
+ this.storage = storage
195
203
  val newSession = createSession(config, storage)
196
204
  session = newSession
197
205
  onSessionCreated(newSession)
198
206
  }
199
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
+
200
225
  /**
201
226
  * Implementation behind `flush`. Same dispatcher hopping discipline;
202
227
  * a missing session is treated as a successful
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/usesmileid_huawei_face",
3
- "version": "12.1.1",
3
+ "version": "12.2.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": {
@@ -44,8 +44,8 @@
44
44
  "CHANGELOG.md"
45
45
  ],
46
46
  "dependencies": {
47
- "@smileid/usesmileid_bridge": "12.1.1",
48
- "@smileid/usesmileid_platform_interface": "12.1.1"
47
+ "@smileid/usesmileid_bridge": "12.2.0",
48
+ "@smileid/usesmileid_platform_interface": "12.2.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "expo": ">=56.0.0",
@@ -53,7 +53,7 @@
53
53
  "react-native": ">=0.83.0"
54
54
  },
55
55
  "devDependencies": {
56
- "expo-modules-core": "^57.0.16",
56
+ "expo-modules-core": "^57.0.19",
57
57
  "react-native": "^0.86.3",
58
58
  "typescript": "^6.0.3"
59
59
  },