Commit befb2c86 authored by Javokhir's avatar Javokhir
Browse files

refactor: tighten inline comments across the native bridge

Keeps the non-obvious rationale (pod naming, repository injection, one-shot
promise settling, optional guards) and drops restatements of what the code
already shows.
parent 954742e1
...@@ -87,3 +87,6 @@ nitrogen/ ...@@ -87,3 +87,6 @@ nitrogen/
# local demo credentials # local demo credentials
example/.env* example/.env*
# Local notes, not part of the package
plans/
...@@ -2,11 +2,9 @@ require "json" ...@@ -2,11 +2,9 @@ require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json"))) package = JSON.parse(File.read(File.join(__dir__, "package.json")))
# The pod is deliberately NOT named "MyidSdk": that differs from the vendor pod # Not named "MyidSdk": that differs from the vendor pod "MyIdSDK" only by case,
# "MyIdSDK" only by case, and on a case-insensitive filesystem the two targets # and on a case-insensitive filesystem both targets share one build directory,
# collide on the same intermediate build directory, so this pod's generated # leaving this pod's generated Swift header where ObjC++ cannot import it.
# Swift header lands in the vendor's build dir and the ObjC++ layer cannot
# import it. The JS module name is unaffected and stays "MyidSdk".
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = "RNMyidSdk" s.name = "RNMyidSdk"
s.version = package["version"] s.version = package["version"]
...@@ -21,12 +19,11 @@ Pod::Spec.new do |s| ...@@ -21,12 +19,11 @@ Pod::Spec.new do |s|
s.source_files = "ios/**/*.{h,m,mm,swift,cpp}" s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
s.private_header_files = "ios/**/*.h" s.private_header_files = "ios/**/*.h"
# Binary XCFramework distributed on the public CocoaPods trunk.
s.dependency "MyIdSDK", "3.1.3" s.dependency "MyIdSDK", "3.1.3"
s.swift_version = "5.0" s.swift_version = "5.0"
# DEFINES_MODULE lets the ObjC++ layer import the generated RNMyidSdk-Swift.h. # Lets the ObjC++ layer import the generated RNMyidSdk-Swift.h.
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }
install_modules_dependencies(s) install_modules_dependencies(s)
......
...@@ -3,8 +3,7 @@ buildscript { ...@@ -3,8 +3,7 @@ buildscript {
kotlinVersion: "2.0.21", kotlinVersion: "2.0.21",
minSdkVersion: 24, minSdkVersion: 24,
compileSdkVersion: 36, compileSdkVersion: 36,
// Override from the app's root build.gradle: // Override with: ext { myIdCaptureSdkVersion = "3.1.9" }
// ext { myIdCaptureSdkVersion = "3.1.9" }
myIdCaptureSdkVersion: "3.1.9" myIdCaptureSdkVersion: "3.1.9"
] ]
...@@ -53,20 +52,12 @@ android { ...@@ -53,20 +52,12 @@ android {
} }
} }
// MyID artifacts live outside Maven Central: // MyID artifacts live outside Maven Central. Registered on every project, not
// artifactory.aigroup.uz - the SDK itself, public read access, no credentials // just this one: a consuming app resolves our transitive dependencies against
// developer.huawei.com - com.huawei.hms:safetydetect, pulled in by // its own repository set, so a module-level block here is not enough.
// myid-integrity-sdk, which the capture SDK depends on
// //
// These are registered on every project, not just this one, because a consuming // Apps managing repositories centrally (FAIL_ON_PROJECT_REPOS) should set
// app resolves our transitive dependencies against ITS OWN repository set when // myIdSkipRepositoryInjection=true and declare the URLs themselves.
// building its runtime classpath. A module-level repositories block here is
// enough to compile this library in isolation but still fails the app build
// with "Could not find uz.myid.sdk.capture:myid-capture-sdk".
//
// Apps that manage repositories centrally (dependencyResolutionManagement with
// FAIL_ON_PROJECT_REPOS) must set myIdSkipRepositoryInjection=true in
// gradle.properties and declare the two URLs themselves — see the README.
if (!rootProject.hasProperty("myIdSkipRepositoryInjection")) { if (!rootProject.hasProperty("myIdSkipRepositoryInjection")) {
rootProject.allprojects { rootProject.allprojects {
repositories { repositories {
...@@ -86,9 +77,8 @@ repositories { ...@@ -86,9 +77,8 @@ repositories {
dependencies { dependencies {
implementation "com.facebook.react:react-android" implementation "com.facebook.react:react-android"
// The debug artifact is a separate, non-obfuscated build of the same SDK. // The debug artifact is a non-obfuscated build of the same SDK. Apps with
// Apps that define build types other than debug/release must declare // build types beyond debug/release need matchingFallbacks — see the README.
// matchingFallbacks for them, otherwise resolution fails — see the README.
debugImplementation "uz.myid.sdk.capture:myid-capture-sdk-debug:${getExtOrDefault('myIdCaptureSdkVersion')}" debugImplementation "uz.myid.sdk.capture:myid-capture-sdk-debug:${getExtOrDefault('myIdCaptureSdkVersion')}"
releaseImplementation "uz.myid.sdk.capture:myid-capture-sdk:${getExtOrDefault('myIdCaptureSdkVersion')}" releaseImplementation "uz.myid.sdk.capture:myid-capture-sdk:${getExtOrDefault('myIdCaptureSdkVersion')}"
} }
...@@ -17,13 +17,8 @@ import uz.myid.android.sdk.capture.model.MyIdScreenOrientation ...@@ -17,13 +17,8 @@ import uz.myid.android.sdk.capture.model.MyIdScreenOrientation
/** /**
* Translates the JS config payload into a [MyIdConfig]. * Translates the JS config payload into a [MyIdConfig].
* *
* A `withX(...)` call only happens when the key is actually present, so an * A setter is only called when its key is present, so an omitted option — or an
* omitted option keeps the SDK's own default instead of being overwritten with * unrecognised enum string — leaves the SDK default in place.
* a value invented here.
*
* 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 { internal object MyidSdkConfigMapper {
...@@ -40,10 +35,6 @@ internal object MyidSdkConfigMapper { ...@@ -40,10 +35,6 @@ 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 {
when (it) { when (it) {
"DEBUG" -> MyIdEnvironment.Debug "DEBUG" -> MyIdEnvironment.Debug
...@@ -123,12 +114,8 @@ internal object MyidSdkConfigMapper { ...@@ -123,12 +114,8 @@ internal object MyidSdkConfigMapper {
} }
}?.let(builder::withScreenOrientation) }?.let(builder::withScreenOrientation)
// Only forwarded when the caller actually supplied something. withOrganizationDetails // withOrganizationDetails copies the object verbatim, so an empty one would
// copies the object verbatim, so an empty one would replace the SDK default // blank out the SDK default carrying MyID's call-centre number.
// (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") config.optMap("organizationDetails")
?.takeIf { it.hasKey("phone") || it.hasKey("logo") } ?.takeIf { it.hasKey("phone") || it.hasKey("logo") }
?.let { details -> ?.let { details ->
...@@ -149,7 +136,7 @@ internal object MyidSdkConfigMapper { ...@@ -149,7 +136,7 @@ internal object MyidSdkConfigMapper {
private fun ReadableMap.optString(key: String): String? = private fun ReadableMap.optString(key: String): String? =
if (hasKey(key) && !isNull(key)) getString(key) else null if (hasKey(key) && !isNull(key)) getString(key) else null
/** Enum values travel as strings; normalise case so 'debug' works like 'DEBUG'. */ /** Normalise case so 'debug' works like 'DEBUG'. */
private fun ReadableMap.optEnum(key: String): String? = optString(key)?.uppercase() private fun ReadableMap.optEnum(key: String): String? = optString(key)?.uppercase()
private fun ReadableMap.optInt(key: String): Int? = private fun ReadableMap.optInt(key: String): Int? =
...@@ -164,7 +151,7 @@ internal object MyidSdkConfigMapper { ...@@ -164,7 +151,7 @@ internal object MyidSdkConfigMapper {
private fun ReadableMap.optMap(key: String): ReadableMap? = private fun ReadableMap.optMap(key: String): ReadableMap? =
if (hasKey(key) && !isNull(key)) getMap(key) else null if (hasKey(key) && !isNull(key)) getMap(key) else null
/** Resolves a drawable by name; a missing asset degrades to no logo. */ /** A missing asset degrades to no logo. */
private fun Context.drawableIdOrNull(name: String): Int? = private fun Context.drawableIdOrNull(name: String): Int? =
resources.getIdentifier(name, "drawable", packageName).takeIf { it != 0 } resources.getIdentifier(name, "drawable", packageName).takeIf { it != 0 }
} }
...@@ -26,21 +26,13 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -26,21 +26,13 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
private val client = MyIdClient() private val client = MyIdClient()
/** /**
* The SDK owns the screen for the whole flow, so at most one verification can * At most one verification can be in flight. Atomic because three threads
* be in flight. Guarded rather than queued: a second concurrent call is a * touch it: start() on the JS thread, onActivityResult on the UI thread and
* caller bug, not something to silently serialise. * invalidate() on the native-modules thread.
*
* 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 val pending = AtomicReference<PendingFlow?>(null) private val pending = AtomicReference<PendingFlow?>(null)
/** /** Keeps a late duplicate callback from settling a newer flow's promise. */
* 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 val flowCounter = AtomicLong(0)
private data class PendingFlow(val id: Long, val promise: Promise) private data class PendingFlow(val id: Long, val promise: Promise)
...@@ -52,8 +44,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -52,8 +44,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
resultCode: Int, resultCode: Int,
data: Intent? data: Intent?
) { ) {
// handleActivityResult does not check the request code itself, so // handleActivityResult ignores the request code, so filter here.
// filtering here keeps unrelated results from settling our promise.
if (requestCode != MYID_REQUEST_CODE) return if (requestCode != MYID_REQUEST_CODE) return
val flowId = pending.get()?.id ?: return val flowId = pending.get()?.id ?: return
...@@ -76,22 +67,16 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -76,22 +67,16 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
takePending(flowId)?.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) = Unit
// Progress telemetry (camera opened, face captured, ...). Not surfaced:
// the JS API is a single promise, and the SDK renders its own progress.
}
} }
init { init {
reactContext.addActivityEventListener(activityEventListener) reactContext.addActivityEventListener(activityEventListener)
} }
/** /** @param appearance unused: Android theming is XML resource overrides. */
* @param appearance accepted for bridge symmetry; theming on Android is done
* 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) {
// Resolved before claiming the slot, so a missing activity cannot wedge it. // Resolved before claiming the slot so a missing activity cannot wedge it.
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")
...@@ -100,7 +85,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -100,7 +85,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
val flow = PendingFlow(flowCounter.incrementAndGet(), promise) val flow = PendingFlow(flowCounter.incrementAndGet(), promise)
// compareAndSet is both the single-flight guard and the claim, atomically. // Guard and claim in one atomic step.
if (!pending.compareAndSet(null, flow)) { if (!pending.compareAndSet(null, flow)) {
promise.reject(CODE_FAILURE, "A MyID verification is already in progress") promise.reject(CODE_FAILURE, "A MyID verification is already in progress")
return return
...@@ -115,7 +100,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -115,7 +100,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
listener = resultListenerFor(flow.id) listener = resultListenerFor(flow.id)
) )
} catch (error: Exception) { } catch (error: Exception) {
// Never leak the config into the message — it carries clientHash. // Never put the config in the message: it carries clientHash.
takePending(flow.id)?.reject(CODE_FAILURE, "Failed to start MyID: ${error.message}") takePending(flow.id)?.reject(CODE_FAILURE, "Failed to start MyID: ${error.message}")
} }
} }
...@@ -128,11 +113,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -128,11 +113,7 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
super.invalidate() super.invalidate()
} }
/** /** Settles exactly once, and only for the flow that owns the promise. */
* 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(flowId: Long): Promise? { private fun takePending(flowId: Long): Promise? {
val flow = pending.get() val flow = pending.get()
...@@ -144,10 +125,10 @@ class MyidSdkModule(reactContext: ReactApplicationContext) : ...@@ -144,10 +125,10 @@ class MyidSdkModule(reactContext: ReactApplicationContext) :
private fun MyIdResult.toPayload(): WritableMap = Arguments.createMap().apply { private fun MyIdResult.toPayload(): WritableMap = Arguments.createMap().apply {
putString("code", code) putString("code", code)
// A missing portrait must not fail an otherwise successful verification.
val portrait = try { val portrait = try {
getGraphicFieldImageByType(MyIdGraphicFieldType.FacePortrait) getGraphicFieldImageByType(MyIdGraphicFieldType.FacePortrait)
} catch (error: Exception) { } catch (error: Exception) {
// A missing portrait must not fail an otherwise successful verification.
null null
} }
......
#import "MyidSdk.h" #import "MyidSdk.h"
// Generated by the Swift compiler from MyidSdkImpl.swift. The bracketed form is // Framework linkage resolves the bracketed form; static linkage the quoted one.
// used when the pod is built as a framework; the quoted fallback covers static
// linkage, where the header is copied next to the sources instead.
#if __has_include(<RNMyidSdk/RNMyidSdk-Swift.h>) #if __has_include(<RNMyidSdk/RNMyidSdk-Swift.h>)
#import <RNMyidSdk/RNMyidSdk-Swift.h> #import <RNMyidSdk/RNMyidSdk-Swift.h>
#else #else
...@@ -10,12 +8,11 @@ ...@@ -10,12 +8,11 @@
#endif #endif
/** /**
* Codegen hands this layer C++ structs (JS::NativeMyidSdk::*Input), which Swift * Codegen hands this layer C++ structs that Swift cannot see, so the only job
* cannot see. So the only job here is to flatten them into NSDictionary and * here is to flatten them into NSDictionary for MyidSdkImpl.
* hand off to MyidSdkImpl, which owns all the actual SDK interaction.
* *
* Optional fields are inserted only when they carry a value, so an omitted * Optional fields are inserted only when set, so an omitted option stays absent
* option stays absent all the way down and the SDK keeps its own default. * and the SDK keeps its default.
*/ */
@implementation MyidSdk { @implementation MyidSdk {
MyidSdkImpl *_impl; MyidSdkImpl *_impl;
...@@ -30,7 +27,7 @@ ...@@ -30,7 +27,7 @@
return self; return self;
} }
/** Inserts the string under `key`, skipping nil (an absent JS key). */ /** Skips nil, which means the JS key was absent. */
static void MyidSdkPutString(NSMutableDictionary *target, NSString *key, NSString *value) static void MyidSdkPutString(NSMutableDictionary *target, NSString *key, NSString *value)
{ {
if (value != nil) { if (value != nil) {
...@@ -71,8 +68,7 @@ static NSDictionary *MyidSdkConfigToDictionary(JS::NativeMyidSdk::MyIdConfigInpu ...@@ -71,8 +68,7 @@ static NSDictionary *MyidSdkConfigToDictionary(JS::NativeMyidSdk::MyIdConfigInpu
MyidSdkPutBool(result, @"showErrorScreen", config.showErrorScreen()); MyidSdkPutBool(result, @"showErrorScreen", config.showErrorScreen());
// cameraResolution, imageFormat, screenOrientation, withSoundGuides and // cameraResolution, imageFormat, screenOrientation, withSoundGuides and
// huaweiAppId are Android-only and have no counterpart in MyIdConfig here, // huaweiAppId are Android-only and deliberately not forwarded.
// so they are intentionally not forwarded.
auto organizationDetails = config.organizationDetails(); auto organizationDetails = config.organizationDetails();
if (organizationDetails.has_value()) { if (organizationDetails.has_value()) {
...@@ -121,8 +117,7 @@ static NSDictionary *MyidSdkAppearanceToDictionary(JS::NativeMyidSdk::MyIdAppear ...@@ -121,8 +117,7 @@ static NSDictionary *MyidSdkAppearanceToDictionary(JS::NativeMyidSdk::MyIdAppear
}]; }];
} }
/// Called when the module is torn down, e.g. a reload mid-flow. Mirrors the /// Settles an in-flight promise when the module is torn down, e.g. a reload.
/// Android module: settle the in-flight promise instead of leaving it hanging.
- (void)invalidate - (void)invalidate
{ {
[_impl cancelActive]; [_impl cancelActive];
......
...@@ -4,13 +4,9 @@ import UIKit ...@@ -4,13 +4,9 @@ import UIKit
/// Translates the JS payload into the SDK's own config objects. /// Translates the JS payload into the SDK's own config objects.
/// ///
/// A property is assigned only when its key is present, so an omitted option /// A property is assigned only when its key is present, so an omitted option —
/// keeps whatever default `MyIdConfig()` / `MyIdAppearance()` already set rather /// or an unrecognised enum string — keeps the default `MyIdConfig()` and
/// than being overwritten with a value invented here. /// `MyIdAppearance()` already set.
///
/// 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 { enum MyidSdkConfigBuilder {
static func makeConfig( static func makeConfig(
...@@ -35,9 +31,6 @@ enum MyidSdkConfigBuilder { ...@@ -35,9 +31,6 @@ 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") {
switch environment { switch environment {
case "DEBUG": config.environment = .debug case "DEBUG": config.environment = .debug
...@@ -68,8 +61,7 @@ enum MyidSdkConfigBuilder { ...@@ -68,8 +61,7 @@ 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
// Documented platform fallback, not an unknown value: iOS has no // iOS has no Cyrillic, Karakalpak or Tajik locale.
// Cyrillic, Karakalpak or Tajik locale.
case "UZBEK", "UZBEK_CYRILLIC", "KARAKALPAK", "TAJIK": config.locale = .uzbek case "UZBEK", "UZBEK_CYRILLIC", "KARAKALPAK", "TAJIK": config.locale = .uzbek
default: break default: break
} }
...@@ -99,9 +91,8 @@ enum MyidSdkConfigBuilder { ...@@ -99,9 +91,8 @@ enum MyidSdkConfigBuilder {
} }
} }
// Only forwarded when the caller supplied something: an empty details object // An empty details object would replace the SDK default, which carries
// would replace the SDK's own default, which carries MyID's call-centre // MyID's call-centre number.
// number. Each field is likewise assigned only when present.
if let details = dictionary["organizationDetails"] as? NSDictionary, details.count > 0 { if let details = dictionary["organizationDetails"] as? NSDictionary, details.count > 0 {
let organizationDetails = MyIdOrganizationDetails() let organizationDetails = MyIdOrganizationDetails()
...@@ -123,10 +114,8 @@ enum MyidSdkConfigBuilder { ...@@ -123,10 +114,8 @@ enum MyidSdkConfigBuilder {
return config return config
} }
/// Every assignment is guarded: `MyIdAppearance()` seeds its own values, so /// Assignments are guarded: `MyIdAppearance()` seeds its own values, so
/// writing nil for a key the caller omitted would strip the SDK's defaults /// writing nil for an omitted key would strip them.
/// 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()
...@@ -169,7 +158,7 @@ private extension NSDictionary { ...@@ -169,7 +158,7 @@ private extension NSDictionary {
self[key] as? NSNumber self[key] as? NSNumber
} }
/// Enum values travel as strings; normalise case so "debug" works like "DEBUG". /// Normalise case so "debug" works like "DEBUG".
func enumValue(for key: String) -> String? { func enumValue(for key: String) -> String? {
string(for: key)?.uppercased() string(for: key)?.uppercased()
} }
...@@ -183,8 +172,7 @@ private extension NSDictionary { ...@@ -183,8 +172,7 @@ private extension NSDictionary {
extension UIColor { extension UIColor {
/// Accepts `#RRGGBB` and `RRGGBB`. Returns nil for anything unparseable so a /// Accepts `#RRGGBB` and `RRGGBB`; nil for anything unparseable.
/// bad value leaves the SDK's own color in place.
convenience init?(myIdHex hex: String) { convenience init?(myIdHex hex: String) {
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
......
...@@ -4,15 +4,13 @@ import UIKit ...@@ -4,15 +4,13 @@ import UIKit
/// Entry point used by the ObjC++ turbo module. /// Entry point used by the ObjC++ turbo module.
/// ///
/// Deliberately imports no React headers: the ObjC++ layer passes plain /// Imports no React headers: the bridge passes plain closures rather than
/// closures instead of RCTPromise blocks, which keeps this compiling the same /// RCTPromise blocks, so this compiles the same under static and framework
/// way under static and framework linkage. /// linkage.
/// ///
/// The `MyIdClientDelegate` conformance lives on an internal proxy rather than /// `MyIdClientDelegate` conformance lives on an internal proxy. Anything public
/// on this class. Anything public and `@objc` here is re-emitted into the /// and `@objc` here is re-emitted into the generated Swift header, which ObjC++
/// generated `RNMyidSdk-Swift.h`, and that header is parsed by ObjC++ that has /// parses without visibility of MyIdSDK's Swift types.
/// no visibility of MyIdSDK's Swift types — exposing the conformance makes the
/// bridge fail to compile on `MyIdClientDelegate` and `MyIdEvent`.
@objc(MyidSdkImpl) @objc(MyidSdkImpl)
public final class MyidSdkImpl: NSObject { public final class MyidSdkImpl: NSObject {
...@@ -22,11 +20,8 @@ public final class MyidSdkImpl: NSObject { ...@@ -22,11 +20,8 @@ public final class MyidSdkImpl: NSObject {
static let codeUserCancelled = "101" static let codeUserCancelled = "101"
static let codeFailure = "103" static let codeFailure = "103"
/// The SDK presents full screen, so at most one flow can be in flight. /// At most one flow can be in flight. Confined to the main queue: `start`
/// /// runs on the JS thread while delegate callbacks arrive on the main thread.
/// 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
...@@ -36,8 +31,8 @@ public final class MyidSdkImpl: NSObject { ...@@ -36,8 +31,8 @@ public final class MyidSdkImpl: NSObject {
onSuccess: @escaping SuccessHandler, onSuccess: @escaping SuccessHandler,
onError: @escaping ErrorHandler onError: @escaping ErrorHandler
) { ) {
// The SDK presents view controllers, so it must be touched on the main // Main queue: the SDK presents view controllers, and confining the whole
// queue anyway; doing the whole state transition here keeps it race-free. // state transition here keeps it race-free.
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { guard let self else {
onError( onError(
...@@ -75,9 +70,8 @@ public final class MyidSdkImpl: NSObject { ...@@ -75,9 +70,8 @@ public final class MyidSdkImpl: NSObject {
} }
} }
/// Settles an in-flight flow, mirroring the Android module's `invalidate`. /// Settles an in-flight flow on teardown; without it a reload mid-flow leaves
/// Without this a reload mid-flow leaves the promise hanging forever and the /// the promise hanging and the proxy deallocated while the SDK holds it.
/// proxy deallocated while the SDK still holds it.
@objc @objc
public func cancelActive() { public func cancelActive() {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
...@@ -92,15 +86,12 @@ public final class MyidSdkImpl: NSObject { ...@@ -92,15 +86,12 @@ public final class MyidSdkImpl: NSObject {
} }
} }
/// Outcome of a single verification attempt.
enum MyidSdkOutcome { enum MyidSdkOutcome {
case success(NSDictionary) case success(NSDictionary)
case failure(code: String, message: String) case failure(code: String, message: String)
} }
/// Bridges `MyIdClientDelegate` to a one-shot callback. /// Bridges `MyIdClientDelegate` to a one-shot callback. Internal on purpose.
///
/// Internal on purpose — see the note on `MyidSdkImpl`.
final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate { final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
private var settle: ((MyidSdkOutcome) -> Void)? private var settle: ((MyidSdkOutcome) -> Void)?
...@@ -109,12 +100,10 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate { ...@@ -109,12 +100,10 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
self.settle = settle self.settle = settle
} }
/// Claims the callback so the promise settles exactly once — a delegate /// Settles exactly once; a delegate method can fire more than once.
/// method can fire more than once in edge cases.
/// ///
/// `withExtendedLifetime` because the callback clears the only strong /// `withExtendedLifetime` because the callback clears the only strong
/// reference to this proxy, which would otherwise deallocate it while a /// reference to this proxy while a delegate method is still on the stack.
/// delegate method is still on the stack.
private func settleOnce(_ outcome: MyidSdkOutcome) { private func settleOnce(_ outcome: MyidSdkOutcome) {
guard let callback = settle else { return } guard let callback = settle else { return }
settle = nil settle = nil
...@@ -130,8 +119,7 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate { ...@@ -130,8 +119,7 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
let payload = NSMutableDictionary() let payload = NSMutableDictionary()
payload["code"] = result.code payload["code"] = result.code
// JPEG rather than PNG keeps the base64 small enough to cross the bridge // JPEG keeps the base64 small and matches the Android side.
// comfortably, and matches what the Android side returns.
if let base64 = result.image?.jpegData(compressionQuality: 1.0)?.base64EncodedString() { if let base64 = result.image?.jpegData(compressionQuality: 1.0)?.base64EncodedString() {
payload["base64"] = base64 payload["base64"] = base64
} }
...@@ -149,8 +137,5 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate { ...@@ -149,8 +137,5 @@ final class MyidSdkDelegateProxy: NSObject, MyIdClientDelegate {
) )
} }
func onEvent(event: MyIdEvent) { func onEvent(event: MyIdEvent) {}
// Progress telemetry (camera opened, face captured, ...). Not surfaced:
// the JS API is a single promise and the SDK renders its own progress.
}
} }
...@@ -2,19 +2,16 @@ import type { TurboModule } from 'react-native'; ...@@ -2,19 +2,16 @@ import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native'; import { TurboModuleRegistry } from 'react-native';
/** /**
* Codegen spec. This is the raw bridge contract, NOT the public API. * Codegen spec — the raw bridge contract, not the public API.
* *
* Every enum crosses as a plain `string` because codegen cannot express TS * Enums cross as plain strings because codegen cannot express TS string unions.
* string unions — the narrowing lives in `myid-types.ts`, and the native side * Nested params must be named type aliases; an inline object literal degrades to
* uppercases and falls back to the SDK default for anything it does not know. * `AnyTypeAnnotation`.
*
* Nested object params must be *named* exported type aliases; an inline object
* literal degrades to `AnyTypeAnnotation` and loses type safety on both platforms.
*/ */
export type MyIdOrganizationDetailsInput = { export type MyIdOrganizationDetailsInput = {
phone?: string; phone?: string;
/** Native asset name: Android drawable / iOS image-set. Not a file path or URL. */ /** Native asset name: Android drawable / iOS image set. */
logo?: string; logo?: string;
}; };
...@@ -62,10 +59,6 @@ export type MyIdResultPayload = { ...@@ -62,10 +59,6 @@ export type MyIdResultPayload = {
}; };
export interface Spec extends TurboModule { export interface Spec extends TurboModule {
/**
* Both params are required at the bridge level — the public wrapper always
* sends an appearance object, passing `{}` when the caller omits it.
*/
start( start(
config: MyIdConfigInput, config: MyIdConfigInput,
appearance: MyIdAppearanceInput appearance: MyIdAppearanceInput
......
/** /**
* Wire values for every MyID enum. * Wire values for every MyID enum. Matches the MyID Flutter plugin, so backend
* * docs transfer unchanged. Native uppercases the value and skips unknown ones.
* These strings are the bridge format and match the MyID Flutter plugin
* exactly, so backend docs and existing integration knowledge transfer
* unchanged. The native layer uppercases the incoming value and falls back to
* the SDK's own default when it does not recognise it.
*/ */
export type MyIdEnvironment = 'PRODUCTION' | 'DEBUG'; export type MyIdEnvironment = 'PRODUCTION' | 'DEBUG';
......
...@@ -8,12 +8,8 @@ import { MyIdError, MyIdErrorCode } from './myid-error'; ...@@ -8,12 +8,8 @@ import { MyIdError, MyIdErrorCode } from './myid-error';
import type { MyIdResult, StartMyIdOptions } from './myid-types'; import type { MyIdResult, StartMyIdOptions } from './myid-types';
/** /**
* Drops keys whose value is `undefined`. * Drops undefined keys. Native only applies a setting when its key is present,
* * so an absent key preserves the SDK default.
* This matters: the native layer only applies a setting when its key is
* present, so an absent key preserves the SDK's own default. Forwarding
* `undefined` would instead push a null through the bridge and could override a
* default with an empty value.
*/ */
function stripUndefined<T extends object>(value: T): T { function stripUndefined<T extends object>(value: T): T {
const entries = Object.entries(value).filter( const entries = Object.entries(value).filter(
...@@ -23,11 +19,7 @@ function stripUndefined<T extends object>(value: T): T { ...@@ -23,11 +19,7 @@ function stripUndefined<T extends object>(value: T): T {
return Object.fromEntries(entries) as T; return Object.fromEntries(entries) as T;
} }
/** /** Arrives as a string on one platform and a number on the other. */
* Reads the `code` a native rejection carries. React Native surfaces both
* platforms' reject(code, message) as an Error with a `code` property, but it
* arrives as a string on one platform and can be a number on the other.
*/
function readErrorCode(error: unknown): string { function readErrorCode(error: unknown): string {
if (typeof error === 'object' && error !== null && 'code' in error) { if (typeof error === 'object' && error !== null && 'code' in error) {
const { code } = error as { code: unknown }; const { code } = error as { code: unknown };
...@@ -53,36 +45,24 @@ function readErrorMessage(error: unknown): string { ...@@ -53,36 +45,24 @@ function readErrorMessage(error: unknown): string {
} }
/** /**
* Launches the MyID verification flow and resolves once the user completes it. * Launches the MyID verification flow.
* *
* Rejects with a {@link MyIdError} for every failure path — including the user * Rejects with a {@link MyIdError} on every failure path, including the user
* simply backing out, which arrives as code `101`. * dismissing the flow (code `101`).
* *
* @example * @example
* ```ts * ```ts
* try { * const result = await start({
* const result = await start({ * config: { sessionId, clientHash, clientHashId },
* config: { sessionId, clientHash, clientHashId }, * });
* });
* console.log(result.code, result.base64);
* } catch (error) {
* if (isMyIdError(error) && error.code === MyIdErrorCode.USER_CANCELLED) {
* // user backed out — usually not worth surfacing
* }
* }
* ``` * ```
*/ */
export async function start(options: StartMyIdOptions): Promise<MyIdResult> { export async function start(options: StartMyIdOptions): Promise<MyIdResult> {
const { config, iosAppearance } = options; const { config, iosAppearance } = options;
// An organizationDetails that strips to {} must not be forwarded: native would // An empty details object would replace the SDK default, which carries MyID's
// build an empty details object and overwrite the SDK's own default, which // call-centre number. The ternary also blocks an explicit null: the generated
// carries MyID's call-centre number. // iOS accessor checks for nil but not NSNull, and would crash on first access.
//
// 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 const organizationDetails = config.organizationDetails
? stripUndefined<MyIdOrganizationDetailsInput>(config.organizationDetails) ? stripUndefined<MyIdOrganizationDetailsInput>(config.organizationDetails)
: undefined; : undefined;
......
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