@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.
- package/README.md +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/finos/sdk/ekyc/EKYCModule.kt +504 -38
- package/android/src/main/java/finos/sdk/ekyc/SDKeKYCExitHandlerManager.kt +28 -0
- package/dist/EKYCModule.d.ts +5 -2
- package/dist/EKYCModule.js +58 -4
- package/dist/index.d.ts +3 -3
- package/dist/package.json +1 -1
- package/dist/src/modules/FinosEKYCModule.d.ts +32 -4
- package/dist/src/modules/FinosEKYCModule.js +75 -1
- package/dist/src/types/ekycC06Type.d.ts +9 -0
- package/dist/src/types/ekycLivenessType.d.ts +0 -13
- package/dist/src/types/ekycNFCType.d.ts +10 -1
- package/dist/src/types/ekycOCRType.d.ts +9 -0
- package/dist/src/types/finos-ekyc.d.ts +1 -1
- package/dist/src/utils/utils.d.ts +2 -2
- package/dist/src/utils/utils.js +1 -1
- package/ios/EKYCModule.swift +17 -1
- package/ios/EKYCModuleBridge.m +8 -0
- package/package.json +1 -1
- package/src/modules/FinosEKYCModule.ts +85 -6
- package/src/types/ekycC06Type.ts +10 -0
- package/src/types/ekycLivenessType.ts +0 -15
- package/src/types/ekycNFCType.ts +12 -2
- package/src/types/ekycOCRType.ts +11 -1
- package/src/types/finos-ekyc.ts +1 -1
- package/src/utils/utils.ts +3 -3
|
@@ -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
|
-
/**
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|