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.
- package/README.md +267 -373
- package/android/build.gradle +7 -2
- package/android/src/main/java/com/usesense/reactnative/UseSenseModule.kt +6 -1
- package/ios/UseSenseModule.swift +223 -88
- package/lib/commonjs/index.js +122 -101
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/index.js +123 -102
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/index.d.ts +157 -141
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/react-native-usesense.podspec +9 -3
- package/src/index.ts +222 -164
package/android/build.gradle
CHANGED
|
@@ -35,8 +35,13 @@ repositories {
|
|
|
35
35
|
dependencies {
|
|
36
36
|
implementation "com.facebook.react:react-native:+"
|
|
37
37
|
|
|
38
|
-
// UseSense Android SDK
|
|
39
|
-
|
|
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()
|
package/ios/UseSenseModule.swift
CHANGED
|
@@ -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
|
|
24
|
-
|
|
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:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
60
|
-
reject("no_view_controller",
|
|
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
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
}
|