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) + } +}