Commit 44516365 authored by Javokhir's avatar Javokhir
Browse files

fix: preserve SDK defaults and make promise settling thread-safe

Three paths overwrote SDK defaults instead of leaving them alone:

- iOS assigned every appearance color unconditionally, so theming one color
  nil-ed the other eleven. MyIdAppearance seeds its own values.
- An organizationDetails carrying only a logo replaced the whole details object,
  clearing the default call-centre number. It is now forwarded only when the
  caller supplied a field, and each field is assigned individually.
- An unrecognised enum value called the setter with a guessed default. The guess
  was not always right: the SDK defaults imageFormat to JPEG while the fallback
  substituted PNG. Unknown values now skip the setter.

Android settled a promise through a plain field touched by the JS, UI and
native-modules threads, giving neither visibility nor an atomic check-then-set;
a reload racing a start could wedge the module for the rest of the process. It
is now an AtomicReference claimed with compareAndSet, and the result listener is
scoped to a flow id so a late duplicate callback cannot settle a newer promise.

iOS gained the invalidate path Android already had, confines its state to the
main queue, and extends the proxy's lifetime across the settle callback that
releases it.

Documented defaults corrected against MyIdDefaults: imageFormat is JPEG,
withSoundGuides is true, distance depends on camera shape, iOS floor is 15.1.
parent 7ffb29ca
...@@ -16,7 +16,7 @@ const result = await start({ ...@@ -16,7 +16,7 @@ const result = await start({
|---|---| |---|---|
| React Native | New Architecture enabled (`newArchEnabled=true`, the default since 0.76). Verified against 0.86.2. | | React Native | New Architecture enabled (`newArchEnabled=true`, the default since 0.76). Verified against 0.86.2. |
| Android | `minSdkVersion` 24+, `compileSdk` 36 | | Android | `minSdkVersion` 24+, `compileSdk` 36 |
| iOS | Deployment target 13.0+ | | iOS | Deployment target 15.1+ (RN 0.86's floor; the MyID SDK itself needs 13.0) |
`sessionId` is issued by **your backend** through the MyID API. This library `sessionId` is issued by **your backend** through the MyID API. This library
never talks to the MyID backend itself — it only launches the SDK. never talks to the MyID backend itself — it only launches the SDK.
...@@ -103,7 +103,7 @@ own. Options not supported by a platform are ignored there rather than throwing. ...@@ -103,7 +103,7 @@ own. Options not supported by a platform are ignored there rather than throwing.
| `clientHash` | string | — | both | | `clientHash` | string | — | both |
| `clientHashId` | string | — | both | | `clientHashId` | string | — | both |
| `minAge` | number | `16` | both | | `minAge` | number | `16` | both |
| `distance` | number (0..1) | `0.65` android / `0.60` ios | both | | `distance` | number (0..1) | `0.65` circle / `0.6` ellipse | both |
| `residency` | `USER_DEFINED` `RESIDENT` `NON_RESIDENT` | `RESIDENT` | both | | `residency` | `USER_DEFINED` `RESIDENT` `NON_RESIDENT` | `RESIDENT` | both |
| `environment` | `PRODUCTION` `DEBUG` | `PRODUCTION` | both | | `environment` | `PRODUCTION` `DEBUG` | `PRODUCTION` | both |
| `entryType` | `IDENTIFICATION` `VIDEO_IDENTIFICATION` `FACE_DETECTION` | `IDENTIFICATION` | both | | `entryType` | `IDENTIFICATION` `VIDEO_IDENTIFICATION` `FACE_DETECTION` | `IDENTIFICATION` | both |
...@@ -111,9 +111,9 @@ own. Options not supported by a platform are ignored there rather than throwing. ...@@ -111,9 +111,9 @@ own. Options not supported by a platform are ignored there rather than throwing.
| `cameraShape` | `CIRCLE` `ELLIPSE` | `CIRCLE` | both | | `cameraShape` | `CIRCLE` `ELLIPSE` | `CIRCLE` | both |
| `cameraSelector` | `FRONT` `BACK` | `FRONT` | both | | `cameraSelector` | `FRONT` `BACK` | `FRONT` | both |
| `cameraResolution` | `LOW` `HIGH` | `LOW` | android | | `cameraResolution` | `LOW` `HIGH` | `LOW` | android |
| `imageFormat` | `JPEG` `PNG` | `PNG` | android | | `imageFormat` | `JPEG` `PNG` | `JPEG` | android |
| `screenOrientation` | `PORTRAIT` `LANDSCAPE` `FULL` | `PORTRAIT` | android | | `screenOrientation` | `PORTRAIT` `LANDSCAPE` `FULL` | `PORTRAIT` | android |
| `withSoundGuides` | boolean | SDK default | android | | `withSoundGuides` | boolean | `true` | android |
| `huaweiAppId` | string | — | android | | `huaweiAppId` | string | — | android |
| `presentationStyle` | `SHEET` `FULL` | `FULL` | ios | | `presentationStyle` | `SHEET` `FULL` | `FULL` | ios |
| `showErrorScreen` | boolean | `true` | both | | `showErrorScreen` | boolean | `true` | both |
...@@ -129,6 +129,10 @@ as unstable. ...@@ -129,6 +129,10 @@ as unstable.
drawable or an iOS image set — not a path, URL or `require()`. It should fit drawable or an iOS image set — not a path, URL or `require()`. It should fit
roughly 240x60. `phone` replaces MyID's call-centre number on the error screen. roughly 240x60. `phone` replaces MyID's call-centre number on the error screen.
The SDK replaces the whole details object rather than merging it, so passing
`organizationDetails` with only a `logo` clears the default call-centre number
(`712022202`). Pass `phone` alongside `logo` if a number should still appear.
## Theming ## Theming
iOS theming is runtime, via `iosAppearance`: iOS theming is runtime, via `iosAppearance`:
...@@ -207,6 +211,14 @@ dependencyResolutionManagement { ...@@ -207,6 +211,14 @@ dependencyResolutionManagement {
} }
``` ```
**The screen never returns and every later call rejects with `103`.**
In a brownfield app that hosts React Native inside your own activity or
fragment, the host must forward `onActivityResult` to React
(`ReactActivityDelegate.onActivityResult` / `ReactInstanceManager.onActivityResult`).
Without it the result never arrives, the first `start()` hangs forever, and
every subsequent call rejects with "already in progress" for the rest of the
session. Apps using the standard `ReactActivity` get this for free.
**The app crashes on an emulator.** **The app crashes on an emulator.**
The MyID SDK ships native libraries for `arm64-v8a` and `armeabi-v7a` only. On an The MyID SDK ships native libraries for `arm64-v8a` and `armeabi-v7a` only. On an
x86/x86_64 emulator the app installs but crashes when the SDK loads its native x86/x86_64 emulator the app installs but crashes when the SDK loads its native
......
...@@ -21,8 +21,9 @@ import uz.myid.android.sdk.capture.model.MyIdScreenOrientation ...@@ -21,8 +21,9 @@ import uz.myid.android.sdk.capture.model.MyIdScreenOrientation
* omitted option keeps the SDK's own default instead of being overwritten with * omitted option keeps the SDK's own default instead of being overwritten with
* a value invented here. * a value invented here.
* *
* Unrecognised enum strings fall back to the SDK default rather than throwing — * An unrecognised enum string skips its setter rather than throwing — a typo
* a typo should not hard-fail a verification the user already started. * should not hard-fail a verification the user already started, and skipping is
* what actually leaves the SDK default in place.
*/ */
internal object MyidSdkConfigMapper { internal object MyidSdkConfigMapper {
...@@ -39,102 +40,105 @@ internal object MyidSdkConfigMapper { ...@@ -39,102 +40,105 @@ internal object MyidSdkConfigMapper {
config.optBoolean("showErrorScreen")?.let { builder.withErrorScreen(it) } config.optBoolean("showErrorScreen")?.let { builder.withErrorScreen(it) }
config.optString("huaweiAppId")?.let { builder.withHuaweiAppId(it) } config.optString("huaweiAppId")?.let { builder.withHuaweiAppId(it) }
// An unrecognised value maps to null and the setter is skipped, so the SDK
// keeps its own default. Substituting a guess here would silently change
// behaviour on a typo — and the guess is not always right: the SDK defaults
// imageFormat to JPEG, not PNG.
config.optEnum("environment")?.let { config.optEnum("environment")?.let {
builder.withEnvironment( when (it) {
when (it) { "DEBUG" -> MyIdEnvironment.Debug
"DEBUG" -> MyIdEnvironment.Debug "PRODUCTION" -> MyIdEnvironment.Production
else -> MyIdEnvironment.Production else -> null
} }
) }?.let(builder::withEnvironment)
}
config.optEnum("entryType")?.let { config.optEnum("entryType")?.let {
builder.withEntryType( when (it) {
when (it) { "IDENTIFICATION" -> MyIdEntryType.Identification
"FACE_DETECTION" -> MyIdEntryType.FaceDetection "VIDEO_IDENTIFICATION" -> MyIdEntryType.VideoIdentification
"VIDEO_IDENTIFICATION" -> MyIdEntryType.VideoIdentification "FACE_DETECTION" -> MyIdEntryType.FaceDetection
else -> MyIdEntryType.Identification else -> null
} }
) }?.let(builder::withEntryType)
}
config.optEnum("residency")?.let { config.optEnum("residency")?.let {
builder.withResidency( when (it) {
when (it) { "USER_DEFINED" -> MyIdResidency.UserDefined
"USER_DEFINED" -> MyIdResidency.UserDefined "RESIDENT" -> MyIdResidency.Resident
"NON_RESIDENT" -> MyIdResidency.NonResident "NON_RESIDENT" -> MyIdResidency.NonResident
else -> MyIdResidency.Resident else -> null
} }
) }?.let(builder::withResidency)
}
config.optEnum("locale")?.let { config.optEnum("locale")?.let {
builder.withLocale( when (it) {
when (it) { "UZBEK" -> MyIdLocale.Uzbek
"UZBEK_CYRILLIC" -> MyIdLocale.UzbekCyrillic "UZBEK_CYRILLIC" -> MyIdLocale.UzbekCyrillic
"KARAKALPAK" -> MyIdLocale.Karakalpak "KARAKALPAK" -> MyIdLocale.Karakalpak
"TAJIK" -> MyIdLocale.Tajik "TAJIK" -> MyIdLocale.Tajik
"ENGLISH" -> MyIdLocale.English "ENGLISH" -> MyIdLocale.English
"RUSSIAN" -> MyIdLocale.Russian "RUSSIAN" -> MyIdLocale.Russian
else -> MyIdLocale.Uzbek else -> null
} }
) }?.let(builder::withLocale)
}
config.optEnum("cameraShape")?.let { config.optEnum("cameraShape")?.let {
builder.withCameraShape( when (it) {
when (it) { "CIRCLE" -> MyIdCameraShape.Circle
"ELLIPSE" -> MyIdCameraShape.Ellipse "ELLIPSE" -> MyIdCameraShape.Ellipse
else -> MyIdCameraShape.Circle else -> null
} }
) }?.let(builder::withCameraShape)
}
config.optEnum("cameraSelector")?.let { config.optEnum("cameraSelector")?.let {
builder.withCameraSelector( when (it) {
when (it) { "FRONT" -> MyIdCameraSelector.Front
"BACK" -> MyIdCameraSelector.Back "BACK" -> MyIdCameraSelector.Back
else -> MyIdCameraSelector.Front else -> null
} }
) }?.let(builder::withCameraSelector)
}
config.optEnum("cameraResolution")?.let { config.optEnum("cameraResolution")?.let {
builder.withCameraResolution( when (it) {
when (it) { "LOW" -> MyIdCameraResolution.Low
"HIGH" -> MyIdCameraResolution.High "HIGH" -> MyIdCameraResolution.High
else -> MyIdCameraResolution.Low else -> null
} }
) }?.let(builder::withCameraResolution)
}
config.optEnum("imageFormat")?.let { config.optEnum("imageFormat")?.let {
builder.withImageFormat( when (it) {
when (it) { "JPEG" -> MyIdImageFormat.JPEG
"JPEG" -> MyIdImageFormat.JPEG "PNG" -> MyIdImageFormat.PNG
else -> MyIdImageFormat.PNG else -> null
} }
) }?.let(builder::withImageFormat)
}
config.optEnum("screenOrientation")?.let { config.optEnum("screenOrientation")?.let {
builder.withScreenOrientation( when (it) {
when (it) { "PORTRAIT" -> MyIdScreenOrientation.Portrait
"LANDSCAPE" -> MyIdScreenOrientation.Landscape "LANDSCAPE" -> MyIdScreenOrientation.Landscape
"FULL" -> MyIdScreenOrientation.Full "FULL" -> MyIdScreenOrientation.Full
else -> MyIdScreenOrientation.Portrait else -> null
} }
) }?.let(builder::withScreenOrientation)
}
// Only forwarded when the caller actually supplied something. withOrganizationDetails
config.optMap("organizationDetails")?.let { details -> // copies the object verbatim, so an empty one would replace the SDK default
builder.withOrganizationDetails( // (which carries MyID's call-centre number) with blanks.
MyIdOrganizationDetails( //
phoneNumber = details.optString("phone"), // Note the same copy semantics mean supplying only `logo` clears the default
logo = details.optString("logo")?.let { context.drawableIdOrNull(it) } // phone number; the SDK exposes no way to read it back. Documented in README.
config.optMap("organizationDetails")
?.takeIf { it.hasKey("phone") || it.hasKey("logo") }
?.let { details ->
builder.withOrganizationDetails(
MyIdOrganizationDetails(
phoneNumber = details.optString("phone"),
logo = details.optString("logo")?.let { context.drawableIdOrNull(it) }
)
) )
) }
}
return builder.build() return builder.build()
} }
......
...@@ -17,6 +17,8 @@ import uz.myid.android.sdk.capture.MyIdResultListener ...@@ -17,6 +17,8 @@ import uz.myid.android.sdk.capture.MyIdResultListener
import uz.myid.android.sdk.capture.model.MyIdEvent import uz.myid.android.sdk.capture.model.MyIdEvent
import uz.myid.android.sdk.capture.model.MyIdGraphicFieldType import uz.myid.android.sdk.capture.model.MyIdGraphicFieldType
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
class MyidSdkModule(reactContext: ReactApplicationContext) : class MyidSdkModule(reactContext: ReactApplicationContext) :
NativeMyidSdkSpec(reactContext) { NativeMyidSdkSpec(reactContext) {
...@@ -27,8 +29,21 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -27,8 +29,21 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
* The SDK owns the screen for the whole flow, so at most one verification can * The SDK owns the screen for the whole flow, so at most one verification can
* be in flight. Guarded rather than queued: a second concurrent call is a * be in flight. Guarded rather than queued: a second concurrent call is a
* caller bug, not something to silently serialise. * caller bug, not something to silently serialise.
*
* Atomic because three threads touch it: start() runs on the JS thread,
* onActivityResult on the UI thread, and invalidate() on the native-modules
* thread. A plain field gives neither visibility nor an atomic check-then-set.
*/ */
private var pendingPromise: Promise? = null private val pending = AtomicReference<PendingFlow?>(null)
/**
* Distinguishes one flow from the next, so a late duplicate callback from a
* finished flow cannot settle the promise of a newer one. The SDK can invoke a
* listener callback more than once.
*/
private val flowCounter = AtomicLong(0)
private data class PendingFlow(val id: Long, val promise: Promise)
private val activityEventListener = object : BaseActivityEventListener() { private val activityEventListener = object : BaseActivityEventListener() {
override fun onActivityResult( override fun onActivityResult(
...@@ -41,22 +56,24 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -41,22 +56,24 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
// filtering here keeps unrelated results from settling our promise. // filtering here keeps unrelated results from settling our promise.
if (requestCode != MYID_REQUEST_CODE) return if (requestCode != MYID_REQUEST_CODE) return
client.handleActivityResult(resultCode, resultListener) val flowId = pending.get()?.id ?: return
client.handleActivityResult(resultCode, resultListenerFor(flowId))
} }
} }
private val resultListener = object : MyIdResultListener { private fun resultListenerFor(flowId: Long) = object : MyIdResultListener {
override fun onSuccess(result: MyIdResult) { override fun onSuccess(result: MyIdResult) {
val promise = takePending() ?: return val promise = takePending(flowId) ?: return
promise.resolve(result.toPayload()) promise.resolve(result.toPayload())
} }
override fun onError(exception: MyIdException) { override fun onError(exception: MyIdException) {
takePending()?.reject(exception.code.toString(), exception.message) takePending(flowId)?.reject(exception.code.toString(), exception.message)
} }
override fun onUserExited() { override fun onUserExited() {
takePending()?.reject(CODE_USER_CANCELLED, "User canceled flow") takePending(flowId)?.reject(CODE_USER_CANCELLED, "User canceled flow")
} }
override fun onEvent(event: MyIdEvent) { override fun onEvent(event: MyIdEvent) {
...@@ -74,18 +91,20 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -74,18 +91,20 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
* by overriding the SDK's XML resources, not at runtime. * by overriding the SDK's XML resources, not at runtime.
*/ */
override fun start(config: ReadableMap, appearance: ReadableMap, promise: Promise) { override fun start(config: ReadableMap, appearance: ReadableMap, promise: Promise) {
if (pendingPromise != null) { // Resolved before claiming the slot, so a missing activity cannot wedge it.
promise.reject(CODE_FAILURE, "A MyID verification is already in progress")
return
}
val activity = currentActivity val activity = currentActivity
if (activity == null) { if (activity == null) {
promise.reject(CODE_FAILURE, "Cannot start MyID: no foreground activity") promise.reject(CODE_FAILURE, "Cannot start MyID: no foreground activity")
return return
} }
pendingPromise = promise val flow = PendingFlow(flowCounter.incrementAndGet(), promise)
// compareAndSet is both the single-flight guard and the claim, atomically.
if (!pending.compareAndSet(null, flow)) {
promise.reject(CODE_FAILURE, "A MyID verification is already in progress")
return
}
try { try {
val myIdConfig = MyidSdkConfigMapper.build(config, activity) val myIdConfig = MyidSdkConfigMapper.build(config, activity)
...@@ -93,28 +112,33 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -93,28 +112,33 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
activity = activity, activity = activity,
requestCode = MYID_REQUEST_CODE, requestCode = MYID_REQUEST_CODE,
config = myIdConfig, config = myIdConfig,
listener = resultListener listener = resultListenerFor(flow.id)
) )
} catch (error: Exception) { } catch (error: Exception) {
// Never leak the config into the message — it carries clientHash. // Never leak the config into the message — it carries clientHash.
takePending()?.reject(CODE_FAILURE, "Failed to start MyID: ${error.message}") takePending(flow.id)?.reject(CODE_FAILURE, "Failed to start MyID: ${error.message}")
} }
} }
override fun invalidate() { override fun invalidate() {
reactApplicationContext.removeActivityEventListener(activityEventListener) reactApplicationContext.removeActivityEventListener(activityEventListener)
takePending()?.reject(CODE_FAILURE, "MyID was interrupted because the app reloaded") pending.getAndSet(null)
?.promise
?.reject(CODE_FAILURE, "MyID was interrupted because the app reloaded")
super.invalidate() super.invalidate()
} }
/** /**
* Claims the pending promise so it can be settled exactly once — the SDK may * Claims the pending promise so it settles exactly once, and only for the flow
* invoke a listener callback more than once in edge cases. * that owns it — a duplicate callback from an earlier flow finds a different
* id and is dropped.
*/ */
private fun takePending(): Promise? { private fun takePending(flowId: Long): Promise? {
val promise = pendingPromise val flow = pending.get()
pendingPromise = null
return promise if (flow == null || flow.id != flowId) return null
return if (pending.compareAndSet(flow, null)) flow.promise else null
} }
private fun MyIdResult.toPayload(): WritableMap = Arguments.createMap().apply { private fun MyIdResult.toPayload(): WritableMap = Arguments.createMap().apply {
......
...@@ -121,6 +121,13 @@ static NSDictionary *MyidSdkAppearanceToDictionary(JS::NativeMyidSdk::MyIdAppear ...@@ -121,6 +121,13 @@ static NSDictionary *MyidSdkAppearanceToDictionary(JS::NativeMyidSdk::MyIdAppear
}]; }];
} }
/// Called when the module is torn down, e.g. a reload mid-flow. Mirrors the
/// Android module: settle the in-flight promise instead of leaving it hanging.
- (void)invalidate
{
[_impl cancelActive];
}
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule: - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params (const facebook::react::ObjCTurboModule::InitParams &)params
{ {
......
...@@ -8,8 +8,9 @@ import UIKit ...@@ -8,8 +8,9 @@ import UIKit
/// keeps whatever default `MyIdConfig()` / `MyIdAppearance()` already set rather /// keeps whatever default `MyIdConfig()` / `MyIdAppearance()` already set rather
/// than being overwritten with a value invented here. /// than being overwritten with a value invented here.
/// ///
/// Unrecognised enum strings fall back to the SDK default instead of throwing — /// An unrecognised enum string skips its assignment rather than throwing — a
/// a typo should not hard-fail a verification the user already started. /// typo should not hard-fail a verification the user already started, and
/// skipping is what actually leaves the SDK default in place.
enum MyidSdkConfigBuilder { enum MyidSdkConfigBuilder {
static func makeConfig( static func makeConfig(
...@@ -34,23 +35,32 @@ enum MyidSdkConfigBuilder { ...@@ -34,23 +35,32 @@ enum MyidSdkConfigBuilder {
config.showErrorScreen = showErrorScreen.boolValue config.showErrorScreen = showErrorScreen.boolValue
} }
// An unrecognised value maps to nil and the assignment is skipped, so the
// SDK keeps its own default. Substituting a guess would silently change
// behaviour on a typo.
if let environment = dictionary.enumValue(for: "environment") { if let environment = dictionary.enumValue(for: "environment") {
config.environment = environment == "DEBUG" ? .debug : .production switch environment {
case "DEBUG": config.environment = .debug
case "PRODUCTION": config.environment = .production
default: break
}
} }
if let entryType = dictionary.enumValue(for: "entryType") { if let entryType = dictionary.enumValue(for: "entryType") {
switch entryType { switch entryType {
case "FACE_DETECTION": config.entryType = .faceDetection case "IDENTIFICATION": config.entryType = .identification
case "VIDEO_IDENTIFICATION": config.entryType = .videoIdentification case "VIDEO_IDENTIFICATION": config.entryType = .videoIdentification
default: config.entryType = .identification case "FACE_DETECTION": config.entryType = .faceDetection
default: break
} }
} }
if let residency = dictionary.enumValue(for: "residency") { if let residency = dictionary.enumValue(for: "residency") {
switch residency { switch residency {
case "USER_DEFINED": config.residency = .userDefined case "USER_DEFINED": config.residency = .userDefined
case "RESIDENT": config.residency = .resident
case "NON_RESIDENT": config.residency = .nonResident case "NON_RESIDENT": config.residency = .nonResident
default: config.residency = .resident default: break
} }
} }
...@@ -58,26 +68,46 @@ enum MyidSdkConfigBuilder { ...@@ -58,26 +68,46 @@ enum MyidSdkConfigBuilder {
switch locale { switch locale {
case "ENGLISH": config.locale = .english case "ENGLISH": config.locale = .english
case "RUSSIAN": config.locale = .russian case "RUSSIAN": config.locale = .russian
// UZBEK_CYRILLIC, KARAKALPAK and TAJIK exist on Android only. // Documented platform fallback, not an unknown value: iOS has no
default: config.locale = .uzbek // Cyrillic, Karakalpak or Tajik locale.
case "UZBEK", "UZBEK_CYRILLIC", "KARAKALPAK", "TAJIK": config.locale = .uzbek
default: break
} }
} }
if let cameraShape = dictionary.enumValue(for: "cameraShape") { if let cameraShape = dictionary.enumValue(for: "cameraShape") {
config.cameraShape = cameraShape == "ELLIPSE" ? .ellipse : .circle switch cameraShape {
case "CIRCLE": config.cameraShape = .circle
case "ELLIPSE": config.cameraShape = .ellipse
default: break
}
} }
if let cameraSelector = dictionary.enumValue(for: "cameraSelector") { if let cameraSelector = dictionary.enumValue(for: "cameraSelector") {
config.cameraSelector = cameraSelector == "BACK" ? .back : .front switch cameraSelector {
case "FRONT": config.cameraSelector = .front
case "BACK": config.cameraSelector = .back
default: break
}
} }
if let presentationStyle = dictionary.enumValue(for: "presentationStyle") { if let presentationStyle = dictionary.enumValue(for: "presentationStyle") {
config.presentationStyle = presentationStyle == "SHEET" ? .sheet : .full switch presentationStyle {
case "SHEET": config.presentationStyle = .sheet
case "FULL": config.presentationStyle = .full
default: break
}
} }
if let details = dictionary["organizationDetails"] as? NSDictionary { // Only forwarded when the caller supplied something: an empty details object
// would replace the SDK's own default, which carries MyID's call-centre
// number. Each field is likewise assigned only when present.
if let details = dictionary["organizationDetails"] as? NSDictionary, details.count > 0 {
let organizationDetails = MyIdOrganizationDetails() let organizationDetails = MyIdOrganizationDetails()
organizationDetails.phoneNumber = details.string(for: "phone")
if let phone = details.string(for: "phone") {
organizationDetails.phoneNumber = phone
}
if let logoName = details.string(for: "logo") { if let logoName = details.string(for: "logo") {
organizationDetails.logo = UIImage(named: logoName) organizationDetails.logo = UIImage(named: logoName)
...@@ -93,21 +123,33 @@ enum MyidSdkConfigBuilder { ...@@ -93,21 +123,33 @@ enum MyidSdkConfigBuilder {
return config return config
} }
/// Every assignment is guarded: `MyIdAppearance()` seeds its own values, so
/// writing nil for a key the caller omitted would strip the SDK's defaults
/// rather than leave them alone. The same guard makes an unparseable color a
/// no-op instead of a wipe.
private static func makeAppearance(from dictionary: NSDictionary) -> MyIdAppearance { private static func makeAppearance(from dictionary: NSDictionary) -> MyIdAppearance {
let appearance = MyIdAppearance() let appearance = MyIdAppearance()
appearance.colorPrimary = dictionary.color(for: "colorPrimary") let colors: [(String, (UIColor) -> Void)] = [
appearance.colorOnPrimary = dictionary.color(for: "colorOnPrimary") ("colorPrimary", { appearance.colorPrimary = $0 }),
appearance.colorError = dictionary.color(for: "colorError") ("colorOnPrimary", { appearance.colorOnPrimary = $0 }),
appearance.colorOnError = dictionary.color(for: "colorOnError") ("colorError", { appearance.colorError = $0 }),
appearance.colorOutline = dictionary.color(for: "colorOutline") ("colorOnError", { appearance.colorOnError = $0 }),
appearance.colorDivider = dictionary.color(for: "colorDivider") ("colorOutline", { appearance.colorOutline = $0 }),
appearance.colorSuccess = dictionary.color(for: "colorSuccess") ("colorDivider", { appearance.colorDivider = $0 }),
appearance.colorButtonContainer = dictionary.color(for: "colorButtonContainer") ("colorSuccess", { appearance.colorSuccess = $0 }),
appearance.colorButtonContainerDisabled = dictionary.color(for: "colorButtonContainerDisabled") ("colorButtonContainer", { appearance.colorButtonContainer = $0 }),
appearance.colorButtonContent = dictionary.color(for: "colorButtonContent") ("colorButtonContainerDisabled", { appearance.colorButtonContainerDisabled = $0 }),
appearance.colorButtonContentDisabled = dictionary.color(for: "colorButtonContentDisabled") ("colorButtonContent", { appearance.colorButtonContent = $0 }),
appearance.colorScanButtonContainer = dictionary.color(for: "colorScanButtonContainer") ("colorButtonContentDisabled", { appearance.colorButtonContentDisabled = $0 }),
("colorScanButtonContainer", { appearance.colorScanButtonContainer = $0 }),
]
for (key, assign) in colors {
if let color = dictionary.color(for: key) {
assign(color)
}
}
if let cornerRadius = dictionary.number(for: "buttonCornerRadius") { if let cornerRadius = dictionary.number(for: "buttonCornerRadius") {
appearance.buttonCornerRadius = cornerRadius.floatValue appearance.buttonCornerRadius = cornerRadius.floatValue
......
...@@ -23,7 +23,10 @@ public final class MyidSdkImpl: NSObject { ...@@ -23,7 +23,10 @@ public final class MyidSdkImpl: NSObject {
static let codeFailure = "103" static let codeFailure = "103"
/// The SDK presents full screen, so at most one flow can be in flight. /// The SDK presents full screen, so at most one flow can be in flight.
/// Held strongly for the duration: MyIdClient does not retain its delegate. ///
/// Confined to the main queue: `start` is invoked on the JS thread while the
/// SDK's delegate callbacks arrive on the main thread, so an unsynchronised
/// field would race between the guard, the assignment and the clear.
private var activeDelegate: MyidSdkDelegateProxy? private var activeDelegate: MyidSdkDelegateProxy?
@objc @objc
...@@ -33,28 +36,36 @@ public final class MyidSdkImpl: NSObject { ...@@ -33,28 +36,36 @@ public final class MyidSdkImpl: NSObject {
onSuccess: @escaping SuccessHandler, onSuccess: @escaping SuccessHandler,
onError: @escaping ErrorHandler onError: @escaping ErrorHandler
) { ) {
guard activeDelegate == nil else { // The SDK presents view controllers, so it must be touched on the main
onError( // queue anyway; doing the whole state transition here keeps it race-free.
Self.codeFailure as NSString, DispatchQueue.main.async { [weak self] in
"A MyID verification is already in progress" as NSString guard let self else {
) onError(
return Self.codeFailure as NSString,
} "MyID module was released before the flow could start" as NSString
)
return
}
let delegate = MyidSdkDelegateProxy { [weak self] outcome in guard self.activeDelegate == nil else {
// Released before settling so a repeated SDK callback cannot settle twice. onError(
self?.activeDelegate = nil Self.codeFailure as NSString,
"A MyID verification is already in progress" as NSString
)
return
}
switch outcome { let delegate = MyidSdkDelegateProxy { [weak self] outcome in
case let .success(payload): onSuccess(payload) self?.activeDelegate = nil
case let .failure(code, message): onError(code as NSString, message as NSString)
switch outcome {
case let .success(payload): onSuccess(payload)
case let .failure(code, message): onError(code as NSString, message as NSString)
}
} }
}
activeDelegate = delegate self.activeDelegate = delegate
// The SDK presents view controllers, so it must be touched on the main queue.
DispatchQueue.main.async {
let myIdConfig = MyidSdkConfigBuilder.makeConfig( let myIdConfig = MyidSdkConfigBuilder.makeConfig(
from: config, from: config,
appearance: appearance appearance: appearance
...@@ -63,6 +74,22 @@ public final class MyidSdkImpl: NSObject { ...@@ -63,6 +74,22 @@ public final class MyidSdkImpl: NSObject {
MyIdClient.start(withConfig: myIdConfig, withDelegate: delegate) MyIdClient.start(withConfig: myIdConfig, withDelegate: delegate)
} }
} }
/// Settles an in-flight flow, mirroring the Android module's `invalidate`.
/// Without this a reload mid-flow leaves the promise hanging forever and the
/// proxy deallocated while the SDK still holds it.
@objc
public func cancelActive() {
DispatchQueue.main.async { [weak self] in
guard let delegate = self?.activeDelegate else { return }
self?.activeDelegate = nil
delegate.cancel(
code: Self.codeFailure,
message: "MyID was interrupted because the app reloaded"
)
}
}
} }
/// Outcome of a single verification attempt. /// Outcome of a single verification attempt.
...@@ -84,10 +111,19 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate { ...@@ -84,10 +111,19 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
/// Claims the callback so the promise settles exactly once — a delegate /// Claims the callback so the promise settles exactly once — a delegate
/// method can fire more than once in edge cases. /// method can fire more than once in edge cases.
private func take() -> ((MyidSdkOutcome) -> Void)? { ///
let callback = settle /// `withExtendedLifetime` because the callback clears the only strong
/// reference to this proxy, which would otherwise deallocate it while a
/// delegate method is still on the stack.
private func settleOnce(_ outcome: MyidSdkOutcome) {
guard let callback = settle else { return }
settle = nil settle = nil
return callback
withExtendedLifetime(self) { callback(outcome) }
}
func cancel(code: String, message: String) {
settleOnce(.failure(code: code, message: message))
} }
func onSuccess(result: MyIdResult) { func onSuccess(result: MyIdResult) {
...@@ -100,15 +136,15 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate { ...@@ -100,15 +136,15 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
payload["base64"] = base64 payload["base64"] = base64
} }
take()?(.success(payload)) settleOnce(.success(payload))
} }
func onError(exception: MyIdException) { func onError(exception: MyIdException) {
take()?(.failure(code: String(exception.code), message: exception.message)) settleOnce(.failure(code: String(exception.code), message: exception.message))
} }
func onUserExited() { func onUserExited() {
take()?( settleOnce(
.failure(code: MyidSdkImpl.codeUserCancelled, message: "User canceled flow") .failure(code: MyidSdkImpl.codeUserCancelled, message: "User canceled flow")
) )
} }
......
...@@ -17,7 +17,12 @@ import type { ...@@ -17,7 +17,12 @@ import type {
export type MyIdOrganizationDetails = { export type MyIdOrganizationDetails = {
/** /**
* Call-centre number shown on the error screen. * Call-centre number shown on the error screen.
* Defaults to MyID's own number (712022202) when omitted. * Defaults to MyID's own number (712022202) when `organizationDetails` is
* omitted entirely.
*
* Note: the SDK replaces the whole details object, so passing
* `organizationDetails` with only a `logo` clears that default number. Pass
* `phone` alongside `logo` if you want a number shown.
*/ */
phone?: string; phone?: string;
/** /**
...@@ -45,7 +50,10 @@ export type MyIdConfig = { ...@@ -45,7 +50,10 @@ export type MyIdConfig = {
clientHashId: string; clientHashId: string;
/** Minimum age the user must meet. SDK default: 16. */ /** Minimum age the user must meet. SDK default: 16. */
minAge?: number; minAge?: number;
/** Face-match threshold, 0..1. SDK default: 0.65 (Android) / 0.60 (iOS). */ /**
* Face-match threshold, 0..1. Values outside that range are ignored by the
* SDK. Android's default depends on `cameraShape` (0.65 circle, 0.6 ellipse).
*/
distance?: number; distance?: number;
/** /**
* When set to `USER_DEFINED` and no passport data is available, the SDK shows * When set to `USER_DEFINED` and no passport data is available, the SDK shows
...@@ -72,13 +80,16 @@ export type MyIdConfig = { ...@@ -72,13 +80,16 @@ export type MyIdConfig = {
cameraSelector?: MyIdCameraSelector; cameraSelector?: MyIdCameraSelector;
/** @platform android — ignored on iOS. SDK default: `LOW`. */ /** @platform android — ignored on iOS. SDK default: `LOW`. */
cameraResolution?: MyIdCameraResolution; cameraResolution?: MyIdCameraResolution;
/** @platform android — ignored on iOS. SDK default: `PNG`. */ /** @platform android — ignored on iOS. SDK default: `JPEG`. */
imageFormat?: MyIdImageFormat; imageFormat?: MyIdImageFormat;
/** @platform android — ignored on iOS. */ /** @platform android — ignored on iOS. */
screenOrientation?: MyIdScreenOrientation; screenOrientation?: MyIdScreenOrientation;
/** @platform ios — ignored on Android. How the SDK is presented modally. */ /** @platform ios — ignored on Android. How the SDK is presented modally. */
presentationStyle?: MyIdPresentationStyle; presentationStyle?: MyIdPresentationStyle;
/** @platform android — ignored on iOS. Audio guidance during capture. */ /**
* @platform android — ignored on iOS.
* Audio guidance during capture. SDK default: `true`.
*/
withSoundGuides?: boolean; withSoundGuides?: boolean;
/** Whether the SDK renders its own error screens. SDK default: `true`. */ /** Whether the SDK renders its own error screens. SDK default: `true`. */
showErrorScreen?: boolean; showErrorScreen?: boolean;
......
...@@ -75,12 +75,25 @@ function readErrorMessage(error: unknown): string { ...@@ -75,12 +75,25 @@ function readErrorMessage(error: unknown): string {
export async function start(options: StartMyIdOptions): Promise<MyIdResult> { export async function start(options: StartMyIdOptions): Promise<MyIdResult> {
const { config, iosAppearance } = options; const { config, iosAppearance } = options;
const payload = stripUndefined({ // An organizationDetails that strips to {} must not be forwarded: native would
// build an empty details object and overwrite the SDK's own default, which
// carries MyID's call-centre number.
//
// The ternary also neutralises an explicit null. That matters more than it
// looks: the generated iOS accessor only checks for nil, not NSNull, so a null
// here would construct the param struct around NSNull and crash on first
// access.
const organizationDetails = config.organizationDetails
? stripUndefined<MyIdOrganizationDetailsInput>(config.organizationDetails)
: undefined;
const payload = stripUndefined<MyIdConfigInput>({
...config, ...config,
organizationDetails: config.organizationDetails organizationDetails:
? stripUndefined<MyIdOrganizationDetailsInput>(config.organizationDetails) organizationDetails && Object.keys(organizationDetails).length > 0
: undefined, ? organizationDetails
}) as MyIdConfigInput; : undefined,
});
const appearance: MyIdAppearanceInput = iosAppearance const appearance: MyIdAppearanceInput = iosAppearance
? stripUndefined<MyIdAppearanceInput>(iosAppearance) ? stripUndefined<MyIdAppearanceInput>(iosAppearance)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment