@finos_sdk/sdk-ekyc 1.4.7 → 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,13 +468,36 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
97
468
  return Pair(eventMap, promiseMap)
98
469
  }
99
470
 
100
- /** Recursively convert org.json.JSONObject → WritableMap (preserves key names as-is). */
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). */
101
494
  private fun jsonObjectToWritableMap(jsonObject: JSONObject): WritableMap {
102
495
  val map = Arguments.createMap()
103
496
  val keys = jsonObject.keys()
104
497
  while (keys.hasNext()) {
105
- val key = keys.next()
106
- when (val value = jsonObject.get(key)) {
498
+ val rawKey = keys.next()
499
+ val key = normalizeKeyToCamelCase(rawKey)
500
+ when (val value = jsonObject.get(rawKey)) {
107
501
  is JSONObject -> map.putMap(key, jsonObjectToWritableMap(value))
108
502
  is JSONArray -> map.putArray(key, jsonArrayToWritableArray(value))
109
503
  is Boolean -> map.putBoolean(key, value)
@@ -470,8 +864,6 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
470
864
  activeActionCount: Int?,
471
865
  forceCaptureTimeout: Double?,
472
866
  isActiveLivenessColor: Boolean?,
473
- isShowBackConfirmation: Boolean?,
474
- backConfirmConfigJson: String?,
475
867
  promise: Promise
476
868
  ) {
477
869
  Log.d(TAG, "▶️ startLiveness() called")
@@ -505,7 +897,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
505
897
  null
506
898
  }
507
899
 
508
- val backConfirmConfig = parseBackConfirmConfig(backConfirmConfigJson)
900
+
509
901
  val livenessConfig =
510
902
  LivenessConfig(
511
903
  isActiveLiveness = isActiveLiveness ?: false,
@@ -520,9 +912,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
520
912
  activeActionCount = activeActionCount ?: 2,
521
913
  forceCaptureTimeout = (forceCaptureTimeout ?: 0.0).toLong(),
522
914
  selfieImage = imageFile,
523
- transactionId = transactionId,
524
- isShowBackConfirmation = isShowBackConfirmation ?: false,
525
- backConfirmConfig = backConfirmConfig,
915
+ transactionId = transactionId
526
916
  )
527
917
  val ekycConfig =
528
918
  EKYCConfigSDK(
@@ -536,7 +926,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
536
926
  Log.d(TAG, "🔥 NATIVE CALLBACK (Liveness): event=${event.name}")
537
927
  val response = (data as? SDKEkycResult)?.checkLivenessResponse
538
928
  val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data)
539
-
929
+
540
930
  // Helper to create fresh data map to avoid 'Map already consumed' error
541
931
  fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() }
542
932
 
@@ -828,18 +1218,13 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
828
1218
  else -> AppIDType.NONE
829
1219
  }
830
1220
 
831
- val isShowBackConfirmation = extractBooleanValue(optionConfigJson, "isShowBackConfirmation") ?: false
832
- val backConfirmConfig = parseBackConfirmConfig(extractObjectJson(optionConfigJson, "backConfirmConfig"))
833
-
834
1221
  val livenessConfig = if (finalFlow.contains(SDKType.LIVENESS)) {
835
1222
  LivenessConfig(
836
1223
  isActiveLiveness = isActiveLiveness,
837
1224
  forceCaptureTimeout = (forceCaptureTimeoutSec * 1000L).coerceAtLeast(0),
838
1225
  isShowCameraFont = switchFrontCamera,
839
1226
  customActions = customActionsList?.takeIf { it.isNotEmpty() },
840
- activeActionCount = activeActionCount,
841
- isShowBackConfirmation = isShowBackConfirmation,
842
- backConfirmConfig = backConfirmConfig,
1227
+ activeActionCount = activeActionCount
843
1228
  )
844
1229
  } else null
845
1230
 
@@ -858,11 +1243,11 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
858
1243
  ekycConfigSDK = ekycConfigSDK,
859
1244
  callbackSuccess = { event, data ->
860
1245
  Log.d(TAG, "🔥 NATIVE CALLBACK (EkycUI): event=${event.name}")
861
-
1246
+
862
1247
  val result = data as? SDKEkycResult
863
1248
  val response = result?.ekycStateModel
864
1249
  val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data)
865
-
1250
+
866
1251
  // Helper to create fresh data map to avoid 'Map already consumed' error
867
1252
  fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() }
868
1253
 
@@ -876,13 +1261,13 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
876
1261
  sendEvent("onEkycUISuccess", Arguments.createMap().apply {
877
1262
  putString("status", statusStr)
878
1263
  putString("event", event.name)
879
-
1264
+
880
1265
  if (event == EKYCEvent.SDK_START_SUCCESS && response == null) {
881
1266
  // Bỏ qua data nếu là SDK_START_SUCCESS (chỉ có event/status) để giống code cũ
882
1267
  } else {
883
1268
  putMap("data", createWritableData())
884
1269
  }
885
-
1270
+
886
1271
  // Retain the convenient root properties cho backward compatibility
887
1272
  val ekycFiles = result?.ekycStateModel?.eKYCFileModel
888
1273
  val ekycTransactionId = result?.ekycStateModel?.transactionId ?: ""
@@ -892,6 +1277,22 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
892
1277
  ekycFiles?.imageOcrBack?.absolutePath?.let { putString("imageOcrBackPath", it) }
893
1278
  })
894
1279
 
1280
+ // Re-emit onLivenessSuccess: dùng checkLivenessResponse (đúng format
1281
+ // CheckLivenessResponse) thay vì 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
+
895
1296
  // IMPORTANT: Chỉ resolve promise ở SDK_END_SUCCESS để `await FinosEKYC.startEkycUI()` không bị kết thúc sớm
896
1297
  if (event == EKYCEvent.SDK_END_SUCCESS) {
897
1298
  promise.resolve(Arguments.createMap().apply {
@@ -979,24 +1380,7 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
979
1380
  }
980
1381
  }
981
1382
 
982
- private fun parseBackConfirmConfig(json: String?): LivenessBackConfirmConfig? {
983
- if (json.isNullOrBlank() || json == "{}") return null
984
- return try {
985
- LivenessBackConfirmConfig(
986
- titleVi = extractStringValue(json, "titleVi"),
987
- titleEn = extractStringValue(json, "titleEn"),
988
- bodyVi = extractStringValue(json, "bodyVi"),
989
- bodyEn = extractStringValue(json, "bodyEn"),
990
- confirmButtonVi = extractStringValue(json, "confirmButtonVi"),
991
- confirmButtonEn = extractStringValue(json, "confirmButtonEn"),
992
- cancelButtonVi = extractStringValue(json, "cancelButtonVi"),
993
- cancelButtonEn = extractStringValue(json, "cancelButtonEn"),
994
- )
995
- } catch (e: Exception) {
996
- Log.e(TAG, "Error parsing backConfirmConfig: ${e.message}", e)
997
- null
998
- }
999
- }
1383
+
1000
1384
 
1001
1385
  private fun extractIntValue(json: String, key: String): Int? {
1002
1386
  return try {
@@ -2210,6 +2594,74 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
2210
2594
  }
2211
2595
  }
2212
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
+
2213
2665
  @ReactMethod
2214
2666
  fun addListener(eventName: String) {
2215
2667
  // Keep: Required for RN built-in Event Emitter Calls.
@@ -2219,4 +2671,18 @@ class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
2219
2671
  fun removeListeners(count: Int) {
2220
2672
  // Keep: Required for RN built-in Event Emitter Calls.
2221
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
+ }
2222
2688
  }