From 30cb5ecb7f940bed2469dd421c5a402de7de245f Mon Sep 17 00:00:00 2001 From: Aman tonk Date: Wed, 9 Oct 2024 18:33:25 +0900 Subject: [PATCH] Fix Offsite Payments with Live Env (#45) --- android/build.gradle.kts | 1 + .../sdk/ui/screens/KomojuPaymentRoutes.kt | 17 --------- .../ui/screens/payment/KomojuPaymentScreen.kt | 13 +++++++ .../payment/KomojuPaymentScreenModel.kt | 20 ++++++++++- .../sdk/ui/screens/webview/WebViewClient.kt | 33 +++++++++++------ .../komoju/android/sdk/utils/AmountUtils.kt | 1 + .../utils/OffsiteCustomTabResultContract.kt | 36 +++++++++++++++++++ gradle/libs.versions.toml | 2 ++ .../sdk/remote/mappers/SessionMapper.kt | 28 +++++++-------- 9 files changed, 109 insertions(+), 42 deletions(-) create mode 100644 android/src/main/java/com/komoju/android/sdk/utils/OffsiteCustomTabResultContract.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 2ebd485..da8d179 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation(libs.voyager.navigator) implementation(libs.voyager.screenModel) implementation(libs.voyager.transitions) + implementation(libs.androidx.browser) testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) 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 70dbdce..a0b8d62 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 @@ -1,14 +1,9 @@ package com.komoju.android.sdk.ui.screens -import android.content.Context -import android.content.Intent -import android.net.Uri import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.komoju.android.sdk.KomojuSDK @@ -28,8 +23,6 @@ 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 Handle(val url: String) : Router() - data class Browser(val url: String) : Router() data class SetPaymentResultAndPop(val result: KomojuSDK.PaymentResult) : Router() } @@ -62,7 +55,6 @@ internal sealed interface KomojuPaymentRoute { @Composable internal fun RouterEffect(routerState: State, onHandled: () -> Unit) { val navigator = LocalNavigator.currentOrThrow - val context = LocalContext.current val router = routerState.value val backPressDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val resultScreenModel = navigator.paymentResultScreenModel() @@ -74,12 +66,7 @@ internal fun RouterEffect(routerState: State, onHandled: () -> Unit) { is Router.Push -> navigator.push(router.route.screen) is Router.Replace -> navigator.replace(router.route.screen) is Router.ReplaceAll -> navigator.replaceAll(router.route.screen) - is Router.Handle -> when (router.url.canOpenAnApp(context)) { - true -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(router.url))) - false -> navigator.push(KomojuPaymentRoute.WebView(router.url).screen) - } - is Router.Browser -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(router.url))) null -> Unit is Router.SetPaymentResultAndPop -> { resultScreenModel.setResult(router.result) @@ -89,7 +76,3 @@ internal fun RouterEffect(routerState: State, onHandled: () -> Unit) { onHandled() } } - -internal fun String.canOpenAnApp(context: Context): Boolean = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(this@canOpenAnApp) -}.resolveActivity(context.packageManager) != null 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 072ef23..25f5a68 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 @@ -1,6 +1,7 @@ package com.komoju.android.sdk.ui.screens.payment import android.os.Parcelable +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -30,6 +31,7 @@ import com.komoju.android.sdk.ui.screens.payment.composables.PaymentMethodForm 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 kotlinx.parcelize.Parcelize @Parcelize @@ -40,9 +42,20 @@ internal data class KomojuPaymentScreen(private val sdkConfiguration: KomojuSDK. override fun Content() { val screenViewModel = rememberScreenModel { KomojuPaymentScreenModel(sdkConfiguration) } val uiState by screenViewModel.state.collectAsStateWithLifecycle() + val offSitePaymentURL by screenViewModel.offSitePaymentURL.collectAsStateWithLifecycle() + val offsitePaymentLauncher = rememberLauncherForActivityResult(OffsiteCustomTabResultContract()) { + screenViewModel.onOffsitePaymentResult() + } LaunchedEffect(sdkConfiguration.sessionId) { screenViewModel.init() } + LaunchedEffect(offSitePaymentURL) { + val url = offSitePaymentURL + if (url != null) { + offsitePaymentLauncher.launch(url) + screenViewModel.onOffSitePaymentURLConsumed() + } + } RouterEffect(screenViewModel.router.collectAsStateWithLifecycle(), screenViewModel::onRouteConsumed) Box { if (uiState.session != null) { 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 de5a474..c4fcd58 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 @@ -21,11 +21,15 @@ 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configuration) : RouterStateScreenModel(KomojuPaymentUIState()) { private val komojuApi: KomojuRemoteApi = KomojuRemoteApi(config.publishableKey, config.language.languageCode) + private val _offSitePaymentURL = MutableStateFlow(null) + val offSitePaymentURL = _offSitePaymentURL.asStateFlow() fun init() { val sessionId = config.sessionId @@ -116,6 +120,7 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat processType = KomojuPaymentRoute.ProcessPayment.ProcessType.PayByToken(it.id, request.amount, request.currency), ), ) + NEEDS_VERIFY -> mutableRouter.value = Router.ReplaceAll(KomojuPaymentRoute.WebView(url = it.authURL, isJavaScriptEnabled = true)) ERRORED, UNKNOWN -> mutableRouter.value = Router.ReplaceAll(KomojuPaymentRoute.PaymentFailed(Reason.CREDIT_CARD_ERROR)) } @@ -128,7 +133,7 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat private fun Payment.handle() { when (this) { is Payment.Konbini -> mutableRouter.value = Router.Replace(KomojuPaymentRoute.KonbiniAwaitingPayment(config, payment = this)) - is Payment.PayPay -> mutableRouter.value = Router.Handle(url = redirectURL) + is Payment.PayPay -> _offSitePaymentURL.value = redirectURL else -> Unit } } @@ -217,4 +222,17 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat fun onCloseClicked() { mutableRouter.pop() } + + fun onOffsitePaymentResult() { + mutableRouter.value = Router.ReplaceAll( + KomojuPaymentRoute.ProcessPayment( + configuration = config, + processType = KomojuPaymentRoute.ProcessPayment.ProcessType.Session, + ), + ) + } + + fun onOffSitePaymentURLConsumed() { + _offSitePaymentURL.value = null + } } diff --git a/android/src/main/java/com/komoju/android/sdk/ui/screens/webview/WebViewClient.kt b/android/src/main/java/com/komoju/android/sdk/ui/screens/webview/WebViewClient.kt index 8bbf2d9..e41a27a 100644 --- a/android/src/main/java/com/komoju/android/sdk/ui/screens/webview/WebViewClient.kt +++ b/android/src/main/java/com/komoju/android/sdk/ui/screens/webview/WebViewClient.kt @@ -1,6 +1,8 @@ package com.komoju.android.sdk.ui.screens.webview +import android.content.Context import android.content.Intent +import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebView import androidx.core.app.ActivityOptionsCompat @@ -19,17 +21,11 @@ internal class WebViewClient : AccompanistWebViewClient() { private fun WebView.checkAndOpen(url: String): Boolean { try { val uri = url.toUri() - if (uri.scheme == resources.getString(R.string.komoju_consumer_app_scheme)) { - startActivity( - context, - Intent(context, KomojuPaymentActivity::class.java).apply { - data = uri - }, - ActivityOptionsCompat.makeBasic().toBundle(), - ) - return true - } else { + val handled = uri.handle(context) + if (handled.not()) { error("Unsupported scheme for deeplink, load in webView Instead.") + } else { + return handled } } catch (_: Exception) { loadUrl(url) @@ -37,3 +33,20 @@ internal class WebViewClient : AccompanistWebViewClient() { } } } + +private fun Uri.handle(context: Context): Boolean = openKomojuSDKIfAvailable(context) + +private fun Uri.openKomojuSDKIfAvailable(context: Context): Boolean { + if (scheme == context.resources.getString(R.string.komoju_consumer_app_scheme)) { + startActivity( + context, + Intent(context, KomojuPaymentActivity::class.java).apply { + data = this@openKomojuSDKIfAvailable + }, + ActivityOptionsCompat.makeBasic().toBundle(), + ) + return true + } else { + return false + } +} diff --git a/android/src/main/java/com/komoju/android/sdk/utils/AmountUtils.kt b/android/src/main/java/com/komoju/android/sdk/utils/AmountUtils.kt index 8f3c158..06f5391 100644 --- a/android/src/main/java/com/komoju/android/sdk/utils/AmountUtils.kt +++ b/android/src/main/java/com/komoju/android/sdk/utils/AmountUtils.kt @@ -6,6 +6,7 @@ import com.komoju.android.sdk.types.Currency internal object AmountUtils { fun formatToDecimal(currency: Currency, amount: String): String { + if (amount.isBlank()) return "" val locale = currency.toLocale() return NumberFormat.getCurrencyInstance(locale).apply { this.maximumFractionDigits = 2 diff --git a/android/src/main/java/com/komoju/android/sdk/utils/OffsiteCustomTabResultContract.kt b/android/src/main/java/com/komoju/android/sdk/utils/OffsiteCustomTabResultContract.kt new file mode 100644 index 0000000..ddc5446 --- /dev/null +++ b/android/src/main/java/com/komoju/android/sdk/utils/OffsiteCustomTabResultContract.kt @@ -0,0 +1,36 @@ +package com.komoju.android.sdk.utils + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_HEIGHT_FIXED +import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT + +internal class OffsiteCustomTabResultContract : ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + val height = context.resources.displayMetrics.heightPixels + val builder = CustomTabsIntent.Builder() + .setColorScheme(COLOR_SCHEME_LIGHT) + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(Color.WHITE) + .setNavigationBarColor(Color.WHITE) + .setSecondaryToolbarColor(Color.WHITE) + .setNavigationBarDividerColor(Color.BLACK) + .build(), + ) + .setShareState(CustomTabsIntent.SHARE_STATE_OFF) + .setInitialActivityHeightPx(height, ACTIVITY_HEIGHT_FIXED) + .setCloseButtonPosition(CustomTabsIntent.CLOSE_BUTTON_POSITION_END) + .setToolbarCornerRadiusDp(10) + val customTabsIntent = builder.build().intent + customTabsIntent.setData(Uri.parse(input)) + return customTabsIntent + } + + override fun parseResult(resultCode: Int, intent: Intent?): Int = resultCode +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 994624f..7ca6a37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.7.0" +browser = "1.8.0" kotlin = "2.0.20" nexus-publish = "2.0.0" android-minSdk = "24" @@ -27,6 +28,7 @@ nmcp = "0.0.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" } 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" } diff --git a/shared/src/commonMain/kotlin/com/komoju/mobile/sdk/remote/mappers/SessionMapper.kt b/shared/src/commonMain/kotlin/com/komoju/mobile/sdk/remote/mappers/SessionMapper.kt index bf55d87..1824916 100644 --- a/shared/src/commonMain/kotlin/com/komoju/mobile/sdk/remote/mappers/SessionMapper.kt +++ b/shared/src/commonMain/kotlin/com/komoju/mobile/sdk/remote/mappers/SessionMapper.kt @@ -16,7 +16,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), brands = (paymentMethod.brands as? JsonArray)?.map { it.toString() }.orEmpty(), displayName = i18nTexts[paymentMethodType], @@ -26,7 +26,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), customerFee = paymentMethod.customerFee ?: 0, displayName = i18nTexts[paymentMethodType], @@ -51,7 +51,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), isOffsite = paymentMethod.offsite ?: false, displayName = i18nTexts[paymentMethodType], @@ -61,7 +61,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), isOffsite = paymentMethod.offsite ?: false, displayName = i18nTexts[paymentMethodType], @@ -71,7 +71,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), customerFee = paymentMethod.customerFee ?: 0, displayName = i18nTexts[paymentMethodType], @@ -81,7 +81,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), customerFee = paymentMethod.customerFee ?: 0, displayName = i18nTexts[paymentMethodType], @@ -91,7 +91,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), displayName = i18nTexts[paymentMethodType], ) @@ -100,7 +100,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), displayName = i18nTexts[paymentMethodType], ) @@ -109,7 +109,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), displayName = i18nTexts[paymentMethodType], ) @@ -118,7 +118,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), isOffsite = paymentMethod.offsite ?: false, displayName = i18nTexts[paymentMethodType], @@ -128,7 +128,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), isOffsite = paymentMethod.offsite ?: false, displayName = i18nTexts[paymentMethodType], @@ -138,7 +138,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), isOffsite = paymentMethod.offsite ?: false, displayName = i18nTexts[paymentMethodType], @@ -148,7 +148,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), isOffsite = paymentMethod.offsite ?: false, secondIcon = paymentMethod.secondIcon.orEmpty(), @@ -159,7 +159,7 @@ internal object SessionMapper { hashedGateway = paymentMethod.hashedGateway.orEmpty(), exchangeRate = paymentMethod.exchangeRate ?: 1.0, currency = paymentMethod.currency.orEmpty(), - amount = paymentMethod.amount.orEmpty(), + amount = (response.amount ?: 0).toString(), additionalFields = paymentMethod.additionalFields?.filterNotNull().orEmpty(), displayName = i18nTexts[paymentMethodType], )