From 53337719345ce83b5fc760f29a4a64a60debd8e4 Mon Sep 17 00:00:00 2001 From: Aman tonk Date: Thu, 17 Oct 2024 12:36:13 +0900 Subject: [PATCH] [Experiment] Webview Trick to bypass 3DS (#27) * Inlined Payment Processing * Inlined Payment Processing * Version Upgrade --- .../InlinedPaymentPrimaryButton.kt | 103 ++++++++++++++++ .../sdk/ui/composables/InlinedWebView.kt | 110 ++++++++++++++++++ .../sdk/ui/composables/PrimaryButton.kt | 12 +- .../sdk/ui/screens/KomojuPaymentRoutes.kt | 2 +- .../payment/InlinedPaymentProcessor.kt | 51 ++++++++ .../ui/screens/payment/KomojuPaymentScreen.kt | 44 ++++++- .../payment/KomojuPaymentScreenModel.kt | 74 ++++++++++-- .../screens/payment/KomojuPaymentUIState.kt | 4 + .../payment/composables/CreditCardForm.kt | 29 +++-- .../payment/composables/PaymentMethodsRow.kt | 7 +- gradle/libs.versions.toml | 10 +- uitests/credit_card_inlined_success_flow.yml | 27 +++++ 12 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedPaymentPrimaryButton.kt create mode 100644 android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedWebView.kt create mode 100644 android/src/main/java/com/komoju/android/sdk/ui/screens/payment/InlinedPaymentProcessor.kt create mode 100644 uitests/credit_card_inlined_success_flow.yml diff --git a/android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedPaymentPrimaryButton.kt b/android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedPaymentPrimaryButton.kt new file mode 100644 index 0000000..d49322e --- /dev/null +++ b/android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedPaymentPrimaryButton.kt @@ -0,0 +1,103 @@ +package com.komoju.android.sdk.ui.composables + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.komoju.android.sdk.ui.theme.KomojuMobileSdkTheme +import com.komoju.android.sdk.ui.theme.LocalConfigurableTheme +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +internal fun InlinedPaymentPrimaryButton(text: String, state: InlinedPaymentPrimaryButtonState, modifier: Modifier = Modifier, onClick: () -> Unit) { + val configurableTheme = LocalConfigurableTheme.current + Button( + modifier = modifier, + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = Color(configurableTheme.primaryButtonColor), + contentColor = Color(configurableTheme.primaryButtonContentColor), + ), + shape = RoundedCornerShape(configurableTheme.primaryButtonCornerRadiusInDP.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(38.dp), + contentAlignment = Alignment.Center, + ) { + when (state) { + InlinedPaymentPrimaryButtonState.LOADING -> CircularProgressIndicator(strokeWidth = 2.dp, color = LocalContentColor.current, modifier = Modifier.size(24.dp)) + InlinedPaymentPrimaryButtonState.IDLE -> Text(modifier = Modifier.padding(8.dp), text = text, style = TextStyle(fontWeight = FontWeight.Bold), maxLines = 1) + InlinedPaymentPrimaryButtonState.SUCCESS -> Icon(Icons.Rounded.CheckCircle, contentDescription = null, modifier = Modifier.size(24.dp)) + InlinedPaymentPrimaryButtonState.ERROR -> Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(24.dp)) + } + } + } +} + +enum class InlinedPaymentPrimaryButtonState { + LOADING, + IDLE, + SUCCESS, + ERROR, +} + +@Composable +fun rememberInlinedPaymentPrimaryButtonState(default: InlinedPaymentPrimaryButtonState = InlinedPaymentPrimaryButtonState.IDLE): MutableState = + rememberSaveable { mutableStateOf(default) } + +@Composable +@Preview(showBackground = true, showSystemUi = true) +private fun PaymentButtonPreview() { + var state by rememberInlinedPaymentPrimaryButtonState() + val coroutineScope = rememberCoroutineScope() + KomojuMobileSdkTheme { + InlinedPaymentPrimaryButton( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + state = state, + text = "Pay \$100.00", + ) { + if (state == InlinedPaymentPrimaryButtonState.IDLE) { + coroutineScope.launch { + state = InlinedPaymentPrimaryButtonState.LOADING + delay(2.seconds) + state = InlinedPaymentPrimaryButtonState.SUCCESS + delay(2.seconds) + state = InlinedPaymentPrimaryButtonState.ERROR + delay(2.seconds) + state = InlinedPaymentPrimaryButtonState.IDLE + } + } + } + } +} diff --git a/android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedWebView.kt b/android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedWebView.kt new file mode 100644 index 0000000..2230c37 --- /dev/null +++ b/android/src/main/java/com/komoju/android/sdk/ui/composables/InlinedWebView.kt @@ -0,0 +1,110 @@ +package com.komoju.android.sdk.ui.composables + +import android.annotation.SuppressLint +import android.graphics.Color +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.kevinnzou.web.AccompanistWebViewClient +import com.kevinnzou.web.WebView +import com.kevinnzou.web.rememberWebViewState +import com.komoju.android.sdk.R + +@SuppressLint("SetJavaScriptEnabled") +@Composable +internal fun InlinedWebView(modifier: Modifier, url: String, onDone: (String) -> Unit, onChallengePresented: () -> Unit, onCloseButtonClicked: () -> Unit) { + val state = rememberWebViewState(url) + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + ) { + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .align(Alignment.CenterVertically), + text = state.pageTitle.orEmpty(), + fontSize = 20.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Image( + imageVector = Icons.Rounded.Close, + contentDescription = "Close Payment Sheet", + modifier = Modifier + .clickable( + indication = ripple(bounded = true, radius = 24.dp), + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onCloseButtonClicked() + }, + ) + .padding(16.dp), + ) + } + WebView( + modifier = Modifier.weight(1f), + state = state, + onCreated = { nativeWebView -> + nativeWebView.clipToOutline = true + nativeWebView.setBackgroundColor(Color.TRANSPARENT) + nativeWebView.settings.apply { + domStorageEnabled = true + javaScriptEnabled = true + } + }, + captureBackPresses = false, + client = remember { InlinedWebViewClient(onDone, onChallengePresented) }, + ) + } +} + +private class InlinedWebViewClient(private val onDeeplinkCaptured: (String) -> Unit, private val onChallengePresented: () -> Unit) : AccompanistWebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = view.checkAndOpen(url) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = view.checkAndOpen(request.url.toString()) + + private fun WebView.checkAndOpen(url: String): Boolean { + try { + val uri = url.toUri() + if (uri.scheme == resources.getString(R.string.komoju_consumer_app_scheme)) { + onDeeplinkCaptured(url) + return true + } else { + error("Unsupported scheme for deeplink, load in webView Instead.") + } + } catch (_: Exception) { + loadUrl(url) + return false + } + } + + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + if (request?.url.toString().contains("acs-challenge.testlab.3dsecure.cloud")) { + onChallengePresented() + } + return super.shouldInterceptRequest(view, request) + } +} diff --git a/android/src/main/java/com/komoju/android/sdk/ui/composables/PrimaryButton.kt b/android/src/main/java/com/komoju/android/sdk/ui/composables/PrimaryButton.kt index f8af807..9006016 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/composables/PrimaryButton.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/composables/PrimaryButton.kt @@ -1,12 +1,15 @@ package com.komoju.android.sdk.ui.composables +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle @@ -28,7 +31,14 @@ internal fun PrimaryButton(text: String, modifier: Modifier = Modifier, onClick: ), shape = RoundedCornerShape(configurableTheme.primaryButtonCornerRadiusInDP.dp), ) { - Text(modifier = Modifier.padding(8.dp), text = text, style = TextStyle(fontWeight = FontWeight.Bold), maxLines = 1) + Box( + modifier = Modifier + .fillMaxWidth() + .height(38.dp), + contentAlignment = Alignment.Center, + ) { + Text(modifier = Modifier.padding(8.dp), text = text, style = TextStyle(fontWeight = FontWeight.Bold), maxLines = 1) + } } } diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/KomojuPaymentRoutes.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/KomojuPaymentRoutes.kt index a0b8d62..f47b37e 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/KomojuPaymentRoutes.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/KomojuPaymentRoutes.kt @@ -23,7 +23,7 @@ internal sealed class Router { data class Push(val route: KomojuPaymentRoute) : Router() data class Replace(val route: KomojuPaymentRoute) : Router() data class ReplaceAll(val route: KomojuPaymentRoute) : Router() - data class SetPaymentResultAndPop(val result: KomojuSDK.PaymentResult) : Router() + data class SetPaymentResultAndPop(val result: KomojuSDK.PaymentResult = KomojuSDK.PaymentResult(false)) : Router() } internal sealed interface KomojuPaymentRoute { diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/InlinedPaymentProcessor.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/InlinedPaymentProcessor.kt new file mode 100644 index 0000000..bcc8ecf --- /dev/null +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/InlinedPaymentProcessor.kt @@ -0,0 +1,51 @@ +package com.komoju.android.sdk.ui.screens.payment + +import com.komoju.android.sdk.ui.screens.failed.Reason +import com.komoju.mobile.sdk.entities.PaymentStatus +import com.komoju.mobile.sdk.remote.apis.KomojuRemoteApi + +internal suspend fun KomojuRemoteApi.verifyTokenAndProcessPayment( + sessionId: String, + token: String, + amount: String, + currency: String, + onError: (Reason) -> Unit, + onSuccess: suspend (PaymentStatus) -> Unit, +) { + tokens.verifySecureToken(token).onSuccess { isVerifiedByToken -> + if (isVerifiedByToken) { + payByToken(sessionId, token, amount, currency, onError, onSuccess) + } else { + onError(Reason.CREDIT_CARD_ERROR) + } + }.onFailure { + onError(Reason.CREDIT_CARD_ERROR) + } +} + +private suspend fun KomojuRemoteApi.payByToken( + sessionId: String, + token: String, + amount: String, + currency: String, + onError: (Reason) -> Unit, + onSuccess: suspend (PaymentStatus) -> Unit, +) { + sessions.pay(sessionId, token, amount, currency).onSuccess { response -> + if (response.status == PaymentStatus.CAPTURED) { + processBySession(sessionId, onSuccess, onError) + } else { + onError(Reason.CREDIT_CARD_ERROR) + } + }.onFailure { + onError(Reason.CREDIT_CARD_ERROR) + } +} + +private suspend fun KomojuRemoteApi.processBySession(sessionId: String, onSuccess: suspend (PaymentStatus) -> Unit, onError: (Reason) -> Unit) { + sessions.verifyPaymentBySessionID(sessionId).onSuccess { paymentDetails -> + onSuccess(paymentDetails.status) + }.onFailure { + onError(Reason.OTHER) + } +} diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreen.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreen.kt index 60f864a..a33537c 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreen.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreen.kt @@ -2,30 +2,38 @@ package com.komoju.android.sdk.ui.screens.payment import android.os.Parcelable import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import com.komoju.android.sdk.KomojuSDK import com.komoju.android.sdk.R +import com.komoju.android.sdk.ui.composables.InlinedWebView import com.komoju.android.sdk.ui.composables.ThemedCircularProgressIndicator import com.komoju.android.sdk.ui.screens.RouterEffect import com.komoju.android.sdk.ui.screens.payment.composables.PaymentMethodForm @@ -33,6 +41,7 @@ import com.komoju.android.sdk.ui.screens.payment.composables.PaymentMethodsRow import com.komoju.android.sdk.ui.screens.payment.composables.PaymentSheetHandle import com.komoju.android.sdk.ui.theme.LocalI18nTexts import com.komoju.android.sdk.utils.OffsiteCustomTabResultContract +import com.komoju.mobile.sdk.entities.PaymentMethod import kotlinx.parcelize.Parcelize @Parcelize @@ -58,13 +67,18 @@ internal data class KomojuPaymentScreen(private val sdkConfiguration: KomojuSDK. } } RouterEffect(screenViewModel.router.collectAsStateWithLifecycle(), screenViewModel::onRouteConsumed) + val inlineWebViewURL = uiState.inlinedCreditCardProcessingURL + var shouldShowFullScreenWebView by remember { mutableStateOf(false) } Box { if (uiState.session != null) { Column { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - PaymentSheetHandle(LocalI18nTexts.current["PAYMENT_OPTIONS"], onCloseClicked = { - screenViewModel.onCloseClicked() - }) + PaymentSheetHandle( + LocalI18nTexts.current["PAYMENT_OPTIONS"], + onCloseClicked = { + screenViewModel.onCloseClicked() + }, + ) PaymentMethodsRow( paymentMethods = uiState.session!!.paymentMethods, selectedPaymentMethod = uiState.selectedPaymentMethod, @@ -98,7 +112,10 @@ internal data class KomojuPaymentScreen(private val sdkConfiguration: KomojuSDK. ) } } - if (uiState.isLoading) { + if (uiState.selectedPaymentMethod is PaymentMethod.CreditCard && + sdkConfiguration.inlinedProcessing.not() && + uiState.isLoading + ) { Box( modifier = Modifier .fillMaxSize() @@ -109,6 +126,25 @@ internal data class KomojuPaymentScreen(private val sdkConfiguration: KomojuSDK. ThemedCircularProgressIndicator() } } + if (inlineWebViewURL != null) { + InlinedWebView( + modifier = Modifier.background(Color.White).align(Alignment.CenterEnd).animateContentSize().then( + when { + shouldShowFullScreenWebView -> Modifier.fillMaxSize() + else -> Modifier.width(Dp.Hairline).fillMaxHeight() + }, + ), + url = inlineWebViewURL, + onDone = { + screenViewModel.onInlinedDeeplinkCaptured(it) + }, + onChallengePresented = { + shouldShowFullScreenWebView = true + }, + ) { + screenViewModel.onCloseClicked() + } + } } } } diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreenModel.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreenModel.kt index 0ac9493..f302cf2 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreenModel.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentScreenModel.kt @@ -3,6 +3,7 @@ package com.komoju.android.sdk.ui.screens.payment import cafe.adriel.voyager.core.model.screenModelScope import com.komoju.android.sdk.KomojuSDK import com.komoju.android.sdk.navigation.RouterStateScreenModel +import com.komoju.android.sdk.ui.composables.InlinedPaymentPrimaryButtonState import com.komoju.android.sdk.ui.screens.KomojuPaymentRoute import com.komoju.android.sdk.ui.screens.Router import com.komoju.android.sdk.ui.screens.failed.Reason @@ -10,10 +11,12 @@ import com.komoju.android.sdk.utils.CreditCardUtils.isValidCVV import com.komoju.android.sdk.utils.CreditCardUtils.isValidCardHolderNameChar import com.komoju.android.sdk.utils.CreditCardUtils.isValidCardNumber import com.komoju.android.sdk.utils.CreditCardUtils.isValidExpiryDate +import com.komoju.android.sdk.utils.DeeplinkEntity import com.komoju.android.sdk.utils.isValidEmail import com.komoju.mobile.sdk.entities.Payment import com.komoju.mobile.sdk.entities.PaymentMethod import com.komoju.mobile.sdk.entities.PaymentRequest +import com.komoju.mobile.sdk.entities.PaymentStatus.Companion.isSuccessful import com.komoju.mobile.sdk.entities.SecureTokenRequest import com.komoju.mobile.sdk.entities.SecureTokenResponse.Status.ERRORED import com.komoju.mobile.sdk.entities.SecureTokenResponse.Status.NEEDS_VERIFY @@ -21,6 +24,8 @@ import com.komoju.mobile.sdk.entities.SecureTokenResponse.Status.OK import com.komoju.mobile.sdk.entities.SecureTokenResponse.Status.SKIPPED import com.komoju.mobile.sdk.entities.SecureTokenResponse.Status.UNKNOWN import com.komoju.mobile.sdk.remote.apis.KomojuRemoteApi +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -41,6 +46,9 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat isLoading = false, session = session, selectedPaymentMethod = session.paymentMethods.firstOrNull(), + creditCardDisplayData = it.creditCardDisplayData.copy( + inlinePaymentEnabled = config.inlinedProcessing, + ), ) } } @@ -90,6 +98,7 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat fun onPaymentRequested(paymentMethod: PaymentMethod) { if (paymentMethod.validate()) { + changeInlinePaymentState(InlinedPaymentPrimaryButtonState.LOADING) mutableState.update { it.copy(isLoading = true) } if (paymentMethod is PaymentMethod.CreditCard) { paymentMethod.createSecureTokens() @@ -98,9 +107,11 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat screenModelScope.launch { komojuApi.sessions.pay(config.sessionId.orEmpty(), request).onSuccess { payment -> mutableState.update { it.copy(isLoading = true) } + changeInlinePaymentState(InlinedPaymentPrimaryButtonState.LOADING) payment.handle() }.onFailure { mutableState.update { it.copy(isLoading = false) } + changeInlinePaymentState(InlinedPaymentPrimaryButtonState.ERROR) } } } @@ -110,18 +121,28 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat private fun PaymentMethod.CreditCard.createSecureTokens() { val request = toSecureTokenRequest() screenModelScope.launch { - komojuApi.tokens.generateSecureToken(request).onSuccess { - when (it.status) { - OK, SKIPPED -> - mutableRouter.value = - Router.ReplaceAll( + komojuApi.tokens.generateSecureToken(request).onSuccess { tokens -> + when (tokens.status) { + OK, SKIPPED -> { + if (config.inlinedProcessing) { + verifyAndProcessInlinedPayment(tokens.id, request.amount, request.currency) + } else { + mutableRouter.value = Router.ReplaceAll( KomojuPaymentRoute.ProcessPayment( - config, - processType = KomojuPaymentRoute.ProcessPayment.ProcessType.PayByToken(it.id, request.amount, request.currency), + configuration = config, + processType = KomojuPaymentRoute.ProcessPayment.ProcessType.PayByToken(tokens.id, request.amount, request.currency), ), ) + } + } - NEEDS_VERIFY -> mutableRouter.value = Router.ReplaceAll(KomojuPaymentRoute.WebView(url = it.authURL, isJavaScriptEnabled = true)) + NEEDS_VERIFY -> { + if (config.inlinedProcessing) { + mutableState.update { it.copy(inlinedCreditCardProcessingURL = tokens.authURL) } + } else { + mutableRouter.value = Router.ReplaceAll(KomojuPaymentRoute.WebView(url = tokens.authURL, isJavaScriptEnabled = true)) + } + } ERRORED, UNKNOWN -> mutableRouter.value = Router.ReplaceAll(KomojuPaymentRoute.PaymentFailed(Reason.CREDIT_CARD_ERROR)) } }.onFailure { @@ -130,6 +151,43 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat } } + private fun changeInlinePaymentState(newState: InlinedPaymentPrimaryButtonState) { + mutableState.update { + it.copy( + creditCardDisplayData = it.creditCardDisplayData.copy( + inlinedPaymentPrimaryButtonState = newState, + ), + ) + } + } + + private suspend fun verifyAndProcessInlinedPayment(token: String, amount: String, currency: String) { + komojuApi.verifyTokenAndProcessPayment( + sessionId = config.sessionId.orEmpty(), + token = token, + amount = amount, + currency = currency, + onError = { + changeInlinePaymentState(InlinedPaymentPrimaryButtonState.ERROR) + mutableRouter.value = Router.SetPaymentResultAndPop() + }, + onSuccess = { + changeInlinePaymentState(InlinedPaymentPrimaryButtonState.SUCCESS) + delay(400.milliseconds) + mutableRouter.value = Router.SetPaymentResultAndPop(KomojuSDK.PaymentResult(isSuccessFul = it.isSuccessful())) + }, + ) + } + + fun onInlinedDeeplinkCaptured(deeplink: String) { + val deeplinkEntity = DeeplinkEntity.from(deeplink) + if (deeplinkEntity is DeeplinkEntity.Verify.BySecureToken) { + screenModelScope.launch { + verifyAndProcessInlinedPayment(deeplinkEntity.secureTokenId, deeplinkEntity.amount, deeplinkEntity.currency) + } + } + } + private fun Payment.handle() { when (this) { is Payment.Konbini -> mutableRouter.value = Router.Replace(KomojuPaymentRoute.KonbiniAwaitingPayment(config, payment = this)) diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentUIState.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentUIState.kt index a8951c9..bcc2f98 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentUIState.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/KomojuPaymentUIState.kt @@ -1,5 +1,6 @@ package com.komoju.android.sdk.ui.screens.payment +import com.komoju.android.sdk.ui.composables.InlinedPaymentPrimaryButtonState import com.komoju.android.sdk.utils.empty import com.komoju.mobile.sdk.entities.PaymentMethod import com.komoju.mobile.sdk.entities.Session @@ -15,6 +16,7 @@ internal data class KomojuPaymentUIState( val bitCashDisplayData: BitCashDisplayData = BitCashDisplayData(), val netCashDisplayData: NetCashDisplayData = NetCashDisplayData(), val webMoneyDisplayData: WebMoneyDisplayData = WebMoneyDisplayData(), + val inlinedCreditCardProcessingURL: String? = null, ) internal data class CommonDisplayData( @@ -35,6 +37,8 @@ internal data class CreditCardDisplayData( val creditCardError: String? = null, val canSaveCard: Boolean = false, val saveCard: Boolean = false, + val inlinePaymentEnabled: Boolean = false, + val inlinedPaymentPrimaryButtonState: InlinedPaymentPrimaryButtonState = InlinedPaymentPrimaryButtonState.IDLE, ) internal data class KonbiniDisplayData( diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/CreditCardForm.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/CreditCardForm.kt index 215ac98..21a7959 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/CreditCardForm.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/CreditCardForm.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.komoju.android.sdk.R import com.komoju.android.sdk.types.Currency +import com.komoju.android.sdk.ui.composables.InlinedPaymentPrimaryButton import com.komoju.android.sdk.ui.composables.PrimaryButton import com.komoju.android.sdk.ui.screens.payment.CreditCardDisplayData import com.komoju.android.sdk.ui.theme.Gray200 @@ -205,13 +206,24 @@ internal fun CreditCardForm( ) Spacer(modifier = Modifier.height(8.dp)) - PrimaryButton( - modifier = Modifier.testID("credit_card_pay") - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = "${LocalI18nTexts.current["PAY"]} $displayPayableAmount", - onClick = onPayButtonClicked, - ) + if (creditCardDisplayData.inlinePaymentEnabled) { + InlinedPaymentPrimaryButton( + modifier = Modifier.testID("credit_card_pay") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = "${LocalI18nTexts.current["PAY"]} $displayPayableAmount", + onClick = onPayButtonClicked, + state = creditCardDisplayData.inlinedPaymentPrimaryButtonState, + ) + } else { + PrimaryButton( + modifier = Modifier.testID("credit_card_pay") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = "${LocalI18nTexts.current["PAY"]} $displayPayableAmount", + onClick = onPayButtonClicked, + ) + } if (creditCardDisplayData.canSaveCard) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp)) { @@ -250,8 +262,7 @@ private fun CreditCardFormPreview() { onCreditCardDisplayDataChange = { creditCardDisplayData = it }, - onPayButtonClicked = { - }, + onPayButtonClicked = {}, ) } } diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/PaymentMethodsRow.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/PaymentMethodsRow.kt index 50a9c9a..93d010e 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/PaymentMethodsRow.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/payment/composables/PaymentMethodsRow.kt @@ -16,9 +16,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.komoju.android.sdk.R import com.komoju.android.sdk.ui.theme.Gray200 import com.komoju.android.sdk.ui.theme.KomojuDarkGreen @@ -49,11 +52,11 @@ private fun PaymentMethodComposable(paymentMethod: PaymentMethod, isSelected: Bo .clip(RoundedCornerShape(8.dp)) .border(1.dp, if (isSelected) KomojuDarkGreen else Gray200, RoundedCornerShape(8.dp)) .clickable(onClick = onSelected) - .padding(12.dp), + .padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp), ) { Image(painter = painterResource(paymentMethod.displayIcon), contentDescription = "${paymentMethod.displayName} icon", modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(4.dp)) - Text(paymentMethod.displayName) + Text(paymentMethod.displayName, color = Color.Black, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d8dbd0..9232b22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,8 @@ [versions] -agp = "8.7.0" +agp = "8.7.1" browser = "1.8.0" kotlin = "2.0.21" +leakcanaryAndroid = "2.14" nexus-publish = "2.0.0" android-minSdk = "24" android-compileSdk = "34" @@ -13,13 +14,13 @@ espressoCore = "3.6.1" appcompat = "1.7.0" material = "1.12.0" lifecycleRuntimeKtx = "2.8.6" -activityCompose = "1.9.2" -composeBom = "2024.09.03" +activityCompose = "1.9.3" +composeBom = "2024.10.00" retrofit = "2.11.0" ktor = "3.0.0" coroutines = "1.9.0" datetime = "0.6.1" -uiTooling = "1.7.3" +uiTooling = "1.7.4" composeWebView = "0.33.6" konlinXJson = "1.7.3" voyager = "1.1.0-beta03" @@ -30,6 +31,7 @@ dokka = "1.9.20" [libraries] androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } nexus-publish = { module = "io.github.gradle-nexus.publish-plugin:io.github.gradle-nexus.publish-plugin.gradle.plugin", version.ref = "nexus-publish" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } diff --git a/uitests/credit_card_inlined_success_flow.yml b/uitests/credit_card_inlined_success_flow.yml new file mode 100644 index 0000000..3b58944 --- /dev/null +++ b/uitests/credit_card_inlined_success_flow.yml @@ -0,0 +1,27 @@ +# android_flow.yaml + +appId: com.komoju.android +--- +- launchApp: + appId: com.komoju.android + clearState: true +- tapOn: "Premium Wireless Headphone" +- tapOn: "Buy this item" +- assertVisible: "Credit Card" +- assertVisible: "Konbini" +- assertVisible: "PayPay" +- assertVisible: "Cardholder name" +- tapOn: "Full name on card" +- inputRandomPersonName +- assertVisible: "Card Number" +- tapOn: "1234 1234 1234 1234" +- inputText: "4100000000000100" +- tapOn: "MM/YY" +- inputText: "0825" +- tapOn: "CVV" +- inputText: "123" +- hideKeyboard +- tapOn: + id: "credit_card_pay" +- waitForAnimationToEnd +- assertVisible: "Order Confirmed" \ No newline at end of file