@finos_sdk/sdk-ekyc 1.4.6 → 1.4.8

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.
@@ -13,10 +13,14 @@ import com.facebook.react.bridge.ReactApplicationContext
13
13
  import com.facebook.react.bridge.ReactContextBaseJavaModule
14
14
  import com.facebook.react.bridge.ReactMethod
15
15
  import com.facebook.react.bridge.ReadableArray
16
+ import com.facebook.react.bridge.ReadableMap
16
17
  import com.facebook.react.bridge.WritableArray
17
18
  import com.facebook.react.bridge.WritableMap
18
19
  import com.facebook.react.module.annotations.ReactModule
19
20
  import com.facebook.react.modules.core.DeviceEventManagerModule
21
+ import com.facebook.react.ReactRootView
22
+ import com.facebook.react.ReactApplication
23
+ import com.facebook.react.bridge.ReactContext
20
24
  import finos.sdk.c06.eKYCFinOSC06
21
25
  import finos.sdk.core.define.EKYCErrorResult
22
26
  import finos.sdk.core.define.EKYCEvent
@@ -44,6 +48,15 @@ import finos.sdk.smsotp.OTPFinOS
44
48
  import finos.sdk.core.model.sdk.config.SmsOtpConfig
45
49
  import vn.softdreams.easyca.sdk.eSignFinOS
46
50
  import vn.softdreams.easyca.sdk.esign.ESignModels
51
+ import com.google.android.material.bottomsheet.BottomSheetDialog
52
+ import android.view.LayoutInflater
53
+ import android.view.View
54
+ import android.widget.Button
55
+ import android.widget.TextView
56
+ import android.widget.LinearLayout
57
+ import android.view.Gravity
58
+ import android.graphics.Color
59
+ import android.util.TypedValue
47
60
 
48
61
  import org.json.JSONArray
49
62
  import org.json.JSONObject
@@ -52,6 +65,8 @@ import java.io.FileOutputStream
52
65
  import java.io.FileInputStream
53
66
  import java.io.InputStream
54
67
  import java.util.Date
68
+ import android.app.Activity
69
+ import android.view.ViewGroup
55
70
 
56
71
  @ReactModule(name = EKYCModule.NAME)
57
72
  class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
@@ -63,6 +78,362 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
63
78
 
64
79
  override fun getName(): String = NAME
65
80
 
81
+ /**
82
+ * Reference tới BottomSheetDialog hiện đang hiển thị.
83
+ * @Volatile: resolveExit() chạy trên JS bridge thread, đọc field này được viết bởi UI thread.
84
+ * Không có @Volatile → JVM cache → bridge thread thấy stale null → dismiss bị bỏ qua.
85
+ */
86
+ @Volatile
87
+ private var currentExitSheet: BottomSheetDialog? = null
88
+
89
+ // Đánh dấu khi dismiss được trigger bởi resolveExit (button), không phải backdrop tap.
90
+ // @Volatile vì được set từ bridge thread, đọc từ UI thread trong setOnDismissListener.
91
+ @Volatile
92
+ private var exitSheetResolvedViaButton = false
93
+
94
+ private fun getTrueCurrentActivity(): Activity? {
95
+ // Try React Native's currentActivity first
96
+ val rnActivity = currentActivity
97
+ if (rnActivity != null && rnActivity.javaClass.simpleName != "MainActivity") {
98
+ return rnActivity
99
+ }
100
+
101
+ // If RN gives us MainActivity or null, but we know an SDK is showing,
102
+ // we must find the actual top activity via reflection or ActivityThread
103
+ try {
104
+ val activityThreadClass = Class.forName("android.app.ActivityThread")
105
+ val activityThread = activityThreadClass.getMethod("currentActivityThread").invoke(null)
106
+ val activitiesField = activityThreadClass.getDeclaredField("mActivities")
107
+ activitiesField.isAccessible = true
108
+
109
+ val activities = activitiesField.get(activityThread) as Map<Any, Any>
110
+ Log.d(TAG, "🔍 Scanning ${activities.size} activities...")
111
+
112
+ var topActivity: Activity? = null
113
+
114
+ for (activityRecord in activities.values) {
115
+ val activityRecordClass = activityRecord.javaClass
116
+
117
+ val activityField = activityRecordClass.getDeclaredField("activity")
118
+ activityField.isAccessible = true
119
+ val activity = activityField.get(activityRecord) as Activity
120
+
121
+ val pausedField = activityRecordClass.getDeclaredField("paused")
122
+ pausedField.isAccessible = true
123
+ val isPaused = pausedField.get(activityRecord) as Boolean
124
+
125
+ val stoppedField = activityRecordClass.getDeclaredField("stopped")
126
+ stoppedField.isAccessible = true
127
+ val isStopped = stoppedField.get(activityRecord) as Boolean
128
+
129
+ Log.d(TAG, " - Found: ${activity.javaClass.simpleName} (Paused: $isPaused, Stopped: $isStopped, Finishing: ${activity.isFinishing})")
130
+
131
+ if (!activity.isFinishing && !isStopped) {
132
+ topActivity = activity
133
+ if (!isPaused) break // Found the resumed one
134
+ }
135
+ }
136
+ return topActivity ?: rnActivity
137
+ } catch (e: Exception) {
138
+ Log.e(TAG, "Error finding true current activity", e)
139
+ }
140
+
141
+ return rnActivity
142
+ }
143
+
144
+ @ReactMethod
145
+ fun showRNExitSheet(bundleName: String, initialProps: ReadableMap, promise: Promise) {
146
+ val activity = getTrueCurrentActivity() ?: run {
147
+ Log.e(TAG, "❌ showRNExitSheet failed: No visible activity found")
148
+ promise.reject("NO_ACTIVITY", "No visible activity found")
149
+ return
150
+ }
151
+
152
+ if (activity.isFinishing || activity.isDestroyed) {
153
+ Log.e(TAG, "❌ showRNExitSheet failed: Activity is finishing/destroyed")
154
+ promise.reject("ACTIVITY_INVALID", "Activity is finishing or destroyed")
155
+ return
156
+ }
157
+
158
+ // Guard chống double-resolve/reject promise (vì có nhiều entry point: sync error, async listener, exception)
159
+ val promiseSettled = java.util.concurrent.atomic.AtomicBoolean(false)
160
+ fun safeResolve(value: Any?) {
161
+ if (promiseSettled.compareAndSet(false, true)) promise.resolve(value)
162
+ }
163
+ fun safeReject(code: String, msg: String, e: Throwable? = null) {
164
+ if (promiseSettled.compareAndSet(false, true)) {
165
+ if (e != null) promise.reject(code, msg, e) else promise.reject(code, msg)
166
+ }
167
+ }
168
+
169
+ activity.runOnUiThread {
170
+ try {
171
+ val activityName = activity.javaClass.simpleName
172
+ Log.d(TAG, "▶️ showRNExitSheet: Top Activity=$activityName, bundle=$bundleName")
173
+
174
+ val reactApplication = activity.application as? ReactApplication
175
+ if (reactApplication == null) {
176
+ Log.e(TAG, "❌ Application does not implement ReactApplication")
177
+ safeReject("NO_REACT_APP", "Application does not implement ReactApplication")
178
+ return@runOnUiThread
179
+ }
180
+ val reactInstanceManager = reactApplication.reactNativeHost.reactInstanceManager
181
+
182
+ if (reactInstanceManager.currentReactContext != null) {
183
+ presentRNBottomSheet(activity, reactInstanceManager, bundleName, initialProps,
184
+ ::safeResolve, ::safeReject)
185
+ return@runOnUiThread
186
+ }
187
+
188
+ // Context chưa sẵn sàng: chờ listener
189
+ Log.w(TAG, "⚠️ ReactContext chưa ready, chờ init…")
190
+ val listener = object : com.facebook.react.ReactInstanceEventListener {
191
+ override fun onReactContextInitialized(context: ReactContext) {
192
+ reactInstanceManager.removeReactInstanceEventListener(this)
193
+ activity.runOnUiThread {
194
+ // Re-validate activity vì có thể đã destroy trong lúc chờ
195
+ if (activity.isFinishing || activity.isDestroyed) {
196
+ Log.w(TAG, "⚠️ Activity đã destroy trong lúc chờ ReactContext")
197
+ safeReject("ACTIVITY_INVALID", "Activity destroyed while waiting for RN context")
198
+ return@runOnUiThread
199
+ }
200
+ presentRNBottomSheet(activity, reactInstanceManager, bundleName, initialProps,
201
+ ::safeResolve, ::safeReject)
202
+ }
203
+ }
204
+ }
205
+ reactInstanceManager.addReactInstanceEventListener(listener)
206
+
207
+ // FIX RACE: re-check sau khi add listener vì context có thể đã init
208
+ // giữa null-check ở trên và lúc add listener (listener không fire cho event đã xảy ra)
209
+ val ctxAfterAdd = reactInstanceManager.currentReactContext
210
+ if (ctxAfterAdd != null) {
211
+ reactInstanceManager.removeReactInstanceEventListener(listener)
212
+ Log.d(TAG, "✅ ReactContext init xong giữa lúc add listener — present luôn")
213
+ presentRNBottomSheet(activity, reactInstanceManager, bundleName, initialProps,
214
+ ::safeResolve, ::safeReject)
215
+ return@runOnUiThread
216
+ }
217
+
218
+ // Chỉ trigger background create nếu chưa start (tránh IllegalStateException)
219
+ if (!reactInstanceManager.hasStartedCreatingInitialContext()) {
220
+ try {
221
+ reactInstanceManager.createReactContextInBackground()
222
+ } catch (e: Exception) {
223
+ reactInstanceManager.removeReactInstanceEventListener(listener)
224
+ Log.e(TAG, "❌ Failed createReactContextInBackground", e)
225
+ safeReject("RN_CONTEXT_INIT_FAILED", e.message ?: "createReactContextInBackground failed", e)
226
+ }
227
+ }
228
+ } catch (e: Exception) {
229
+ Log.e(TAG, "❌ Exception in showRNExitSheet", e)
230
+ safeReject("RN_SHEET_ERROR", e.message ?: "unknown", e)
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Hiển thị BottomSheetDialog chứa ReactRootView. Phải gọi trên UI thread.
237
+ *
238
+ * Quan trọng:
239
+ * - Set BottomSheetBehavior.peekHeight = sheetHeight và state = STATE_EXPANDED
240
+ * để sheet xuất hiện đầy đủ (mặc định peek nhỏ → sheet "không hiển thị" cảm giác).
241
+ * - Auto-dismiss khi activity host bị detach (memory leak guard).
242
+ * - safeResolve/safeReject để tránh promise resolve/reject 2 lần.
243
+ */
244
+ private fun presentRNBottomSheet(
245
+ activity: Activity,
246
+ reactInstanceManager: com.facebook.react.ReactInstanceManager,
247
+ bundleName: String,
248
+ initialProps: ReadableMap,
249
+ safeResolve: (Any?) -> Unit,
250
+ safeReject: (String, String, Throwable?) -> Unit
251
+ ) {
252
+ try {
253
+ val container = LinearLayout(activity).apply {
254
+ orientation = LinearLayout.VERTICAL
255
+ setBackgroundColor(Color.TRANSPARENT)
256
+ }
257
+
258
+ val rootView = ReactRootView(activity)
259
+ rootView.layoutParams = LinearLayout.LayoutParams(
260
+ LinearLayout.LayoutParams.MATCH_PARENT,
261
+ LinearLayout.LayoutParams.WRAP_CONTENT
262
+ )
263
+ container.addView(rootView)
264
+
265
+ // Bug 3 guard: nếu có sheet cũ chưa dismiss, dismiss nó trước để tránh leak
266
+ currentExitSheet?.let { old ->
267
+ if (old.isShowing) {
268
+ try { old.dismiss() } catch (e: Exception) {
269
+ Log.w(TAG, "presentRNBottomSheet: error dismissing old sheet: ${e.message}")
270
+ }
271
+ }
272
+ }
273
+
274
+ val bottomSheetDialog = BottomSheetDialog(activity)
275
+ currentExitSheet = bottomSheetDialog
276
+ bottomSheetDialog.setContentView(container)
277
+ bottomSheetDialog.setCancelable(true)
278
+ bottomSheetDialog.setCanceledOnTouchOutside(true)
279
+
280
+ // isFitToContents=true → sheet height = wrap_content của ReactRootView,
281
+ // không expand fullscreen. setExitSheetHeight() từ JS sẽ fine-tune nếu cần.
282
+ bottomSheetDialog.setOnShowListener { dlg ->
283
+ val d = dlg as BottomSheetDialog
284
+ val bs = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
285
+ ?: return@setOnShowListener
286
+ val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bs)
287
+ behavior.isFitToContents = true
288
+ behavior.skipCollapsed = true
289
+ behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
290
+ }
291
+
292
+ // Auto-dismiss & unmount khi activity decor view bị detach (activity destroy)
293
+ val decorView = activity.window.decorView
294
+ val attachListener = object : View.OnAttachStateChangeListener {
295
+ override fun onViewAttachedToWindow(v: View) {}
296
+ override fun onViewDetachedFromWindow(v: View) {
297
+ decorView.removeOnAttachStateChangeListener(this)
298
+ if (bottomSheetDialog.isShowing) {
299
+ try { bottomSheetDialog.dismiss() } catch (_: Exception) {}
300
+ }
301
+ }
302
+ }
303
+ decorView.addOnAttachStateChangeListener(attachListener)
304
+
305
+ bottomSheetDialog.setOnDismissListener {
306
+ currentExitSheet = null
307
+ decorView.removeOnAttachStateChangeListener(attachListener)
308
+ // Nếu dismiss KHÔNG phải từ button (backdrop tap / activity destroy),
309
+ // tự resolve CANCEL để SDK không bị kẹt và lần sau back vẫn trigger sheet lại.
310
+ if (!exitSheetResolvedViaButton && SDKeKYCExitHandlerManager.pendingCancel != null) {
311
+ SDKeKYCExitHandlerManager.resolve("CANCEL")
312
+ Log.d(TAG, "🔙 Sheet dismissed by backdrop — auto resolved CANCEL")
313
+ }
314
+ try {
315
+ rootView.unmountReactApplication()
316
+ Log.d(TAG, "🧹 RN sheet dismissed, root unmounted")
317
+ } catch (e: Exception) {
318
+ Log.w(TAG, "Error unmounting ReactRootView: ${e.message}")
319
+ }
320
+ }
321
+
322
+ // Start RN trước show — view sẽ measure khi attached vào dialog window
323
+ rootView.startReactApplication(
324
+ reactInstanceManager,
325
+ bundleName,
326
+ Arguments.toBundle(initialProps)
327
+ )
328
+
329
+ bottomSheetDialog.show()
330
+
331
+ Log.d(TAG, "✅ showRNExitSheet: BottomSheetDialog shown (wrap content), bundle=$bundleName")
332
+ safeResolve(true)
333
+ } catch (e: Exception) {
334
+ Log.e(TAG, "❌ Exception in presentRNBottomSheet", e)
335
+ safeReject("RN_SHEET_ERROR", e.message ?: "unknown", e)
336
+ }
337
+ }
338
+
339
+ @ReactMethod
340
+ fun showNativeExitDialog(config: ReadableMap, promise: Promise) {
341
+ val activity = currentActivity ?: run {
342
+ promise.reject("NO_ACTIVITY", "Current activity is null")
343
+ return
344
+ }
345
+
346
+ val title = if (config.hasKey("title")) config.getString("title") else "Xác nhận thoát"
347
+ val message = if (config.hasKey("message")) config.getString("message") else "Bạn có chắc chắn muốn thoát khỏi quá trình eKYC?"
348
+ val confirmText = if (config.hasKey("confirmText")) config.getString("confirmText") else "Thoát ra"
349
+ val cancelText = if (config.hasKey("cancelText")) config.getString("cancelText") else "Hủy bỏ"
350
+
351
+ activity.runOnUiThread {
352
+ try {
353
+ val bottomSheetDialog = BottomSheetDialog(activity)
354
+
355
+ // Create a simple programmatic layout for the Bottom Sheet
356
+ val context = activity
357
+ val root = LinearLayout(context).apply {
358
+ orientation = LinearLayout.VERTICAL
359
+ setPadding(60, 40, 60, 80)
360
+ setBackgroundColor(Color.WHITE)
361
+ }
362
+
363
+ // Title
364
+ val titleView = TextView(context).apply {
365
+ text = title
366
+ textSize = 20f
367
+ setTextColor(Color.BLACK)
368
+ setTypeface(null, android.graphics.Typeface.BOLD)
369
+ gravity = Gravity.CENTER
370
+ setPadding(0, 0, 0, 20)
371
+ }
372
+ root.addView(titleView)
373
+
374
+ // Message
375
+ val messageView = TextView(context).apply {
376
+ text = message
377
+ textSize = 16f
378
+ setTextColor(Color.DKGRAY)
379
+ gravity = Gravity.CENTER
380
+ setPadding(0, 0, 0, 60)
381
+ }
382
+ root.addView(messageView)
383
+
384
+ // Buttons Container
385
+ val buttonsContainer = LinearLayout(context).apply {
386
+ orientation = LinearLayout.HORIZONTAL
387
+ layoutParams = LinearLayout.LayoutParams(
388
+ LinearLayout.LayoutParams.MATCH_PARENT,
389
+ LinearLayout.LayoutParams.WRAP_CONTENT
390
+ )
391
+ }
392
+
393
+ // Cancel Button
394
+ val btnCancel = Button(context).apply {
395
+ text = cancelText
396
+ layoutParams = LinearLayout.LayoutParams(0, 140, 1f).apply {
397
+ setMargins(0, 0, 10, 0)
398
+ }
399
+ setBackgroundColor(Color.parseColor("#F2F2F7"))
400
+ setTextColor(Color.BLACK)
401
+ setOnClickListener {
402
+ SDKeKYCExitHandlerManager.resolve("CANCEL")
403
+ bottomSheetDialog.dismiss()
404
+ promise.resolve("CANCEL")
405
+ }
406
+ }
407
+ buttonsContainer.addView(btnCancel)
408
+
409
+ // Confirm Button
410
+ val btnConfirm = Button(context).apply {
411
+ text = confirmText
412
+ layoutParams = LinearLayout.LayoutParams(0, 140, 1f).apply {
413
+ setMargins(10, 0, 0, 0)
414
+ }
415
+ setBackgroundColor(Color.parseColor("#FF3B30"))
416
+ setTextColor(Color.WHITE)
417
+ setOnClickListener {
418
+ SDKeKYCExitHandlerManager.resolve("CONFIRM")
419
+ bottomSheetDialog.dismiss()
420
+ promise.resolve("CONFIRM")
421
+ }
422
+ }
423
+ buttonsContainer.addView(btnConfirm)
424
+
425
+ root.addView(buttonsContainer)
426
+ bottomSheetDialog.setContentView(root)
427
+ bottomSheetDialog.setCancelable(false)
428
+ bottomSheetDialog.show()
429
+
430
+ } catch (e: Exception) {
431
+ Log.e(TAG, "❌ showNativeExitDialog error: ${e.message}")
432
+ promise.reject("DIALOG_ERROR", e.message)
433
+ }
434
+ }
435
+ }
436
+
66
437
  private fun sendEvent(eventName: String, params: WritableMap?) {
67
438
  reactApplicationContext
68
439
  .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@@ -97,6 +468,69 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
97
468
  return Pair(eventMap, promiseMap)
98
469
  }
99
470
 
471
+ /**
472
+ * Convert snake_case (match_score) and kebab-case (to-be-reviewed) keys to camelCase.
473
+ * Native SDK responses use these formats via Gson @SerializedName, but TS API expects camelCase.
474
+ * Examples:
475
+ * - "match_score" → "matchScore"
476
+ * - "to-be-reviewed" → "toBeReviewed"
477
+ * - "ageRange" → "ageRange" (already camelCase, unchanged)
478
+ */
479
+ private fun normalizeKeyToCamelCase(key: String): String {
480
+ if (!key.contains('_') && !key.contains('-')) return key
481
+ val parts = key.split('_', '-')
482
+ if (parts.isEmpty()) return key
483
+ val sb = StringBuilder(parts[0])
484
+ for (i in 1 until parts.size) {
485
+ val p = parts[i]
486
+ if (p.isEmpty()) continue
487
+ sb.append(p[0].uppercaseChar())
488
+ if (p.length > 1) sb.append(p.substring(1))
489
+ }
490
+ return sb.toString()
491
+ }
492
+
493
+ /** Recursively convert org.json.JSONObject → WritableMap (normalizes keys to camelCase). */
494
+ private fun jsonObjectToWritableMap(jsonObject: JSONObject): WritableMap {
495
+ val map = Arguments.createMap()
496
+ val keys = jsonObject.keys()
497
+ while (keys.hasNext()) {
498
+ val rawKey = keys.next()
499
+ val key = normalizeKeyToCamelCase(rawKey)
500
+ when (val value = jsonObject.get(rawKey)) {
501
+ is JSONObject -> map.putMap(key, jsonObjectToWritableMap(value))
502
+ is JSONArray -> map.putArray(key, jsonArrayToWritableArray(value))
503
+ is Boolean -> map.putBoolean(key, value)
504
+ is Int -> map.putInt(key, value)
505
+ is Long -> map.putDouble(key, value.toDouble())
506
+ is Double -> map.putDouble(key, value)
507
+ is String -> map.putString(key, value)
508
+ JSONObject.NULL -> map.putNull(key)
509
+ else -> map.putString(key, value.toString())
510
+ }
511
+ }
512
+ return map
513
+ }
514
+
515
+ /** Recursively convert org.json.JSONArray → WritableArray. */
516
+ private fun jsonArrayToWritableArray(jsonArray: JSONArray): WritableArray {
517
+ val array = Arguments.createArray()
518
+ for (i in 0 until jsonArray.length()) {
519
+ when (val value = jsonArray.get(i)) {
520
+ is JSONObject -> array.pushMap(jsonObjectToWritableMap(value))
521
+ is JSONArray -> array.pushArray(jsonArrayToWritableArray(value))
522
+ is Boolean -> array.pushBoolean(value)
523
+ is Int -> array.pushInt(value)
524
+ is Long -> array.pushDouble(value.toDouble())
525
+ is Double -> array.pushDouble(value)
526
+ is String -> array.pushString(value)
527
+ JSONObject.NULL -> array.pushNull()
528
+ else -> array.pushString(value.toString())
529
+ }
530
+ }
531
+ return array
532
+ }
533
+
100
534
  /** Convert ESignOpenSessionResult to WritableMap for RN. */
101
535
  private fun eSignOpenSessionResultToWritableMap(result: ESignModels.ESignOpenSessionResult): WritableMap =
102
536
  Arguments.createMap().apply {
@@ -430,8 +864,6 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
430
864
  activeActionCount: Int?,
431
865
  forceCaptureTimeout: Double?,
432
866
  isActiveLivenessColor: Boolean?,
433
- isShowBackConfirmation: Boolean?,
434
- backConfirmConfigJson: String?,
435
867
  promise: Promise
436
868
  ) {
437
869
  Log.d(TAG, "▶️ startLiveness() called")
@@ -465,7 +897,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
465
897
  null
466
898
  }
467
899
 
468
- val backConfirmConfig = parseBackConfirmConfig(backConfirmConfigJson)
900
+
469
901
  val livenessConfig =
470
902
  LivenessConfig(
471
903
  isActiveLiveness = isActiveLiveness ?: false,
@@ -480,9 +912,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
480
912
  activeActionCount = activeActionCount ?: 2,
481
913
  forceCaptureTimeout = (forceCaptureTimeout ?: 0.0).toLong(),
482
914
  selfieImage = imageFile,
483
- transactionId = transactionId,
484
- isShowBackConfirmation = isShowBackConfirmation ?: false,
485
- backConfirmConfig = backConfirmConfig,
915
+ transactionId = transactionId
486
916
  )
487
917
  val ekycConfig =
488
918
  EKYCConfigSDK(
@@ -493,24 +923,25 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
493
923
  eKYCFinOSLiveness.startEkyc(
494
924
  ekycConfigSDK = ekycConfig,
495
925
  callbackSuccess = { event, data ->
496
- // LOG_SUCCESS = internal submit result event (v1.4.6.1), don't resolve promise
497
- if (event == EKYCEvent.LOG_SUCCESS) {
498
- val logDataStr = data?.customData?.let { Gson().toJson(it) } ?: (data as? SDKEkycResult)?.checkLivenessResponse?.let { Gson().toJson(it) } ?: Gson().toJson(data)
499
- Log.d(TAG, "LOG_SUCCESS [Liveness]: $logDataStr")
500
- val logMap = Arguments.createMap().apply {
501
- putString("event", event.name.toString())
502
- putString("data", logDataStr)
503
- }
504
- sendEvent("onLivenessSuccess", logMap)
505
- return@startEkyc
506
- }
507
- Log.d(TAG, "✅ startLiveness() success")
508
- val (eventMap, promiseMap) = createSeparateMaps { map ->
509
- map.putString("event", event.name.toString())
510
- map.putString("data", Gson().toJson((data as SDKEkycResult).checkLivenessResponse))
511
- }
512
- sendEvent("onLivenessSuccess", eventMap)
513
- promise.resolve(promiseMap)
926
+ Log.d(TAG, "🔥 NATIVE CALLBACK (Liveness): event=${event.name}")
927
+ val response = (data as? SDKEkycResult)?.checkLivenessResponse
928
+ val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data)
929
+
930
+ // Helper to create fresh data map to avoid 'Map already consumed' error
931
+ fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() }
932
+
933
+ // Send the event directly to React Native
934
+ sendEvent("onLivenessSuccess", Arguments.createMap().apply {
935
+ putString("event", event.name)
936
+ putMap("data", createWritableData())
937
+ })
938
+
939
+ // Always attempt to resolve the promise.
940
+ // RN safely ignores subsequent resolves if the native SDK fires multiple events.
941
+ promise.resolve(Arguments.createMap().apply {
942
+ putString("event", event.name)
943
+ putMap("data", createWritableData())
944
+ })
514
945
  },
515
946
  callbackError = { event, errorResult ->
516
947
  Log.e(TAG, "❌ startLiveness() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}")
@@ -577,40 +1008,21 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
577
1008
  eKYCFinOSFaceService.startEkyc(
578
1009
  ekycConfigSDK = ekycConfig,
579
1010
  callbackSuccess = { event, data ->
580
- // LOG_SUCCESS = internal submit result event (v1.4.6.1), don't resolve promise
581
- if (event == EKYCEvent.LOG_SUCCESS) {
582
- val faceResponse = (data as? SDKEkycResult)?.checkFaceResponse
583
- val logDataStr = data?.customData?.let { Gson().toJson(it) } ?: faceResponse?.let { Gson().toJson(it) } ?: "null"
584
- Log.d(TAG, "LOG_SUCCESS [FaceCompare]: $logDataStr")
585
- val logMap = Arguments.createMap().apply {
586
- putString("event", event.name.toString())
587
- putString("data", logDataStr)
588
- }
589
- sendEvent("onFaceCompareSuccess", logMap)
590
- return@startEkyc
591
- }
592
- Log.d(TAG, "✅ startFaceCompare() success")
593
- val faceResponse = (data as? SDKEkycResult)?.checkFaceResponse
594
- val (eventMap, promiseMap) = createSeparateMaps { map ->
595
- map.putString("event", event.name.toString())
596
- val resultMap = Arguments.createMap().apply {
597
- putDouble("conf", faceResponse?.result?.conf?.toString()?.toDoubleOrNull() ?: 0.0)
598
- putString("match", faceResponse?.result?.match?.toString())
599
- putInt("matchScore", faceResponse?.result?.matchScore?.toString()?.toIntOrNull() ?: 0)
600
- putString("toBeReviewed", faceResponse?.result?.toBeReviewed?.toString())
601
- }
602
- val faceMap = Arguments.createMap().apply {
603
- putString("requestId", faceResponse?.requestId?.toString())
604
- putMap("result", resultMap)
605
- putString("status", faceResponse?.status?.toString())
606
- putInt("statusCode", faceResponse?.statusCode?.toString()?.toIntOrNull() ?: 0)
607
- putString("error", faceResponse?.error?.toString())
608
- putString("type", faceResponse?.type?.toString())
609
- }
610
- map.putMap("data", faceMap)
611
- }
612
- sendEvent("onFaceCompareSuccess", eventMap)
613
- promise.resolve(promiseMap)
1011
+ Log.d(TAG, "🔥 NATIVE CALLBACK (Face Compare): event=${event.name}")
1012
+ val response = (data as? SDKEkycResult)?.checkFaceResponse
1013
+ val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data)
1014
+ fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() }
1015
+
1016
+ sendEvent("onFaceCompareSuccess", Arguments.createMap().apply {
1017
+ putString("event", event.name)
1018
+ putMap("data", createWritableData())
1019
+ })
1020
+
1021
+ // Always attempt to resolve the promise.
1022
+ promise.resolve(Arguments.createMap().apply {
1023
+ putString("event", event.name)
1024
+ putMap("data", createWritableData())
1025
+ })
614
1026
  },
615
1027
  callbackError = { event, errorResult ->
616
1028
  Log.e(TAG, "❌ startFaceCompare() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}")
@@ -806,18 +1218,13 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
806
1218
  else -> AppIDType.NONE
807
1219
  }
808
1220
 
809
- val isShowBackConfirmation = extractBooleanValue(optionConfigJson, "isShowBackConfirmation") ?: false
810
- val backConfirmConfig = parseBackConfirmConfig(extractObjectJson(optionConfigJson, "backConfirmConfig"))
811
-
812
1221
  val livenessConfig = if (finalFlow.contains(SDKType.LIVENESS)) {
813
1222
  LivenessConfig(
814
1223
  isActiveLiveness = isActiveLiveness,
815
1224
  forceCaptureTimeout = (forceCaptureTimeoutSec * 1000L).coerceAtLeast(0),
816
1225
  isShowCameraFont = switchFrontCamera,
817
1226
  customActions = customActionsList?.takeIf { it.isNotEmpty() },
818
- activeActionCount = activeActionCount,
819
- isShowBackConfirmation = isShowBackConfirmation,
820
- backConfirmConfig = backConfirmConfig,
1227
+ activeActionCount = activeActionCount
821
1228
  )
822
1229
  } else null
823
1230
 
@@ -835,41 +1242,71 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
835
1242
  activity = currentActivity,
836
1243
  ekycConfigSDK = ekycConfigSDK,
837
1244
  callbackSuccess = { event, data ->
838
- // LOG_SUCCESS = internal submit result event (v1.4.5), emit event only, don't resolve promise
839
- if (event == EKYCEvent.LOG_SUCCESS) {
840
- val logDataStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(data)
841
- Log.d(TAG, "LOG_SUCCESS [EkycUI]: $logDataStr")
842
- sendEvent("onEkycUISuccess", Arguments.createMap().apply {
843
- putString("status", "log")
844
- putString("event", event.name.toString())
845
- putString("data", logDataStr)
846
- })
847
- return@startEkyc
848
- }
849
- Log.d(TAG, "✅ startEkycUI() callback - event=${event.name}")
1245
+ Log.d(TAG, "🔥 NATIVE CALLBACK (EkycUI): event=${event.name}")
1246
+
850
1247
  val result = data as? SDKEkycResult
851
- val hasRealData = result?.ekycStateModel != null
852
- // Chỉ resolve promise khi flow hoàn thành có data (SDK_END_SUCCESS); SDK_START_SUCCESS = activity vừa mở, bỏ qua
853
- if (event == EKYCEvent.SDK_START_SUCCESS && !hasRealData) {
854
- Log.d(TAG, "⏳ startEkycUI() SDK_START_SUCCESS chờ SDK_END_SUCCESS")
855
- sendEvent("onEkycUISuccess", Arguments.createMap().apply {
856
- putString("status", "started")
857
- putString("event", event.name.toString())
858
- })
859
- } else {
1248
+ val response = result?.ekycStateModel
1249
+ val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data)
1250
+
1251
+ // Helper to create fresh data map to avoid 'Map already consumed' error
1252
+ fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() }
1253
+
1254
+ val statusStr = when (event) {
1255
+ EKYCEvent.LOG_SUCCESS -> "log"
1256
+ EKYCEvent.SDK_START_SUCCESS -> "started"
1257
+ else -> "success"
1258
+ }
1259
+
1260
+ // Send EVERY event directly to React Native Event Listener (Pass-through)
1261
+ sendEvent("onEkycUISuccess", Arguments.createMap().apply {
1262
+ putString("status", statusStr)
1263
+ putString("event", event.name)
1264
+
1265
+ if (event == EKYCEvent.SDK_START_SUCCESS && response == null) {
1266
+ // Bỏ qua data nếu là SDK_START_SUCCESS (chỉ có event/status) để giống code cũ
1267
+ } else {
1268
+ putMap("data", createWritableData())
1269
+ }
1270
+
1271
+ // Retain the convenient root properties cho backward compatibility
860
1272
  val ekycFiles = result?.ekycStateModel?.eKYCFileModel
861
1273
  val ekycTransactionId = result?.ekycStateModel?.transactionId ?: ""
862
- val (eventMap, promiseMap) = createSeparateMaps { map ->
863
- map.putString("status", "success")
864
- map.putString("event", event.name.toString())
865
- map.putString("data", Gson().toJson(data))
866
- map.putString("transactionId", ekycTransactionId)
867
- ekycFiles?.imageFace?.absolutePath?.let { map.putString("imageFacePath", it) }
868
- ekycFiles?.imageOcrFront?.absolutePath?.let { map.putString("imageOcrFrontPath", it) }
869
- ekycFiles?.imageOcrBack?.absolutePath?.let { map.putString("imageOcrBackPath", it) }
870
- }
871
- sendEvent("onEkycUISuccess", eventMap)
872
- promise.resolve(promiseMap)
1274
+ if (ekycTransactionId.isNotEmpty()) putString("transactionId", ekycTransactionId)
1275
+ ekycFiles?.imageFace?.absolutePath?.let { putString("imageFacePath", it) }
1276
+ ekycFiles?.imageOcrFront?.absolutePath?.let { putString("imageOcrFrontPath", it) }
1277
+ ekycFiles?.imageOcrBack?.absolutePath?.let { putString("imageOcrBackPath", it) }
1278
+ })
1279
+
1280
+ // Re-emit onLivenessSuccess: dùng checkLivenessResponse (đúng format
1281
+ // CheckLivenessResponse) thay ekycStateModel để JS nhận đủ fields.
1282
+ if (event == EKYCEvent.LIVENESS_SUCCESS || event == EKYCEvent.LOG_SUCCESS) {
1283
+ val lrJson = result?.checkLivenessResponse
1284
+ ?.let { Gson().toJson(it) }
1285
+ ?: data?.customData?.let { Gson().toJson(it) }
1286
+ ?: jsonStr
1287
+ fun createLivenessData() = try {
1288
+ jsonObjectToWritableMap(JSONObject(lrJson))
1289
+ } catch (e: Exception) { Arguments.createMap() }
1290
+ sendEvent("onLivenessSuccess", Arguments.createMap().apply {
1291
+ putString("event", event.name)
1292
+ putMap("data", createLivenessData())
1293
+ })
1294
+ }
1295
+
1296
+ // IMPORTANT: Chỉ resolve promise ở SDK_END_SUCCESS để `await FinosEKYC.startEkycUI()` không bị kết thúc sớm
1297
+ if (event == EKYCEvent.SDK_END_SUCCESS) {
1298
+ promise.resolve(Arguments.createMap().apply {
1299
+ putString("status", "success")
1300
+ putString("event", event.name)
1301
+ putMap("data", createWritableData())
1302
+
1303
+ val ekycFiles = result?.ekycStateModel?.eKYCFileModel
1304
+ val ekycTransactionId = result?.ekycStateModel?.transactionId ?: ""
1305
+ if (ekycTransactionId.isNotEmpty()) putString("transactionId", ekycTransactionId)
1306
+ ekycFiles?.imageFace?.absolutePath?.let { putString("imageFacePath", it) }
1307
+ ekycFiles?.imageOcrFront?.absolutePath?.let { putString("imageOcrFrontPath", it) }
1308
+ ekycFiles?.imageOcrBack?.absolutePath?.let { putString("imageOcrBackPath", it) }
1309
+ })
873
1310
  }
874
1311
  },
875
1312
  callbackError = { event, errorResult ->
@@ -881,14 +1318,14 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
881
1318
  putString("customMessage", errorResult.message)
882
1319
  putString("message", errorResult.message)
883
1320
  }
1321
+ sendEvent("onEkycUIError", errorMap)
1322
+
884
1323
  val promiseErrorMap = Arguments.createMap().apply {
885
1324
  putString("status", "error")
886
1325
  putString("event", event.name.toString())
887
1326
  putString("customCode", errorResult.code)
888
1327
  putString("customMessage", errorResult.message)
889
- putString("message", errorResult.message)
890
1328
  }
891
- sendEvent("onEkycUIError", errorMap)
892
1329
  promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap)
893
1330
  }
894
1331
  )
@@ -943,24 +1380,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
943
1380
  }
944
1381
  }
945
1382
 
946
- private fun parseBackConfirmConfig(json: String?): LivenessBackConfirmConfig? {
947
- if (json.isNullOrBlank() || json == "{}") return null
948
- return try {
949
- LivenessBackConfirmConfig(
950
- titleVi = extractStringValue(json, "titleVi"),
951
- titleEn = extractStringValue(json, "titleEn"),
952
- bodyVi = extractStringValue(json, "bodyVi"),
953
- bodyEn = extractStringValue(json, "bodyEn"),
954
- confirmButtonVi = extractStringValue(json, "confirmButtonVi"),
955
- confirmButtonEn = extractStringValue(json, "confirmButtonEn"),
956
- cancelButtonVi = extractStringValue(json, "cancelButtonVi"),
957
- cancelButtonEn = extractStringValue(json, "cancelButtonEn"),
958
- )
959
- } catch (e: Exception) {
960
- Log.e(TAG, "Error parsing backConfirmConfig: ${e.message}", e)
961
- null
962
- }
963
- }
1383
+
964
1384
 
965
1385
  private fun extractIntValue(json: String, key: String): Int? {
966
1386
  return try {
@@ -2174,4 +2594,95 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
2174
2594
  }
2175
2595
  }
2176
2596
 
2597
+ @ReactMethod
2598
+ fun registerExitHandler(promise: Promise) {
2599
+ Log.d(TAG, "▶️ registerExitHandler() called")
2600
+ try {
2601
+ SDKeKYCExitHandlerManager.uiListener = { fm ->
2602
+ sendEvent("onShowExitConfirm", Arguments.createMap())
2603
+ }
2604
+ SDKeKYCExitHandlerManager.register()
2605
+ promise.resolve(true)
2606
+ } catch (e: Exception) {
2607
+ Log.e(TAG, "❌ registerExitHandler() exception: ${e.message}", e)
2608
+ promise.reject("REGISTER_EXIT_HANDLER_EXCEPTION", e.message, e)
2609
+ }
2610
+ }
2611
+
2612
+ @ReactMethod
2613
+ fun resolveExit(action: String, promise: Promise) {
2614
+ Log.d(TAG, "▶️ resolveExit() called with action: $action")
2615
+ try {
2616
+ val sheet = currentExitSheet
2617
+ val activity = getTrueCurrentActivity()
2618
+
2619
+ if (sheet != null && sheet.isShowing && activity != null) {
2620
+ // Đánh dấu trước khi dismiss để setOnDismissListener biết đây là button-triggered,
2621
+ // không phải backdrop tap → tránh auto-resolve CANCEL sai.
2622
+ exitSheetResolvedViaButton = true
2623
+ val latch = java.util.concurrent.CountDownLatch(1)
2624
+ activity.runOnUiThread {
2625
+ try {
2626
+ sheet.dismiss()
2627
+ } catch (e: Exception) {
2628
+ Log.w(TAG, "resolveExit: error dismissing sheet: ${e.message}")
2629
+ } finally {
2630
+ latch.countDown()
2631
+ }
2632
+ }
2633
+ latch.await(500, java.util.concurrent.TimeUnit.MILLISECONDS)
2634
+ exitSheetResolvedViaButton = false
2635
+ }
2636
+
2637
+ // Gọi SDK callback SAU KHI sheet đã dismiss
2638
+ SDKeKYCExitHandlerManager.resolve(action)
2639
+ promise.resolve(true)
2640
+ } catch (e: Exception) {
2641
+ Log.e(TAG, "❌ resolveExit() exception: ${e.message}", e)
2642
+ promise.reject("RESOLVE_EXIT_EXCEPTION", e.message, e)
2643
+ }
2644
+ }
2645
+
2646
+ @ReactMethod
2647
+ fun setExitSheetHeight(heightDp: Float) {
2648
+ val activity = getTrueCurrentActivity() ?: return
2649
+ val heightPx = (heightDp * activity.resources.displayMetrics.density).toInt()
2650
+ Log.d(TAG, "▶️ setExitSheetHeight: ${heightDp}dp → ${heightPx}px")
2651
+ activity.runOnUiThread {
2652
+ val sheet = currentExitSheet ?: return@runOnUiThread
2653
+ if (!sheet.isShowing) return@runOnUiThread
2654
+ val bs = sheet.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
2655
+ ?: return@runOnUiThread
2656
+ val lp = bs.layoutParams ?: return@runOnUiThread
2657
+ lp.height = heightPx
2658
+ bs.layoutParams = lp
2659
+ val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bs)
2660
+ behavior.peekHeight = heightPx
2661
+ behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
2662
+ }
2663
+ }
2664
+
2665
+ @ReactMethod
2666
+ fun addListener(eventName: String) {
2667
+ // Keep: Required for RN built-in Event Emitter Calls.
2668
+ }
2669
+
2670
+ @ReactMethod
2671
+ fun removeListeners(count: Int) {
2672
+ // Keep: Required for RN built-in Event Emitter Calls.
2673
+ }
2674
+
2675
+ override fun onCatalystInstanceDestroy() {
2676
+ super.onCatalystInstanceDestroy()
2677
+ SDKeKYCExitHandlerManager.uiListener = null
2678
+ // Dismiss sheet nếu vẫn đang hiển thị khi RN catalyst instance destroy
2679
+ val sheet = currentExitSheet
2680
+ currentExitSheet = null
2681
+ if (sheet != null && sheet.isShowing) {
2682
+ val activity = currentActivity
2683
+ activity?.runOnUiThread {
2684
+ try { sheet.dismiss() } catch (_: Exception) {}
2685
+ }
2686
+ }
2687
+ }
2177
2688
  }