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({
|---|---|
| React Native | New Architecture enabled (`newArchEnabled=true`, the default since 0.76). Verified against 0.86.2. |
| 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
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.
| `clientHash` | string | — | both |
| `clientHashId` | string | — | 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 |
| `environment` | `PRODUCTION` `DEBUG` | `PRODUCTION` | 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.
| `cameraShape` | `CIRCLE` `ELLIPSE` | `CIRCLE` | both |
| `cameraSelector` | `FRONT` `BACK` | `FRONT` | both |
| `cameraResolution` | `LOW` `HIGH` | `LOW` | android |
| `imageFormat` | `JPEG` `PNG` | `PNG` | android |
| `imageFormat` | `JPEG` `PNG` | `JPEG` | android |
| `screenOrientation` | `PORTRAIT` `LANDSCAPE` `FULL` | `PORTRAIT` | android |
| `withSoundGuides` | boolean | SDK default | android |
| `withSoundGuides` | boolean | `true` | android |
| `huaweiAppId` | string | — | android |
| `presentationStyle` | `SHEET` `FULL` | `FULL` | ios |
| `showErrorScreen` | boolean | `true` | both |
......@@ -129,6 +129,10 @@ as unstable.
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.
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
iOS theming is runtime, via `iosAppearance`:
......@@ -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 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
......
......@@ -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
* a value invented here.
*
* Unrecognised enum strings fall back to the SDK default rather than throwing —
* a typo should not hard-fail a verification the user already started.
* An unrecognised enum string skips its setter rather than throwing — a typo
* should not hard-fail a verification the user already started, and skipping is
* what actually leaves the SDK default in place.
*/
internal object MyidSdkConfigMapper {
......@@ -39,95 +40,98 @@ internal object MyidSdkConfigMapper {
config.optBoolean("showErrorScreen")?.let { builder.withErrorScreen(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 {
builder.withEnvironment(
when (it) {
"DEBUG" -> MyIdEnvironment.Debug
else -> MyIdEnvironment.Production
}
)
"PRODUCTION" -> MyIdEnvironment.Production
else -> null
}
}?.let(builder::withEnvironment)
config.optEnum("entryType")?.let {
builder.withEntryType(
when (it) {
"FACE_DETECTION" -> MyIdEntryType.FaceDetection
"IDENTIFICATION" -> MyIdEntryType.Identification
"VIDEO_IDENTIFICATION" -> MyIdEntryType.VideoIdentification
else -> MyIdEntryType.Identification
}
)
"FACE_DETECTION" -> MyIdEntryType.FaceDetection
else -> null
}
}?.let(builder::withEntryType)
config.optEnum("residency")?.let {
builder.withResidency(
when (it) {
"USER_DEFINED" -> MyIdResidency.UserDefined
"RESIDENT" -> MyIdResidency.Resident
"NON_RESIDENT" -> MyIdResidency.NonResident
else -> MyIdResidency.Resident
}
)
else -> null
}
}?.let(builder::withResidency)
config.optEnum("locale")?.let {
builder.withLocale(
when (it) {
"UZBEK" -> MyIdLocale.Uzbek
"UZBEK_CYRILLIC" -> MyIdLocale.UzbekCyrillic
"KARAKALPAK" -> MyIdLocale.Karakalpak
"TAJIK" -> MyIdLocale.Tajik
"ENGLISH" -> MyIdLocale.English
"RUSSIAN" -> MyIdLocale.Russian
else -> MyIdLocale.Uzbek
}
)
else -> null
}
}?.let(builder::withLocale)
config.optEnum("cameraShape")?.let {
builder.withCameraShape(
when (it) {
"CIRCLE" -> MyIdCameraShape.Circle
"ELLIPSE" -> MyIdCameraShape.Ellipse
else -> MyIdCameraShape.Circle
}
)
else -> null
}
}?.let(builder::withCameraShape)
config.optEnum("cameraSelector")?.let {
builder.withCameraSelector(
when (it) {
"FRONT" -> MyIdCameraSelector.Front
"BACK" -> MyIdCameraSelector.Back
else -> MyIdCameraSelector.Front
}
)
else -> null
}
}?.let(builder::withCameraSelector)
config.optEnum("cameraResolution")?.let {
builder.withCameraResolution(
when (it) {
"LOW" -> MyIdCameraResolution.Low
"HIGH" -> MyIdCameraResolution.High
else -> MyIdCameraResolution.Low
}
)
else -> null
}
}?.let(builder::withCameraResolution)
config.optEnum("imageFormat")?.let {
builder.withImageFormat(
when (it) {
"JPEG" -> MyIdImageFormat.JPEG
else -> MyIdImageFormat.PNG
}
)
"PNG" -> MyIdImageFormat.PNG
else -> null
}
}?.let(builder::withImageFormat)
config.optEnum("screenOrientation")?.let {
builder.withScreenOrientation(
when (it) {
"PORTRAIT" -> MyIdScreenOrientation.Portrait
"LANDSCAPE" -> MyIdScreenOrientation.Landscape
"FULL" -> MyIdScreenOrientation.Full
else -> MyIdScreenOrientation.Portrait
}
)
}
config.optMap("organizationDetails")?.let { details ->
else -> null
}
}?.let(builder::withScreenOrientation)
// Only forwarded when the caller actually supplied something. withOrganizationDetails
// copies the object verbatim, so an empty one would replace the SDK default
// (which carries MyID's call-centre number) with blanks.
//
// Note the same copy semantics mean supplying only `logo` clears the default
// 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"),
......
......@@ -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.MyIdGraphicFieldType
import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
class MyidSdkModule(reactContext: ReactApplicationContext) :
NativeMyidSdkSpec(reactContext) {
......@@ -27,8 +29,21 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
* 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
* 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() {
override fun onActivityResult(
......@@ -41,22 +56,24 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
// filtering here keeps unrelated results from settling our promise.
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) {
val promise = takePending() ?: return
val promise = takePending(flowId) ?: return
promise.resolve(result.toPayload())
}
override fun onError(exception: MyIdException) {
takePending()?.reject(exception.code.toString(), exception.message)
takePending(flowId)?.reject(exception.code.toString(), exception.message)
}
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) {
......@@ -74,18 +91,20 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
* by overriding the SDK's XML resources, not at runtime.
*/
override fun start(config: ReadableMap, appearance: ReadableMap, promise: Promise) {
if (pendingPromise != null) {
promise.reject(CODE_FAILURE, "A MyID verification is already in progress")
return
}
// Resolved before claiming the slot, so a missing activity cannot wedge it.
val activity = currentActivity
if (activity == null) {
promise.reject(CODE_FAILURE, "Cannot start MyID: no foreground activity")
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 {
val myIdConfig = MyidSdkConfigMapper.build(config, activity)
......@@ -93,28 +112,33 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
activity = activity,
requestCode = MYID_REQUEST_CODE,
config = myIdConfig,
listener = resultListener
listener = resultListenerFor(flow.id)
)
} catch (error: Exception) {
// 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() {
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()
}
/**
* Claims the pending promise so it can be settled exactly once — the SDK may
* invoke a listener callback more than once in edge cases.
* Claims the pending promise so it settles exactly once, and only for the flow
* that owns it — a duplicate callback from an earlier flow finds a different
* id and is dropped.
*/
private fun takePending(): Promise? {
val promise = pendingPromise
pendingPromise = null
return promise
private fun takePending(flowId: Long): Promise? {
val flow = pending.get()
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 {
......
......@@ -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:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
......
......@@ -8,8 +8,9 @@ import UIKit
/// keeps whatever default `MyIdConfig()` / `MyIdAppearance()` already set rather
/// than being overwritten with a value invented here.
///
/// Unrecognised enum strings fall back to the SDK default instead of throwing —
/// a typo should not hard-fail a verification the user already started.
/// An unrecognised enum string skips its assignment rather than throwing — a
/// typo should not hard-fail a verification the user already started, and
/// skipping is what actually leaves the SDK default in place.
enum MyidSdkConfigBuilder {
static func makeConfig(
......@@ -34,23 +35,32 @@ enum MyidSdkConfigBuilder {
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") {
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") {
switch entryType {
case "FACE_DETECTION": config.entryType = .faceDetection
case "IDENTIFICATION": config.entryType = .identification
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") {
switch residency {
case "USER_DEFINED": config.residency = .userDefined
case "RESIDENT": config.residency = .resident
case "NON_RESIDENT": config.residency = .nonResident
default: config.residency = .resident
default: break
}
}
......@@ -58,26 +68,46 @@ enum MyidSdkConfigBuilder {
switch locale {
case "ENGLISH": config.locale = .english
case "RUSSIAN": config.locale = .russian
// UZBEK_CYRILLIC, KARAKALPAK and TAJIK exist on Android only.
default: config.locale = .uzbek
// Documented platform fallback, not an unknown value: iOS has no
// Cyrillic, Karakalpak or Tajik locale.
case "UZBEK", "UZBEK_CYRILLIC", "KARAKALPAK", "TAJIK": config.locale = .uzbek
default: break
}
}
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") {
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") {
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()
organizationDetails.phoneNumber = details.string(for: "phone")
if let phone = details.string(for: "phone") {
organizationDetails.phoneNumber = phone
}
if let logoName = details.string(for: "logo") {
organizationDetails.logo = UIImage(named: logoName)
......@@ -93,21 +123,33 @@ enum MyidSdkConfigBuilder {
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 {
let appearance = MyIdAppearance()
appearance.colorPrimary = dictionary.color(for: "colorPrimary")
appearance.colorOnPrimary = dictionary.color(for: "colorOnPrimary")
appearance.colorError = dictionary.color(for: "colorError")
appearance.colorOnError = dictionary.color(for: "colorOnError")
appearance.colorOutline = dictionary.color(for: "colorOutline")
appearance.colorDivider = dictionary.color(for: "colorDivider")
appearance.colorSuccess = dictionary.color(for: "colorSuccess")
appearance.colorButtonContainer = dictionary.color(for: "colorButtonContainer")
appearance.colorButtonContainerDisabled = dictionary.color(for: "colorButtonContainerDisabled")
appearance.colorButtonContent = dictionary.color(for: "colorButtonContent")
appearance.colorButtonContentDisabled = dictionary.color(for: "colorButtonContentDisabled")
appearance.colorScanButtonContainer = dictionary.color(for: "colorScanButtonContainer")
let colors: [(String, (UIColor) -> Void)] = [
("colorPrimary", { appearance.colorPrimary = $0 }),
("colorOnPrimary", { appearance.colorOnPrimary = $0 }),
("colorError", { appearance.colorError = $0 }),
("colorOnError", { appearance.colorOnError = $0 }),
("colorOutline", { appearance.colorOutline = $0 }),
("colorDivider", { appearance.colorDivider = $0 }),
("colorSuccess", { appearance.colorSuccess = $0 }),
("colorButtonContainer", { appearance.colorButtonContainer = $0 }),
("colorButtonContainerDisabled", { appearance.colorButtonContainerDisabled = $0 }),
("colorButtonContent", { appearance.colorButtonContent = $0 }),
("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") {
appearance.buttonCornerRadius = cornerRadius.floatValue
......
......@@ -23,7 +23,10 @@ public final class MyidSdkImpl: NSObject {
static let codeFailure = "103"
/// 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?
@objc
......@@ -33,7 +36,18 @@ public final class MyidSdkImpl: NSObject {
onSuccess: @escaping SuccessHandler,
onError: @escaping ErrorHandler
) {
guard activeDelegate == nil else {
// The SDK presents view controllers, so it must be touched on the main
// queue anyway; doing the whole state transition here keeps it race-free.
DispatchQueue.main.async { [weak self] in
guard let self else {
onError(
Self.codeFailure as NSString,
"MyID module was released before the flow could start" as NSString
)
return
}
guard self.activeDelegate == nil else {
onError(
Self.codeFailure as NSString,
"A MyID verification is already in progress" as NSString
......@@ -42,7 +56,6 @@ public final class MyidSdkImpl: NSObject {
}
let delegate = MyidSdkDelegateProxy { [weak self] outcome in
// Released before settling so a repeated SDK callback cannot settle twice.
self?.activeDelegate = nil
switch outcome {
......@@ -51,10 +64,8 @@ public final class MyidSdkImpl: NSObject {
}
}
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(
from: config,
appearance: appearance
......@@ -63,6 +74,22 @@ public final class MyidSdkImpl: NSObject {
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.
......@@ -84,10 +111,19 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
/// Claims the callback so the promise settles exactly once — a delegate
/// 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
return callback
withExtendedLifetime(self) { callback(outcome) }
}
func cancel(code: String, message: String) {
settleOnce(.failure(code: code, message: message))
}
func onSuccess(result: MyIdResult) {
......@@ -100,15 +136,15 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
payload["base64"] = base64
}
take()?(.success(payload))
settleOnce(.success(payload))
}
func onError(exception: MyIdException) {
take()?(.failure(code: String(exception.code), message: exception.message))
settleOnce(.failure(code: String(exception.code), message: exception.message))
}
func onUserExited() {
take()?(
settleOnce(
.failure(code: MyidSdkImpl.codeUserCancelled, message: "User canceled flow")
)
}
......
......@@ -17,7 +17,12 @@ import type {
export type MyIdOrganizationDetails = {
/**
* 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;
/**
......@@ -45,7 +50,10 @@ export type MyIdConfig = {
clientHashId: string;
/** Minimum age the user must meet. SDK default: 16. */
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;
/**
* When set to `USER_DEFINED` and no passport data is available, the SDK shows
......@@ -72,13 +80,16 @@ export type MyIdConfig = {
cameraSelector?: MyIdCameraSelector;
/** @platform android — ignored on iOS. SDK default: `LOW`. */
cameraResolution?: MyIdCameraResolution;
/** @platform android — ignored on iOS. SDK default: `PNG`. */
/** @platform android — ignored on iOS. SDK default: `JPEG`. */
imageFormat?: MyIdImageFormat;
/** @platform android — ignored on iOS. */
screenOrientation?: MyIdScreenOrientation;
/** @platform ios — ignored on Android. How the SDK is presented modally. */
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;
/** Whether the SDK renders its own error screens. SDK default: `true`. */
showErrorScreen?: boolean;
......
......@@ -75,12 +75,25 @@ function readErrorMessage(error: unknown): string {
export async function start(options: StartMyIdOptions): Promise<MyIdResult> {
const { config, iosAppearance } = options;
const payload = stripUndefined({
...config,
organizationDetails: config.organizationDetails
// 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,
organizationDetails:
organizationDetails && Object.keys(organizationDetails).length > 0
? organizationDetails
: undefined,
}) as MyIdConfigInput;
});
const appearance: 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