diff --git a/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/ClickToPayScripts.kt b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/ClickToPayScripts.kt
new file mode 100644
index 00000000..931d1e5e
--- /dev/null
+++ b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/ClickToPayScripts.kt
@@ -0,0 +1,324 @@
+package io.hyperswitch.click_to_pay
+
+/**
+ * Centralized container for all JavaScript code used in Click to Pay WebView communication.
+ *
+ * This file contains all JS snippets that are evaluated in the WebView, keeping the main
+ * launcher code clean and making the JavaScript logic easier to maintain and test.
+ */
+object ClickToPayScripts {
+
+ /**
+ * Generates the HTML page for SDK initialization.
+ *
+ * @param publishableKey The publishable API key
+ * @param customBackendUrl Optional custom backend URL
+ * @param customLogUrl Optional custom logging URL
+ * @param requestId Unique request identifier for tracking
+ * @param hyperLoaderUrl URL to load the HyperLoader.js script
+ * @return HTML string containing the initialization page
+ */
+ fun createInitializationHtml(
+ publishableKey: String,
+ customBackendUrl: String?,
+ customLogUrl: String?,
+ requestId: String,
+ hyperLoaderUrl: String
+ ): String {
+ val customBackendParam = customBackendUrl?.let { "customBackendUrl:'$customBackendUrl'," } ?: ""
+ val customLogParam = customLogUrl?.let { "customLogUrl:'$customLogUrl'," } ?: ""
+
+ return """
+
+
+
+
+
+
+"""
+ }
+
+ /**
+ * Initializes a new Click to Pay session.
+ *
+ * @param clientSecret The client secret from the payment intent
+ * @param profileId The merchant profile identifier
+ * @param authenticationId The authentication session identifier
+ * @param merchantId The merchant identifier
+ * @param request3DSAuthentication Whether to request 3DS authentication
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun initClickToPaySession(
+ clientSecret: String,
+ profileId: String,
+ authenticationId: String,
+ merchantId: String,
+ request3DSAuthentication: Boolean,
+ requestId: String
+ ): String = """
+ (async function() {
+ try {
+ const authenticationSession = window.hyperInstance.initAuthenticationSession({
+ clientSecret: '$clientSecret',
+ profileId: '$profileId',
+ authenticationId: '$authenticationId',
+ merchantId: '$merchantId'
+ });
+ window.ClickToPaySession = await authenticationSession.initClickToPaySession({
+ request3DSAuthentication: $request3DSAuthentication
+ });
+ const data = window.ClickToPaySession.error ? window.ClickToPaySession : {success: true};
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: data
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'InitClickToPaySessionError', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Gets the active Click to Pay session for an activity switch.
+ *
+ * @param clientSecret The client secret from the payment intent
+ * @param profileId The merchant profile identifier
+ * @param authenticationId The authentication session identifier
+ * @param merchantId The merchant identifier
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun getActiveClickToPaySession(
+ clientSecret: String,
+ profileId: String,
+ authenticationId: String,
+ merchantId: String,
+ requestId: String
+ ): String = """
+ (async function() {
+ try {
+ let authenticationSession = window.hyperInstance.initAuthenticationSession({
+ clientSecret: '$clientSecret',
+ profileId: '$profileId',
+ authenticationId: '$authenticationId',
+ merchantId: '$merchantId'
+ });
+ window.ClickToPaySession = await authenticationSession?.getActiveClickToPaySession();
+ const data = window.ClickToPaySession.error ? window.ClickToPaySession : {success: true};
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: data
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'getActiveClickToPaySessionError', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Checks if a customer has an existing Click to Pay profile.
+ *
+ * @param email Optional customer email
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun isCustomerPresent(email: String?, requestId: String): String {
+ val emailParam = email?.let { "email: '$email'" } ?: ""
+ return """
+ (async function() {
+ try {
+ const isCustomerPresent = await window.ClickToPaySession.isCustomerPresent({$emailParam});
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: isCustomerPresent
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'IsCustomerPresentError', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+ }
+
+ /**
+ * Gets the user type (cards status).
+ *
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun getUserType(requestId: String): String = """
+ (async function() {
+ try {
+ const userType = await window.ClickToPaySession.getUserType();
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: userType
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: error.type || 'ERROR', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Gets the list of recognized cards for the customer.
+ *
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun getRecognizedCards(requestId: String): String = """
+ (async function() {
+ try {
+ const cards = await window.ClickToPaySession.getRecognizedCards();
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: cards
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'GetRecognizedCardsError', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Validates customer authentication with OTP.
+ *
+ * @param otpValue The OTP value entered by the customer
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun validateCustomerAuthentication(otpValue: String, requestId: String): String = """
+ (async function() {
+ try {
+ const cards = await window.ClickToPaySession.validateCustomerAuthentication({value: '$otpValue'});
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: cards
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: error.type || 'ERROR', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Processes checkout with a selected card.
+ *
+ * @param srcDigitalCardId The selected card's digital ID
+ * @param rememberMe Whether to remember the customer
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun checkoutWithCard(srcDigitalCardId: String, rememberMe: Boolean, requestId: String): String = """
+ (async function() {
+ try {
+ const checkoutResponse = await window.ClickToPaySession.checkoutWithCard({
+ srcDigitalCardId: '$srcDigitalCardId',
+ rememberMe: $rememberMe
+ });
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: checkoutResponse
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'CheckoutWithCardError', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Closes the Hyper instance.
+ *
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun closeHyperInstance(requestId: String): String = """
+ (async function() {
+ try {
+ await window.hyperInstance.deinit();
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {code: 'success'}
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'CloseInstanceFailed', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+
+ /**
+ * Signs out the customer and clears cookies.
+ *
+ * @param requestId Unique request identifier for tracking
+ * @return JavaScript code to execute
+ */
+ fun signOut(requestId: String): String = """
+ (async function() {
+ try {
+ const signOutResponse = await window.ClickToPaySession.signOut();
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: signOutResponse
+ }));
+ } catch (error) {
+ window.HSAndroidInterface.postMessage(JSON.stringify({
+ requestId: '$requestId',
+ data: {error: {type: 'SignOutError', message: error.message}}
+ }));
+ }
+ })();
+ """.trimIndent()
+}
diff --git a/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/ClickToPayWebViewManager.kt b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/ClickToPayWebViewManager.kt
new file mode 100644
index 00000000..8e1b3f6b
--- /dev/null
+++ b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/ClickToPayWebViewManager.kt
@@ -0,0 +1,323 @@
+package io.hyperswitch.click_to_pay
+
+import android.app.Activity
+import android.view.ViewGroup
+import androidx.webkit.WebViewCompat
+import io.hyperswitch.click_to_pay.models.ClickToPayErrorType
+import io.hyperswitch.click_to_pay.models.ClickToPayException
+import io.hyperswitch.logs.EventName
+import io.hyperswitch.logs.LogCategory
+import io.hyperswitch.logs.LogType
+import io.hyperswitch.webview.utils.Callback
+import io.hyperswitch.webview.utils.HSWebViewManagerImpl
+import io.hyperswitch.webview.utils.HSWebViewWrapper
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Manages WebView lifecycle and operations for Click to Pay.
+ *
+ * This class handles all WebView-related concerns including initialization,
+ * attachment/detachment, correlation ID tracking, and lifecycle state management.
+ */
+internal class ClickToPayWebViewManager(
+ private var activity: Activity,
+ private val logger: (type: LogType, eventName: EventName, value: String, category: LogCategory) -> Unit
+) {
+ private lateinit var webViewManager: HSWebViewManagerImpl
+ private lateinit var webViewWrapper: HSWebViewWrapper
+
+ private val pendingRequests = ConcurrentHashMap>()
+ private val correlationIds = mutableSetOf()
+ private val captureCorrelationIds = AtomicBoolean(true)
+
+ private val isWebViewInitialized = AtomicBoolean(false)
+ private val isWebViewAttached = AtomicBoolean(false)
+ private val lifecycleMutex = Mutex()
+
+ private val onMessageCallback = Callback { args ->
+ (args["data"] as? String)?.let { jsonString ->
+ val requestId = JSONObject(jsonString).optString("requestId", "")
+ if (requestId.isNotEmpty()) {
+ pendingRequests.remove(requestId)?.resume(jsonString) {}
+ }
+ }
+ }
+
+ /**
+ * Checks if the WebView provider is available on this device.
+ */
+ fun isWebViewAvailable(): Boolean {
+ return try {
+ WebViewCompat.getCurrentWebViewPackage(activity) != null
+ } catch (_: Throwable) {
+ false
+ }
+ }
+
+ /**
+ * Returns true if the WebView has been initialized.
+ */
+ fun isInitialized(): Boolean = isWebViewInitialized.get()
+
+ /**
+ * Returns the WebView wrapper instance.
+ */
+ fun getWebViewWrapper(): HSWebViewWrapper = webViewWrapper
+
+ /**
+ * Returns the WebView manager instance.
+ */
+ fun getWebViewManager(): HSWebViewManagerImpl = webViewManager
+
+ /**
+ * Returns the current set of captured correlation IDs.
+ */
+ fun getCorrelationIds(): Set = correlationIds.toSet()
+
+ /**
+ * Clears all captured correlation IDs.
+ */
+ fun clearCorrelationIds() {
+ correlationIds.clear()
+ }
+
+ /**
+ * Starts capturing correlation IDs from network requests.
+ */
+ fun startCapturingCorrelationIds() {
+ captureCorrelationIds.set(true)
+ }
+
+ /**
+ * Stops capturing correlation IDs.
+ */
+ fun stopCapturingCorrelationIds() {
+ captureCorrelationIds.set(false)
+ }
+
+ /**
+ * Registers a pending request continuation for async JS communication.
+ */
+ fun registerPendingRequest(requestId: String, continuation: CancellableContinuation) {
+ pendingRequests[requestId] = continuation
+ }
+
+ /**
+ * Removes a pending request without resuming it.
+ */
+ fun removePendingRequest(requestId: String) {
+ pendingRequests.remove(requestId)
+ }
+
+ /**
+ * Cancels all pending requests.
+ */
+ fun cancelPendingRequests(errorMessage: String = "Operation cancelled") {
+ val snapshot = pendingRequests.keys.toList()
+ if (snapshot.isEmpty()) return
+ logger(LogType.DEBUG, EventName.WEBVIEW, "Cancelling ${snapshot.size} pending requests", LogCategory.USER_EVENT)
+ for (key in snapshot) {
+ pendingRequests.remove(key)?.cancel(kotlinx.coroutines.CancellationException(errorMessage))
+ }
+ }
+
+ /**
+ * Resumes a pending continuation with the given value.
+ */
+ fun resumePendingRequest(requestId: String, value: String) {
+ pendingRequests.remove(requestId)?.resume(value) {}
+ }
+
+ /**
+ * Resets the internal state for reinitialization.
+ */
+ fun resetState() {
+ isWebViewInitialized.set(false)
+ isWebViewAttached.set(false)
+ correlationIds.clear()
+ pendingRequests.clear()
+ }
+
+ /**
+ * Initializes the WebView components asynchronously.
+ *
+ * @param allowReinitialize Whether to allow reinitializing an already initialized WebView
+ * @throws ClickToPayException if WebView initialization fails
+ */
+ suspend fun ensureInitialized(allowReinitialize: Boolean = false) {
+ lifecycleMutex.withLock {
+ if (isWebViewInitialized.get() && !allowReinitialize) return@withLock
+
+ logger(LogType.DEBUG, EventName.CREATE_WEBVIEW_INIT, "creating webview", LogCategory.USER_EVENT)
+
+ try {
+ if (!isWebViewAvailable()) {
+ logger(LogType.ERROR, EventName.CREATE_WEBVIEW_RETURNED, "WebView provider unavailable", LogCategory.USER_ERROR)
+ throw IllegalStateException("WebView provider unavailable")
+ }
+
+ repeat(2) { attempt ->
+ try {
+ withContext(Dispatchers.Main) {
+ initializeWebViewInternal()
+ }
+ isWebViewInitialized.set(true)
+ isWebViewAttached.set(true)
+ logger(LogType.DEBUG, EventName.CREATE_WEBVIEW_RETURNED, "webview created", LogCategory.USER_EVENT)
+ return@withLock
+ } catch (t: Throwable) {
+ logger(LogType.ERROR, EventName.CREATE_WEBVIEW_RETURNED, "Attempt $attempt failed", LogCategory.USER_ERROR)
+ if (attempt == 1) throw t
+ delay(200)
+ }
+ }
+ } catch (e: Exception) {
+ logger(LogType.ERROR, EventName.CREATE_WEBVIEW_RETURNED, "Failed to create webview: ${e.message}", LogCategory.USER_ERROR)
+ throw ClickToPayException("Unable to initialize ClickToPay: ${e.message}", "WEBVIEW_ERROR")
+ }
+ }
+ }
+
+ /**
+ * Internal WebView initialization on the main thread.
+ */
+ private fun initializeWebViewInternal() {
+ webViewManager = HSWebViewManagerImpl(activity, onMessageCallback)
+ webViewWrapper = webViewManager.createViewInstance()
+
+ webViewManager.setJavaScriptEnabled(webViewWrapper, true)
+ webViewManager.setMessagingEnabled(webViewWrapper, true)
+ webViewManager.setJavaScriptCanOpenWindowsAutomatically(webViewWrapper, true)
+ webViewManager.setScalesPageToFit(webViewWrapper, true)
+ webViewManager.setMixedContentMode(webViewWrapper, "always")
+ webViewManager.setThirdPartyCookiesEnabled(webViewWrapper, true)
+ webViewManager.setCacheEnabled(webViewWrapper, true)
+
+ webViewWrapper.webView.setRequestInterceptor { data ->
+ try {
+ val headers = data["headers"] as? Map<*, *>
+ val correlationId = headers?.get("X-CORRELATION-ID")?.toString()
+ if (correlationId != null && captureCorrelationIds.get()) {
+ correlationIds.add(correlationId)
+ }
+ } catch (_: Exception) {
+ // Ignore interceptor errors
+ }
+ }
+
+ webViewWrapper.apply {
+ isFocusable = false
+ isFocusableInTouchMode = false
+ layoutParams = android.widget.FrameLayout.LayoutParams(1, 1)
+ contentDescription = "Click to Pay"
+ importantForAccessibility = android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ }
+
+ activity.findViewById(android.R.id.content).addView(webViewWrapper)
+ }
+
+ /**
+ * Updates the activity reference and reattaches the WebView.
+ */
+ suspend fun updateActivity(newActivity: Activity) {
+ if (activity === newActivity) return
+
+ lifecycleMutex.withLock {
+ activity = newActivity
+
+ if (isWebViewInitialized.get() && isWebViewAttached.get()) {
+ withContext(Dispatchers.Main) {
+ (webViewWrapper.parent as? ViewGroup)?.removeView(webViewWrapper)
+ val rootView = newActivity.findViewById(android.R.id.content)
+ ?: throw IllegalStateException("Failed to find root view")
+ rootView.addView(webViewWrapper)
+ }
+ }
+ }
+ }
+
+ /**
+ * Detaches the WebView from the view hierarchy.
+ */
+ suspend fun detach() {
+ lifecycleMutex.withLock {
+ if (isWebViewInitialized.get() && isWebViewAttached.get()) {
+ withContext(Dispatchers.Main) {
+ (webViewWrapper.parent as? ViewGroup)?.removeView(webViewWrapper)
+ }
+ isWebViewAttached.set(false)
+ logger(LogType.DEBUG, EventName.WEBVIEW, "webview detached, JS execution paused", LogCategory.USER_EVENT)
+ }
+ }
+ }
+
+ /**
+ * Reattaches the WebView to the view hierarchy.
+ */
+ suspend fun reattach() {
+ lifecycleMutex.withLock {
+ if (isWebViewInitialized.get() && !isWebViewAttached.get()) {
+ withContext(Dispatchers.Main) {
+ val rootView = activity.findViewById(android.R.id.content)
+ rootView.addView(webViewWrapper)
+ }
+ isWebViewAttached.set(true)
+ logger(LogType.DEBUG, EventName.WEBVIEW, "webview reattached, JS execution resumed", LogCategory.USER_EVENT)
+ }
+ }
+ }
+
+ /**
+ * Destroys the WebView and cleans up resources.
+ */
+ suspend fun destroy() {
+ lifecycleMutex.withLock {
+ if (isWebViewInitialized.get()) {
+ withContext(Dispatchers.Main) {
+ (webViewWrapper.parent as? ViewGroup)?.removeView(webViewWrapper)
+ webViewWrapper.webView.destroy()
+ }
+ isWebViewInitialized.set(false)
+ isWebViewAttached.set(false)
+ }
+ }
+ }
+
+ /**
+ * Evaluates JavaScript code in the WebView and returns the response.
+ *
+ * @param jsCode The JavaScript code to execute
+ * @return The JSON response string from the WebView
+ */
+ suspend fun evaluateJavaScript(jsCode: String): String {
+ val requestId = UUID.randomUUID().toString()
+ return evaluateJavaScript(jsCode, requestId)
+ }
+
+ /**
+ * Evaluates JavaScript code in the WebView with a specific request ID.
+ *
+ * @param jsCode The JavaScript code to execute
+ * @param requestId Unique identifier for tracking this request
+ * @return The JSON response string from the WebView
+ */
+ suspend fun evaluateJavaScript(jsCode: String, requestId: String): String {
+ return withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { continuation ->
+ registerPendingRequest(requestId, continuation)
+ continuation.invokeOnCancellation { removePendingRequest(requestId) }
+ webViewManager.evaluateJavascriptWithFallback(webViewWrapper, jsCode)
+ }
+ }
+ }
+}
diff --git a/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/DefaultClickToPaySessionLauncher.kt b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/DefaultClickToPaySessionLauncher.kt
index 6af57153..cd7a18fa 100644
--- a/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/DefaultClickToPaySessionLauncher.kt
+++ b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/DefaultClickToPaySessionLauncher.kt
@@ -47,9 +47,7 @@ import kotlin.coroutines.resume
* @property customBackendUrl Optional custom backend URL for API calls
* @property customLogUrl Optional custom URL for logging
* @property customParams Optional additional parameters
- * @property hSWebViewManagerImpl WebView manager for JavaScript execution
- * @property hSWebViewWrapper Wrapper for the WebView instance
- * @property pendingRequests Map of pending async requests awaiting responses
+ * @property webViewManager Manages WebView lifecycle and operations
*/
class DefaultClickToPaySessionLauncher(
private var activity: Activity,
@@ -58,15 +56,10 @@ class DefaultClickToPaySessionLauncher(
private val customLogUrl: String? = null,
private val customParams: Bundle? = null,
) : ClickToPaySessionLauncher {
- private lateinit var hSWebViewManagerImpl: HSWebViewManagerImpl
- private lateinit var hSWebViewWrapper: HSWebViewWrapper
- private val correlationIds = mutableSetOf()
- private val captureCorrelationIds = AtomicBoolean(true)
- private val pendingRequests = ConcurrentHashMap>()
- private val isWebViewInitialized = AtomicBoolean(false)
- private val isWebViewAttached = AtomicBoolean(false)
+ private val webViewManager = ClickToPayWebViewManager(activity) { type, eventName, value, category ->
+ logger(type, eventName, value, category)
+ }
private val isDestroyed = AtomicBoolean(false)
- private val lifecycleMutex = Mutex()
private val originalAccessibility = HashMap()
private var authenticationId: String? = null
private val deviceUniqueSessionId = getOrCreateUniqueKey(activity, "click_to_pay")
@@ -88,13 +81,9 @@ class DefaultClickToPaySessionLauncher(
/**
* Atomically resumes a pending continuation exactly once.
- *
- * Uses [ConcurrentHashMap.remove] so that concurrent calls from [cancelPendingRequests]
- * and the WebView onMessage callback cannot both reach the same continuation — only
- * whichever removes it first succeeds. This eliminates the double-resume race (RC4).
*/
private fun resumeContinuation(requestId: String, value: String) {
- pendingRequests.remove(requestId)?.resume(value)
+ webViewManager.resumePendingRequest(requestId, value)
}
/**
@@ -109,34 +98,14 @@ class DefaultClickToPaySessionLauncher(
* @throws ClickToPayException if operation times out or fails, or if session is destroyed
*/
private suspend fun evaluateJavascriptOnMainThread(requestId: String, jsCode: String): String {
- return withContext(Dispatchers.Main) {
- // Guard against destroyed WebView to prevent "call on destroyed WebView" crash
- if (isDestroyed.get()) {
- throw ClickToPayException(
- "ClickToPay session has been destroyed",
- "SESSION_DESTROYED"
- )
- }
- suspendCancellableCoroutine { continuation ->
- // Double-check after suspension setup to handle race conditions
- if (isDestroyed.get()) {
- continuation.resumeWith(
- Result.failure(
- ClickToPayException(
- "ClickToPay session has been destroyed",
- "SESSION_DESTROYED"
- )
- )
- )
- return@suspendCancellableCoroutine
- }
- pendingRequests[requestId] = continuation
- continuation.invokeOnCancellation {
- pendingRequests.remove(requestId)
- }
- hSWebViewManagerImpl.evaluateJavascriptWithFallback(hSWebViewWrapper, jsCode)
- }
+ // Guard against destroyed session
+ if (isDestroyed.get()) {
+ throw ClickToPayException(
+ "ClickToPay session has been destroyed",
+ "SESSION_DESTROYED"
+ )
}
+ return webViewManager.evaluateJavaScript(jsCode, requestId)
}
// URL Helpers
@@ -148,153 +117,7 @@ class DefaultClickToPaySessionLauncher(
}
}
- // Parsing Helpers
-
- private fun parseJSONObject(data: String, eventName: EventName): JSONObject {
- try {
- return JSONObject(data)
- } catch (e: Exception) {
- logger(
- LogType.ERROR,
- eventName,
- "Type: ERROR, Message: Failed to parse JSONObject",
- LogCategory.USER_ERROR
- )
- throw ClickToPayException(
- "Failed to read response: ${e.message}", "ERROR"
- )
- }
- }
-
- private fun getOptJSONArray(arr: JSONArray, index: Int): JSONObject {
- return arr.optJSONObject(index) ?: JSONObject()
- }
-
- private fun getOptJSONObject(obj: JSONObject, name: String): JSONObject {
- return obj.optJSONObject(name) ?: JSONObject()
- }
-
- private fun safeReturnStringValue(
- obj: JSONObject, key: String
- ): String? {
- return when {
- obj.isNull(key) -> null
- else -> obj.getString(key).takeIf { it.isNotEmpty() }
- }
- }
-
- private fun parseMaskedValidationChannelData(obj: JSONObject): MaskedValidationChannel {
- return MaskedValidationChannel(
- email = safeReturnStringValue(obj, "email"),
- phoneNumber = safeReturnStringValue(obj, "phoneNumber"),
- )
- }
-
- private fun parseSupportedValidationChannelsData(obj: JSONObject): SupportedValidationChannel {
- return SupportedValidationChannel(
- validationChannelId = safeReturnStringValue(obj, "validationChannelId"),
- identityProvider = safeReturnStringValue(obj, "identityProvider"),
- identityType = safeReturnStringValue(obj, "identityType"),
- maskedValidationChannel = safeReturnStringValue(obj, "maskedValidationChannel")
- )
- }
-
-
- private fun parseRecognizedCard(cardObj: JSONObject): RecognizedCard {
- val digitalCardDataObj = cardObj.optJSONObject("digitalCardData")
- val maskedBillingAddressObj = cardObj.optJSONObject("maskedBillingAddress")
- val dcfObj = cardObj.optJSONObject("dcf")
-
- val authMethods = digitalCardDataObj?.optJSONArray("authenticationMethods")?.let { arr ->
- (0 until arr.length()).map { idx ->
- AuthenticationMethod(
- getOptJSONArray(arr, idx).optString("authenticationMethodType", "")
- )
- }
- }
-
- val pendingEvents = digitalCardDataObj?.optJSONArray("pendingEvents")?.let { arr ->
- (0 until arr.length()).map { idx -> arr.optString(idx, "") }
- }
-
- return RecognizedCard(
- srcDigitalCardId = safeReturnStringValue(cardObj, "srcDigitalCardId") ?: "",
- panBin = safeReturnStringValue(cardObj, "panBin"),
- panLastFour = safeReturnStringValue(cardObj, "panLastFour"),
- panExpirationMonth = safeReturnStringValue(cardObj, "panExpirationMonth"),
- panExpirationYear = safeReturnStringValue(cardObj, "panExpirationYear"),
- tokenLastFour = safeReturnStringValue(cardObj, "tokenLastFour"),
- tokenBinRange = safeReturnStringValue(cardObj, "tokenBinRange"),
- digitalCardData = digitalCardDataObj?.let {
- DigitalCardData(
- status = safeReturnStringValue(it, "status"),
- presentationName = safeReturnStringValue(it, "presentationName"),
- descriptorName = it.optString("descriptorName", ""),
- artUri = safeReturnStringValue(it, "artUri"),
- artHeight = it.optInt("artHeight", -1).takeIf { h -> h > 0 },
- artWidth = it.optInt("artWidth", -1).takeIf { w -> w > 0 },
- authenticationMethods = authMethods,
- pendingEvents = pendingEvents
- )
- },
- countryCode = safeReturnStringValue(cardObj, "countryCode"),
- maskedBillingAddress = maskedBillingAddressObj?.let { obj ->
- if (obj.length() > 0) {
- MaskedBillingAddress(
- addressId = safeReturnStringValue(obj, "addressId"),
- name = safeReturnStringValue(obj, "name"),
- line1 = safeReturnStringValue(obj, "line1"),
- line2 = safeReturnStringValue(obj, "line2"),
- line3 = safeReturnStringValue(obj, "line3"),
- city = safeReturnStringValue(obj, "city"),
- state = safeReturnStringValue(obj, "state"),
- countryCode = safeReturnStringValue(obj, "countryCode"),
- zip = safeReturnStringValue(obj, "zip")
- )
- } else null
- },
- dateOfCardCreated = safeReturnStringValue(cardObj, "dateOfCardCreated"),
- dateOfCardLastUsed = safeReturnStringValue(cardObj, "dateOfCardLastUsed"),
- paymentAccountReference = safeReturnStringValue(cardObj, "paymentAccountReference"),
- paymentCardDescriptor = CardType.from(
- cardObj.optString(
- "paymentCardDescriptor", "unknown"
- )
- ),
- paymentCardType = safeReturnStringValue(cardObj, "paymentCardType"),
- dcf = dcfObj?.let {
- DCF(
- name = safeReturnStringValue(it, "name"),
- uri = safeReturnStringValue(it, "uri"),
- logoUri = safeReturnStringValue(it, "logoUri")
- )
- },
- digitalCardFeatures = cardObj.optJSONObject("digitalCardFeatures")?.let { emptyMap() })
- }
-
- private fun parsePaymentData(obj: JSONObject?): PaymentData? {
- obj ?: return null
- val typeStr = obj.optString("type", "").uppercase()
- val tokenType = runCatching { DataType.valueOf(typeStr) }.getOrNull()
-
- return when (tokenType) {
- DataType.CARD_DATA -> PaymentData.CardData(
- cardNumber = safeReturnStringValue(obj, "cardNumber"),
- cardCvc = safeReturnStringValue(obj, "cardCvc"),
- cardExpiryMonth = safeReturnStringValue(obj, "cardExpiryMonth"),
- cardExpiryYear = safeReturnStringValue(obj, "cardExpiryYear"),
- )
-
- DataType.NETWORK_TOKEN_DATA -> PaymentData.NetworkTokenData(
- networkToken = safeReturnStringValue(obj, "networkToken"),
- networkTokenCryptogram = safeReturnStringValue(obj, "networkTokenCryptogram"),
- networkTokenExpiryMonth = safeReturnStringValue(obj, "networkTokenExpiryMonth"),
- networkTokenExpiryYear = safeReturnStringValue(obj, "networkTokenExpiryYear")
- )
-
- else -> null
- }
- }
+ // JSON parsing is handled by ClickToPayResponseDecoder
/**
* Sets modal accessibility mode by hiding all views except the target view.
@@ -353,76 +176,6 @@ class DefaultClickToPaySessionLauncher(
}
- /**
- * Detaches the WebView from the view hierarchy to pause JavaScript execution.
- * This triggers the same lifecycle behavior as backgrounding an app,
- * automatically pausing timers, network requests, and all JavaScript operations.
- */
- private suspend fun detachWebView() {
- lifecycleMutex.withLock {
- if (isWebViewInitialized.get() && isWebViewAttached.get()) {
- withContext(Dispatchers.Main) {
- val parent = hSWebViewWrapper.parent
- if (parent is ViewGroup) {
- parent.removeView(hSWebViewWrapper)
- }
- }
- isWebViewAttached.set(false)
- logger(
- LogType.DEBUG, EventName.WEBVIEW, "webview de-attached JS execution paused"
- )
- }
- }
-
- }
-
- /**
- * Reattaches the WebView to the view hierarchy to resume JavaScript execution.
- * This automatically resumes all paused operations.
- */
- private suspend fun reattachWebView() {
- lifecycleMutex.withLock {
- if (isWebViewInitialized.get() && !isWebViewAttached.get()) {
- withContext(Dispatchers.Main) {
- val rootView = activity.findViewById(android.R.id.content)
- rootView.addView(hSWebViewWrapper)
- }
- isWebViewAttached.set(true)
- logger(LogType.DEBUG, EventName.WEBVIEW, "webview reattached JS execution resumed")
- }
- }
- }
-
-
- // Pending-request management
- /**
- * Cancels all pending requests to prevent stale callbacks from executing.
- *
- * @param errorMessage Optional error message for cancellation
- */
- private fun cancelPendingRequests(errorMessage: String = "Operation cancelled due to error") {
- val snapshot = pendingRequests.keys.toList()
- if (snapshot.isEmpty()) return
- logger(LogType.DEBUG, EventName.WEBVIEW, "Cancelling ${snapshot.size} pending requests")
- for (key in snapshot) {
- pendingRequests.remove(key)
- ?.cancel(kotlinx.coroutines.CancellationException(errorMessage))
- }
- }
-
- /**
- * Ensures the instance has not been destroyed.
- * @throws ClickToPayException if instance has been destroyed
- */
- private fun ensureNotDestroyed() {
- if (isDestroyed.get()) {
- throw ClickToPayException(
- "ClickToPaySessionLauncher has been destroyed and cannot be used",
- ClickToPayErrorType.INSTANCE_DESTROYED
- )
- }
- }
-
/**
* Ensures the session is ready for operations.
* Combines three essential checks:
@@ -433,130 +186,14 @@ class DefaultClickToPaySessionLauncher(
* @throws ClickToPayException if the session is destroyed or initialization fails
*/
private suspend fun ensureReady() {
- ensureNotDestroyed()
- ensureWebViewInitialized()
- reattachWebView()
- }
-
- private fun isWebViewAvailable(): Boolean {
- return try {
- WebViewCompat.getCurrentWebViewPackage(activity) != null
- } catch (_: Throwable) {
- false
- }
- }
-
- private fun initializeWebViewInternal() {
- val onMessage = Callback { args ->
- (args["data"] as? String)?.let { jsonString ->
- val requestId = JSONObject(jsonString).optString("requestId", "")
- if (requestId.isNotEmpty()) {
- resumeContinuation(requestId, jsonString)
- }
- }
- }
-
- hSWebViewManagerImpl = HSWebViewManagerImpl(activity, onMessage)
- hSWebViewWrapper = hSWebViewManagerImpl.createViewInstance()
-
- hSWebViewManagerImpl.setJavaScriptEnabled(hSWebViewWrapper, true)
- hSWebViewManagerImpl.setMessagingEnabled(hSWebViewWrapper, true)
- hSWebViewManagerImpl.setJavaScriptCanOpenWindowsAutomatically(hSWebViewWrapper, true)
- hSWebViewManagerImpl.setScalesPageToFit(hSWebViewWrapper, true)
- hSWebViewManagerImpl.setMixedContentMode(hSWebViewWrapper, "always")
- hSWebViewManagerImpl.setThirdPartyCookiesEnabled(hSWebViewWrapper, true)
- hSWebViewManagerImpl.setCacheEnabled(hSWebViewWrapper, true)
- hSWebViewWrapper.webView.setRequestInterceptor { data ->
- try {
- val headers = data["headers"] as? Map<*, *>
- val correlationId = headers?.get("X-CORRELATION-ID")?.toString()
- if (correlationId != null && captureCorrelationIds.get()) {
- correlationIds.add(correlationId)
- }
- } catch (_: Exception) {
- }
- }
- hSWebViewWrapper.apply {
- isFocusable = false
- isFocusableInTouchMode = false
- layoutParams = LayoutParams(1, 1)
- contentDescription = "Click to Pay"
- importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
- }
-
- activity.findViewById(android.R.id.content).addView(hSWebViewWrapper)
-
-// isWebViewAttached = true
- }
-
-
- /**
- * Initializes the WebView components asynchronously on the main thread.
- * This method is idempotent and can be called multiple times safely.
- * If the session was previously closed, this will reinitialize it.
- *
- * @throws ClickToPayException if WebView initialization fails
- */
- private suspend fun ensureWebViewInitialized(
- allowReinitialize: Boolean = false
- ) {
- lifecycleMutex.withLock {
- if (isWebViewInitialized.get() && !allowReinitialize) return@withLock
- logger(
- LogType.DEBUG,
- EventName.CREATE_WEBVIEW_INIT,
- "creating webview",
- LogCategory.USER_EVENT
+ if (isDestroyed.get()) {
+ throw ClickToPayException(
+ "ClickToPaySessionLauncher has been destroyed and cannot be used",
+ ClickToPayErrorType.INSTANCE_DESTROYED
)
- try {
- if (!isWebViewAvailable()) {
- logger(
- LogType.ERROR,
- EventName.CREATE_WEBVIEW_RETURNED,
- "WebView provider unavailable",
- LogCategory.USER_ERROR
- )
- throw IllegalStateException("WebView provider unavailable")
- }
- // Retry WebView creation once (important for Android 15/16 bug)
- repeat(2) { attempt ->
- try {
- withContext(Dispatchers.Main) {
- initializeWebViewInternal()
- }
- isWebViewInitialized.set(true)
- isWebViewAttached.set(true)
- logger(
- LogType.DEBUG,
- EventName.CREATE_WEBVIEW_RETURNED,
- "webview created",
- LogCategory.USER_EVENT
- )
- return@withLock
- } catch (t: Throwable) {
- logger(
- LogType.ERROR,
- EventName.CREATE_WEBVIEW_RETURNED,
- "Attempted to create = $attempt",
- LogCategory.USER_ERROR
- )
- if (attempt == 1) throw t
- delay(200) // retry delay
- }
- }
- } catch (e: Exception) {
- logger(
- LogType.ERROR,
- EventName.CREATE_WEBVIEW_RETURNED,
- "Failed to create webview ${e.message}",
- LogCategory.USER_ERROR
- )
- throw ClickToPayException(
- "Unable to initialize ClickToPay: ${e.message}",
- "WEBVIEW_ERROR",
- )
- }
}
+ webViewManager.ensureInitialized()
+ webViewManager.reattach()
}
/**
@@ -582,14 +219,11 @@ class DefaultClickToPaySessionLauncher(
)
HyperLogManager.initialise(publishableKey, loggingEndPoint)
HyperLogManager.sendLogsFromFile(LogFileManager(activity))
- lifecycleMutex.withLock {
- if (isDestroyed.get()) {
- isDestroyed.set(false)
- isWebViewInitialized.set(false)
- isWebViewAttached.set(false)
- }
+ if (isDestroyed.get()) {
+ isDestroyed.set(false)
+ webViewManager.resetState()
}
- ensureWebViewInitialized(allowReinitialize = true)
+ webViewManager.ensureInitialized(allowReinitialize = true)
loadUrl()
}
@@ -615,43 +249,41 @@ class DefaultClickToPaySessionLauncher(
"hyperLoaderUrl: $hyperLoaderUrl, baseUrl: $baseUrl",
LogCategory.USER_EVENT
)
- val baseHtml =
- ""
+ val baseHtml = ClickToPayScripts.createInitializationHtml(
+ publishableKey = publishableKey,
+ customBackendUrl = customBackendUrl,
+ customLogUrl = customLogUrl,
+ requestId = requestId,
+ hyperLoaderUrl = hyperLoaderUrl
+ )
val responseJson = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
- pendingRequests[requestId] = continuation
- continuation.invokeOnCancellation { pendingRequests.remove(requestId) }
+ webViewManager.registerPendingRequest(requestId, continuation)
+ continuation.invokeOnCancellation { webViewManager.removePendingRequest(requestId) }
val map = Arguments.createMap()
map.putString("html", baseHtml)
map.putString("baseUrl", baseUrl)
- hSWebViewManagerImpl.loadSource(hSWebViewWrapper, map)
+ webViewManager.getWebViewManager().loadSource(webViewManager.getWebViewWrapper(), map)
}
}
withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.SCRIPT_LOAD_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "Unknown")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.SCRIPT_LOAD_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- "Failed to load URL - Type: $errorType, Message: $errorMessage",
- "SCRIPT_LOAD_ERROR"
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.SCRIPT_LOAD_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
}
- logger(
- LogType.DEBUG, EventName.SCRIPT_LOAD_RETURNED, "success", LogCategory.USER_EVENT
- )
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.SCRIPT_LOAD_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to load URL - Type: $errorType, Message: $errorMessage", "SCRIPT_LOAD_ERROR")
+ }
+ logger(LogType.DEBUG, EventName.SCRIPT_LOAD_RETURNED, "success", LogCategory.USER_EVENT)
}
}
@@ -684,42 +316,37 @@ class DefaultClickToPaySessionLauncher(
)
this.authenticationId = authenticationId
this.sessionId = "${deviceUniqueSessionId}_${UUID.randomUUID()}"
- captureCorrelationIds.set(true)
+ webViewManager.startCapturingCorrelationIds()
ensureReady()
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){try{const authenticationSession=window.hyperInstance.initAuthenticationSession({clientSecret:'$clientSecret',profileId:'$profileId',authenticationId:'$authenticationId',merchantId:'$merchantId'});window.ClickToPaySession=await authenticationSession.initClickToPaySession({request3DSAuthentication:$request3DSAuthentication});const data=window.ClickToPaySession.error?window.ClickToPaySession:{success:true};window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:data}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'InitClickToPaySessionError',message:error.message}}}))}})();"
-
+ val jsCode = ClickToPayScripts.initClickToPaySession(
+ clientSecret = clientSecret,
+ profileId = profileId,
+ authenticationId = authenticationId,
+ merchantId = merchantId,
+ request3DSAuthentication = request3DSAuthentication,
+ requestId = requestId
+ )
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.INIT_CLICK_TO_PAY_SESSION_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "Unknown")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.INIT_CLICK_TO_PAY_SESSION_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- "Failed to initialize Click to Pay session - Type: $errorType, Message: $errorMessage",
- "INIT_CLICK_TO_PAY_SESSION_ERROR"
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.INIT_CLICK_TO_PAY_SESSION_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
}
- logger(
- LogType.DEBUG,
- EventName.INIT_CLICK_TO_PAY_SESSION_RETURNED,
- correlationIds.joinToString(", "),
- LogCategory.USER_EVENT
- )
- captureCorrelationIds.set(false)
- correlationIds.clear()
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.INIT_CLICK_TO_PAY_SESSION_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to initialize Click to Pay session - Type: $errorType, Message: $errorMessage", "INIT_CLICK_TO_PAY_SESSION_ERROR")
+ }
+ logger(LogType.DEBUG, EventName.INIT_CLICK_TO_PAY_SESSION_RETURNED, webViewManager.getCorrelationIds().joinToString(", "), LogCategory.USER_EVENT)
+ webViewManager.stopCapturingCorrelationIds()
+ webViewManager.clearCorrelationIds()
}
}
@@ -740,19 +367,8 @@ class DefaultClickToPaySessionLauncher(
ensureReady()
try {
if (this.activity !== activity) {
- lifecycleMutex.withLock {
- withContext(Dispatchers.Main) {
- if (isWebViewInitialized.get() && isWebViewAttached.get()) {
- (hSWebViewWrapper.parent as? ViewGroup)?.removeView(hSWebViewWrapper)
- isWebViewAttached.set(false)
- }
- val rootView = activity.findViewById(android.R.id.content)
- ?: throw IllegalStateException("Failed to find root view in new activity")
- rootView.addView(hSWebViewWrapper)
- isWebViewAttached.set(true)
- }
- this.activity = activity
- }
+ webViewManager.updateActivity(activity)
+ this.activity = activity
}
} catch (e: Exception) {
logger(
@@ -764,34 +380,31 @@ class DefaultClickToPaySessionLauncher(
throw ClickToPayException("WebView is not found", "C2P_NOT_FOUND")
}
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){ try {let authenticationSession=window.hyperInstance.initAuthenticationSession({clientSecret:'$clientSecret',profileId:'$profileId',authenticationId:'$authenticationId',merchantId:'$merchantId'}); window.ClickToPaySession = await authenticationSession?.getActiveClickToPaySession();const data=window.ClickToPaySession.error?window.ClickToPaySession:{success:true};window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:data}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'getActiveClickToPaySessionError',message:error.message}}}))}})();"
-
+ val jsCode = ClickToPayScripts.getActiveClickToPaySession(
+ clientSecret = clientSecret,
+ profileId = profileId,
+ authenticationId = authenticationId,
+ merchantId = merchantId,
+ requestId = requestId
+ )
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.GET_ACTIVE_CLICK_TO_PAY_SESSION_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "Unknown")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.GET_ACTIVE_CLICK_TO_PAY_SESSION_RETURNED,
- "Failed to get Click to Pay session - Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- "Failed to get Click to Pay session - Type: $errorType, Message: $errorMessage",
- "INIT_CLICK_TO_PAY_SESSION_ERROR"
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.GET_ACTIVE_CLICK_TO_PAY_SESSION_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
}
- logger(
- LogType.DEBUG, EventName.GET_ACTIVE_CLICK_TO_PAY_SESSION_RETURNED, ""
- )
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.GET_ACTIVE_CLICK_TO_PAY_SESSION_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to get Click to Pay session - Type: $errorType, Message: $errorMessage", "INIT_CLICK_TO_PAY_SESSION_ERROR")
+ }
+ logger(LogType.DEBUG, EventName.GET_ACTIVE_CLICK_TO_PAY_SESSION_RETURNED, "")
}
}
@@ -810,40 +423,49 @@ class DefaultClickToPaySessionLauncher(
logger(LogType.DEBUG, EventName.IS_CUSTOMER_PRESENT_INIT, "")
ensureReady()
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){try{const isCustomerPresent=await window.ClickToPaySession.isCustomerPresent({${request.email?.let { "email:'${request.email}'" } ?: ""}});window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:isCustomerPresent}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'IsCustomerPresentError',message:error.message}}}))}})();"
-
-
+ val jsCode = ClickToPayScripts.isCustomerPresent(
+ email = request.email,
+ requestId = requestId
+ )
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
return withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.IS_CUSTOMER_PRESENT_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "ERROR")
- val errorMessage = error.optString("message", "Unknown Error")
- logger(
- LogType.ERROR,
- EventName.IS_CUSTOMER_PRESENT_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- "Failed to get customer present: $errorMessage", errorType
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.IS_CUSTOMER_PRESENT_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
+ }
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.IS_CUSTOMER_PRESENT_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to get customer present: $errorMessage", errorType)
+ }
+ when (val result = ClickToPayResponseDecoder.decodeCustomerPresenceResponse(data)) {
+ is DecodeResult.Success -> {
+ logger(
+ LogType.DEBUG,
+ EventName.IS_CUSTOMER_PRESENT_RETURNED,
+ "customerPresent: ${result.data.customerPresent}"
+ )
+ result.data
+ }
+ is DecodeResult.Error -> {
+ logger(
+ LogType.ERROR,
+ EventName.IS_CUSTOMER_PRESENT_RETURNED,
+ "Failed to parse customer presence: ${result.message}",
+ LogCategory.USER_ERROR
+ )
+ throw ClickToPayException(
+ "Failed to parse customer presence: ${result.message}",
+ "PARSE_ERROR"
+ )
+ }
}
- val customerPresent = data.optBoolean("customerPresent", false)
- logger(
- LogType.DEBUG,
- EventName.IS_CUSTOMER_PRESENT_RETURNED,
- "customerPresent: $customerPresent",
- )
- CustomerPresenceResponse(
- customerPresent = customerPresent
- )
}
}
@@ -861,51 +483,46 @@ class DefaultClickToPaySessionLauncher(
logger(LogType.DEBUG, EventName.GET_USER_TYPE_INIT, "")
ensureReady()
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){try{const userType=await window.ClickToPaySession.getUserType();window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:userType}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:error.type||'ERROR',message:error.message}}}))}})();"
-
+ val jsCode = ClickToPayScripts.getUserType(requestId)
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
return withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.GET_USER_TYPE_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "ERROR")
- val errorMessage = error.optString("message", "Unknown Error")
- logger(
- LogType.ERROR,
- EventName.GET_USER_TYPE_RETURNED,
- "Type: $errorType, Message: $errorMessage"
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- message = "Failed to get user type : $errorMessage", errorType
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.GET_USER_TYPE_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
}
- val statusCodeStr = data.optString("statusCode", "NO_CARDS_PRESENT").uppercase()
- val maskedValidationChannelDetails =
- parseMaskedValidationChannelData(getOptJSONObject(data, "maskedValidationChannel"))
- val supportedValidationChannelsArray = data.optJSONArray("supportedValidationChannels")
- val supportedValidationChannels = supportedValidationChannelsArray?.let { array ->
- (0 until array.length()).map { i ->
- parseSupportedValidationChannelsData(getOptJSONArray(array, i))
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.GET_USER_TYPE_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to get user type: $errorMessage", errorType)
+ }
+ when (val result = ClickToPayResponseDecoder.decodeCardsStatusResponse(data)) {
+ is DecodeResult.Success -> {
+ logger(
+ LogType.DEBUG,
+ EventName.GET_USER_TYPE_RETURNED,
+ "statusCode: ${result.data.statusCode}, maskedValidationChannels: ${result.data.maskedValidationChannel}"
+ )
+ result.data
}
- } ?: emptyList()
-
- logger(
- LogType.DEBUG,
- EventName.GET_USER_TYPE_RETURNED,
- "statusCode: $statusCodeStr, maskedValidationChannels: $maskedValidationChannelDetails"
- )
-
-
- CardsStatusResponse(
- statusCode = StatusCode.from(statusCodeStr),
- maskedValidationChannel = maskedValidationChannelDetails,
- supportedValidationChannels = supportedValidationChannels
- )
+ is DecodeResult.Error -> {
+ logger(
+ LogType.ERROR,
+ EventName.GET_USER_TYPE_RETURNED,
+ "Failed to parse cards status: ${result.message}",
+ LogCategory.USER_ERROR
+ )
+ throw ClickToPayException(
+ "Failed to parse cards status: ${result.message}",
+ "PARSE_ERROR"
+ )
+ }
+ }
}
}
@@ -923,44 +540,52 @@ class DefaultClickToPaySessionLauncher(
logger(LogType.DEBUG, EventName.GET_RECOGNISED_CARDS_INIT, "")
ensureReady()
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){try{const cards=await window.ClickToPaySession.getRecognizedCards();window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:cards}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'GetRecognizedCardsError',message:error.message}}}))}})();"
-
+ val jsCode = ClickToPayScripts.getRecognizedCards(requestId)
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
return withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.GET_RECOGNISED_CARDS_RETURNED)
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.GET_RECOGNISED_CARDS_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
+ }
val data = jsonObject.get("data")
-
if (data is JSONObject && data.has("error")) {
- val error = getOptJSONObject(data, "error")
- val errorType = error.optString("type", "ERROR")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.GET_RECOGNISED_CARDS_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- "Failed to get recognized cards - Type: $errorType, Message: $errorMessage",
- errorType
- )
+ val error = ClickToPayResponseDecoder.getNestedObject(data, "error")
+ val errorType = ClickToPayResponseDecoder.extractString(error, "type") ?: "ERROR"
+ val errorMessage = ClickToPayResponseDecoder.extractString(error, "message") ?: "Unknown error"
+ logger(LogType.ERROR, EventName.GET_RECOGNISED_CARDS_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to get recognized cards - Type: $errorType, Message: $errorMessage", errorType)
}
val cardsArray = data as JSONArray
- val cards = (0 until cardsArray.length()).map { i ->
- parseRecognizedCard(getOptJSONArray(cardsArray, i))
+ when (val result = ClickToPayResponseDecoder.decodeRecognizedCards(cardsArray)) {
+ is DecodeResult.Success -> {
+ val visaCount = result.data.count { it.paymentCardDescriptor == CardType.VISA }
+ val masterCardCount = result.data.count { it.paymentCardDescriptor == CardType.MASTERCARD }
+ logger(
+ LogType.DEBUG,
+ EventName.GET_RECOGNISED_CARDS_RETURNED,
+ "Visa: $visaCount, Mastercard: $masterCardCount"
+ )
+ result.data
+ }
+ is DecodeResult.Error -> {
+ logger(
+ LogType.ERROR,
+ EventName.GET_RECOGNISED_CARDS_RETURNED,
+ "Failed to parse recognized cards: ${result.message}",
+ LogCategory.USER_ERROR
+ )
+ throw ClickToPayException(
+ "Failed to parse recognized cards: ${result.message}",
+ "PARSE_ERROR"
+ )
+ }
}
- val visaCount = cards.count { it.paymentCardDescriptor == CardType.VISA }
- val masterCardCount = cards.count { it.paymentCardDescriptor == CardType.MASTERCARD }
- logger(
- LogType.DEBUG,
- EventName.GET_RECOGNISED_CARDS_RETURNED,
- "Visa: $visaCount, Mastercard: $masterCardCount"
- )
- cards
}
}
@@ -979,41 +604,54 @@ class DefaultClickToPaySessionLauncher(
logger(LogType.DEBUG, EventName.VALIDATE_CUSTOMER_AUTHENTICATION_INIT, "")
ensureReady()
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){try{const cards=await window.ClickToPaySession.validateCustomerAuthentication({value:'$otpValue'});window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:cards}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:error.type||'ERROR',message:error.message}}}))}})();"
+ val jsCode = ClickToPayScripts.validateCustomerAuthentication(
+ otpValue = otpValue,
+ requestId = requestId
+ )
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
return withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED)
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
+ }
val data = jsonObject.get("data")
-
if (data is JSONObject && data.has("error")) {
- val error = getOptJSONObject(data, "error")
- val errorType = error.optString("type", "ERROR")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- errorMessage, errorType
- )
+ val error = ClickToPayResponseDecoder.getNestedObject(data, "error")
+ val errorType = ClickToPayResponseDecoder.extractString(error, "type") ?: "ERROR"
+ val errorMessage = ClickToPayResponseDecoder.extractString(error, "message") ?: "Unknown error"
+ logger(LogType.ERROR, EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException(errorMessage, errorType)
}
val cardsArray = data as JSONArray
- val cards = (0 until cardsArray.length()).map { i ->
- parseRecognizedCard(getOptJSONArray(cardsArray, i))
+ when (val result = ClickToPayResponseDecoder.decodeRecognizedCards(cardsArray)) {
+ is DecodeResult.Success -> {
+ val visaCount = result.data.count { it.paymentCardDescriptor == CardType.VISA }
+ val masterCardCount = result.data.count { it.paymentCardDescriptor == CardType.MASTERCARD }
+ logger(
+ LogType.DEBUG,
+ EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED,
+ "Visa: $visaCount, Mastercard: $masterCardCount"
+ )
+ result.data
+ }
+ is DecodeResult.Error -> {
+ logger(
+ LogType.ERROR,
+ EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED,
+ "Failed to parse validated cards: ${result.message}",
+ LogCategory.USER_ERROR
+ )
+ throw ClickToPayException(
+ "Failed to parse validated cards: ${result.message}",
+ "PARSE_ERROR"
+ )
+ }
}
- val visaCount = cards.count { it.paymentCardDescriptor == CardType.VISA }
- val masterCardCount = cards.count { it.paymentCardDescriptor == CardType.MASTERCARD }
- logger(
- LogType.DEBUG,
- EventName.VALIDATE_CUSTOMER_AUTHENTICATION_RETURNED,
- "Visa: $visaCount, Mastercard: $masterCardCount"
- )
- cards
}
}
@@ -1033,111 +671,50 @@ class DefaultClickToPaySessionLauncher(
logger(LogType.DEBUG, EventName.CHECKOUT_INIT, "rememberMe: ${request.rememberMe}")
ensureReady()
val rootView = activity.findViewById(android.R.id.content)
- setModalAccessibility(rootView, hSWebViewWrapper)
+ setModalAccessibility(rootView, webViewManager.getWebViewWrapper())
val requestId = UUID.randomUUID().toString()
- logger(
- LogType.DEBUG, EventName.CREATE_NEW_WEBVIEW_INIT, ""
- ) //TODO: should we rename to CHECKOUT_VIEW_INIT
- val jsCode =
- "(async function(){try{const checkoutResponse=await window.ClickToPaySession.checkoutWithCard({srcDigitalCardId:'${request.srcDigitalCardId}',rememberMe:${request.rememberMe}});window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:checkoutResponse}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'CheckoutWithCardError',message:error.message}}}))}})();"
+ logger(LogType.DEBUG, EventName.CREATE_NEW_WEBVIEW_INIT, "")
+ val jsCode = ClickToPayScripts.checkoutWithCard(
+ srcDigitalCardId = request.srcDigitalCardId,
+ rememberMe = request.rememberMe,
+ requestId = requestId
+ )
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
logger(LogType.DEBUG, EventName.CREATE_NEW_WEBVIEW_RETURNED, "")
restoreAccessibility()
return withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.CHECKOUT_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "ERROR")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.CHECKOUT_RETURNED,
- "Type: $errorType, Message: $errorMessage, error: $error",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(errorMessage, errorType)
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.CHECKOUT_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
}
- val vaultTokenDataObj = data.optJSONObject("vaultTokenData")
- val vaultTokenData = parsePaymentData(vaultTokenDataObj)
- val paymentMethodDataObj = data.optJSONObject("paymentMethodData")
- val paymentMethodData = parsePaymentData(paymentMethodDataObj)
-
- val acquirerDetailsObj = data.optJSONObject("acquirerDetails")
- val acquirerDetails = acquirerDetailsObj?.let {
- AcquirerDetails(
- acquirerBin = safeReturnStringValue(it, "acquirerBin"),
- acquirerMerchantId = safeReturnStringValue(it, "acquirerMerchantId"),
- merchantCountryCode = safeReturnStringValue(it, "merchantCountryCode")
- )
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.CHECKOUT_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException(errorMessage, errorType)
}
-
- val statusStr = data.optString("status", "").uppercase()
- val authStatus = try {
- AuthenticationStatus.valueOf(statusStr)
- } catch (_: IllegalArgumentException) {
- null
+ when (val result = ClickToPayResponseDecoder.decodeCheckoutResponse(data)) {
+ is DecodeResult.Success -> {
+ logger(LogType.DEBUG, EventName.CHECKOUT_RETURNED, "status: ${result.data.status}")
+ result.data
+ }
+ is DecodeResult.Error -> {
+ logger(
+ LogType.ERROR,
+ EventName.CHECKOUT_RETURNED,
+ "Failed to parse checkout response: ${result.message}",
+ LogCategory.USER_ERROR
+ )
+ throw ClickToPayException(
+ "Failed to parse checkout response: ${result.message}",
+ "PARSE_ERROR"
+ )
+ }
}
-
- val response = CheckoutResponse(
- authenticationId = safeReturnStringValue(data, "authenticationId"),
- merchantId = safeReturnStringValue(data, "merchantId"),
- status = authStatus,
- clientSecret = safeReturnStringValue(data, "clientSecret"),
- amount = data.optInt("amount", -1).takeIf { it >= 0 },
- currency = safeReturnStringValue(data, "currency"),
- authenticationConnector = safeReturnStringValue(
- data,
- "authenticationConnector",
- ),
- force3dsChallenge = data.optBoolean("force3dsChallenge", false),
- returnUrl = safeReturnStringValue(data, "returnUrl"),
- createdAt = safeReturnStringValue(data, "createdAt"),
- profileId = safeReturnStringValue(data, "profileId"),
- psd2ScaExemptionType = safeReturnStringValue(data, "psd2ScaExemptionType"),
- acquirerDetails = acquirerDetails,
- threedsServerTransactionId = safeReturnStringValue(
- data,
- "threeDsServerTransactionId",
- ),
- maximumSupported3dsVersion = safeReturnStringValue(
- data,
- "maximumSupported3dsVersion",
- ),
- connectorAuthenticationId = safeReturnStringValue(
- data, "connectorAuthenticationId"
- ),
- threeDsMethodData = safeReturnStringValue(data, "threeDsMethod_data"),
- threeDsMethodUrl = safeReturnStringValue(data, "threeDsMethodUrl"),
- messageVersion = safeReturnStringValue(data, "messageVersion"),
- connectorMetadata = safeReturnStringValue(data, "connectorMetadata"),
- directoryServerId = safeReturnStringValue(data, "directoryServerId"),
- vaultTokenData = vaultTokenData,
- paymentMethodData = paymentMethodData,
- billing = safeReturnStringValue(data, "billing"),
- shipping = safeReturnStringValue(data, "shipping"),
- browserInformation = safeReturnStringValue(data, "browserInformation"),
- email = safeReturnStringValue(data, "email"),
- transStatus = safeReturnStringValue(data, "transStatus"),
- acsUrl = safeReturnStringValue(data, "acsUrl"),
- challengeRequest = safeReturnStringValue(data, "challengeRequest"),
- acsReferenceNumber = safeReturnStringValue(data, "acsReferenceNumber"),
- acsTransId = safeReturnStringValue(data, "acsTransId"),
- acsSignedContent = safeReturnStringValue(data, "acsSignedContent"),
- threeDsRequestorUrl = safeReturnStringValue(data, "threeDsRequestorUrl"),
- threeDsRequestorAppUrl = safeReturnStringValue(
- data,
- "threeDsRequestorAppUrl",
- ),
- eci = safeReturnStringValue(data, "eci"),
- errorMessage = safeReturnStringValue(data, "errorMessage"),
- errorCode = safeReturnStringValue(data, "errorCode"),
- profileAcquirerId = safeReturnStringValue(data, "profileAcquirerId")
- )
- logger(LogType.DEBUG, EventName.CHECKOUT_RETURNED, "status: $statusStr")
- response
}
}
@@ -1146,25 +723,20 @@ class DefaultClickToPaySessionLauncher(
ensureReady()
val requestId = UUID.randomUUID().toString()
logger(LogType.DEBUG, EventName.CLOSE_HYPER_INSTANCE, "")
- val jsCode =
- "(async function(){try{await window.hyperInstance.deinit();window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{code:'success'}}));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'CloseInstanceFailed',message:error.message}}}));}})();"
+ val jsCode = ClickToPayScripts.closeHyperInstance(requestId)
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
- val jsonObject = parseJSONObject(responseJson, EventName.CLOSE_HYPER_INSTANCE_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorType = error.optString("type", "ERROR")
- val errorMessage = error.optString("message", "Unknown error")
- logger(
- LogType.ERROR,
- EventName.CLOSE_HYPER_INSTANCE_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.CLOSE_HYPER_INSTANCE_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
}
- logger(
- LogType.DEBUG, EventName.CLOSE_HYPER_INSTANCE_RETURNED, ""
- )
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.CLOSE_HYPER_INSTANCE_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ }
+ logger(LogType.DEBUG, EventName.CLOSE_HYPER_INSTANCE_RETURNED, "")
} catch (e: Exception) {
logger(
LogType.ERROR, EventName.CLOSE_HYPER_INSTANCE_RETURNED, "message: ${e.message}"
@@ -1190,26 +762,12 @@ class DefaultClickToPaySessionLauncher(
try {
logger(LogType.DEBUG, EventName.CLOSE_INIT, "")
closeHyperInstance()
- cancelPendingRequests("session is being closed")
+ webViewManager.cancelPendingRequests("session is being closed")
restoreAccessibility()
- lifecycleMutex.withLock {
- withContext(Dispatchers.Main) {
- if (isWebViewInitialized.get()) {
- val parent = hSWebViewWrapper.parent
- if (parent is ViewGroup) {
- parent.removeView(hSWebViewWrapper)
- }
- hSWebViewWrapper.webView.destroy()
- }
- }
- isWebViewInitialized.set(false)
- isWebViewAttached.set(false)
- isDestroyed.set(true)
- }
+ webViewManager.destroy()
+ isDestroyed.set(true)
- Thread.setDefaultUncaughtExceptionHandler(
- originalHandler
- )
+ Thread.setDefaultUncaughtExceptionHandler(originalHandler)
logger(LogType.DEBUG, EventName.CLOSE_RETURNED, "")
} catch (e: Exception) {
logger(
@@ -1235,38 +793,45 @@ class DefaultClickToPaySessionLauncher(
logger(LogType.DEBUG, EventName.SIGN_OUT_INIT, "")
ensureReady()
val requestId = UUID.randomUUID().toString()
- val jsCode =
- "(async function(){try{const signOutResponse = await window.ClickToPaySession.signOut();window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data: signOutResponse }));}catch(error){window.HSAndroidInterface.postMessage(JSON.stringify({requestId:'$requestId',data:{error:{type:'SignOutError',message:error.message}}}))}})();"
-
+ val jsCode = ClickToPayScripts.signOut(requestId)
val responseJson = evaluateJavascriptOnMainThread(requestId, jsCode)
return withContext(Dispatchers.Default) {
- val jsonObject = parseJSONObject(responseJson, EventName.SIGN_OUT_RETURNED)
- val data = getOptJSONObject(jsonObject, "data")
- val error = data.optJSONObject("error")
- if (error != null) {
- val errorMessage = error.optString(
- "message", "SignOut Error"
- )
- val errorType = error.optString("type", "SignOutError")
- logger(
- LogType.ERROR,
- EventName.SIGN_OUT_RETURNED,
- "Type: $errorType, Message: $errorMessage",
- LogCategory.USER_ERROR
- )
- cancelPendingRequests()
- detachWebView()
- throw ClickToPayException(
- "Failed to SignOut : $errorMessage", errorType
- )
+ val jsonObject = when (val result = ClickToPayResponseDecoder.parseJSONObject(responseJson)) {
+ is DecodeResult.Success -> result.data
+ is DecodeResult.Error -> {
+ logger(LogType.ERROR, EventName.SIGN_OUT_RETURNED, result.message, LogCategory.USER_ERROR)
+ throw ClickToPayException(result.message, "ERROR")
+ }
+ }
+ val data = ClickToPayResponseDecoder.getNestedObject(jsonObject, "data")
+ ClickToPayResponseDecoder.decodeError(data)?.let { (errorType, errorMessage) ->
+ logger(LogType.ERROR, EventName.SIGN_OUT_RETURNED, "Type: $errorType, Message: $errorMessage", LogCategory.USER_ERROR)
+ webViewManager.cancelPendingRequests()
+ webViewManager.detach()
+ throw ClickToPayException("Failed to SignOut: $errorMessage", errorType)
+ }
+ when (val result = ClickToPayResponseDecoder.decodeSignOutResponse(data)) {
+ is DecodeResult.Success -> {
+ logger(
+ LogType.DEBUG,
+ EventName.SIGN_OUT_RETURNED,
+ "recognized: ${result.data.recognized}"
+ )
+ result.data
+ }
+ is DecodeResult.Error -> {
+ logger(
+ LogType.ERROR,
+ EventName.SIGN_OUT_RETURNED,
+ "Failed to parse sign out response: ${result.message}",
+ LogCategory.USER_ERROR
+ )
+ throw ClickToPayException(
+ "Failed to parse sign out response: ${result.message}",
+ "PARSE_ERROR"
+ )
+ }
}
- val recognized = data.optBoolean("recognized", false)
- logger(
- LogType.DEBUG, EventName.SIGN_OUT_RETURNED, "recognized: $recognized"
- )
- SignOutResponse(
- recognized = recognized
- )
}
}
}
diff --git a/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/models/ClickToPayResponseDecoder.kt b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/models/ClickToPayResponseDecoder.kt
new file mode 100644
index 00000000..655ccc5b
--- /dev/null
+++ b/hyperswitch-sdk-android-click-to-pay/src/main/kotlin/io/hyperswitch/click_to_pay/models/ClickToPayResponseDecoder.kt
@@ -0,0 +1,483 @@
+package io.hyperswitch.click_to_pay.models
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Sealed class representing the result of a decode operation.
+ */
+sealed class DecodeResult {
+ data class Success(val data: T) : DecodeResult()
+ data class Error(val message: String, val field: String? = null) : DecodeResult()
+}
+
+/**
+ * Decoder for Click to Pay API responses from WebView JavaScript bridge.
+ *
+ * This class centralizes all JSON parsing logic for Click to Pay responses,
+ * providing type-safe decoding with proper error handling and validation.
+ */
+object ClickToPayResponseDecoder {
+
+ /**
+ * Parses a raw JSON string into a JSONObject.
+ *
+ * @param data The JSON string to parse
+ * @return DecodeResult containing the JSONObject or error details
+ */
+ fun parseJSONObject(data: String): DecodeResult {
+ return try {
+ DecodeResult.Success(JSONObject(data))
+ } catch (e: Exception) {
+ DecodeResult.Error(
+ message = "Failed to parse JSON response: ${e.message}",
+ field = "root"
+ )
+ }
+ }
+
+ /**
+ * Safely extracts a nested JSONObject from a parent object.
+ *
+ * @param obj The parent JSONObject
+ * @param name The key name for the nested object
+ * @return The nested JSONObject, or empty JSONObject if not found or null
+ */
+ fun getNestedObject(obj: JSONObject, name: String): JSONObject {
+ return obj.optJSONObject(name) ?: JSONObject()
+ }
+
+ /**
+ * Safely extracts an item from a JSONArray as a JSONObject.
+ *
+ * @param array The source JSONArray
+ * @param index The index to retrieve
+ * @return The JSONObject at the index, or empty JSONObject if invalid
+ */
+ fun getArrayItem(array: JSONArray, index: Int): JSONObject {
+ return array.optJSONObject(index) ?: JSONObject()
+ }
+
+ /**
+ * Safely extracts a string value from a JSONObject, handling null values.
+ *
+ * @param obj The JSONObject to read from
+ * @param key The key to look up
+ * @return The string value if present and non-empty, null otherwise
+ */
+ fun extractString(obj: JSONObject, key: String): String? {
+ return when {
+ !obj.has(key) -> null
+ obj.isNull(key) -> null
+ else -> obj.optString(key).takeIf { it.isNotEmpty() }
+ }
+ }
+
+ /**
+ * Extracts a required string value, returning an error if missing.
+ *
+ * @param obj The JSONObject to read from
+ * @param key The key to look up
+ * @return DecodeResult containing the string or error
+ */
+ fun extractRequiredString(obj: JSONObject, key: String): DecodeResult {
+ return when (val value = extractString(obj, key)) {
+ null -> DecodeResult.Error(
+ message = "Required field '$key' is missing or empty",
+ field = key
+ )
+ else -> DecodeResult.Success(value)
+ }
+ }
+
+ /**
+ * Extracts an integer value, returning null for invalid or missing values.
+ *
+ * @param obj The JSONObject to read from
+ * @param key The key to look up
+ * @param minValue Optional minimum valid value (returns null if below)
+ * @return The integer value if valid, null otherwise
+ */
+ fun extractInt(obj: JSONObject, key: String, minValue: Int? = null): Int? {
+ if (!obj.has(key) || obj.isNull(key)) return null
+ val value = obj.optInt(key, -1)
+ if (value == -1 && !obj.isNull(key)) {
+ // optInt returned default, but value exists - might not be a number
+ return try {
+ obj.getInt(key)
+ } catch (_: Exception) {
+ null
+ }
+ }
+ return minValue?.let { if (value >= it) value else null } ?: value.takeIf { it >= 0 }
+ }
+
+ /**
+ * Extracts a boolean value with a default fallback.
+ *
+ * @param obj The JSONObject to read from
+ * @param key The key to look up
+ * @param default The default value if missing
+ * @return The boolean value or default
+ */
+ fun extractBoolean(obj: JSONObject, key: String, default: Boolean = false): Boolean {
+ return if (obj.has(key) && !obj.isNull(key)) {
+ obj.optBoolean(key, default)
+ } else default
+ }
+
+ /**
+ * Decodes error information from a response data object.
+ *
+ * @param data The data JSONObject that may contain an error
+ * @return Pair of (errorType, errorMessage) if error exists, null otherwise
+ */
+ fun decodeError(data: JSONObject): Pair? {
+ val error = data.optJSONObject("error") ?: return null
+ val errorType = error.optString("type", "Unknown")
+ val errorMessage = error.optString("message", "Unknown error")
+ return errorType to errorMessage
+ }
+
+ /**
+ * Decodes a CustomerPresenceResponse from JSON data.
+ *
+ * @param data The JSONObject containing the response data
+ * @return DecodeResult containing the parsed response
+ */
+ fun decodeCustomerPresenceResponse(data: JSONObject): DecodeResult {
+ val customerPresent = extractBoolean(data, "customerPresent", false)
+ return DecodeResult.Success(CustomerPresenceResponse(customerPresent = customerPresent))
+ }
+
+ /**
+ * Decodes masked validation channel information.
+ *
+ * @param obj The JSONObject containing the masked validation channel
+ * @return MaskedValidationChannel with extracted data
+ */
+ fun decodeMaskedValidationChannel(obj: JSONObject?): MaskedValidationChannel? {
+ if (obj == null || obj.length() == 0) return null
+ return MaskedValidationChannel(
+ email = extractString(obj, "email"),
+ phoneNumber = extractString(obj, "phoneNumber")
+ )
+ }
+
+ /**
+ * Decodes a list of supported validation channels.
+ *
+ * @param array The JSONArray containing validation channels
+ * @return List of SupportedValidationChannel objects
+ */
+ fun decodeSupportedValidationChannels(array: JSONArray?): List {
+ if (array == null) return emptyList()
+ return (0 until array.length()).mapNotNull { index ->
+ val obj = getArrayItem(array, index)
+ if (obj.length() == 0) null else decodeSupportedValidationChannel(obj)
+ }
+ }
+
+ /**
+ * Decodes a single supported validation channel.
+ *
+ * @param obj The JSONObject containing the channel data
+ * @return Decoded SupportedValidationChannel
+ */
+ fun decodeSupportedValidationChannel(obj: JSONObject): SupportedValidationChannel {
+ return SupportedValidationChannel(
+ validationChannelId = extractString(obj, "validationChannelId"),
+ identityProvider = extractString(obj, "identityProvider"),
+ identityType = extractString(obj, "identityType"),
+ maskedValidationChannel = extractString(obj, "maskedValidationChannel")
+ )
+ }
+
+ /**
+ * Decodes a CardsStatusResponse from JSON data.
+ *
+ * @param data The JSONObject containing the response data
+ * @return DecodeResult containing the parsed response
+ */
+ fun decodeCardsStatusResponse(data: JSONObject): DecodeResult {
+ val statusCodeStr = extractString(data, "statusCode") ?: "NO_CARDS_PRESENT"
+ val maskedValidationChannel = decodeMaskedValidationChannel(
+ data.optJSONObject("maskedValidationChannel")
+ )
+ val supportedValidationChannels = decodeSupportedValidationChannels(
+ data.optJSONArray("supportedValidationChannels")
+ )
+
+ return DecodeResult.Success(
+ CardsStatusResponse(
+ statusCode = StatusCode.from(statusCodeStr),
+ maskedValidationChannel = maskedValidationChannel,
+ supportedValidationChannels = supportedValidationChannels.takeIf { it.isNotEmpty() }
+ )
+ )
+ }
+
+ /**
+ * Decodes a list of recognized cards from a JSON array.
+ *
+ * @param data The JSONArray containing card data
+ * @return DecodeResult containing the list of parsed cards
+ */
+ fun decodeRecognizedCards(data: JSONArray): DecodeResult> {
+ return try {
+ val cards = (0 until data.length()).map { index ->
+ decodeRecognizedCard(getArrayItem(data, index))
+ }
+ DecodeResult.Success(cards)
+ } catch (e: Exception) {
+ DecodeResult.Error(
+ message = "Failed to parse recognized cards: ${e.message}",
+ field = "cards"
+ )
+ }
+ }
+
+ /**
+ * Decodes a single recognized card from JSON.
+ *
+ * @param obj The JSONObject containing card data
+ * @return Decoded RecognizedCard
+ */
+ fun decodeRecognizedCard(obj: JSONObject): RecognizedCard {
+ val digitalCardData = decodeDigitalCardData(obj.optJSONObject("digitalCardData"))
+ val maskedBillingAddress = decodeMaskedBillingAddress(obj.optJSONObject("maskedBillingAddress"))
+ val dcf = decodeDCF(obj.optJSONObject("dcf"))
+
+ return RecognizedCard(
+ srcDigitalCardId = extractRequiredString(obj, "srcDigitalCardId").let {
+ when (it) {
+ is DecodeResult.Success -> it.data
+ is DecodeResult.Error -> ""
+ }
+ },
+ panBin = extractString(obj, "panBin"),
+ panLastFour = extractString(obj, "panLastFour"),
+ panExpirationMonth = extractString(obj, "panExpirationMonth"),
+ panExpirationYear = extractString(obj, "panExpirationYear"),
+ tokenLastFour = extractString(obj, "tokenLastFour"),
+ tokenBinRange = extractString(obj, "tokenBinRange"),
+ digitalCardData = digitalCardData,
+ countryCode = extractString(obj, "countryCode"),
+ maskedBillingAddress = maskedBillingAddress,
+ dateOfCardCreated = extractString(obj, "dateOfCardCreated"),
+ dateOfCardLastUsed = extractString(obj, "dateOfCardLastUsed"),
+ paymentAccountReference = extractString(obj, "paymentAccountReference"),
+ paymentCardDescriptor = CardType.from(extractString(obj, "paymentCardDescriptor")),
+ paymentCardType = extractString(obj, "paymentCardType"),
+ dcf = dcf,
+ digitalCardFeatures = obj.optJSONObject("digitalCardFeatures")?.let { emptyMap() }
+ )
+ }
+
+ /**
+ * Decodes digital card data from JSON.
+ *
+ * @param obj The JSONObject containing digital card data
+ * @return Decoded DigitalCardData or null
+ */
+ fun decodeDigitalCardData(obj: JSONObject?): DigitalCardData? {
+ if (obj == null) return null
+
+ val authMethods = obj.optJSONArray("authenticationMethods")?.let { array ->
+ (0 until array.length()).map { index ->
+ val methodObj = getArrayItem(array, index)
+ AuthenticationMethod(
+ authenticationMethodType = methodObj.optString("authenticationMethodType", "")
+ )
+ }
+ }
+
+ val pendingEvents = obj.optJSONArray("pendingEvents")?.let { array ->
+ (0 until array.length()).map { index ->
+ array.optString(index, "")
+ }.filter { it.isNotEmpty() }
+ }
+
+ return DigitalCardData(
+ status = extractString(obj, "status"),
+ presentationName = extractString(obj, "presentationName"),
+ descriptorName = obj.optString("descriptorName", "").takeIf { it.isNotEmpty() },
+ artUri = extractString(obj, "artUri"),
+ artHeight = extractInt(obj, "artHeight", minValue = 1),
+ artWidth = extractInt(obj, "artWidth", minValue = 1),
+ authenticationMethods = authMethods,
+ pendingEvents = pendingEvents
+ )
+ }
+
+ /**
+ * Decodes masked billing address from JSON.
+ *
+ * @param obj The JSONObject containing address data
+ * @return Decoded MaskedBillingAddress or null
+ */
+ fun decodeMaskedBillingAddress(obj: JSONObject?): MaskedBillingAddress? {
+ if (obj == null || obj.length() == 0) return null
+ return MaskedBillingAddress(
+ addressId = extractString(obj, "addressId"),
+ name = extractString(obj, "name"),
+ line1 = extractString(obj, "line1"),
+ line2 = extractString(obj, "line2"),
+ line3 = extractString(obj, "line3"),
+ city = extractString(obj, "city"),
+ state = extractString(obj, "state"),
+ countryCode = extractString(obj, "countryCode"),
+ zip = extractString(obj, "zip")
+ )
+ }
+
+ /**
+ * Decodes DCF (Digital Card Facilitator) information.
+ *
+ * @param obj The JSONObject containing DCF data
+ * @return Decoded DCF or null
+ */
+ fun decodeDCF(obj: JSONObject?): DCF? {
+ if (obj == null) return null
+ return DCF(
+ name = extractString(obj, "name"),
+ uri = extractString(obj, "uri"),
+ logoUri = extractString(obj, "logoUri")
+ )
+ }
+
+ /**
+ * Decodes payment data (either card data or network token).
+ *
+ * @param obj The JSONObject containing payment data
+ * @return Decoded PaymentData or null
+ */
+ fun decodePaymentData(obj: JSONObject?): PaymentData? {
+ if (obj == null) return null
+
+ val typeStr = obj.optString("type", "").uppercase()
+ val tokenType = runCatching { DataType.valueOf(typeStr) }.getOrNull()
+
+ return when (tokenType) {
+ DataType.CARD_DATA -> PaymentData.CardData(
+ cardNumber = extractString(obj, "cardNumber"),
+ cardCvc = extractString(obj, "cardCvc"),
+ cardExpiryMonth = extractString(obj, "cardExpiryMonth"),
+ cardExpiryYear = extractString(obj, "cardExpiryYear")
+ )
+
+ DataType.NETWORK_TOKEN_DATA -> PaymentData.NetworkTokenData(
+ networkToken = extractString(obj, "networkToken"),
+ networkTokenCryptogram = extractString(obj, "networkTokenCryptogram"),
+ networkTokenExpiryMonth = extractString(obj, "networkTokenExpiryMonth"),
+ networkTokenExpiryYear = extractString(obj, "networkTokenExpiryYear")
+ )
+
+ else -> null
+ }
+ }
+
+ /**
+ * Decodes acquirer details from JSON.
+ *
+ * @param obj The JSONObject containing acquirer details
+ * @return Decoded AcquirerDetails or null
+ */
+ fun decodeAcquirerDetails(obj: JSONObject?): AcquirerDetails? {
+ if (obj == null) return null
+ return AcquirerDetails(
+ acquirerBin = extractString(obj, "acquirerBin"),
+ acquirerMerchantId = extractString(obj, "acquirerMerchantId"),
+ merchantCountryCode = extractString(obj, "merchantCountryCode")
+ )
+ }
+
+ /**
+ * Decodes a CheckoutResponse from JSON data.
+ *
+ * @param data The JSONObject containing checkout response data
+ * @return DecodeResult containing the parsed response
+ */
+ fun decodeCheckoutResponse(data: JSONObject): DecodeResult {
+ return try {
+ val vaultTokenData = decodePaymentData(data.optJSONObject("vaultTokenData"))
+ val paymentMethodData = decodePaymentData(data.optJSONObject("paymentMethodData"))
+ val acquirerDetails = decodeAcquirerDetails(data.optJSONObject("acquirerDetails"))
+
+ val statusStr = extractString(data, "status")?.uppercase() ?: ""
+ val authStatus = runCatching { AuthenticationStatus.valueOf(statusStr) }.getOrNull()
+
+ val response = CheckoutResponse(
+ authenticationId = extractString(data, "authenticationId"),
+ merchantId = extractString(data, "merchantId"),
+ status = authStatus,
+ clientSecret = extractString(data, "clientSecret"),
+ amount = extractInt(data, "amount"),
+ currency = extractString(data, "currency"),
+ authenticationConnector = extractString(data, "authenticationConnector"),
+ force3dsChallenge = extractBoolean(data, "force3dsChallenge", false),
+ returnUrl = extractString(data, "returnUrl"),
+ createdAt = extractString(data, "createdAt"),
+ profileId = extractString(data, "profileId"),
+ psd2ScaExemptionType = extractString(data, "psd2ScaExemptionType"),
+ acquirerDetails = acquirerDetails,
+ threedsServerTransactionId = extractString(data, "threeDsServerTransactionId"),
+ maximumSupported3dsVersion = extractString(data, "maximumSupported3dsVersion"),
+ connectorAuthenticationId = extractString(data, "connectorAuthenticationId"),
+ threeDsMethodData = extractString(data, "threeDsMethod_data"),
+ threeDsMethodUrl = extractString(data, "threeDsMethodUrl"),
+ messageVersion = extractString(data, "messageVersion"),
+ connectorMetadata = extractString(data, "connectorMetadata"),
+ directoryServerId = extractString(data, "directoryServerId"),
+ vaultTokenData = vaultTokenData,
+ paymentMethodData = paymentMethodData,
+ billing = extractString(data, "billing"),
+ shipping = extractString(data, "shipping"),
+ browserInformation = extractString(data, "browserInformation"),
+ email = extractString(data, "email"),
+ transStatus = extractString(data, "transStatus"),
+ acsUrl = extractString(data, "acsUrl"),
+ challengeRequest = extractString(data, "challengeRequest"),
+ acsReferenceNumber = extractString(data, "acsReferenceNumber"),
+ acsTransId = extractString(data, "acsTransId"),
+ acsSignedContent = extractString(data, "acsSignedContent"),
+ threeDsRequestorUrl = extractString(data, "threeDsRequestorUrl"),
+ threeDsRequestorAppUrl = extractString(data, "threeDsRequestorAppUrl"),
+ eci = extractString(data, "eci"),
+ errorMessage = extractString(data, "errorMessage"),
+ errorCode = extractString(data, "errorCode"),
+ profileAcquirerId = extractString(data, "profileAcquirerId")
+ )
+ DecodeResult.Success(response)
+ } catch (e: Exception) {
+ DecodeResult.Error(
+ message = "Failed to parse checkout response: ${e.message}",
+ field = "checkout"
+ )
+ }
+ }
+
+ /**
+ * Decodes a SignOutResponse from JSON data.
+ *
+ * @param data The JSONObject containing the response data
+ * @return DecodeResult containing the parsed response
+ */
+ fun decodeSignOutResponse(data: JSONObject): DecodeResult {
+ val recognized = extractBoolean(data, "recognized", false)
+ return DecodeResult.Success(SignOutResponse(recognized = recognized))
+ }
+
+ /**
+ * Validates that a response contains a success flag without errors.
+ *
+ * @param data The JSONObject containing the response data
+ * @return DecodeResult containing Unit on success, or error details
+ */
+ fun decodeSuccessResponse(data: JSONObject): DecodeResult {
+ decodeError(data)?.let { (type, message) ->
+ return DecodeResult.Error(message = message, field = type)
+ }
+ return DecodeResult.Success(Unit)
+ }
+}