react-native-usesense 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,8 +35,13 @@ repositories {
35
35
  dependencies {
36
36
  implementation "com.facebook.react:react-native:+"
37
37
 
38
- // UseSense Android SDK
39
- implementation "ai.usesense:sdk:1.0.0"
38
+ // UseSense Android SDK — published to Maven Central at
39
+ // central.sonatype.com/artifact/ai.usesense/sdk. Pinned to 4.2.1
40
+ // because that's the first release with the vanniktech-based
41
+ // Maven Central artifact shape and the terminal-screen centering
42
+ // fix. mavenCentral() is already in the `repositories` block above,
43
+ // so no custom repository declaration is needed.
44
+ implementation "ai.usesense:sdk:4.2.1"
40
45
 
41
46
  implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '2.0.0')}"
42
47
  }
@@ -32,11 +32,16 @@ class UseSenseModule(private val reactContext: ReactApplicationContext) :
32
32
  else -> UseSenseEnvironment.AUTO
33
33
  }
34
34
 
35
+ // `gatewayKey` is intentionally NOT passed to UseSenseConfig.
36
+ // It existed on the v1.x Android SDK but was removed in v4.0
37
+ // when the Cloudflare Worker proxy took over gateway
38
+ // responsibilities server-side. The JS API no longer exposes
39
+ // the field; any stale JS callers that still pass it will
40
+ // have the key silently ignored.
35
41
  val config = UseSenseConfig(
36
42
  apiKey = apiKey,
37
43
  environment = environment,
38
44
  baseUrl = configMap.getString("baseUrl") ?: UseSenseConfig.DEFAULT_BASE_URL,
39
- gatewayKey = configMap.getString("gatewayKey"),
40
45
  branding = branding,
41
46
  googleCloudProjectNumber = if (configMap.hasKey("googleCloudProjectNumber"))
42
47
  configMap.getDouble("googleCloudProjectNumber").toLong()
@@ -2,11 +2,33 @@ import Foundation
2
2
  import React
3
3
  import UseSenseSDK
4
4
 
5
+ /// React Native bridge for the UseSense iOS SDK.
6
+ ///
7
+ /// Exposes five JS-callable methods — `initialize`, `startVerification`,
8
+ /// `reset`, `isInitialized`, plus RCTEventEmitter `supportedEvents`/
9
+ /// `startObserving`/`stopObserving` — that mirror the native SDK's
10
+ /// public API without leaking any internal scoring data through the
11
+ /// bridge.
12
+ ///
13
+ /// The result payload returned from `startVerification` is the
14
+ /// five-field redacted shape defined by the native SDK's
15
+ /// `RedactedDecisionObject`: `sessionId`, `sessionType`, `identityId`,
16
+ /// `decision` (`APPROVE` / `REJECT` / `MANUAL_REVIEW`), `timestamp`.
17
+ /// Pillar scores (channel trust, liveness, matchsense risk) are
18
+ /// intentionally NOT exposed — integrators must consume those via
19
+ /// the signed webhook delivered to their backend, never from the
20
+ /// client. Matches the behaviour of the native Android bridge at
21
+ /// `android/src/main/java/com/usesense/reactnative/UseSenseModule.kt`
22
+ /// and the Flutter plugin at `ios/Classes/UseSenseFlutterPlugin.swift`.
5
23
  @objc(UseSenseModule)
6
24
  class UseSenseModule: RCTEventEmitter {
7
25
 
26
+ private var client: UseSense?
27
+ private var eventUnsubscribe: (() -> Void)?
8
28
  private var hasListeners = false
9
29
 
30
+ // MARK: - RCTEventEmitter
31
+
10
32
  override func supportedEvents() -> [String]! {
11
33
  return ["UseSenseEvent"]
12
34
  }
@@ -20,129 +42,242 @@ class UseSenseModule: RCTEventEmitter {
20
42
  }
21
43
 
22
44
  @objc
23
- func initialize(_ config: NSDictionary) {
24
- guard let apiKey = config["apiKey"] as? String else {
45
+ override static func requiresMainQueueSetup() -> Bool {
46
+ return false
47
+ }
48
+
49
+ // MARK: - Module methods
50
+
51
+ @objc
52
+ func initialize(_ config: NSDictionary,
53
+ resolver resolve: @escaping RCTPromiseResolveBlock,
54
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
55
+ guard let apiKey = config["apiKey"] as? String, !apiKey.isEmpty else {
56
+ reject("invalid_config", "apiKey is required", nil)
25
57
  return
26
58
  }
27
59
 
28
- let environment: UseSenseEnvironment = {
29
- switch config["environment"] as? String {
30
- case "production": return .production
31
- case "sandbox": return .sandbox
32
- default: return .sandbox
33
- }
34
- }()
60
+ let environment: Environment
61
+ switch config["environment"] as? String {
62
+ case "sandbox":
63
+ environment = .sandbox
64
+ case "production":
65
+ environment = .production
66
+ case "auto", nil:
67
+ environment = .auto
68
+ default:
69
+ environment = .auto
70
+ }
71
+
72
+ var brandingConfig: BrandingConfig? = nil
73
+ if let b = config["branding"] as? [String: Any] {
74
+ // iOS BrandingConfig accepts logoUrl, primaryColor, buttonRadius,
75
+ // fontFamily. Android-only fields (displayName, redirectUrl) are
76
+ // accepted by the JS API for cross-platform consistency but
77
+ // ignored here since the iOS SDK doesn't support them.
78
+ brandingConfig = BrandingConfig(
79
+ logoUrl: b["logoUrl"] as? String,
80
+ primaryColor: (b["primaryColor"] as? String) ?? "#4F7CFF",
81
+ buttonRadius: CGFloat((b["buttonRadius"] as? Int) ?? 10),
82
+ fontFamily: b["fontFamily"] as? String
83
+ )
84
+ }
35
85
 
36
- let useSenseConfig = UseSenseConfig(
86
+ let apiEndpoint = (config["apiEndpoint"] as? String)
87
+ ?? UseSenseConfig.defaultEndpoint
88
+
89
+ let sdkConfig = UseSenseConfig(
90
+ apiEndpoint: apiEndpoint,
37
91
  apiKey: apiKey,
38
92
  environment: environment,
39
- organizationId: config["organizationId"] as? String,
40
- sessionType: (config["sessionType"] as? String) == "authentication" ? .authentication : .enrollment,
41
- challengePolicy: {
42
- switch config["challengePolicy"] as? String {
43
- case "enhanced": return .enhanced
44
- case "adaptive": return .adaptive
45
- default: return .standard
46
- }
47
- }(),
48
- enableAudio: config["enableAudio"] as? Bool ?? false,
49
- timeout: config["timeout"] as? TimeInterval ?? 60000,
50
- metadata: config["metadata"] as? [String: String]
93
+ branding: brandingConfig
51
94
  )
52
95
 
53
- UseSense.initialize(config: useSenseConfig)
96
+ // Create (or replace) the client. If a previous client was
97
+ // active, its event subscription is disposed before we
98
+ // replace it so we don't accumulate stale listeners on
99
+ // repeated initialize() calls.
100
+ eventUnsubscribe?()
101
+ eventUnsubscribe = nil
102
+
103
+ let newClient = UseSense(config: sdkConfig)
104
+ eventUnsubscribe = newClient.onEvent { [weak self] event in
105
+ self?.emitEvent(event)
106
+ }
107
+ client = newClient
108
+
109
+ resolve(nil)
54
110
  }
55
111
 
56
112
  @objc
57
- func startSession(_ options: NSDictionary, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
113
+ func startVerification(_ request: NSDictionary,
114
+ resolver resolve: @escaping RCTPromiseResolveBlock,
115
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
116
+ guard let client = client else {
117
+ reject("sdk_not_initialized",
118
+ "UseSense SDK is not initialized. Call initialize() first.",
119
+ nil)
120
+ return
121
+ }
122
+
123
+ let sessionType: SessionType = (request["sessionType"] as? String) == "authentication"
124
+ ? .authentication
125
+ : .enrollment
126
+
127
+ // The native iOS `VerificationRequest` takes an `AnyCodableValue`
128
+ // map for metadata. RN can only pass plain JSON so we coerce
129
+ // every value to a string — matches what the Flutter plugin
130
+ // does over the Pigeon channel.
131
+ let rawMetadata = request["metadata"] as? [String: Any]
132
+ let metadata: [String: AnyCodableValue]? = rawMetadata?.mapValues { value in
133
+ if let s = value as? String { return .string(s) }
134
+ if let b = value as? Bool { return .bool(b) }
135
+ if let i = value as? Int { return .int(i) }
136
+ if let d = value as? Double { return .double(d) }
137
+ return .string(String(describing: value))
138
+ }
139
+
140
+ let nativeRequest = VerificationRequest(
141
+ sessionType: sessionType,
142
+ externalUserId: request["externalUserId"] as? String,
143
+ identityId: request["identityId"] as? String,
144
+ metadata: metadata
145
+ )
146
+
58
147
  DispatchQueue.main.async {
59
- guard let viewController = RCTPresentedViewController() else {
60
- reject("no_view_controller", "No view controller available", nil)
148
+ guard let rootVC = Self.topmostViewController() else {
149
+ reject("no_view_controller",
150
+ "No view controller available to present the camera UI.",
151
+ nil)
61
152
  return
62
153
  }
63
154
 
64
- let sessionType: SessionType = (options["sessionType"] as? String) == "authentication" ? .authentication : .enrollment
65
- let identityId = options["identityId"] as? String
66
-
67
- UseSense.startSession(
68
- from: viewController,
69
- sessionType: sessionType,
70
- identityId: identityId
71
- ) { result in
155
+ let session = client.startVerification(request: nativeRequest)
156
+ let vc = UseSenseViewController(session: session) { [weak rootVC] result in
157
+ // The view controller dismisses itself on the main
158
+ // thread before invoking this closure. We're safe to
159
+ // resolve/reject directly without hopping.
160
+ _ = rootVC
72
161
  switch result {
73
- case .success(let useSenseResult):
74
- let resultDict: [String: Any] = [
75
- "sessionId": useSenseResult.sessionId,
76
- "decision": useSenseResult.decision.rawValue,
77
- "channelTrustScore": useSenseResult.channelTrustScore,
78
- "livenessScore": useSenseResult.livenessScore,
79
- "matchSenseRiskScore": useSenseResult.matchSenseRiskScore,
80
- "presenceConfidence": useSenseResult.presenceConfidence,
81
- "reasons": useSenseResult.reasons,
82
- "ruleTriggered": useSenseResult.ruleTriggered as Any,
83
- "recommendedAction": useSenseResult.recommendedAction as Any,
84
- "sessionSignature": useSenseResult.sessionSignature,
85
- ]
86
- resolve(resultDict)
87
-
162
+ case .success(let decision):
163
+ resolve(Self.redactedResultDict(decision))
88
164
  case .failure(let error):
89
- reject(error.code, error.message, nil)
165
+ if error.code == .userCancelled {
166
+ reject("session_cancelled",
167
+ "User cancelled the verification session.",
168
+ nil)
169
+ } else {
170
+ reject(Self.bridgeErrorCode(error.code),
171
+ error.message,
172
+ error)
173
+ }
90
174
  }
91
175
  }
176
+ vc.modalPresentationStyle = .fullScreen
177
+ rootVC.present(vc, animated: true)
92
178
  }
93
179
  }
94
180
 
95
181
  @objc
96
- func cancelSession(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
97
- UseSense.cancelSession()
182
+ func reset(_ resolve: @escaping RCTPromiseResolveBlock,
183
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
184
+ eventUnsubscribe?()
185
+ eventUnsubscribe = nil
186
+ client?.reset()
187
+ client = nil
98
188
  resolve(nil)
99
189
  }
100
190
 
101
191
  @objc
102
- func getSessionStatus(_ sessionId: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
103
- UseSense.getSessionStatus(sessionId: sessionId) { result in
104
- switch result {
105
- case .success(let status):
106
- var dict: [String: Any] = ["status": status.status]
107
- if let r = status.result {
108
- dict["result"] = [
109
- "sessionId": r.sessionId,
110
- "decision": r.decision.rawValue,
111
- "channelTrustScore": r.channelTrustScore,
112
- "livenessScore": r.livenessScore,
113
- "matchSenseRiskScore": r.matchSenseRiskScore,
114
- "presenceConfidence": r.presenceConfidence,
115
- "reasons": r.reasons,
116
- "sessionSignature": r.sessionSignature,
117
- ]
118
- }
119
- resolve(dict)
120
- case .failure(let error):
121
- reject(error.code, error.message, nil)
122
- }
123
- }
192
+ func isInitialized(_ resolve: @escaping RCTPromiseResolveBlock,
193
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
194
+ resolve(client != nil)
124
195
  }
125
196
 
126
- @objc
127
- func subscribeToEvents() {
128
- UseSense.onEvent { [weak self] event in
129
- guard let self = self, self.hasListeners else { return }
130
- self.sendEvent(withName: "UseSenseEvent", body: event.toDictionary())
197
+ // MARK: - Private helpers
198
+
199
+ private func emitEvent(_ event: UseSenseEvent) {
200
+ guard hasListeners else { return }
201
+ let eventTypeString = Self.eventTypeString(event.type)
202
+ let body: [String: Any] = [
203
+ "type": eventTypeString,
204
+ "timestamp": Int64(event.timestamp.timeIntervalSince1970 * 1000),
205
+ "data": event.data ?? [:]
206
+ ]
207
+ DispatchQueue.main.async { [weak self] in
208
+ self?.sendEvent(withName: "UseSenseEvent", body: body)
131
209
  }
132
210
  }
133
211
 
134
- @objc
135
- func unsubscribeFromEvents() {
136
- UseSense.removeEventListener()
212
+ /// Maps the SDK's `RedactedDecisionObject` into the exact five-field
213
+ /// dict the JS API exposes. Adding or returning anything beyond
214
+ /// these five fields would leak internal scoring data through the
215
+ /// bridge — do NOT add channel/liveness/matchsense scores here.
216
+ private static func redactedResultDict(_ decision: RedactedDecisionObject) -> [String: Any] {
217
+ return [
218
+ "sessionId": decision.sessionId,
219
+ "sessionType": decision.sessionType as Any,
220
+ "identityId": decision.identityId as Any,
221
+ "decision": decision.decision,
222
+ "timestamp": decision.timestamp,
223
+ // Convenience booleans computed on the native side so the
224
+ // JS consumer doesn't have to string-compare decision codes.
225
+ "isApproved": decision.isApproved,
226
+ "isRejected": decision.isRejected,
227
+ "isPendingReview": decision.isPendingReview
228
+ ]
137
229
  }
138
230
 
139
- @objc
140
- func getSdkVersion() -> String {
141
- return UseSense.sdkVersion
231
+ private static func bridgeErrorCode(_ code: UseSenseErrorCode) -> String {
232
+ // Return the raw error code string (e.g. "NETWORK_ERROR"). JS
233
+ // consumers can key off this via UseSenseError.code. Matches the
234
+ // string-valued error codes the Flutter plugin emits.
235
+ return code.rawValue
142
236
  }
143
237
 
144
- @objc
145
- override static func requiresMainQueueSetup() -> Bool {
146
- return false
238
+ private static func eventTypeString(_ type: UseSenseEventType) -> String {
239
+ // RN bridges typically emit UPPER_SNAKE_CASE event type strings.
240
+ // Map the SDK's enum cases to the same canonical form the
241
+ // Android bridge uses (event.type.name) so JS consumers can
242
+ // switch on a single constant set regardless of platform.
243
+ switch type {
244
+ case .sessionCreated: return "SESSION_CREATED"
245
+ case .permissionsRequested: return "PERMISSIONS_REQUESTED"
246
+ case .permissionsGranted: return "PERMISSIONS_GRANTED"
247
+ case .permissionsDenied: return "PERMISSIONS_DENIED"
248
+ case .captureStarted: return "CAPTURE_STARTED"
249
+ case .frameCaptured: return "FRAME_CAPTURED"
250
+ case .captureCompleted: return "CAPTURE_COMPLETED"
251
+ case .audioRecordStarted: return "AUDIO_RECORD_STARTED"
252
+ case .audioRecordCompleted: return "AUDIO_RECORD_COMPLETED"
253
+ case .challengeStarted: return "CHALLENGE_STARTED"
254
+ case .challengeCompleted: return "CHALLENGE_COMPLETED"
255
+ case .uploadStarted: return "UPLOAD_STARTED"
256
+ case .uploadProgress: return "UPLOAD_PROGRESS"
257
+ case .uploadCompleted: return "UPLOAD_COMPLETED"
258
+ case .completeStarted: return "COMPLETE_STARTED"
259
+ case .decisionReceived: return "DECISION_RECEIVED"
260
+ case .imageQualityCheck: return "IMAGE_QUALITY_CHECK"
261
+ case .error: return "ERROR"
262
+ @unknown default: return "UNKNOWN"
263
+ }
264
+ }
265
+
266
+ /// Walks the currently-presented view controller chain and returns
267
+ /// the topmost one — which is the correct presenter for a new
268
+ /// full-screen modal. `RCTPresentedViewController()` returns the
269
+ /// currently-displayed VC which may itself be a modal, so we
270
+ /// traverse to the leaf to avoid "already presenting" errors.
271
+ private static func topmostViewController() -> UIViewController? {
272
+ guard var top = UIApplication.shared.connectedScenes
273
+ .compactMap({ ($0 as? UIWindowScene)?.keyWindow })
274
+ .first?
275
+ .rootViewController else {
276
+ return RCTPresentedViewController()
277
+ }
278
+ while let presented = top.presentedViewController {
279
+ top = presented
280
+ }
281
+ return top
147
282
  }
148
283
  }