package finos.sdk.ekyc import android.R import android.net.Uri import com.google.gson.Gson import android.os.Handler import android.os.Looper import android.util.Base64 import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.ReactRootView import com.facebook.react.ReactApplication import com.facebook.react.bridge.ReactContext import finos.sdk.c06.eKYCFinOSC06 import finos.sdk.core.define.EKYCErrorResult import finos.sdk.core.define.EKYCEvent import finos.sdk.core.define.SDKType import finos.sdk.core.define.SDKFaceDetectStatus import finos.sdk.core.model.response.SDKEkycResult import finos.sdk.core.model.sdk.config.AppKeyConfig import finos.sdk.core.model.sdk.config.AppKeyLivenessConfig import finos.sdk.core.model.sdk.config.C06Config import finos.sdk.core.model.sdk.config.EKYCConfigSDK import finos.sdk.core.model.sdk.config.ExitConfirmConfig import finos.sdk.core.model.sdk.config.FaceServiceConfig import finos.sdk.core.model.sdk.config.LivenessConfig import finos.sdk.core.model.sdk.config.NfcConfig import finos.sdk.core.model.sdk.config.OcrConfig import finos.sdk.core.model.sdk.config.OptionConfig import finos.sdk.core.model.sdk.config.StyleConfig import finos.sdk.core.define.AppIDType import finos.sdk.ekyc.eKYCFinOS import finos.sdk.ekyc.ui.eKYCFinOSUI import finos.sdk.faceservice.eKYCFinOSFaceService import finos.sdk.liveness.eKYCFinOSLiveness import finos.sdk.nfc.eKYCFinOSNfc import finos.sdk.ocr.eKYCFinOSOcr import finos.sdk.smsotp.OTPFinOS import finos.sdk.core.model.sdk.config.SmsOtpConfig import vn.softdreams.easyca.sdk.eSignFinOS import vn.softdreams.easyca.sdk.esign.ESignModels import com.google.android.material.bottomsheet.BottomSheetDialog import android.view.LayoutInflater import android.view.View import android.widget.Button import android.widget.TextView import android.widget.LinearLayout import android.view.Gravity import android.graphics.Color import android.util.TypedValue import org.json.JSONArray import org.json.JSONObject import java.io.File import java.io.FileOutputStream import java.io.FileInputStream import java.io.InputStream import java.util.Date import android.app.Activity import android.app.Application import android.os.Bundle import android.view.ViewGroup import java.lang.ref.WeakReference @ReactModule(name = EKYCModule.NAME) class EKYCModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { companion object { const val NAME = "EKYCModule" private const val TAG = "EKYCModule" // Full class name của SDK activity duy nhất private const val SDK_EKYC_ACTIVITY = "finos.sdk.ekyc.sdkui.SDKeKYCActivity" } override fun getName(): String = NAME /** * WeakReference tới SDK activity đang foreground. * Được set/clear bởi ActivityLifecycleCallbacks khi SDK activity resume/pause. * @Volatile: viết từ UI thread (lifecycle callback), đọc từ JS bridge thread (showRNExitSheet). * WeakReference để tránh memory leak nếu activity bị destroy mà chưa clear. */ @Volatile private var activeSdkActivity: WeakReference? = null private val sdkActivityLifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { override fun onActivityResumed(activity: Activity) { if (isSDKActivity(activity)) { activeSdkActivity = WeakReference(activity) Log.d(TAG, "📌 SDK Activity resumed: ${activity.javaClass.simpleName}") } } override fun onActivityPaused(activity: Activity) { if (activeSdkActivity?.get() == activity) { activeSdkActivity = null Log.d(TAG, "📌 SDK Activity paused: ${activity.javaClass.simpleName}") } } override fun onActivityDestroyed(activity: Activity) { if (activeSdkActivity?.get() == activity) { activeSdkActivity = null Log.d(TAG, "📌 SDK Activity destroyed: ${activity.javaClass.simpleName}") } } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) {} override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} } private fun isSDKActivity(activity: Activity): Boolean { return activity.javaClass.name == SDK_EKYC_ACTIVITY } init { // Đăng ký lifecycle callback để track SDK activity tự động val app = reactContext.applicationContext as? Application app?.registerActivityLifecycleCallbacks(sdkActivityLifecycleCallbacks) ?: Log.w(TAG, "⚠️ Could not register ActivityLifecycleCallbacks") } /** * Lấy SDK activity đang active, fallback về getTrueCurrentActivity() nếu không có. */ private fun getSDKActivity(): Activity? { val sdk = activeSdkActivity?.get() if (sdk != null && !sdk.isFinishing && !sdk.isDestroyed) { Log.d(TAG, "✅ Using tracked SDK Activity: ${sdk.javaClass.simpleName}") return sdk } Log.d(TAG, "⚠️ No active SDK activity tracked, falling back to getTrueCurrentActivity") return getTrueCurrentActivity() } /** * Reference tới BottomSheetDialog hiện đang hiển thị. * @Volatile: resolveExit() chạy trên JS bridge thread, đọc field này được viết bởi UI thread. * Không có @Volatile → JVM cache → bridge thread thấy stale null → dismiss bị bỏ qua. */ @Volatile private var currentExitSheet: BottomSheetDialog? = null // Đánh dấu khi dismiss được trigger bởi resolveExit (button), không phải backdrop tap. // @Volatile vì được set từ bridge thread, đọc từ UI thread trong setOnDismissListener. @Volatile private var exitSheetResolvedViaButton = false private fun getTrueCurrentActivity(): Activity? { // Try React Native's currentActivity first as a baseline val rnActivity = currentActivity Log.d(TAG, "🔍 getTrueCurrentActivity check: rnActivity=${rnActivity?.javaClass?.simpleName}") // If rnActivity is already an SDK activity, we can likely trust it if (rnActivity != null && rnActivity.javaClass.name.startsWith("finos.sdk.")) { Log.d(TAG, "✅ Current activity is already an SDK activity: ${rnActivity.javaClass.simpleName}") return rnActivity } // Use reflection to find the absolute top resumed activity. // This is crucial when the SDK is running in a native Activity on top of the RN Activity, // and currentActivity might still point to the background Activity. try { val topActivity = findTopActivityViaReflection() if (topActivity != null) { Log.d(TAG, "✅ Found top activity via reflection: ${topActivity.javaClass.simpleName}") return topActivity } } catch (e: Exception) { Log.e(TAG, "Failed to find top activity via reflection", e) } Log.d(TAG, "⚠️ Fallback to rnActivity: ${rnActivity?.javaClass?.simpleName}") return rnActivity } /** * Finds the top-most resumed activity using ActivityThread's internal records. */ private fun findTopActivityViaReflection(): Activity? { try { val activityThreadClass = Class.forName("android.app.ActivityThread") val activityThread = activityThreadClass.getMethod("currentActivityThread").invoke(null) ?: return null val activitiesField = activityThreadClass.getDeclaredField("mActivities") activitiesField.isAccessible = true val activities = activitiesField.get(activityThread) as? Map<*, *> ?: return null Log.d(TAG, "🔍 Scanning ${activities.size} activities via reflection...") var bestCandidate: Activity? = null var highestPriority = -1 for (activityRecord in activities.values) { if (activityRecord == null) continue try { val activityRecordClass = activityRecord.javaClass val activityField = activityRecordClass.getDeclaredField("activity") activityField.isAccessible = true val activity = activityField.get(activityRecord) as? Activity ?: continue if (activity.isFinishing) continue val pausedField = activityRecordClass.getDeclaredField("paused") pausedField.isAccessible = true val isPaused = pausedField.get(activityRecord) as Boolean val stoppedField = activityRecordClass.getDeclaredField("stopped") stoppedField.isAccessible = true val isStopped = stoppedField.get(activityRecord) as Boolean if (isStopped) continue // Identity check: prioritize known SDK activity patterns val className = activity.javaClass.name val isSDK = className.startsWith("finos.sdk.") || className.contains("EKYC", ignoreCase = true) || className.contains("Liveness", ignoreCase = true) || className.contains("OCR", ignoreCase = true) || className.contains("NFC", ignoreCase = true) // Priority scoring: // 3: Resumed SDK Activity // 2: Resumed App Activity // 1: Paused SDK Activity // 0: Paused App Activity val currentPriority = (if (isSDK) 1 else 0) + (if (!isPaused) 2 else 0) Log.v(TAG, " [Audit] ${activity.javaClass.simpleName} -> Prio: $currentPriority (SDK=$isSDK, Paused=$isPaused)") if (currentPriority > highestPriority) { highestPriority = currentPriority bestCandidate = activity } // Optimization: Found the absolute best target if (highestPriority == 3) break } catch (e: Exception) { // Silently continue for individual activity record failures } } if (bestCandidate != null) { Log.d(TAG, "✅ Best candidate found: ${bestCandidate.javaClass.simpleName} (Prio: $highestPriority)") } return bestCandidate } catch (e: Exception) { Log.e(TAG, "❌ Critical error in findTopActivityViaReflection", e) return null } } @ReactMethod fun showRNExitSheet(bundleName: String, initialProps: ReadableMap, promise: Promise) { Handler(Looper.getMainLooper()).post { val activity = getSDKActivity() ?: run { Log.e(TAG, "❌ showRNExitSheet failed: No visible activity found") promise.reject("NO_ACTIVITY", "No visible activity found") return@post } if (activity.isFinishing || (android.os.Build.VERSION.SDK_INT >= 17 && activity.isDestroyed)) { Log.e(TAG, "❌ showRNExitSheet failed: Activity is finishing or destroyed") promise.reject("ACTIVITY_INVALID", "Activity is finishing or destroyed") return@post } // Guard chống double-resolve/reject promise val promiseSettled = java.util.concurrent.atomic.AtomicBoolean(false) fun safeResolve(value: Any?) { if (promiseSettled.compareAndSet(false, true)) promise.resolve(value) } fun safeReject(code: String, msg: String, e: Throwable? = null) { if (promiseSettled.compareAndSet(false, true)) { if (e != null) promise.reject(code, msg, e) else promise.reject(code, msg) } } try { val activityName = activity.javaClass.simpleName Log.d(TAG, "▶️ showRNExitSheet: Top Activity=$activityName, bundle=$bundleName") val reactApplication = activity.application as? ReactApplication if (reactApplication == null) { Log.e(TAG, "❌ showRNExitSheet failed: Application class (${activity.application.javaClass.name}) does not implement ReactApplication. Please ensure your Application class implements ReactApplication or provide a way for the SDK to access the ReactInstanceManager.") safeReject("NO_REACT_APP", "Application does not implement ReactApplication") return@post } val reactInstanceManager = reactApplication.reactNativeHost.reactInstanceManager if (reactInstanceManager.currentReactContext != null) { Log.d(TAG, "✅ ReactContext is ready, presenting sheet...") presentRNBottomSheet(activity, reactInstanceManager, bundleName, initialProps, ::safeResolve, ::safeReject) return@post } // Context chưa sẵn sàng: chờ listener Log.w(TAG, "⚠️ ReactContext chưa ready, chờ init…") val listener = object : com.facebook.react.ReactInstanceEventListener { override fun onReactContextInitialized(context: ReactContext) { reactInstanceManager.removeReactInstanceEventListener(this) activity.runOnUiThread { // Re-validate activity vì có thể đã destroy trong lúc chờ if (activity.isFinishing || activity.isDestroyed) { Log.w(TAG, "⚠️ Activity đã destroy trong lúc chờ ReactContext") safeReject("ACTIVITY_INVALID", "Activity destroyed while waiting for RN context") return@runOnUiThread } presentRNBottomSheet(activity, reactInstanceManager, bundleName, initialProps, ::safeResolve, ::safeReject) } } } reactInstanceManager.addReactInstanceEventListener(listener) // FIX RACE: re-check sau khi add listener vì context có thể đã init // giữa null-check ở trên và lúc add listener (listener không fire cho event đã xảy ra) val ctxAfterAdd = reactInstanceManager.currentReactContext if (ctxAfterAdd != null) { reactInstanceManager.removeReactInstanceEventListener(listener) Log.d(TAG, "✅ ReactContext init xong giữa lúc add listener — present luôn") presentRNBottomSheet(activity, reactInstanceManager, bundleName, initialProps, ::safeResolve, ::safeReject) return@post } // Chỉ trigger background create nếu chưa start (tránh IllegalStateException) if (!reactInstanceManager.hasStartedCreatingInitialContext()) { try { reactInstanceManager.createReactContextInBackground() } catch (e: Exception) { reactInstanceManager.removeReactInstanceEventListener(listener) Log.e(TAG, "❌ Failed createReactContextInBackground", e) safeReject("RN_CONTEXT_INIT_FAILED", e.message ?: "createReactContextInBackground failed", e) } } } catch (e: Exception) { Log.e(TAG, "❌ Exception in showRNExitSheet", e) safeReject("RN_SHEET_ERROR", e.message ?: "unknown", e) } } } /** * Hiển thị BottomSheetDialog chứa ReactRootView. Phải gọi trên UI thread. * * Quan trọng: * - Set BottomSheetBehavior.peekHeight = sheetHeight và state = STATE_EXPANDED * để sheet xuất hiện đầy đủ (mặc định peek nhỏ → sheet "không hiển thị" cảm giác). * - Auto-dismiss khi activity host bị detach (memory leak guard). * - safeResolve/safeReject để tránh promise resolve/reject 2 lần. */ private fun presentRNBottomSheet( activity: Activity, reactInstanceManager: com.facebook.react.ReactInstanceManager, bundleName: String, initialProps: ReadableMap, safeResolve: (Any?) -> Unit, safeReject: (String, String, Throwable?) -> Unit ) { try { val minHeightPx = (400 * activity.resources.displayMetrics.density).toInt() val container = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL setBackgroundColor(Color.TRANSPARENT) minimumHeight = minHeightPx } val rootView = ReactRootView(activity) // Hardening: Set a minimum height to ensure the sheet is visible even before RN finishes layout rootView.minimumHeight = minHeightPx rootView.layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) container.addView(rootView) // Bug 3 guard: nếu có sheet cũ chưa dismiss, dismiss nó trước để tránh leak currentExitSheet?.let { old -> if (old.isShowing) { try { old.dismiss() } catch (e: Exception) { Log.w(TAG, "presentRNBottomSheet: error dismissing old sheet: ${e.message}") } } } val bottomSheetDialog = BottomSheetDialog(activity) currentExitSheet = bottomSheetDialog bottomSheetDialog.setContentView(container) bottomSheetDialog.setCancelable(true) bottomSheetDialog.setCanceledOnTouchOutside(true) // isFitToContents=true → sheet height = wrap_content của ReactRootView, // không expand fullscreen. setExitSheetHeight() từ JS sẽ fine-tune nếu cần. bottomSheetDialog.setOnShowListener { dlg -> val d = dlg as BottomSheetDialog val bs = d.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return@setOnShowListener val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bs) behavior.isFitToContents = true behavior.skipCollapsed = true behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED } // Auto-dismiss & unmount khi activity decor view bị detach (activity destroy) val decorView = activity.window.decorView val attachListener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) {} override fun onViewDetachedFromWindow(v: View) { decorView.removeOnAttachStateChangeListener(this) if (bottomSheetDialog.isShowing) { try { bottomSheetDialog.dismiss() } catch (_: Exception) {} } } } decorView.addOnAttachStateChangeListener(attachListener) bottomSheetDialog.setOnDismissListener { currentExitSheet = null decorView.removeOnAttachStateChangeListener(attachListener) // Nếu dismiss KHÔNG phải từ button (backdrop tap / activity destroy), // tự resolve CANCEL để SDK không bị kẹt và lần sau back vẫn trigger sheet lại. if (!exitSheetResolvedViaButton && SDKeKYCExitHandlerManager.pendingCancel != null) { SDKeKYCExitHandlerManager.resolve("CANCEL") Log.d(TAG, "🔙 Sheet dismissed by backdrop — auto resolved CANCEL") } try { rootView.unmountReactApplication() Log.d(TAG, "🧹 RN sheet dismissed, root unmounted") } catch (e: Exception) { Log.w(TAG, "Error unmounting ReactRootView: ${e.message}") } } // Start RN trước show — view sẽ measure khi attached vào dialog window rootView.startReactApplication( reactInstanceManager, bundleName, Arguments.toBundle(initialProps) ) bottomSheetDialog.show() Log.d(TAG, "✅ showRNExitSheet: BottomSheetDialog shown on ${activity.javaClass.simpleName}, bundle=$bundleName") safeResolve(true) } catch (e: Exception) { Log.e(TAG, "❌ Exception in presentRNBottomSheet", e) safeReject("RN_SHEET_ERROR", e.message ?: "unknown", e) } } @ReactMethod fun showNativeExitDialog(config: ReadableMap, promise: Promise) { val activity = currentActivity ?: run { promise.reject("NO_ACTIVITY", "Current activity is null") return } val title = if (config.hasKey("title")) config.getString("title") else "Xác nhận thoát" 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?" val confirmText = if (config.hasKey("confirmText")) config.getString("confirmText") else "Thoát ra" val cancelText = if (config.hasKey("cancelText")) config.getString("cancelText") else "Hủy bỏ" activity.runOnUiThread { try { val bottomSheetDialog = BottomSheetDialog(activity) // Create a simple programmatic layout for the Bottom Sheet val context = activity val root = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL setPadding(60, 40, 60, 80) setBackgroundColor(Color.WHITE) } // Title val titleView = TextView(context).apply { text = title textSize = 20f setTextColor(Color.BLACK) setTypeface(null, android.graphics.Typeface.BOLD) gravity = Gravity.CENTER setPadding(0, 0, 0, 20) } root.addView(titleView) // Message val messageView = TextView(context).apply { text = message textSize = 16f setTextColor(Color.DKGRAY) gravity = Gravity.CENTER setPadding(0, 0, 0, 60) } root.addView(messageView) // Buttons Container val buttonsContainer = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) } // Cancel Button val btnCancel = Button(context).apply { text = cancelText layoutParams = LinearLayout.LayoutParams(0, 140, 1f).apply { setMargins(0, 0, 10, 0) } setBackgroundColor(Color.parseColor("#F2F2F7")) setTextColor(Color.BLACK) setOnClickListener { SDKeKYCExitHandlerManager.resolve("CANCEL") bottomSheetDialog.dismiss() promise.resolve("CANCEL") } } buttonsContainer.addView(btnCancel) // Confirm Button val btnConfirm = Button(context).apply { text = confirmText layoutParams = LinearLayout.LayoutParams(0, 140, 1f).apply { setMargins(10, 0, 0, 0) } setBackgroundColor(Color.parseColor("#FF3B30")) setTextColor(Color.WHITE) setOnClickListener { SDKeKYCExitHandlerManager.resolve("CONFIRM") bottomSheetDialog.dismiss() promise.resolve("CONFIRM") } } buttonsContainer.addView(btnConfirm) root.addView(buttonsContainer) bottomSheetDialog.setContentView(root) bottomSheetDialog.setCancelable(false) bottomSheetDialog.show() } catch (e: Exception) { Log.e(TAG, "❌ showNativeExitDialog error: ${e.message}") promise.reject("DIALOG_ERROR", e.message) } } } private fun sendEvent(eventName: String, params: WritableMap?) { reactApplicationContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit(eventName, params) } private fun WritableMap.putCustomData(customData: Map<*, *>?) { customData?.forEach { (key, value) -> val k = key.toString() when (value) { is Boolean -> putBoolean(k, value) is Int -> putInt(k, value) is Long -> putInt(k, value.toInt()) is Double -> putDouble(k, value) else -> { val str = value.toString() val intVal = str.toIntOrNull() val boolVal = str.lowercase().toBooleanStrictOrNull() when { boolVal != null -> putBoolean(k, boolVal) intVal != null -> putInt(k, intVal) else -> putString(k, str) } } } } } /** * Helper function to create separate maps for event and promise to avoid "Map already consumed" error * @param builder Lambda function to build the map content * @return Pair of (eventMap, promiseMap) */ private fun createSeparateMaps(builder: (WritableMap) -> Unit): Pair { val eventMap = Arguments.createMap().apply(builder) val promiseMap = Arguments.createMap().apply(builder) return Pair(eventMap, promiseMap) } /** * Helper function to create separate maps with array for event and promise * @param arrayBuilder Lambda function to build the array content * @param mapBuilder Lambda function to build the map with array * @return Pair of (eventMap, promiseMap) */ private fun createSeparateMapsWithArray( arrayBuilder: (WritableArray) -> Unit, mapBuilder: (WritableMap, WritableArray) -> Unit ): Pair { val eventArray = Arguments.createArray().apply(arrayBuilder) val promiseArray = Arguments.createArray().apply(arrayBuilder) val eventMap = Arguments.createMap().apply { mapBuilder(this, eventArray) } val promiseMap = Arguments.createMap().apply { mapBuilder(this, promiseArray) } return Pair(eventMap, promiseMap) } /** * Convert snake_case (match_score) and kebab-case (to-be-reviewed) keys to camelCase. * Native SDK responses use these formats via Gson @SerializedName, but TS API expects camelCase. * Examples: * - "match_score" → "matchScore" * - "to-be-reviewed" → "toBeReviewed" * - "ageRange" → "ageRange" (already camelCase, unchanged) */ private fun normalizeKeyToCamelCase(key: String): String { if (!key.contains('_') && !key.contains('-')) return key val parts = key.split('_', '-') if (parts.isEmpty()) return key val sb = StringBuilder(parts[0]) for (i in 1 until parts.size) { val p = parts[i] if (p.isEmpty()) continue sb.append(p[0].uppercaseChar()) if (p.length > 1) sb.append(p.substring(1)) } return sb.toString() } /** Recursively convert org.json.JSONObject → WritableMap (normalizes keys to camelCase). */ private fun jsonObjectToWritableMap(jsonObject: JSONObject): WritableMap { val map = Arguments.createMap() val keys = jsonObject.keys() while (keys.hasNext()) { val rawKey = keys.next() val key = normalizeKeyToCamelCase(rawKey) when (val value = jsonObject.get(rawKey)) { is JSONObject -> map.putMap(key, jsonObjectToWritableMap(value)) is JSONArray -> map.putArray(key, jsonArrayToWritableArray(value)) is Boolean -> map.putBoolean(key, value) is Int -> map.putInt(key, value) is Long -> map.putDouble(key, value.toDouble()) is Double -> map.putDouble(key, value) is String -> map.putString(key, value) JSONObject.NULL -> map.putNull(key) else -> map.putString(key, value.toString()) } } return map } /** Recursively convert org.json.JSONArray → WritableArray. */ private fun jsonArrayToWritableArray(jsonArray: JSONArray): WritableArray { val array = Arguments.createArray() for (i in 0 until jsonArray.length()) { when (val value = jsonArray.get(i)) { is JSONObject -> array.pushMap(jsonObjectToWritableMap(value)) is JSONArray -> array.pushArray(jsonArrayToWritableArray(value)) is Boolean -> array.pushBoolean(value) is Int -> array.pushInt(value) is Long -> array.pushDouble(value.toDouble()) is Double -> array.pushDouble(value) is String -> array.pushString(value) JSONObject.NULL -> array.pushNull() else -> array.pushString(value.toString()) } } return array } /** Convert ESignOpenSessionResult to WritableMap for RN. */ private fun eSignOpenSessionResultToWritableMap(result: ESignModels.ESignOpenSessionResult): WritableMap = Arguments.createMap().apply { putString("deviceState", result.deviceState) putString("code", result.code) putString("message", result.message) } /** Convert ESignApiResponse to WritableMap for RN. */ private fun eSignApiResponseToWritableMap(result: ESignModels.ESignApiResponse): WritableMap = Arguments.createMap().apply { putInt("status", result.status) putString("msg", result.msg) result.errorCode?.let { putString("errorCode", it) } result.data?.let { d -> putMap("data", Arguments.createMap().apply { d.sessionId?.let { putString("sessionId", it) } d.extra.forEach { (key, value) -> when (value) { is String -> putString(key, value) is Int -> putInt(key, value) is Boolean -> putBoolean(key, value) is Double -> putDouble(key, value) else -> putString(key, value.toString()) } } }) } result.response?.let { putString("response", it) } } /** Convert ESignPdfResult (from EkycNativeAndroid) to WritableMap for RN. Native parses → RN chỉ dùng. */ private fun eSignPdfResultToWritableMap(result: ESignModels.ESignPdfResult): WritableMap = Arguments.createMap().apply { putInt("status", result.status) putString("msg", result.msg) result.errorCode?.let { putString("errorCode", it) } result.data?.let { d -> putMap("data", Arguments.createMap().apply { putString("transactionId", d.transactionId) d.extra.forEach { (key, value) -> when (value) { is String -> putString(key, value) is Int -> putInt(key, value) is Boolean -> putBoolean(key, value) is Double -> putDouble(key, value) else -> putString(key, value.toString()) } } }) } result.response?.let { putString("response", it) } } @ReactMethod fun initSdkEkyc(isProd: Boolean, promise: Promise) { Log.d(TAG, "▶️ initSdkEkyc() called, isProd: $isProd") try { eKYCFinOS.initSDKEkyc(reactApplicationContext, isProd) { Log.d(TAG, "✅ initSdkEkyc() success") val params = Arguments.createMap().apply { putString("status", "success") putString("message", "SDK EKYC initialized successfully") } sendEvent("EKYCInitEvent", params) promise.resolve(true) } } catch (e: Exception) { val msg = e.message?.lowercase() ?: "" // SDK đã được khởi tạo (Koin/DI already started) → coi là thành công, cho icon pass xanh / text xanh if (msg.contains("already been started") || msg.contains("already started") || msg.contains("already initialized")) { Log.d(TAG, "✅ initSdkEkyc() – SDK đã khởi tạo, trả success (không coi là lỗi)") val params = Arguments.createMap().apply { putString("status", "success") putString("message", "SDK EKYC already initialized") } sendEvent("EKYCInitEvent", params) promise.resolve(true) } else { Log.e(TAG, "❌ initSdkEkyc() failed: ${e.message}", e) promise.reject("INIT_ERROR", "Failed to initialize SDK EKYC: ${e.message}") } } } @ReactMethod fun setTransactionId(transactionId: String, promise: Promise) { Log.d(TAG, "▶️ setTransactionId() called with: $transactionId") try { eKYCFinOS.setTransactionId(transactionId) Log.d(TAG, "✅ setTransactionId() success") promise.resolve(true) } catch (e: Exception) { Log.e(TAG, "❌ setTransactionId() failed: ${e.message}", e) promise.reject("SET_TRANSACTION_ID_ERROR", "Failed to set transaction ID: ${e.message}") } } @ReactMethod fun setEnv(env: String, promise: Promise) { Log.d(TAG, "▶️ setEnv() called with: $env") try { val sdkEnv = when (env.uppercase()) { "PROD" -> finos.sdk.core.constant.SDKEnv.PROD else -> finos.sdk.core.constant.SDKEnv.DEV } eKYCFinOS.setEnv(sdkEnv) Log.d(TAG, "✅ setEnv() success: $sdkEnv") promise.resolve(sdkEnv.name) } catch (e: Exception) { Log.e(TAG, "❌ setEnv() failed: ${e.message}", e) promise.reject("SET_ENV_ERROR", "Failed to set environment: ${e.message}") } } @ReactMethod fun getEnv(promise: Promise) { Log.d(TAG, "▶️ getEnv() called") try { val env = eKYCFinOS.getEnv() Log.d(TAG, "✅ getEnv() success: $env") promise.resolve(env.name) } catch (e: Exception) { Log.e(TAG, "❌ getEnv() failed: ${e.message}", e) promise.reject("GET_ENV_ERROR", "Failed to get environment: ${e.message}") } } @ReactMethod fun startNfcScan( appKey: String, documentNumber: String, birthDate: String, expireDate: String, transactionId: String?, facePathStorage: String?, promise: Promise ) { Log.d(TAG, "▶️ startNfcScan() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ startNfcScan() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } val nfcConfig = NfcConfig( documentNumber = documentNumber, birthDate = birthDate, expireDate = expireDate, facePathStorage = facePathStorage ?: "", transactionId = transactionId ?: "" ) val ekycConfig = EKYCConfigSDK(appKey = AppKeyConfig(appKey), sdkType = SDKType.NFC, nfcConfig = nfcConfig) eKYCFinOSNfc.startEkyc( activity = currentActivity, ekycConfigSDK = ekycConfig, callbackSuccess = { event, data -> Log.d(TAG, "NFC Success - Event: $event, Data: $data") when (event) { EKYCEvent.SCAN_NFC_START -> { val eventMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("data", Gson().toJson(data)) } sendEvent("onNfcScanStart", eventMap) } EKYCEvent.SCAN_NFC_SUCCESS -> { Log.d(TAG, "✅ startNfcScan() success") val eventMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("data", (data as SDKEkycResult).nfcResponse) } sendEvent("onNfcScanSuccess", eventMap) val promiseMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("data", (data as SDKEkycResult).nfcResponse) } promise.resolve(promiseMap) } else -> { val eventMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("data", Gson().toJson(data)) } sendEvent("onNfcEvent", eventMap) } } }, callbackError = { event, errorResult -> Log.e(TAG, "❌ startNfcScan() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) putString("message", errorResult.message) } sendEvent("onNfcError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) } promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ startNfcScan() exception: ${e.message}", e) promise.reject("NFC_EXCEPTION", e.message, e) } } @ReactMethod fun checkC06( appKey: String, sod: String, idCardNumber: String, recentLocation: String, transactionId: String?, promise: Promise ) { Log.d(TAG, "▶️ checkC06() called") try { val c06Config = C06Config( transactionId = transactionId ?: "", sod = sod, idCardNumber = idCardNumber, recentLocation = recentLocation ) val ekycConfig = EKYCConfigSDK(appKey = AppKeyConfig(appKey), c06Config = c06Config) eKYCFinOSC06.startEkyc( ekycConfigSDK = ekycConfig, callbackSuccess = { event, data -> Log.d(TAG, "✅ checkC06() success") val c06Json = Gson().toJson((data as? SDKEkycResult)?.checkC06Response) // Create separate maps for event and promise val eventMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("data", c06Json) } val promiseMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("data", c06Json) } sendEvent("onC06Success", eventMap) promise.resolve(promiseMap) }, callbackError = { event, errorResult -> Log.e(TAG, "❌ checkC06() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) putString("message", errorResult.message) } sendEvent("onC06Error", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) } promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ checkC06() exception: ${e.message}", e) promise.reject("C06_EXCEPTION", e.message, e) } } @ReactMethod fun startOcr( appKey: String, idImage: String, expectedDocumentSide: String, transactionId: String, promise: Promise ) { Log.d(TAG, "▶️ startOcr() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ startOcr() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } val imageFile = File(idImage.replace("file://", "")) if (!imageFile.exists()) { promise.reject("FILE_NOT_FOUND", "Image file does not exist") return } val ocrConfig = OcrConfig( idImage = imageFile, expectedDocumentSide = expectedDocumentSide, transactionId = transactionId ) val ekycConfig = EKYCConfigSDK(appKey = AppKeyConfig(appKey), sdkType = SDKType.OCR, ocrConfig = ocrConfig) eKYCFinOSOcr.startEkyc( ekycConfigSDK = ekycConfig, callbackSuccess = { event, data -> Log.d(TAG, "✅ startOcr() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("event", event.name.toString()) map.putString("data", Gson().toJson((data as SDKEkycResult).ocrResponse)) } sendEvent("onOcrSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, errorResult -> Log.e(TAG, "❌ startOcr() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) putString("message", errorResult.message) } sendEvent("onOcrError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) } promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ startOcr() exception: ${e.message}", e) promise.reject("OCR_ERROR", e.message) } } @ReactMethod fun startLiveness( appKey: String, selfieImage: String, transactionId: String, isActiveLiveness: Boolean?, isShowCameraFont: Boolean?, customActionsArray: ReadableArray?, activeActionCount: Int?, forceCaptureTimeout: Double?, isActiveLivenessColor: Boolean?, exitConfirmConfigJson: String?, promise: Promise ) { Log.d(TAG, "▶️ startLiveness() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ startLiveness() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } val imageFile = File(selfieImage.replace("file://", "")) if (!imageFile.exists()) { promise.reject("FILE_NOT_FOUND", "Image file does not exist") return } // Parse customActions from ReadableArray val customActions: List? = if (customActionsArray != null && customActionsArray.size() > 0) { val actions = mutableListOf() for (i in 0 until customActionsArray.size()) { val actionString = customActionsArray.getString(i) when (actionString) { "LEFT" -> actions.add(SDKFaceDetectStatus.LEFT) "RIGHT" -> actions.add(SDKFaceDetectStatus.RIGHT) "STRAIGHT" -> actions.add(SDKFaceDetectStatus.STRAIGHT) else -> Log.w(TAG, "Unknown action: $actionString") } } if (actions.isNotEmpty()) actions else null } else { null } val livenessConfig = LivenessConfig( isActiveLiveness = isActiveLiveness ?: false, isActiveLivenessColor = isActiveLivenessColor ?: false, isShowCameraFont = isShowCameraFont ?: true, customActions = customActions, activeActionCount = activeActionCount ?: 2, forceCaptureTimeout = (forceCaptureTimeout ?: 0.0).toLong(), selfieImage = imageFile, transactionId = transactionId ) val exitConfirmConfig = parseExitConfirmConfig(exitConfirmConfigJson) val ekycConfig = EKYCConfigSDK( appKey = AppKeyConfig(appKey), sdkType = SDKType.LIVENESS, livenessConfig = livenessConfig, exitConfirmConfig = exitConfirmConfig ) eKYCFinOSLiveness.startEkyc( ekycConfigSDK = ekycConfig, callbackSuccess = { event, data -> Log.d(TAG, "🔥 NATIVE CALLBACK (Liveness): event=${event.name}") val response = (data as? SDKEkycResult)?.checkLivenessResponse val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data) // Helper to create fresh data map to avoid 'Map already consumed' error fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() } // Send the event directly to React Native sendEvent("onLivenessSuccess", Arguments.createMap().apply { putString("event", event.name) putMap("data", createWritableData()) }) // Always attempt to resolve the promise. // RN safely ignores subsequent resolves if the native SDK fires multiple events. promise.resolve(Arguments.createMap().apply { putString("event", event.name) putMap("data", createWritableData()) }) }, callbackError = { event, errorResult -> Log.e(TAG, "❌ startLiveness() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) putString("message", errorResult.message) } sendEvent("onLivenessError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) } promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ startLiveness() exception: ${e.message}", e) promise.reject("LIVENESS_ERROR", e.message) } } @ReactMethod fun startFaceCompare( appKey: String, transactionId: String, selfieImage: String, idImage: String, promise: Promise ) { Log.d(TAG, "▶️ startFaceCompare() called") try { // React Native có thể gọi khi currentActivity = null (background / app state). // FaceService không cần Activity, nên không block ở đây. val selfieFile = resolveImageInputToFile(selfieImage, "selfie") val idImageFile = resolveImageInputToFile(idImage, "id") if (!selfieFile.exists() || selfieFile.length() <= 0L) { promise.reject("SELFIE_IMAGE_INVALID", "Selfie image is invalid or not found") return } if (!idImageFile.exists() || idImageFile.length() <= 0L) { promise.reject("ID_IMAGE_INVALID", "ID image is invalid or not found") return } val faceServiceConfig = FaceServiceConfig( transactionId = transactionId, selfieImage = selfieFile, idImage = idImageFile ) // FaceService cần appKeyFaceService. Trước đây RN chỉ truyền 1 key string, // nên map key này vào appKeyFaceService (và set appKey luôn để tương thích). val ekycConfig = EKYCConfigSDK( appKey = AppKeyConfig(appKey = appKey), faceServiceConfig = faceServiceConfig ) // Đảm bảo chạy trên main thread để tránh crash trên một số devices/SDK versions. Handler(Looper.getMainLooper()).post { eKYCFinOSFaceService.startEkyc( ekycConfigSDK = ekycConfig, callbackSuccess = { event, data -> Log.d(TAG, "🔥 NATIVE CALLBACK (Face Compare): event=${event.name}") val response = (data as? SDKEkycResult)?.checkFaceResponse val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data) fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() } sendEvent("onFaceCompareSuccess", Arguments.createMap().apply { putString("event", event.name) putMap("data", createWritableData()) }) // Always attempt to resolve the promise. promise.resolve(Arguments.createMap().apply { putString("event", event.name) putMap("data", createWritableData()) }) }, callbackError = { event, errorResult -> Log.e(TAG, "❌ startFaceCompare() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) putString("message", errorResult.message) } sendEvent("onFaceCompareError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) } promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap) } ) } } catch (e: Exception) { Log.e(TAG, "❌ startFaceCompare() exception: ${e.message}", e) promise.reject("FACE_COMPARE_ERROR", e.message) } } @ReactMethod fun onResume() { reactApplicationContext.currentActivity?.let { activity -> activity.runOnUiThread { eKYCFinOSNfc.onResumeNfc(activity) } } } @ReactMethod fun onPause() { reactApplicationContext.currentActivity?.let { activity -> activity.runOnUiThread { eKYCFinOSNfc.onPauseNfc(activity) } } } @ReactMethod fun handleNewIntent() { reactApplicationContext.currentActivity?.let { activity -> activity.runOnUiThread { eKYCFinOSNfc.onNewIntentNfc(activity.intent) } } } /** * Resolve input image string to a local file. * - Supports: file path (`/...`), file URI (`file://...`), content URI (`content://...`), base64 (with/without data URI). */ private fun resolveImageInputToFile(input: String, prefix: String): File { val value = input.trim() if (value.isEmpty()) { // Return empty file to trigger validation error upstream return File(reactApplicationContext.cacheDir, "empty_${prefix}_${Date().time}.jpg").apply { createNewFile() } } // file://... if (value.startsWith("file://")) { val file = File(value.removePrefix("file://")) if (file.exists()) return file } // content://... if (value.startsWith("content://")) { val copied = copyContentUriToCacheFile(value, prefix) if (copied != null && copied.exists()) return copied } // raw path val rawFile = File(value) if (rawFile.exists()) return rawFile // fallback: treat as base64 return base64ToImageFile(value, prefix) } private fun copyContentUriToCacheFile(uriString: String, prefix: String): File? { return try { val uri = Uri.parse(uriString) val resolver = reactApplicationContext.contentResolver val inputStream: InputStream = resolver.openInputStream(uri) ?: return null val outFile = File(reactApplicationContext.cacheDir, "${prefix}_${Date().time}.jpg") FileOutputStream(outFile).use { out -> inputStream.use { ins -> ins.copyTo(out) } } outFile } catch (e: Exception) { Log.w(TAG, "copyContentUriToCacheFile failed: ${e.message}") null } } private fun base64ToImageFile(base64Image: String, prefix: String = "image"): File { try { // Remove data URI prefix if present (like "data:image/png;base64,") var processedBase64 = base64Image if (base64Image.contains(",")) { processedBase64 = base64Image.split(",")[1] } // Decode base64 string to bytes val bytes = Base64.decode(processedBase64, Base64.DEFAULT) // Create a unique filename with timestamp val timestamp = Date().time.toString() val fileName = "${prefix}_$timestamp.jpg" // Get temporary directory and create file path val directory = reactApplicationContext.cacheDir val filePath = "${directory.path}/$fileName" // Write bytes to file val imageFile = File(filePath) FileOutputStream(imageFile).use { it.write(bytes) } return imageFile } catch (e: Exception) { Log.e(TAG, "Error converting base64 to image file: ${e.message}", e) // In case of error, create an empty file to avoid null returns val errorFile = File(reactApplicationContext.cacheDir, "error_${prefix}_${Date().time}.jpg") errorFile.createNewFile() return errorFile } } /** * Convert File to base64 string for passing to React Native. * Mirrors Android SDK: data?.ekycStateModel?.eKYCFileModel?.imageFace */ private fun fileToBase64(file: File?): String? { if (file == null || !file.exists()) return null return try { Base64.encodeToString(file.readBytes(), Base64.NO_WRAP) } catch (e: Exception) { Log.e(TAG, "fileToBase64 error: ${e.message}", e) null } } @ReactMethod fun startEkycUI( appKey: String, flowSDK: String, // JSON string array like "[\"OCR\", \"NFC\", \"LIVENESS\"]" language: String, transactionId: String, optionConfigJson: String, // JSON string for option config appKeyConfigJson: String, // JSON string for app key config styleConfigJson: String, // JSON string for style config exitConfirmConfigJson: String?, // JSON string for exit confirm bottom sheet config promise: Promise ) { Log.d(TAG, "▶️ startEkycUI() called") try { val currentActivity = currentActivity if (currentActivity == null) { Log.e(TAG, "❌ startEkycUI() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } // Parse flowSDK JSON string to List val flowList = parseFlowSDK(flowSDK) // Use default flow if empty like MainActivity.kt startWithFlow val finalFlow = if (flowList.isEmpty()) { listOf(SDKType.OCR, SDKType.NFC, SDKType.LIVENESS) } else { flowList } // Parse OptionConfig JSON val optionConfig = parseOptionConfig(optionConfigJson) // Parse AppKeyConfig JSON from React Native val appKeyConfig = parseAppKeyConfig(appKeyConfigJson) // Parse StyleConfig JSON from React Native val styleConfig = parseStyleConfig(styleConfigJson) // LivenessConfig theo SDKeKYCActivity (157-169): isActiveLiveness, autoCapture, forceCaptureTimeout, isShowCameraFont, customActions, activeActionCount val switchFrontCamera = extractBooleanValue(optionConfigJson, "switchFrontCamera") ?: false val isActiveLiveness = extractBooleanValue(optionConfigJson, "isActiveLiveness") ?: true val autoCapture = extractBooleanValue(optionConfigJson, "autoCapture") ?: true val forceCaptureTimeoutSec = extractIntValue(optionConfigJson, "forceCaptureTimeout") ?: 30 val customActionsList = parseCustomActionsFromJson(optionConfigJson) val activeActionCount = extractIntValue(optionConfigJson, "activeActionCount") ?: customActionsList?.size ?: 2 val appIDTypeStr = extractStringValue(optionConfigJson, "appIDType") ?: "NONE" val appIDType = when (appIDTypeStr.uppercase()) { "VIKKI" -> AppIDType.VIKKI "HD_BANK" -> AppIDType.HD_BANK else -> AppIDType.NONE } val livenessConfig = if (finalFlow.contains(SDKType.LIVENESS)) { LivenessConfig( isActiveLiveness = isActiveLiveness, forceCaptureTimeout = (forceCaptureTimeoutSec * 1000L).coerceAtLeast(0), isShowCameraFont = switchFrontCamera, customActions = customActionsList?.takeIf { it.isNotEmpty() }, activeActionCount = activeActionCount ) } else null // Parse exit confirm bottom sheet config (optional) val exitConfirmConfig = parseExitConfirmConfig(exitConfirmConfigJson) // Create EKYCConfigSDK like SDKeKYCActivity / MainActivity.kt val ekycConfigSDK = EKYCConfigSDK( appKey = appKeyConfig, optionConfig = optionConfig.copy(language = if (language == "en") "en" else "vi"), livenessConfig = livenessConfig, styleConfig = styleConfig, appIDType = appIDType, flowSDK = finalFlow, exitConfirmConfig = exitConfirmConfig ) eKYCFinOSUI.startEkyc( activity = currentActivity, ekycConfigSDK = ekycConfigSDK, callbackSuccess = { event, data -> Log.d(TAG, "🔥 NATIVE CALLBACK (EkycUI): event=${event.name}") val result = data as? SDKEkycResult val response = result?.ekycStateModel val jsonStr = data?.customData?.let { Gson().toJson(it) } ?: Gson().toJson(response ?: data) // Helper to create fresh data map to avoid 'Map already consumed' error fun createWritableData() = try { jsonObjectToWritableMap(JSONObject(jsonStr)) } catch (e: Exception) { Arguments.createMap() } val statusStr = when (event) { EKYCEvent.LOG_SUCCESS -> "log" EKYCEvent.SDK_START_SUCCESS -> "started" else -> "success" } // Send EVERY event directly to React Native Event Listener (Pass-through) sendEvent("onEkycUISuccess", Arguments.createMap().apply { putString("status", statusStr) putString("event", event.name) if (event == EKYCEvent.SDK_START_SUCCESS && response == null) { // Bỏ qua data nếu là SDK_START_SUCCESS (chỉ có event/status) để giống code cũ } else { putMap("data", createWritableData()) } // Retain the convenient root properties cho backward compatibility val ekycFiles = result?.ekycStateModel?.eKYCFileModel val ekycTransactionId = result?.ekycStateModel?.transactionId ?: "" if (ekycTransactionId.isNotEmpty()) putString("transactionId", ekycTransactionId) ekycFiles?.imageFace?.absolutePath?.let { putString("imageFacePath", it) } ekycFiles?.imageOcrFront?.absolutePath?.let { putString("imageOcrFrontPath", it) } ekycFiles?.imageOcrBack?.absolutePath?.let { putString("imageOcrBackPath", it) } }) // Re-emit onLivenessSuccess: dùng checkLivenessResponse (đúng format // CheckLivenessResponse) thay vì ekycStateModel để JS nhận đủ fields. if (event == EKYCEvent.LIVENESS_SUCCESS || event == EKYCEvent.LOG_SUCCESS) { val lrJson = result?.checkLivenessResponse ?.let { Gson().toJson(it) } ?: data?.customData?.let { Gson().toJson(it) } ?: jsonStr fun createLivenessData() = try { jsonObjectToWritableMap(JSONObject(lrJson)) } catch (e: Exception) { Arguments.createMap() } sendEvent("onLivenessSuccess", Arguments.createMap().apply { putString("event", event.name) putMap("data", createLivenessData()) }) } // IMPORTANT: Chỉ resolve promise ở SDK_END_SUCCESS để `await FinosEKYC.startEkycUI()` không bị kết thúc sớm if (event == EKYCEvent.SDK_END_SUCCESS) { promise.resolve(Arguments.createMap().apply { putString("status", "success") putString("event", event.name) putMap("data", createWritableData()) val ekycFiles = result?.ekycStateModel?.eKYCFileModel val ekycTransactionId = result?.ekycStateModel?.transactionId ?: "" if (ekycTransactionId.isNotEmpty()) putString("transactionId", ekycTransactionId) ekycFiles?.imageFace?.absolutePath?.let { putString("imageFacePath", it) } ekycFiles?.imageOcrFront?.absolutePath?.let { putString("imageOcrFrontPath", it) } ekycFiles?.imageOcrBack?.absolutePath?.let { putString("imageOcrBackPath", it) } }) } }, callbackError = { event, errorResult -> Log.e(TAG, "❌ startEkycUI() failed - Event: $event, Code: ${errorResult.code}, Message: ${errorResult.message}") val errorMap = Arguments.createMap().apply { putString("status", "error") putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) putString("message", errorResult.message) } sendEvent("onEkycUIError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("status", "error") putString("event", event.name.toString()) putString("customCode", errorResult.code) putString("customMessage", errorResult.message) } promise.reject(event.name.toString(), errorResult.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ startEkycUI() exception: ${e.message}", e) promise.reject("EKYC_UI_EXCEPTION", e.message, e) } } private fun parseFlowSDK(flowSDKJson: String): List { return try { // Simple parsing for JSON array like ["OCR", "NFC", "LIVENESS"] val cleanJson = flowSDKJson.replace("[", "").replace("]", "").replace("\"", "") val flowArray = cleanJson.split(",").map { it.trim() } flowArray.mapNotNull { sdkType -> when (sdkType) { "OCR" -> SDKType.OCR "NFC" -> SDKType.NFC "LIVENESS" -> SDKType.LIVENESS else -> null } } } catch (e: Exception) { Log.e(TAG, "Error parsing flowSDK: ${e.message}", e) // Default flow listOf(SDKType.OCR, SDKType.NFC, SDKType.LIVENESS) } } private fun parseOptionConfig(optionConfigJson: String): OptionConfig { return try { if (optionConfigJson.isBlank() || optionConfigJson == "{}") { OptionConfig() } else { val baseUrl = extractStringValue(optionConfigJson, "baseUrl") val countMaxRetry = extractIntValue(optionConfigJson, "countMaxRetry") ?: 3 val language = extractStringValue(optionConfigJson, "language") val networkTimeoutMs = extractLongValue(optionConfigJson, "networkTimeoutMs") ?: 30_000L OptionConfig( baseUrl = baseUrl, countMaxRetry = countMaxRetry, language = language, networkTimeoutMs = networkTimeoutMs, ) } } catch (e: Exception) { Log.e(TAG, "Error parsing optionConfig: ${e.message}", e) OptionConfig() } } private fun extractIntValue(json: String, key: String): Int? { return try { val pattern = "\"$key\"\\s*:\\s*(-?\\d+)".toRegex() val match = pattern.find(json) match?.groupValues?.get(1)?.toLong()?.toInt() } catch (e: Exception) { null } } private fun extractLongValue(json: String, key: String): Long? { return try { val pattern = "\"$key\"\\s*:\\s*(-?\\d+)".toRegex() val match = pattern.find(json) match?.groupValues?.get(1)?.toLong() } catch (e: Exception) { null } } private fun extractBooleanValue(json: String, key: String): Boolean? { return try { val pattern = "\"$key\"\\s*:\\s*(true|false)".toRegex() val match = pattern.find(json) match?.groupValues?.get(1)?.toBoolean() } catch (e: Exception) { null } } /** Parse customActions array from optionConfig JSON. Maps như SDKeKYCActivity getSelectedCustomActions. */ private fun parseCustomActionsFromJson(optionConfigJson: String): List? { return try { val obj = JSONObject(optionConfigJson) val arr = obj.optJSONArray("customActions") ?: return null if (arr.length() == 0) return null val actions = mutableListOf() for (i in 0 until arr.length()) { val s = arr.optString(i, "") when (s) { "LEFT" -> actions.add(SDKFaceDetectStatus.LEFT) "RIGHT" -> actions.add(SDKFaceDetectStatus.RIGHT) "UP" -> actions.add(SDKFaceDetectStatus.UP) "DOWN" -> actions.add(SDKFaceDetectStatus.DOWN) "SMILE" -> actions.add(SDKFaceDetectStatus.SMILE) "BLINK" -> actions.add(SDKFaceDetectStatus.BLINK) "WINK_LEFT" -> actions.add(SDKFaceDetectStatus.WINK_LEFT) "WINK_RIGHT" -> actions.add(SDKFaceDetectStatus.WINK_RIGHT) "STRAIGHT" -> actions.add(SDKFaceDetectStatus.STRAIGHT) else -> if (s.isNotEmpty()) Log.w(TAG, "parseCustomActionsFromJson: unknown action=$s") } } if (actions.isNotEmpty()) actions else null } catch (e: Exception) { Log.w(TAG, "parseCustomActionsFromJson: ${e.message}") null } } private fun parseAppKeyConfig(appKeyConfigJson: String): AppKeyConfig { return try { if (appKeyConfigJson.isBlank() || appKeyConfigJson == "{}") { throw IllegalArgumentException("appKeyConfig is required and cannot be empty") } // Simple JSON parsing (in real implementation, use proper JSON library) val appKey = extractStringValue(appKeyConfigJson, "appKey") ?: throw IllegalArgumentException("appKey is required in appKeyConfig") val appKeyNfc = extractStringValue(appKeyConfigJson, "appKeyNfc") ?: throw IllegalArgumentException("appKeyNfc is required in appKeyConfig") val appKeyOcr = extractStringValue(appKeyConfigJson, "appKeyOcr") ?: throw IllegalArgumentException("appKeyOcr is required in appKeyConfig") val appKeyLiveness = extractStringValue(appKeyConfigJson, "appKeyLiveness") ?: "" val appKeyC06 = extractStringValue(appKeyConfigJson, "appKeyC06") ?: throw IllegalArgumentException("appKeyC06 is required in appKeyConfig") val appKeyFaceService = extractStringValue(appKeyConfigJson, "appKeyFaceService") ?: "" val appKeyESign = extractStringValue(appKeyConfigJson, "appKeyESign") ?: "" val appKeyLivenessActive = extractStringValue(appKeyConfigJson, "appKeyLivenessActive") ?: appKeyLiveness val appKeyLivenessPassive = extractStringValue(appKeyConfigJson, "appKeyLivenessPassive") ?: appKeyLiveness AppKeyConfig( appKey = appKey, appKeyNfc = appKeyNfc, appKeyC06 = appKeyC06, appKeyOcr = appKeyOcr, appKeyLiveness = AppKeyLivenessConfig( appKeyLivenessActive = appKeyLivenessActive, appKeyLivenessPassive = appKeyLivenessPassive, appKeyFaceService = appKeyFaceService ), appKeyESign = appKeyESign ) } catch (e: Exception) { Log.e(TAG, "Error parsing appKeyConfig: ${e.message}", e) throw IllegalArgumentException("Invalid appKeyConfig: ${e.message}") } } private fun parseStyleConfig(styleConfigJson: String): StyleConfig { val defaultStyleConfig = StyleConfig( textSize = 14, textFont = "", textColor = R.color.black, statusBarBackground = null, backIcon = null, titleStyle = null, toolbarStyle = null, instructionStyle = null, errorStyle = null, successStyle = null, warningStyle = null, captureButtonColor = null, captureButtonDisabledColor = null ) return try { if (styleConfigJson.isBlank() || styleConfigJson == "{}") { defaultStyleConfig } else { val json = org.json.JSONObject(styleConfigJson) val textSize = if (json.has("textSize")) json.getInt("textSize") else 14 val textFont = if (json.has("textFont")) json.getString("textFont") else "" val textColor = if (json.has("textColor")) json.getInt("textColor") else R.color.black StyleConfig( textSize = textSize, textFont = textFont, textColor = textColor, statusBarBackground = null, backIcon = null, titleStyle = parseTextStyleFromJson(json, "titleStyle"), toolbarStyle = parseTextStyleFromJson(json, "toolbarStyle"), instructionStyle = parseTextStyleFromJson(json, "instructionStyle"), errorStyle = parseTextStyleFromJson(json, "errorStyle"), successStyle = parseTextStyleFromJson(json, "successStyle"), warningStyle = parseTextStyleFromJson(json, "warningStyle"), captureButtonColor = if (json.has("captureButtonColor")) json.getInt("captureButtonColor") else null, captureButtonDisabledColor = if (json.has("captureButtonDisabledColor")) json.getInt("captureButtonDisabledColor") else null ) } } catch (e: Exception) { Log.e(TAG, "Error parsing styleConfig: ${e.message}", e) defaultStyleConfig } } private fun parseTextStyleFromJson(parentJson: org.json.JSONObject, styleKey: String): StyleConfig.TextStyle? { return try { if (!parentJson.has(styleKey)) return null val obj = parentJson.getJSONObject(styleKey) val textSize = if (obj.has("textSize")) obj.getInt("textSize") else null val textFont = if (obj.has("textFont")) obj.getString("textFont") else null val textColor = if (obj.has("textColor")) obj.getInt("textColor") else null if (textSize != null || textFont != null || textColor != null) { StyleConfig.TextStyle( textSize = textSize, textFont = textFont, textColor = textColor ) } else { null } } catch (e: Exception) { Log.e(TAG, "Error parsing TextStyle for $styleKey: ${e.message}", e) null } } private fun extractStringValue(json: String, key: String): String? { return try { val pattern = "\"$key\"\\s*:\\s*\"([^\"]+)\"".toRegex() val match = pattern.find(json) match?.groupValues?.get(1) } catch (e: Exception) { null } } /** Extract a nested JSON object `{ ... }` for a given key. Returns the raw JSON string or null. */ private fun extractObjectJson(json: String, key: String): String? { return try { val obj = JSONObject(json) val nested = obj.optJSONObject(key) ?: return null nested.toString() } catch (e: Exception) { null } } // ==================== ExitConfirmConfig parsing ==================== /** * Parse JSON từ React Native sang ExitConfirmConfig của SDK. * Trả null nếu json rỗng / lỗi — SDK sẽ dùng giá trị mặc định. */ private fun parseExitConfirmConfig(json: String?): ExitConfirmConfig? { if (json.isNullOrBlank() || json == "{}") return null return try { val obj = JSONObject(json) ExitConfirmConfig( iconProps = parseExitIconProps(obj.optJSONObject("iconProps")), titleProps = parseExitTitleProps(obj.optJSONObject("titleProps")), contentProps = parseExitContentProps(obj.optJSONObject("contentProps")), confirmButtonProps = parseExitButtonProps(obj.optJSONObject("confirmButtonProps")), cancelButtonProps = parseExitButtonProps(obj.optJSONObject("cancelButtonProps")), ) } catch (e: Exception) { Log.e(TAG, "parseExitConfirmConfig failed: ${e.message}", e) null } } private fun parseExitIconProps(obj: JSONObject?): ExitConfirmConfig.IconProps? { if (obj == null) return null val iconResName = obj.optStringOrNull("iconResName") val iconResId: Int? = iconResName?.let { resolveDrawableId(it) } val iconViewStyle = parseExitViewStyle(obj.optJSONObject("iconViewStyle")) if (iconResId == null && iconViewStyle == null) return null return ExitConfirmConfig.IconProps( icon = iconResId, iconViewStyle = iconViewStyle, ) } private fun parseExitTitleProps(obj: JSONObject?): ExitConfirmConfig.TitleProps? { if (obj == null) return null val title = obj.optStringOrNull("title") val titleStyles = parseExitTextStyles(obj.optJSONObject("titleStyles")) if (title == null && titleStyles == null) return null return ExitConfirmConfig.TitleProps(title = title, titleStyles = titleStyles) } private fun parseExitContentProps(obj: JSONObject?): ExitConfirmConfig.ContentProps? { if (obj == null) return null val content = obj.optStringOrNull("content") val contentStyles = parseExitTextStyles(obj.optJSONObject("contentStyles")) if (content == null && contentStyles == null) return null return ExitConfirmConfig.ContentProps(content = content, contentStyles = contentStyles) } private fun parseExitButtonProps(obj: JSONObject?): ExitConfirmConfig.ButtonProps? { if (obj == null) return null val title = obj.optStringOrNull("buttonTitle") val textStyles = parseExitTextStyles(obj.optJSONObject("buttonTextStyles")) val viewStyles = parseExitViewStyle(obj.optJSONObject("buttonStyles")) if (title == null && textStyles == null && viewStyles == null) return null return ExitConfirmConfig.ButtonProps( buttonTitle = title, buttonTextStyles = textStyles, buttonStyles = viewStyles, ) } private fun parseExitTextStyles(obj: JSONObject?): ExitConfirmConfig.TextStyles? { if (obj == null) return null val textColor = obj.optStringOrNull("textColor") val textSize = obj.optIntOrNull("textSize") val textFont = obj.optStringOrNull("textFont") val textStyle = obj.optIntOrNull("textStyle") val textAlign = obj.optIntOrNull("textAlign") if (textColor == null && textSize == null && textFont == null && textStyle == null && textAlign == null) { return null } return ExitConfirmConfig.TextStyles( textColor = textColor, textSize = textSize, textFont = textFont, textStyle = textStyle, textAlign = textAlign, ) } private fun parseExitViewStyle(obj: JSONObject?): ExitConfirmConfig.ViewStyle? { if (obj == null) return null val backgroundColor = obj.optStringOrNull("backgroundColor") val gradientColors = obj.optJSONArray("backgroundGradientColors")?.let { arr -> val list = mutableListOf() for (i in 0 until arr.length()) { arr.optString(i, null)?.takeIf { it.isNotBlank() }?.let(list::add) } list.takeIf { it.isNotEmpty() } } val gradientPositions = obj.optJSONArray("backgroundGradientPositions")?.let { arr -> val list = mutableListOf() for (i in 0 until arr.length()) list.add(arr.optDouble(i, 0.0).toFloat()) list.takeIf { it.isNotEmpty() } } val orientation = obj.optStringOrNull("backgroundGradientOrientation")?.let { try { ExitConfirmConfig.GradientOrientation.valueOf(it) } catch (_: Exception) { null } } val width = obj.optIntOrNull("width") val height = obj.optIntOrNull("height") val marginTop = obj.optIntOrNull("marginTop") val marginBottom = obj.optIntOrNull("marginBottom") val marginStart = obj.optIntOrNull("marginStart") val marginEnd = obj.optIntOrNull("marginEnd") val paddingTop = obj.optIntOrNull("paddingTop") val paddingBottom = obj.optIntOrNull("paddingBottom") val paddingStart = obj.optIntOrNull("paddingStart") val paddingEnd = obj.optIntOrNull("paddingEnd") val gravity = obj.optIntOrNull("gravity") val cornerRadius = if (obj.has("cornerRadius") && !obj.isNull("cornerRadius")) { obj.optDouble("cornerRadius", Double.NaN).takeIf { !it.isNaN() }?.toFloat() } else null val anySet = backgroundColor != null || gradientColors != null || gradientPositions != null || orientation != null || width != null || height != null || marginTop != null || marginBottom != null || marginStart != null || marginEnd != null || paddingTop != null || paddingBottom != null || paddingStart != null || paddingEnd != null || gravity != null || cornerRadius != null if (!anySet) return null return ExitConfirmConfig.ViewStyle( backgroundColor = backgroundColor, backgroundGradientColors = gradientColors, backgroundGradientPositions = gradientPositions, backgroundGradientOrientation = orientation, width = width, height = height, marginTop = marginTop, marginBottom = marginBottom, marginStart = marginStart, marginEnd = marginEnd, paddingTop = paddingTop, paddingBottom = paddingBottom, paddingStart = paddingStart, paddingEnd = paddingEnd, gravity = gravity, cornerRadius = cornerRadius, ) } /** Resolve drawable resource id từ tên (vd "ic_warning"). Trả null nếu host app không có. */ private fun resolveDrawableId(name: String): Int? { return try { val ctx = reactApplicationContext val id = ctx.resources.getIdentifier(name, "drawable", ctx.packageName) if (id != 0) id else null } catch (e: Exception) { Log.w(TAG, "resolveDrawableId('$name') failed: ${e.message}") null } } /** `optString` của org.json trả "" thay vì null khi key missing — wrap để có null thực sự. */ private fun JSONObject.optStringOrNull(key: String): String? { if (!has(key) || isNull(key)) return null val v = optString(key, "") return v.takeIf { it.isNotEmpty() } } private fun JSONObject.optIntOrNull(key: String): Int? { if (!has(key) || isNull(key)) return null return optInt(key, Int.MIN_VALUE).takeIf { it != Int.MIN_VALUE } ?: try { getInt(key) } catch (_: Exception) { null } } // ==================== SMS OTP Methods ==================== @ReactMethod fun sendOtp( appKey: String, phoneNumber: String, purpose: String?, referenceId: String, promise: Promise ) { Log.d(TAG, "▶️ sendOtp() called") Log.d(TAG, " 🔑 AppKey: ${appKey.take(10)}...") Log.d(TAG, " 📱 Phone: $phoneNumber") Log.d(TAG, " 🎯 Purpose: ${purpose ?: "transaction"}") Log.d(TAG, " 🔖 ReferenceId: $referenceId") try { val smsOtpConfig = SmsOtpConfig( phoneNumber = phoneNumber, purpose = purpose ?: "transaction", referenceId = referenceId ) OTPFinOS.sendOtp( apiKey = appKey, smsOtpConfig = smsOtpConfig, callbackSuccess = { event, result -> Log.d(TAG, "✅ sendOtp() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putCustomData(result?.customData) } sendEvent("onSmsOtpSendSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ sendOtp() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message) putString("message", error.message) } sendEvent("onSmsOtpError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message) } promise.reject(event.name.toString(), error.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ sendOtp() exception: ${e.message}", e) promise.reject("SMS_OTP_EXCEPTION", e.message, e) } } @ReactMethod fun verifyOtp( appKey: String, phoneNumber: String, purpose: String?, referenceId: String, requestId: String, otpCode: String, promise: Promise ) { Log.d(TAG, "▶️ verifyOtp() called") try { val smsOtpConfig = SmsOtpConfig( phoneNumber = phoneNumber, purpose = purpose ?: "transaction", referenceId = referenceId, requestId = requestId ) OTPFinOS.verifyOtp( apiKey = appKey, smsOtpConfig = smsOtpConfig, otpCode = otpCode, callbackSuccess = { event, result -> Log.d(TAG, "✅ verifyOtp() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putCustomData(result?.customData) } sendEvent("onSmsOtpVerifySuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ verifyOtp() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message) putString("message", error.message) } sendEvent("onSmsOtpError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message) } promise.reject(event.name.toString(), error.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ verifyOtp() exception: ${e.message}", e) promise.reject("SMS_OTP_EXCEPTION", e.message, e) } } @ReactMethod fun resendOtp( appKey: String, phoneNumber: String, purpose: String?, referenceId: String, requestId: String, promise: Promise ) { Log.d(TAG, "▶️ resendOtp() called") try { val smsOtpConfig = SmsOtpConfig( phoneNumber = phoneNumber, purpose = purpose ?: "transaction", referenceId = referenceId, requestId = requestId ) OTPFinOS.resendOtp( apiKey = appKey, smsOtpConfig = smsOtpConfig, callbackSuccess = { event, result -> Log.d(TAG, "✅ resendOtp() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putCustomData(result?.customData) } sendEvent("onSmsOtpResendSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ resendOtp() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message) putString("message", error.message) } sendEvent("onSmsOtpError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message) } promise.reject(event.name.toString(), error.message, null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ resendOtp() exception: ${e.message}", e) promise.reject("SMS_OTP_EXCEPTION", e.message, e) } } // ==================== eSign Methods ==================== @ReactMethod fun initializeESign(finosToken: String?, isProd: Boolean, promise: Promise) { Log.d(TAG, "▶️ initializeESign() called with token: ${finosToken?.take(10)}... isProd: $isProd") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ initializeESign() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.initializeESign( context = currentActivity, finosToken = finosToken, isProd = isProd, callbackSuccess = { code, message -> Log.d(TAG, "✅ initializeESign() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", code.toString()) map.putString("message", message) } sendEvent("onESignInitSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> val msg = (error.message ?: "").lowercase() // eSign đã được khởi tạo → coi là thành công, cho icon pass xanh / text xanh if (msg.contains("đã được khởi tạo") || msg.contains("already") || msg.contains("already initialized")) { Log.d(TAG, "✅ initializeESign() – eSign đã khởi tạo, trả success (không coi là lỗi)") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", "0") map.putString("message", "eSign already initialized") } sendEvent("onESignInitSuccess", eventMap) promise.resolve(promiseMap) } else { Log.e(TAG, "❌ initializeESign() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } } ) } catch (e: Exception) { Log.e(TAG, "❌ initializeESign() exception: ${e.message}", e) promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun getSdkToken( identity: String, name: String, deviceId: String, promise: Promise ) { Log.d(TAG, "▶️ getSdkToken() called") try { eSignFinOS.getSdkToken( cccd = identity, // Map identity param to cccd name = name, deviceId = deviceId, callbackSuccess = { token -> Log.d(TAG, "✅ getSdkToken() success") promise.resolve(token) }, callbackError = { event, error -> Log.e(TAG, "❌ getSdkToken() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Get SDK Token failed", null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ getSdkToken() exception: ${e.message}", e) promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun openSessionId( accessToken: String?, username: String?, rememberMe: Boolean?, promise: Promise ) { Log.d(TAG, "▶️ openSessionId() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ openSessionId() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } if (accessToken != null && accessToken.isNotEmpty()) { // Option 1: With existing JWT access token eSignFinOS.openSessionId( context = currentActivity, accessToken = accessToken, username = username ?: "", rememberMe = rememberMe ?: false, callbackSuccess = { result -> Log.d(TAG, "✅ openSessionId() success") // WritableMap can be consumed when crossing the RN bridge. // Create separate instances for event emission and promise resolution. val eventMap = eSignOpenSessionResultToWritableMap(result) val promiseMap = eSignOpenSessionResultToWritableMap(result) sendEvent("onESignOpenSessionSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ openSessionId() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } else { Log.e(TAG, "❌ openSessionId() failed: Invalid parameters") promise.reject("INVALID_PARAMS", "accessToken must be provided") } } catch (e: Exception) { Log.e(TAG, "❌ openSessionId() exception: ${e.message}", e) promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun registerDevice( recoverCode: String, pinCode: String, fcmToken: String?, promise: Promise ) { Log.d(TAG, "▶️ registerDevice() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ registerDevice() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.registerDevice( context = currentActivity, recoverCode = recoverCode, pinCode = pinCode, fcmToken = fcmToken ?: "", callbackSuccess = { code, message -> Log.d(TAG, "✅ registerDevice() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", code.toString()) map.putString("message", message) } sendEvent("onESignRegisterDeviceSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ registerDevice() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ registerDevice() exception: ${e.message}", e) promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun listCerts( pageNumber: Int, pageSize: Int, promise: Promise ) { Log.d(TAG, "▶️ listCerts() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.listCerts( context = currentActivity, pageNumber = pageNumber, pageSize = pageSize, callbackSuccess = { certs -> // Bắn raw data lên - để EKYCModule.ts xử lý val (eventMap, promiseMap) = createSeparateMapsWithArray( arrayBuilder = { array: WritableArray -> certs.forEach { cert -> val certMap = Arguments.createMap().apply { putString("serial", cert.serial) putString("subject", cert.subject) putString("validFrom", cert.validFrom) putString("validTo", cert.validTo) } array.pushMap(certMap) } }, mapBuilder = { map: WritableMap, array: WritableArray -> map.putArray("certs", array) } ) sendEvent("onESignListCertsSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ listCerts() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun verifyCert( serial: String, promise: Promise ) { Log.d(TAG, "▶️ verifyCert() called") if (serial.isBlank()) { val errorMsg = "Certificate serial không được để trống" val errorMap = Arguments.createMap().apply { putString("event", "SDK_START_ERROR") putString("message", errorMsg) putString("code", "INVALID_SERIAL") } sendEvent("onESignError", errorMap) promise.reject("INVALID_SERIAL", errorMsg) return } try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.verifyCert( context = currentActivity, serial = serial, callbackSuccess = { code, message -> // Bắn raw data lên - để EKYCModule.ts xử lý val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", code.toString()) map.putString("message", message) } sendEvent("onESignVerifyCertSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ verifyCert() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun listSignRequest( pageNumber: Int, pageSize: Int, promise: Promise ) { Log.d(TAG, "▶️ listSignRequest() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.listSignRequest( context = currentActivity, pageNumber = pageNumber, pageSize = pageSize, callbackSuccess = { requests -> // Bắn raw data lên - để EKYCModule.ts xử lý val (eventMap, promiseMap) = createSeparateMapsWithArray( arrayBuilder = { array: WritableArray -> requests.forEach { request -> val requestMap = Arguments.createMap().apply { putString("requestId", request.requestId) putString("authId", request.authId ?: "") putString("authData", request.authData ?: "") } array.pushMap(requestMap) } }, mapBuilder = { map: WritableMap, array: WritableArray -> map.putArray("requests", array) } ) sendEvent("onESignListSignRequestSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ listSignRequest() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun confirmSign( signRequestId: String, pinCode: String, authId: String?, authData: String?, confirm: Boolean, promise: Promise ) { Log.d(TAG, "▶️ confirmSign() called") Log.d(TAG, " 📋 Request params - signRequestId: $signRequestId, confirm: $confirm") Log.d(TAG, " 📋 Auth params - authId: ${authId?.take(20)}..., authData: ${authData?.take(20)}...") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { Log.e(TAG, "❌ confirmSign() failed: Activity not available") promise.reject("NO_ACTIVITY", "Activity not available") return } Log.d(TAG, " 🔄 Calling eSignFinOS.confirmSign()") eSignFinOS.confirmSign( context = currentActivity, signRequestId = signRequestId, pinCode = pinCode, authId = authId ?: "", authData = authData ?: "", confirm = confirm, callbackSuccess = { code, message -> Log.d(TAG, "✅ confirmSign() success - Code: $code, Message: $message") // Bắn raw data lên - để EKYCModule.ts xử lý val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", code.toString()) map.putString("message", message) } sendEvent("onESignConfirmSignSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> val errorMessage = error.message ?: "" val errorCode = error.code.toString() Log.d(TAG, "🔍 confirmSign() error callback - Message: '$errorMessage', Code: '$errorCode'") // Check if message contains "thành công" or code == "0" -> treat as success if (errorMessage.contains("thành công", ignoreCase = true) || errorCode == "0") { Log.d(TAG, "✅ confirmSign() success (async operation) - Message: $errorMessage") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", "200") map.putString("message", errorMessage) } sendEvent("onESignConfirmSignSuccess", eventMap) promise.resolve(promiseMap) } else { Log.e(TAG, "❌ confirmSign() failed - Event: $event, Code: $errorCode, Message: $errorMessage") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", errorMessage) putString("message", errorMessage) } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", errorMessage) } promise.reject(event.name.toString(), errorMessage, null, promiseErrorMap) } } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun initAuthorize( serial: String, quantity: Int, time: Int, message: String, promise: Promise ) { Log.d(TAG, "▶️ initAuthorize() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.initAuthorize( context = currentActivity, serial = serial, quantity = quantity, time = time, message = message, callbackSuccess = { code, msg -> Log.d(TAG, "✅ initAuthorize() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", code.toString()) map.putString("message", msg) } sendEvent("onESignInitAuthorizeSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ initAuthorize() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun listAuthorize( pageNumber: Int, pageSize: Int, status: String, promise: Promise ) { Log.d(TAG, "▶️ listAuthorize() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.listAuthorize( context = currentActivity, pageNumber = pageNumber, pageSize = pageSize, status = status, callbackSuccess = { list -> // Bắn raw data lên - để EKYCModule.ts xử lý val (eventMap, promiseMap) = createSeparateMapsWithArray( arrayBuilder = { array: WritableArray -> list.forEach { item -> val map = Arguments.createMap().apply { putString("authorizeRequestId", item.authorizeRequestId) putString("serial", item.serial) putInt("quantity", item.quantity) putInt("time", item.time) putString("message", item.message) putString("authId", item.authId) putString("authData", item.authData) putString("createTime", item.createTime) putString("expireTime", item.expireTime) } array.pushMap(map) } }, mapBuilder = { map: WritableMap, array: WritableArray -> map.putArray("requests", array) } ) sendEvent("onESignListAuthorizeSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ listAuthorize() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun registerAuthorize( authId: String, authData: String, authorizeRequestId: String, userPin: String, confirm: Boolean, promise: Promise ) { Log.d(TAG, "▶️ registerAuthorize() called") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity == null) { promise.reject("NO_ACTIVITY", "Activity not available") return } eSignFinOS.registerAuthorize( context = currentActivity, authId = authId, authData = authData, authorizeRequestId = authorizeRequestId, userPin = userPin, confirm = confirm, callbackSuccess = { code, message -> Log.d(TAG, "✅ registerAuthorize() success") val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("code", code.toString()) map.putString("message", message) } sendEvent("onESignRegisterAuthorizeSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ registerAuthorize() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun signPdfMultiplePositions( requestJson: String, promise: Promise ) { Log.d(TAG, "▶️ signPdfMultiplePositions() called") try { eSignFinOS.signPdfMultiplePositions( requestJson = requestJson, callbackSuccess = { result -> Log.d(TAG, "✅ signPdfMultiplePositions() callbackSuccess - status=${result.status}") sendEvent("onESignSignPdfMultiplePositionsSuccess", Arguments.createMap().apply { putString("response", result.response ?: "") }) promise.resolve(eSignPdfResultToWritableMap(result)) }, callbackError = { event, error -> val errorMessage = error.message ?: "" Log.e(TAG, "❌ signPdfMultiplePositions() failed - Event: $event, Message: $errorMessage") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", errorMessage) putString("message", errorMessage) } sendEvent("onESignError", errorMap) promise.reject(event.name.toString(), errorMessage, null, Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", errorMessage) }) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun registerRemoteSigning( requestJson: String, promise: Promise ) { Log.d(TAG, "▶️ registerRemoteSigning() called") try { eSignFinOS.registerRemoteSigning( requestJson = requestJson, callbackSuccess = { result -> Log.d(TAG, "✅ registerRemoteSigning() success") // WritableMap can be consumed when crossing the RN bridge. // Create separate instances for event emission and promise resolution. val eventMap = eSignApiResponseToWritableMap(result) val promiseMap = eSignApiResponseToWritableMap(result) sendEvent("onESignRegisterRemoteSigningSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ registerRemoteSigning() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ registerRemoteSigning() exception: ${e.message}", e) promise.reject("ESIGN_EXCEPTION", e.message, e) } } /** * Composite API: Register Remote Signing + Send Confirmation Document * Align với eSignFinOSImpl.registerAndConfirm: gọi registerRemoteSigning -> parse sessionId -> sendConfirmationDocument */ @ReactMethod fun registerAndConfirm( requestJson: String, confirmationDocBase64: String, promise: Promise ) { Log.d(TAG, "▶️ registerAndConfirm() called") try { eSignFinOS.registerAndConfirm( requestJson = requestJson, confirmationDocBase64 = confirmationDocBase64, callbackSuccess = { result -> Log.d(TAG, "✅ registerAndConfirm() success") // WritableMap can be consumed when crossing the RN bridge. // Create separate instances for event emission and promise resolution. val eventMap = eSignApiResponseToWritableMap(result) val promiseMap = eSignApiResponseToWritableMap(result) sendEvent("onESignRegisterAndConfirmSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ registerAndConfirm() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { Log.e(TAG, "❌ registerAndConfirm() exception: ${e.message}", e) promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun signPdf( requestJson: String, promise: Promise ) { Log.d(TAG, "▶️ signPdf() called") try { eSignFinOS.signPdf( requestJson = requestJson, callbackSuccess = { result -> Log.d(TAG, "✅ signPdf() callbackSuccess - status=${result.status}") sendEvent("onESignSignPdfSuccess", Arguments.createMap().apply { putString("response", result.response ?: "") }) promise.resolve(eSignPdfResultToWritableMap(result)) }, callbackError = { event, error -> val errorMessage = error.message ?: "" Log.e(TAG, "❌ signPdf() failed - Event: $event, Message: $errorMessage") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", errorMessage) putString("message", errorMessage) } sendEvent("onESignError", errorMap) promise.reject(event.name.toString(), errorMessage, null, Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", errorMessage) }) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun sendConfirmationDocument( requestJson: String, promise: Promise ) { Log.d(TAG, "▶️ sendConfirmationDocument() called") try { eSignFinOS.sendConfirmationDocument( requestJson = requestJson, callbackSuccess = { rawResponse -> // Bắn raw data lên - để EKYCModule.ts xử lý val (eventMap, promiseMap) = createSeparateMaps { map -> map.putString("response", rawResponse) } sendEvent("onESignSendConfirmationDocumentSuccess", eventMap) promise.resolve(promiseMap) }, callbackError = { event, error -> Log.e(TAG, "❌ sendConfirmationDocument() failed - Event: $event, Code: ${error.code}, Message: ${error.message}") val errorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") putString("message", error.message ?: "") } sendEvent("onESignError", errorMap) val promiseErrorMap = Arguments.createMap().apply { putString("event", event.name.toString()) putString("customCode", error.code) putString("customMessage", error.message ?: "") } promise.reject(event.name.toString(), error.message ?: "Unknown Error", null, promiseErrorMap) } ) } catch (e: Exception) { promise.reject("ESIGN_EXCEPTION", e.message, e) } } @ReactMethod fun registerExitHandler(promise: Promise) { Log.d(TAG, "▶️ registerExitHandler() called") try { SDKeKYCExitHandlerManager.uiListener = { fm -> sendEvent("onShowExitConfirm", Arguments.createMap()) } SDKeKYCExitHandlerManager.register() promise.resolve(true) } catch (e: Exception) { Log.e(TAG, "❌ registerExitHandler() exception: ${e.message}", e) promise.reject("REGISTER_EXIT_HANDLER_EXCEPTION", e.message, e) } } @ReactMethod fun resolveExit(action: String, promise: Promise) { Log.d(TAG, "▶️ resolveExit() called with action: $action") try { val sheet = currentExitSheet val activity = getTrueCurrentActivity() if (sheet != null && sheet.isShowing && activity != null) { // Đánh dấu trước khi dismiss để setOnDismissListener biết đây là button-triggered, // không phải backdrop tap → tránh auto-resolve CANCEL sai. exitSheetResolvedViaButton = true val latch = java.util.concurrent.CountDownLatch(1) activity.runOnUiThread { try { sheet.dismiss() } catch (e: Exception) { Log.w(TAG, "resolveExit: error dismissing sheet: ${e.message}") } finally { latch.countDown() } } latch.await(500, java.util.concurrent.TimeUnit.MILLISECONDS) exitSheetResolvedViaButton = false } // Gọi SDK callback SAU KHI sheet đã dismiss SDKeKYCExitHandlerManager.resolve(action) promise.resolve(true) } catch (e: Exception) { Log.e(TAG, "❌ resolveExit() exception: ${e.message}", e) promise.reject("RESOLVE_EXIT_EXCEPTION", e.message, e) } } @ReactMethod fun setExitSheetHeight(heightDp: Float) { Handler(Looper.getMainLooper()).post { val activity = getTrueCurrentActivity() ?: return@post val heightPx = (heightDp * activity.resources.displayMetrics.density).toInt() Log.d(TAG, "▶️ setExitSheetHeight: ${heightDp}dp → ${heightPx}px") val sheet = currentExitSheet ?: return@post if (!sheet.isShowing) return@post val bs = sheet.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return@post val lp = bs.layoutParams ?: return@post lp.height = heightPx bs.layoutParams = lp val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bs) behavior.peekHeight = heightPx behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED } } @ReactMethod fun addListener(eventName: String) { // Keep: Required for RN built-in Event Emitter Calls. } @ReactMethod fun removeListeners(count: Int) { // Keep: Required for RN built-in Event Emitter Calls. } override fun onCatalystInstanceDestroy() { super.onCatalystInstanceDestroy() SDKeKYCExitHandlerManager.uiListener = null // Unregister lifecycle callback để tránh memory leak val app = reactApplicationContext.applicationContext as? Application app?.unregisterActivityLifecycleCallbacks(sdkActivityLifecycleCallbacks) activeSdkActivity = null // Dismiss sheet nếu vẫn đang hiển thị khi RN catalyst instance destroy val sheet = currentExitSheet currentExitSheet = null if (sheet != null && sheet.isShowing) { val activity = currentActivity activity?.runOnUiThread { try { sheet.dismiss() } catch (_: Exception) {} } } } }