From b839189609b5f2e4263d3cc90dd0b46175272108 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 31 Jul 2026 15:03:30 +0000 Subject: [PATCH 1/3] chore: sync sample from v1.3.0 (1.3.0) --- README.md | 10 +- app/build.gradle.kts | 12 +- .../java/com/spreedly/app/MainActivity.kt | 6 + .../clicktopay/ClickToPayDetectorLifecycle.kt | 105 +++ .../clicktopay/ClickToPayMerchantPrefill.kt | 52 ++ .../clicktopay/ClickToPayPaymentScreen.kt | 767 +++++++++++++++++ .../clicktopay/ClickToPayPaymentViewModel.kt | 780 ++++++++++++++++++ .../clicktopay/ClickToPaySandboxCatalog.kt | 71 ++ .../screens/mainmenu/MainMenuScreen.kt | 8 + .../example/viewmodel/ViewModelFactories.kt | 5 + app/src/main/res/values/strings.xml | 2 + ...ayPaymentViewModelDetectorLifecycleTest.kt | 401 +++++++++ docs/CHANGELOG.md | 12 + docs/README.md | 1 + docs/guides/ach-bank-account.md | 1 + docs/guides/click-to-pay.md | 705 ++++++++++++++++ docs/guides/custom-payment-forms.md | 19 + docs/guides/express-checkout.md | 1 + docs/guides/privacy-policy.md | 1 + gradle/libs.versions.toml | 2 +- gradle/module-catalog.json | 1 + settings.gradle.kts | 8 +- 22 files changed, 2952 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayDetectorLifecycle.kt create mode 100644 app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayMerchantPrefill.kt create mode 100644 app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentScreen.kt create mode 100644 app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModel.kt create mode 100644 app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPaySandboxCatalog.kt create mode 100644 app/src/test/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModelDetectorLifecycleTest.kt create mode 100644 docs/guides/click-to-pay.md diff --git a/README.md b/README.md index aabb408..4e24815 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spreedly Checkout — Android Example -This sample app demonstrates the [Spreedly Android Checkout SDK](https://github.com/spreedly/checkout-android-sdk) at version **1.2.0** (tag `v1.2.0`). +This sample app demonstrates the [Spreedly Android Checkout SDK](https://github.com/spreedly/checkout-android-sdk) at version **1.3.0** (tag `v1.3.0`). ## Setup @@ -20,10 +20,10 @@ gpr.key=YOUR_GITHUB_TOKEN All SDK modules are resolved from GitHub Packages: ```kotlin -implementation("com.spreedly:checkout-paymentsheet:1.2.0") -implementation("com.spreedly:checkout-braintree-apm:1.2.0") -implementation("com.spreedly:checkout-stripe-apm:1.2.0") -implementation("com.spreedly:checkout-threeds:1.2.0") +implementation("com.spreedly:checkout-paymentsheet:1.3.0") +implementation("com.spreedly:checkout-braintree-apm:1.3.0") +implementation("com.spreedly:checkout-stripe-apm:1.3.0") +implementation("com.spreedly:checkout-threeds:1.3.0") ``` ## SDK Documentation diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77a171e..c31a47d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,7 +130,6 @@ android { testOptions { unitTests { - isIncludeAndroidResources = true isReturnDefaultValues = true } } @@ -144,11 +143,12 @@ kotlin { dependencies { // ✅ Use paymentsheet which includes payments-core and hosted-fields - implementation("com.spreedly:checkout-paymentsheet:1.2.0") - implementation("com.spreedly:checkout-braintree-apm:1.2.0") - implementation("com.spreedly:checkout-stripe-apm:1.2.0") - implementation("com.spreedly:checkout-stripe-radar:1.2.0") - implementation("com.spreedly:checkout-threeds:1.2.0") + implementation("com.spreedly:checkout-paymentsheet:1.3.0") + implementation("com.spreedly:checkout-braintree-apm:1.3.0") + implementation("com.spreedly:checkout-stripe-apm:1.3.0") + implementation("com.spreedly:checkout-stripe-radar:1.3.0") + implementation("com.spreedly:checkout-threeds:1.3.0") + implementation("com.spreedly:checkout-clicktopay:1.3.0") implementation(libs.kotlinx.serialization.json) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/spreedly/app/MainActivity.kt b/app/src/main/java/com/spreedly/app/MainActivity.kt index 3011d66..ea26c06 100644 --- a/app/src/main/java/com/spreedly/app/MainActivity.kt +++ b/app/src/main/java/com/spreedly/app/MainActivity.kt @@ -23,6 +23,7 @@ import com.spreedly.example.screens.headlessbankaccount.HeadlessBankAccountViewM import com.spreedly.example.viewmodel.viewModelWithContext import com.spreedly.example.screens.bottomsheet.BottomSheetPaymentScreen import com.spreedly.example.screens.bottomsheet.BottomSheetPaymentViewModel +import com.spreedly.example.screens.clicktopay.ClickToPayPaymentScreen import com.spreedly.example.screens.braintreepayment.BraintreePaymentScreen import com.spreedly.example.screens.customcheckout.CheckoutWithAdditionalFieldsScreen import com.spreedly.example.screens.customizedcheckout.CustomisedCheckoutScreen @@ -210,6 +211,11 @@ fun MainNavHost(bottomSheetViewModel: BottomSheetPaymentViewModel) { ) } + composable("clicktopay_demo") { + ClickToPayPaymentScreen( + onBackClick = { navController.popBackStack() }, + ) + } } } diff --git a/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayDetectorLifecycle.kt b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayDetectorLifecycle.kt new file mode 100644 index 0000000..3f9f956 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayDetectorLifecycle.kt @@ -0,0 +1,105 @@ +package com.spreedly.example.screens.clicktopay + +import com.spreedly.clicktopay.ClickToPaySavedCardsDetectorResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +internal fun deviceRecognitionForDetectorResult( + result: ClickToPaySavedCardsDetectorResult, +): DeviceRecognitionState = + when { + result.hasSavedCards -> DeviceRecognitionState.Recognized + result.failure != null -> DeviceRecognitionState.DetectionFailed + else -> DeviceRecognitionState.NotRecognized + } + +internal const val CHECKOUT_INACTIVE_REMOUNT_TIMEOUT_MS = 10_000L +internal const val CHECKOUT_INACTIVE_REMOUNT_POLL_MS = 50L +internal const val SAVED_CARDS_DETECTOR_TEARDOWN_TIMEOUT_MS = 5_000L + +internal fun shouldRemountSavedCardsDetector( + deviceRecognition: DeviceRecognitionState, + remountedDetectorForCheckoutGeneration: Int, + checkoutSessionGeneration: Int, +): Boolean = + deviceRecognition != DeviceRecognitionState.UsingDifferentEmail && + remountedDetectorForCheckoutGeneration != checkoutSessionGeneration + +internal suspend fun awaitCheckoutInactiveForRemount( + isCheckoutActive: () -> Boolean, + delayMs: Long = CHECKOUT_INACTIVE_REMOUNT_POLL_MS, + timeoutMs: Long = CHECKOUT_INACTIVE_REMOUNT_TIMEOUT_MS, + delay: suspend (Long) -> Unit, + nowMs: () -> Long = { System.currentTimeMillis() }, +): Boolean { + val deadline = nowMs() + timeoutMs + while (isCheckoutActive()) { + if (nowMs() >= deadline) { + return false + } + delay(delayMs) + } + return true +} + +internal suspend fun awaitCheckoutInactiveUntilRemount( + isCheckoutActive: () -> Boolean, + delayMs: Long = CHECKOUT_INACTIVE_REMOUNT_POLL_MS, + delay: suspend (Long) -> Unit, +) { + while (isCheckoutActive()) { + delay(delayMs) + } +} + +internal class SavedCardsDetectorTearDownAwaiter { + private var inFlight: CompletableDeferred? = null + private var disposeSignaledWithoutAwaiter = false + + fun begin(): CompletableDeferred { + if (disposeSignaledWithoutAwaiter) { + disposeSignaledWithoutAwaiter = false + return CompletableDeferred().also { it.complete(Unit) } + } + val deferred = CompletableDeferred() + inFlight = deferred + return deferred + } + + fun signalDisposed() { + val current = inFlight + if (current != null) { + current.complete(Unit) + inFlight = null + } else { + disposeSignaledWithoutAwaiter = true + } + } + + fun clear() { + inFlight = null + disposeSignaledWithoutAwaiter = false + } +} + +internal suspend fun awaitSavedCardsDetectorTearDown( + hadActiveDetector: Boolean, + controllerAwaitTearDown: (suspend () -> Boolean)?, + awaiter: SavedCardsDetectorTearDownAwaiter, + timeoutMs: Long = SAVED_CARDS_DETECTOR_TEARDOWN_TIMEOUT_MS, +): Boolean { + if (!hadActiveDetector) { + return true + } + if (controllerAwaitTearDown != null) { + return controllerAwaitTearDown() + } + val fallback = awaiter.begin() + return try { + withTimeoutOrNull(timeoutMs) { + fallback.await() + } != null + } finally { + awaiter.clear() + } +} diff --git a/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayMerchantPrefill.kt b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayMerchantPrefill.kt new file mode 100644 index 0000000..6e316d2 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayMerchantPrefill.kt @@ -0,0 +1,52 @@ +package com.spreedly.example.screens.clicktopay + +import com.spreedly.clicktopay.ClickToPayTokenizeBilling + +data class ClickToPayMerchantPrefill( + val firstName: String = "", + val lastName: String = "", + val phoneCountryCode: String = "", + val phoneNumber: String = "", + val addressLine1: String = "", + val addressLine2: String = "", + val city: String = "", + val state: String = "", + val zip: String = "", + val country: String = "", + val copyBillingToShipping: Boolean = false, +) { + fun makeTokenizeBilling(email: String): ClickToPayTokenizeBilling { + val trimmedEmail = email.trim() + val trimmedPhone = phoneNumber.trim() + val billing = + ClickToPayTokenizeBilling( + email = trimmedEmail.ifBlank { null }, + firstName = firstName.nilIfBlank(), + lastName = lastName.nilIfBlank(), + phoneNumber = trimmedPhone.ifBlank { null }, + addressLine1 = addressLine1.nilIfBlank(), + addressLine2 = addressLine2.nilIfBlank(), + city = city.nilIfBlank(), + state = state.nilIfBlank(), + zip = zip.nilIfBlank(), + country = country.nilIfBlank(), + ) + if (!copyBillingToShipping) { + return billing + } + return billing.copy( + shippingAddressLine1 = billing.addressLine1, + shippingAddressLine2 = billing.addressLine2, + shippingCity = billing.city, + shippingState = billing.state, + shippingZip = billing.zip, + shippingCountry = billing.country, + shippingPhoneNumber = billing.phoneNumber, + ) + } +} + +private fun String.nilIfBlank(): String? { + val trimmed = trim() + return trimmed.ifBlank { null } +} diff --git a/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentScreen.kt b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentScreen.kt new file mode 100644 index 0000000..96fcd37 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentScreen.kt @@ -0,0 +1,767 @@ +package com.spreedly.example.screens.clicktopay + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.spreedly.clicktopay.ClickToPayCheckoutConfig +import com.spreedly.clicktopay.ClickToPayButtonConfig +import com.spreedly.clicktopay.ui.ClickToPayBrandColors +import com.spreedly.clicktopay.ui.ClickToPaySavedCardsDetector +import com.spreedly.clicktopay.ui.SpreedlyClickToPayButton +import com.spreedly.example.screens.basiccheckout.SimpleInputField +import com.spreedly.example.screens.common.PaymentErrorCard +import com.spreedly.example.screens.common.PaymentProductGrid +import com.spreedly.example.screens.common.PaymentStageIndicator +import com.spreedly.example.screens.common.PaymentSuccessCard +import com.spreedly.example.ui.components.ThemeConfigurationCard +import com.spreedly.example.ui.components.ThemeConfigurationStyle +import com.spreedly.example.viewmodel.clickToPayPaymentViewModel +import java.text.NumberFormat +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("ComposeModifierMissing") +@Composable +fun ClickToPayPaymentScreen(onBackClick: () -> Unit) { + val context = LocalContext.current + val viewModel: ClickToPayPaymentViewModel = clickToPayPaymentViewModel() + + val isInitializing by viewModel.isInitializing.collectAsStateWithLifecycle() + val stage by viewModel.stage.collectAsStateWithLifecycle() + val selectedProduct by viewModel.selectedProduct.collectAsStateWithLifecycle() + val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() + val successMessage by viewModel.successMessage.collectAsStateWithLifecycle() + val doLookup by viewModel.doLookup.collectAsStateWithLifecycle() + val email by viewModel.email.collectAsStateWithLifecycle() + val emailError by viewModel.emailError.collectAsStateWithLifecycle() + val phoneError by viewModel.phoneError.collectAsStateWithLifecycle() + val deviceRecognition by viewModel.deviceRecognition.collectAsStateWithLifecycle() + val recognizedCardLabels by viewModel.recognizedCardLabels.collectAsStateWithLifecycle() + val savedCardsDetectorKey by viewModel.savedCardsDetectorKey.collectAsStateWithLifecycle() + val merchantPrefill by viewModel.merchantPrefill.collectAsStateWithLifecycle() + val firstNameError by viewModel.firstNameError.collectAsStateWithLifecycle() + val lastNameError by viewModel.lastNameError.collectAsStateWithLifecycle() + val flowPhase by viewModel.flowPhase.collectAsStateWithLifecycle() + val eventLog by viewModel.eventLog.collectAsStateWithLifecycle() + val useCustomTheme by viewModel.useCustomTheme.collectAsStateWithLifecycle() + val selectedThemePreset by viewModel.selectedThemePreset.collectAsStateWithLifecycle() + val isDarkMode = isSystemInDarkTheme() + + LaunchedEffect(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.applyThemeToSdk(isDarkMode) + } + + LaunchedEffect(isInitializing) { + if (!isInitializing) { + viewModel.onMerchantScreenDisplayed() + } + } + + var devOptionsExpanded by remember { mutableStateOf(false) } + var emailHasEverChanged by remember { mutableStateOf(false) } + var emailWasFocused by remember { mutableStateOf(false) } + + val checkoutEnabled = stage == ClickToPayPaymentViewModel.Stage.IDLE + val canStartPayment = + ClickToPayPaymentViewModel.canStartPayment( + stage = stage, + selectedProduct = selectedProduct, + deviceRecognition = deviceRecognition, + ) + val contactHint = + when (deviceRecognition) { + DeviceRecognitionState.Checking -> + "Checking whether this device has saved Click to Pay cards…" + DeviceRecognitionState.Recognized -> + "Your saved cards are ready. Tap Click to Pay to continue." + DeviceRecognitionState.DetectionFailed -> + "We could not check for saved cards on this device. Enter your email or phone to continue." + DeviceRecognitionState.UsingDifferentEmail -> + ClickToPaySandboxCatalog.MERCHANT_PREFILL_HINT + DeviceRecognitionState.NotRecognized -> + ClickToPaySandboxCatalog.MERCHANT_PREFILL_HINT + } + + val showContactIdentityFields = + deviceRecognition == DeviceRecognitionState.NotRecognized || + deviceRecognition == DeviceRecognitionState.DetectionFailed || + deviceRecognition == DeviceRecognitionState.UsingDifferentEmail + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Click to Pay") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { SnackbarHost(hostState = viewModel.snackbarHostState) }, + ) { paddingValues -> + if (isInitializing) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + if (savedCardsDetectorKey >= 0) { + ClickToPaySavedCardsDetector( + config = viewModel.savedCardsDetectorConfig, + detectorKey = savedCardsDetectorKey, + modifier = Modifier.size(0.dp), + onResult = viewModel::onSavedCardsDetectorResult, + onControllerReady = viewModel::onSavedCardsDetectorControllerReady, + onDetectorDisposed = viewModel::onSavedCardsDetectorDisposed, + ) + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 24.dp) + .navigationBarsPadding(), + ) { + PaymentStageIndicator( + stageLabels = C2P_STAGE_LABELS, + currentIndex = c2pStageToIndex(stage), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "SDK phase: $flowPhase", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "1. Select product", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + PaymentProductGrid( + products = viewModel.products, + selectedProduct = selectedProduct, + onProductSelected = viewModel::selectProduct, + enabled = checkoutEnabled, + formatPrice = ::formatPrice, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + ThemeConfigurationCard( + useCustomTheme = useCustomTheme, + selectedPreset = selectedThemePreset, + onUseCustomThemeChange = viewModel::setUseCustomTheme, + onPresetSelected = viewModel::setThemePreset, + onResetTheme = viewModel::resetThemeConfiguration, + style = ThemeConfigurationStyle.SWATCH, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "2. Contact information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = contactHint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ClickToPayContactSection( + email = email, + emailError = emailError, + phoneError = phoneError, + deviceRecognition = deviceRecognition, + recognizedCardLabels = recognizedCardLabels, + phoneCountryCode = merchantPrefill.phoneCountryCode, + phoneNumber = merchantPrefill.phoneNumber, + enabled = checkoutEnabled, + showIdentityFields = showContactIdentityFields, + onEmailChange = { value -> + if (value.isNotEmpty()) emailHasEverChanged = true + viewModel.updateEmail(value) + if (emailHasEverChanged) { + viewModel.validateCustomerIdentity() + } else { + viewModel.clearEmailError() + viewModel.clearPhoneError() + } + }, + onEmailFocus = { focused -> + if (focused) { + emailWasFocused = true + } else if (emailWasFocused) { + viewModel.validateCustomerIdentity() + } + }, + onUseDifferentEmail = viewModel::useDifferentEmail, + onPhoneCountryCodeChange = viewModel::updatePhoneCountryCode, + onPhoneNumberChange = viewModel::updatePhoneNumber, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "3. Billing / shipping address", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "First and last name are required for tokenize and prefilled as cardholder name in the Click to Pay sheet. Other address fields are optional.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ClickToPayBillingPrefillSection( + prefill = merchantPrefill, + firstNameError = firstNameError, + lastNameError = lastNameError, + enabled = checkoutEnabled, + onFirstNameChange = viewModel::updateFirstName, + onLastNameChange = viewModel::updateLastName, + onAddressLine1Change = viewModel::updateAddressLine1, + onAddressLine2Change = viewModel::updateAddressLine2, + onCountryChange = viewModel::updateCountry, + onCityChange = viewModel::updateCity, + onStateChange = viewModel::updateState, + onZipChange = viewModel::updateZip, + onCopyBillingToShippingChange = viewModel::setCopyBillingToShipping, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Card details are collected inside Click to Pay checkout (not on this screen).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = { devOptionsExpanded = !devOptionsExpanded }) { + Text(if (devOptionsExpanded) "Hide developer options" else "Show developer options") + } + + if (devOptionsExpanded) { + ClickToPayDeveloperOptionsSection( + doLookup = doLookup, + eventLog = eventLog, + enabled = checkoutEnabled, + onDoLookupChange = viewModel::setDoLookup, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + errorMessage?.let { error -> + PaymentErrorCard(message = error) + Spacer(modifier = Modifier.height(16.dp)) + } + + successMessage?.let { success -> + PaymentSuccessCard(message = success) + Spacer(modifier = Modifier.height(16.dp)) + } + + when (stage) { + ClickToPayPaymentViewModel.Stage.IDLE -> { + val checkoutConfig: ClickToPayCheckoutConfig? = + remember( + selectedProduct, + email, + doLookup, + merchantPrefill, + ) { + viewModel.merchantCheckoutConfig() + } + if (checkoutConfig != null) { + SpreedlyClickToPayButton( + checkoutConfig = checkoutConfig, + buttonConfig = + ClickToPayButtonConfig( + isEnabled = canStartPayment, + isDark = isDarkMode, + ), + prepareForPresentation = { viewModel.prepareForCheckout(isDarkMode) }, + modifier = Modifier.fillMaxWidth(), + ) + } + if (selectedProduct == null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Select a product to enable Click to Pay", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + ClickToPayPaymentViewModel.Stage.CHECKOUT, + ClickToPayPaymentViewModel.Stage.TOKENIZING, + -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + if (stage == ClickToPayPaymentViewModel.Stage.CHECKOUT) { + "Click to Pay checkout open…" + } else { + "Tokenizing…" + }, + fontSize = 16.sp, + ) + } + } + } + + if (stage == ClickToPayPaymentViewModel.Stage.CHECKOUT) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = viewModel::cancelCheckout, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Cancel checkout") + } + } + } + } + } +} + +@Composable +private fun ClickToPayContactSection( + email: String, + emailError: String?, + phoneError: String?, + deviceRecognition: DeviceRecognitionState, + recognizedCardLabels: RecognizedCardLabelList, + phoneCountryCode: String, + phoneNumber: String, + enabled: Boolean, + showIdentityFields: Boolean, + onEmailChange: (String) -> Unit, + onEmailFocus: (Boolean) -> Unit, + onUseDifferentEmail: () -> Unit, + onPhoneCountryCodeChange: (String) -> Unit, + onPhoneNumberChange: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + when (deviceRecognition) { + DeviceRecognitionState.Checking -> { + ClickToPayMcRecognizedDevicePanel( + title = "Checking this device", + body = "Looking for saved Click to Pay cards on this device…", + showProgress = true, + ) + } + DeviceRecognitionState.Recognized -> { + ClickToPayMcRecognizedDevicePanel( + title = "Welcome back", + body = + if (recognizedCardLabels.items.isNotEmpty()) { + "Use your saved cards to check out faster." + } else { + "This device has saved Click to Pay cards." + }, + cardLabels = recognizedCardLabels, + linkText = "Not you? Use a different email", + onLinkClick = onUseDifferentEmail, + linkEnabled = enabled, + ) + } + DeviceRecognitionState.DetectionFailed -> { + ClickToPayMcRecognizedDevicePanel( + title = "Could not check saved cards", + body = "Saved-card detection did not finish. You can still pay with Click to Pay using your email or phone.", + ) + } + DeviceRecognitionState.UsingDifferentEmail, + DeviceRecognitionState.NotRecognized, + -> Unit + } + + if (showIdentityFields) { + SimpleInputField( + label = "Email", + value = email, + onValueChange = onEmailChange, + isRequired = false, + isError = emailError != null, + errorMessage = emailError, + keyboardType = KeyboardType.Email, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + onFocusChanged = onEmailFocus, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SimpleInputField( + label = "Country code", + value = phoneCountryCode, + onValueChange = onPhoneCountryCodeChange, + isRequired = false, + placeholder = "Country code", + showLabel = false, + keyboardType = KeyboardType.Phone, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + modifier = Modifier.width(120.dp), + ) + SimpleInputField( + label = "Mobile number", + value = phoneNumber, + onValueChange = onPhoneNumberChange, + isRequired = false, + placeholder = "Mobile number", + showLabel = false, + keyboardType = KeyboardType.Phone, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + modifier = Modifier.weight(1f), + ) + } + + if (phoneError != null) { + Text( + text = phoneError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun ClickToPayMcRecognizedDevicePanel( + title: String, + body: String, + modifier: Modifier = Modifier, + cardLabels: RecognizedCardLabelList = RecognizedCardLabelList(), + linkText: String? = null, + onLinkClick: (() -> Unit)? = null, + linkEnabled: Boolean = true, + showProgress: Boolean = false, +) { + val shape = RoundedCornerShape(8.dp) + Surface( + modifier = + modifier + .fillMaxWidth() + .border(1.dp, ClickToPayBrandColors.panelBorder, shape), + shape = shape, + color = ClickToPayBrandColors.panelBackground, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showProgress) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = ClickToPayBrandColors.primaryText, + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = ClickToPayBrandColors.primaryText, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = ClickToPayBrandColors.secondaryText, + ) + if (cardLabels.items.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + cardLabels.items.forEach { label -> + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = ClickToPayBrandColors.primaryText, + ) + } + } + if (linkText != null && onLinkClick != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = linkText, + style = + MaterialTheme.typography.bodyMedium.copy( + color = ClickToPayBrandColors.primaryText, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier.clickable(enabled = linkEnabled) { + onLinkClick() + }, + ) + } + } + } +} + +@Composable +private fun ClickToPayBillingPrefillSection( + prefill: ClickToPayMerchantPrefill, + firstNameError: String?, + lastNameError: String?, + enabled: Boolean, + onFirstNameChange: (String) -> Unit, + onLastNameChange: (String) -> Unit, + onAddressLine1Change: (String) -> Unit, + onAddressLine2Change: (String) -> Unit, + onCountryChange: (String) -> Unit, + onCityChange: (String) -> Unit, + onStateChange: (String) -> Unit, + onZipChange: (String) -> Unit, + onCopyBillingToShippingChange: (Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SimpleInputField( + label = "First name", + value = prefill.firstName, + onValueChange = onFirstNameChange, + isRequired = true, + isError = firstNameError != null, + errorMessage = firstNameError, + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + modifier = Modifier.weight(1f), + ) + SimpleInputField( + label = "Last name", + value = prefill.lastName, + onValueChange = onLastNameChange, + isRequired = true, + isError = lastNameError != null, + errorMessage = lastNameError, + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + modifier = Modifier.weight(1f), + ) + } + + SimpleInputField( + label = "Street address 1", + value = prefill.addressLine1, + onValueChange = onAddressLine1Change, + isRequired = false, + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ) + + SimpleInputField( + label = "Street address 2 (optional)", + value = prefill.addressLine2, + onValueChange = onAddressLine2Change, + isRequired = false, + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ) + + SimpleInputField( + label = "Country (ISO)", + value = prefill.country, + onValueChange = onCountryChange, + isRequired = false, + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Next, + ) + + SimpleInputField( + label = "City", + value = prefill.city, + onValueChange = onCityChange, + isRequired = false, + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SimpleInputField( + label = "State", + value = prefill.state, + onValueChange = onStateChange, + isRequired = false, + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Next, + modifier = Modifier.weight(1f), + ) + SimpleInputField( + label = "ZIP", + value = prefill.zip, + onValueChange = onZipChange, + isRequired = false, + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = prefill.copyBillingToShipping, + onCheckedChange = onCopyBillingToShippingChange, + enabled = enabled, + ) + Text( + text = "Copy billing to shipping on tokenize", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ClickToPayDeveloperOptionsSection( + doLookup: Boolean, + eventLog: ClickToPayEventLog, + enabled: Boolean, + onDoLookupChange: (Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Auto lookup", style = MaterialTheme.typography.bodyMedium) + Switch(checked = doLookup, onCheckedChange = onDoLookupChange, enabled = enabled) + } + + HorizontalDivider() + + if (eventLog.lines.isNotEmpty()) { + Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = 1.dp) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "SpreedlyClickToPayCheckout.events", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + eventLog.lines.forEach { line -> + Text( + text = "• $line", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + ) + } + } + } + } + } +} + +private val C2P_STAGE_LABELS = listOf("Idle", "Checkout", "Tokenize") + +private fun c2pStageToIndex(stage: ClickToPayPaymentViewModel.Stage): Int = when (stage) { + ClickToPayPaymentViewModel.Stage.IDLE -> 0 + ClickToPayPaymentViewModel.Stage.CHECKOUT -> 1 + ClickToPayPaymentViewModel.Stage.TOKENIZING -> 2 +} + +private fun formatPrice(cents: Int): String { + val dollars = cents / 100.0 + val format = NumberFormat.getCurrencyInstance(Locale.US) + return format.format(dollars) +} diff --git a/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModel.kt b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModel.kt new file mode 100644 index 0000000..6216be0 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModel.kt @@ -0,0 +1,780 @@ +package com.spreedly.example.screens.clicktopay + +import android.app.Activity +import android.content.Context +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.spreedly.app.BuildConfig +import com.spreedly.clicktopay.ClickToPaySavedCardsDetectorResult +import com.spreedly.clicktopay.ClickToPayEvent +import com.spreedly.clicktopay.ClickToPayCheckoutConfig +import com.spreedly.clicktopay.ClickToPayCustomer +import com.spreedly.clicktopay.ClickToPayFlowPhase +import com.spreedly.clicktopay.SpreedlyClickToPayCheckout +import com.spreedly.clicktopay.tokenize.ClickToPayMetadata +import com.spreedly.example.AuthService +import com.spreedly.example.repository.PaymentMethodRepository +import com.spreedly.example.screens.common.Product +import com.spreedly.example.ui.theme.SampleThemePreset +import com.spreedly.example.ui.theme.ThemeConfigurationController +import com.spreedly.example.utils.PaymentResultHandler +import com.spreedly.example.utils.SdkSessionManager +import com.spreedly.hostedfields.utils.getDisplayValue +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.PaymentProcessingResult +import com.spreedly.clicktopay.ui.ClickToPaySavedCardsDetectorController +import com.spreedly.validation.EmailValidator +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class DeviceRecognitionState { + Checking, + NotRecognized, + Recognized, + DetectionFailed, + UsingDifferentEmail, +} + +class ClickToPayPaymentViewModel( + private val context: Context, + val spreedlySdk: Spreedly = Spreedly(), +) : ViewModel() { + val snackbarHostState = SnackbarHostState() + + private val sdkSessionManager = SdkSessionManager(AuthService()) + private val paymentResultHandler = PaymentResultHandler(PaymentMethodRepository(context)) + val themeConfiguration = ThemeConfigurationController() + val useCustomTheme = themeConfiguration.useCustomTheme + val selectedThemePreset = themeConfiguration.selectedPreset + + enum class Stage { + IDLE, + CHECKOUT, + TOKENIZING, + } + + private val _stage = MutableStateFlow(Stage.IDLE) + val stage: StateFlow = _stage.asStateFlow() + + private val _isInitializing = MutableStateFlow(false) + val isInitializing: StateFlow = _isInitializing.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _successMessage = MutableStateFlow(null) + val successMessage: StateFlow = _successMessage.asStateFlow() + + private val _selectedProduct = MutableStateFlow(null) + val selectedProduct: StateFlow = _selectedProduct.asStateFlow() + + private val _doLookup = MutableStateFlow(true) + val doLookup: StateFlow = _doLookup.asStateFlow() + + private val _deviceRecognition = MutableStateFlow(DeviceRecognitionState.Checking) + val deviceRecognition: StateFlow = _deviceRecognition.asStateFlow() + + private val _recognizedCardLabels = MutableStateFlow(RecognizedCardLabelList()) + val recognizedCardLabels: StateFlow = _recognizedCardLabels.asStateFlow() + + private val _savedCardsDetectorKey = MutableStateFlow(-1) + val savedCardsDetectorKey: StateFlow = _savedCardsDetectorKey.asStateFlow() + + val savedCardsDetectorConfig: ClickToPayCheckoutConfig = + ClickToPaySandboxCatalog.buildSavedCardsDetectorConfig(transactionAmount = 100.0) + + private var savedCardsDetectorController: ClickToPaySavedCardsDetectorController? = null + private val savedCardsDetectorTearDownAwaiter = SavedCardsDetectorTearDownAwaiter() + + /** Bumped in [prepareForCheckout]; remount runs at most once per checkout session. */ + private var checkoutSessionGeneration = 0 + private var remountedDetectorForCheckoutGeneration = -1 + private var remountJob: Job? = null + + private val _email = MutableStateFlow("") + val email: StateFlow = _email.asStateFlow() + + private val _emailError = MutableStateFlow(null) + val emailError: StateFlow = _emailError.asStateFlow() + + private val _phoneError = MutableStateFlow(null) + val phoneError: StateFlow = _phoneError.asStateFlow() + + private val _merchantPrefill = MutableStateFlow(ClickToPayMerchantPrefill()) + val merchantPrefill: StateFlow = _merchantPrefill.asStateFlow() + + private val _firstNameError = MutableStateFlow(null) + val firstNameError: StateFlow = _firstNameError.asStateFlow() + + private val _lastNameError = MutableStateFlow(null) + val lastNameError: StateFlow = _lastNameError.asStateFlow() + + private val _flowPhase = MutableStateFlow(ClickToPayFlowPhase.IDLE) + val flowPhase: StateFlow = _flowPhase.asStateFlow() + + private val _eventLog = MutableStateFlow(ClickToPayEventLog()) + val eventLog: StateFlow = _eventLog.asStateFlow() + + private val _pendingMetadata = MutableStateFlow(null) + val pendingMetadata: StateFlow = _pendingMetadata.asStateFlow() + + private val _pendingVerificationValue = MutableStateFlow(null) + val pendingVerificationValue: StateFlow = _pendingVerificationValue.asStateFlow() + + val products = listOf( + Product("Sunglasses", "Premium UV protection", 4400, "🕶️"), + Product("Watch", "Swiss precision", 19900, "⌚"), + Product("Headphones", "Noise cancelling", 29900, "🎧"), + Product("Camera", "Professional grade", 89900, "📷"), + Product("Laptop", "Ultra portable", 129900, "💻"), + Product("Phone", "Latest model", 99900, "📱"), + ) + + private var paymentResultJob: Job? = null + private var eventsJob: Job? = null + private var stateJob: Job? = null + + init { + SpreedlyClickToPayCheckout.setAutoTokenizeAuthRefresher { + refreshSdkAuth() + } + initializeSdkOnScreenLoad() + } + + private suspend fun refreshSdkAuth(): Boolean { + val ok = + sdkSessionManager.initializeSdk( + sdk = spreedlySdk, + context = context.applicationContext, + environmentKey = BuildConfig.ENVIRONMENT_KEY, + ).isSuccess + if (ok) { + startPaymentResultObserver() + } + return ok + } + + private fun initializeSdkOnScreenLoad() { + viewModelScope.launch { + val initialized = initializeSdkIfNeeded() + if (initialized) { + startPaymentResultObserver() + startClickToPayObservers() + } + } + } + + private fun startClickToPayObservers() { + eventsJob?.cancel() + eventsJob = viewModelScope.launch { + SpreedlyClickToPayCheckout.events.collect { event -> + handleClickToPayEvent(event) + } + } + stateJob?.cancel() + stateJob = viewModelScope.launch { + SpreedlyClickToPayCheckout.state.collect { state -> + _flowPhase.value = state.phase + } + } + } + + private fun handleClickToPayEvent(event: ClickToPayEvent) { + when (event) { + is ClickToPayEvent.CheckoutStarted -> + appendEvent("CheckoutStarted(${event.checkoutId.take(8)}…)") + + is ClickToPayEvent.StateChanged -> + appendEvent("StateChanged(${event.state.phase})") + + is ClickToPayEvent.DisplayCardsReady -> + appendEvent("DisplayCardsReady(${event.cards.size} cards)") + + is ClickToPayEvent.NewUserEnrollmentRequired -> + appendEvent("NewUserEnrollmentRequired") + + is ClickToPayEvent.CheckoutComplete -> { + appendEvent("CheckoutComplete") + _stage.value = Stage.TOKENIZING + } + + is ClickToPayEvent.CheckoutCancelled -> { + appendEvent("CheckoutCancelled") + if (_stage.value == Stage.CHECKOUT) { + _errorMessage.value = "Click to Pay checkout was canceled." + _stage.value = Stage.IDLE + remountSavedCardsDetectorAfterCheckoutInterrupted() + } + } + + is ClickToPayEvent.Error -> { + appendEvent("Error(${event.code})") + if (_stage.value != Stage.IDLE) { + _errorMessage.value = event.message + _stage.value = Stage.IDLE + _pendingMetadata.value = null + _pendingVerificationValue.value = null + remountSavedCardsDetectorAfterCheckoutInterrupted() + } + } + + is ClickToPayEvent.Initialized -> + appendEvent("Initialized(success=${event.success})") + + is ClickToPayEvent.VerifiedUser -> + appendEvent("VerifiedUser") + + is ClickToPayEvent.ExistingUser -> + appendEvent("ExistingUser") + + is ClickToPayEvent.AddNewCard -> + appendEvent("AddNewCard(${event.availableCardBrands.joinToString()})") + + is ClickToPayEvent.OtpInitiated -> + appendEvent("OtpInitiated(${event.maskedValidationChannel ?: "?"})") + + is ClickToPayEvent.OtpResponse -> + appendEvent("OtpResponse(success=${event.success})") + + is ClickToPayEvent.OtpResend -> + appendEvent("OtpResend") + + is ClickToPayEvent.OtpNotYou -> + appendEvent("OtpNotYou") + + is ClickToPayEvent.SessionDeleted -> { + appendEvent("SessionDeleted") + if (_stage.value == Stage.CHECKOUT) { + _stage.value = Stage.IDLE + remountSavedCardsDetectorAfterCheckoutInterrupted() + } + } + + is ClickToPayEvent.CheckoutDifferentPaymentMethod -> + appendEvent("CheckoutDifferentPaymentMethod") + + is ClickToPayEvent.OtpChannelSelectionRequired -> + appendEvent("OtpChannelSelectionRequired(${event.channels.size})") + + is ClickToPayEvent.PaymentMethodTokenized -> { + appendEvent("PaymentMethodTokenized([redacted])") + _stage.value = Stage.IDLE + _pendingMetadata.value = null + _pendingVerificationValue.value = null + remountSavedCardsDetectorAfterCheckoutInterrupted() + } + + is ClickToPayEvent.CheckoutWindowOpen -> + appendEvent("CheckoutWindowOpen") + + is ClickToPayEvent.CheckoutWindowClose -> + appendEvent("CheckoutWindowClose") + + is ClickToPayEvent.ValidationErrors -> + appendEvent("ValidationErrors(${event.errors.size})") + } + } + + private fun startPaymentResultObserver() { + if (!spreedlySdk.isInitialized) return + paymentResultJob?.cancel() + paymentResultJob = paymentResultHandler.observeResults( + sdk = spreedlySdk, + scope = viewModelScope, + onCompleted = { result -> + if (_stage.value != Stage.CHECKOUT && _stage.value != Stage.TOKENIZING) { + return@observeResults + } + val token = result.token + _successMessage.value = + "Payment method tokenized${if (token.isNotBlank()) ": $token" else "."}" + _stage.value = Stage.IDLE + _pendingMetadata.value = null + _pendingVerificationValue.value = null + remountSavedCardsDetectorAfterCheckoutInterrupted() + }, + onFailed = { result -> + if (_stage.value != Stage.CHECKOUT && _stage.value != Stage.TOKENIZING) { + return@observeResults + } + _errorMessage.value = result.message ?: "Click to Pay tokenization failed." + _stage.value = Stage.IDLE + _pendingMetadata.value = null + _pendingVerificationValue.value = null + remountSavedCardsDetectorAfterCheckoutInterrupted() + }, + onCanceled = { + if (_stage.value != Stage.CHECKOUT && _stage.value != Stage.TOKENIZING) { + return@observeResults + } + _errorMessage.value = "Click to Pay tokenization was canceled." + _stage.value = Stage.IDLE + _pendingMetadata.value = null + _pendingVerificationValue.value = null + remountSavedCardsDetectorAfterCheckoutInterrupted() + }, + ) + } + + suspend fun prepareForCheckout(isDarkMode: Boolean): Boolean { + applyThemeToSdk(isDarkMode) + val product = _selectedProduct.value + if (product == null) { + _errorMessage.value = "Please select a product" + return false + } + if (product.price <= 0) { + _errorMessage.value = "Invalid product price" + return false + } + if (!validateMerchantContactOnPay()) { + return false + } + if (!refreshSdkAuth()) { + _errorMessage.value = "Failed to refresh auth params" + return false + } + + if (!tearDownSavedCardsDetectorAndAwait()) { + _errorMessage.value = + "Click to Pay is still closing the saved-cards detector. Wait a moment and try again." + return false + } + remountJob?.cancel() + remountJob = null + checkoutSessionGeneration++ + remountedDetectorForCheckoutGeneration = -1 + _errorMessage.value = null + _successMessage.value = null + _pendingMetadata.value = null + _pendingVerificationValue.value = null + _eventLog.value = ClickToPayEventLog() + _stage.value = Stage.CHECKOUT + return true + } + + fun merchantCheckoutConfig(): ClickToPayCheckoutConfig? { + val product = _selectedProduct.value ?: return null + if (product.price <= 0) return null + val amount = product.price / 100.0 + val prefill = _merchantPrefill.value + return ClickToPaySandboxCatalog.buildCheckoutConfig( + email = _email.value, + doLookup = _doLookup.value, + transactionAmount = amount, + merchantPrefill = prefill, + currencyCode = "USD", + ) + } + + fun startPayment(activity: Activity, isDarkMode: Boolean) { + applyThemeToSdk(isDarkMode) + val product = _selectedProduct.value + if (product == null) { + _errorMessage.value = "Please select a product" + return + } + if (product.price <= 0) { + _errorMessage.value = "Invalid product price" + return + } + if (!validateMerchantContactOnPay()) { + return + } + + viewModelScope.launch { + if (!prepareForCheckout(isDarkMode)) { + return@launch + } + + val config = merchantCheckoutConfig() ?: return@launch + SpreedlyClickToPayCheckout.present(config, activity) + } + } + + fun tokenizeWithCvv(encryptedCvv: String) { + val metadata = _pendingMetadata.value + if (metadata == null) { + _errorMessage.value = "No checkout metadata — complete Click to Pay checkout first" + return + } + val cvv = getDisplayValue(encryptedCvv, FormFieldType.CVV(true)) + if (cvv.isBlank()) { + _errorMessage.value = "CVV is required" + return + } + + viewModelScope.launch { + if (!refreshSdkAuth()) { + _errorMessage.value = "Failed to refresh auth params" + return@launch + } + if (!spreedlySdk.isInitialized) { + _errorMessage.value = "SDK is still initializing, please wait..." + return@launch + } + + _errorMessage.value = null + val prefill = _merchantPrefill.value + when ( + SpreedlyClickToPayCheckout.tokenize( + metadata = metadata, + verificationValue = cvv, + billing = prefill.makeTokenizeBilling(_email.value).toBillingFields(_email.value), + ) + ) { + is PaymentProcessingResult.ValidationFailed -> + _errorMessage.value = "CVV validation failed" + + else -> Unit + } + } + } + + fun cancelCheckout() { + SpreedlyClickToPayCheckout.cancel() + } + + fun selectProduct(product: Product) { + _selectedProduct.value = product + _errorMessage.value = null + _successMessage.value = null + } + + fun setDoLookup(enabled: Boolean) { + _doLookup.value = enabled + } + + fun onMerchantScreenDisplayed() { + _deviceRecognition.value = DeviceRecognitionState.Checking + _recognizedCardLabels.value = RecognizedCardLabelList() + _savedCardsDetectorKey.value++ + } + + fun onSavedCardsDetectorControllerReady(controller: ClickToPaySavedCardsDetectorController) { + savedCardsDetectorController = controller + } + + fun onSavedCardsDetectorDisposed() { + savedCardsDetectorTearDownAwaiter.signalDisposed() + } + + fun onSavedCardsDetectorResult(result: ClickToPaySavedCardsDetectorResult) { + if (_deviceRecognition.value == DeviceRecognitionState.UsingDifferentEmail) return + val recognition = deviceRecognitionForDetectorResult(result) + if (recognition == DeviceRecognitionState.Recognized) { + _recognizedCardLabels.value = + RecognizedCardLabelList( + result.savedCards.map { ClickToPaySandboxCatalog.labelForMaskedCard(it) }, + ) + } else { + _recognizedCardLabels.value = RecognizedCardLabelList() + } + _deviceRecognition.value = recognition + } + + fun useDifferentEmail() { + _deviceRecognition.value = DeviceRecognitionState.UsingDifferentEmail + _recognizedCardLabels.value = RecognizedCardLabelList() + _email.value = "" + _emailError.value = null + _phoneError.value = null + updateMerchantPrefill { + it.copy(phoneCountryCode = "", phoneNumber = "") + } + savedCardsDetectorController?.signOut() + } + + private suspend fun tearDownSavedCardsDetectorAndAwait(): Boolean { + val hadActiveDetector = _savedCardsDetectorKey.value >= 0 + val controller = savedCardsDetectorController + savedCardsDetectorController = null + if (hadActiveDetector) { + _savedCardsDetectorKey.value = -1 + } + return awaitSavedCardsDetectorTearDown( + hadActiveDetector = hadActiveDetector, + controllerAwaitTearDown = controller?.let { c -> suspend { c.awaitTearDown() } }, + awaiter = savedCardsDetectorTearDownAwaiter, + ) + } + + private fun remountSavedCardsDetectorAfterCheckoutInterrupted() { + remountJob?.cancel() + remountJob = + viewModelScope.launch { + remountSavedCardsDetectorAfterCheckoutInterruptedSuspending() + } + } + + private suspend fun remountSavedCardsDetectorAfterCheckoutInterruptedSuspending() { + if ( + !shouldRemountSavedCardsDetector( + _deviceRecognition.value, + remountedDetectorForCheckoutGeneration, + checkoutSessionGeneration, + ) + ) { + return + } + awaitCheckoutInactiveUntilRemount( + isCheckoutActive = { SpreedlyClickToPayCheckout.isActive }, + delay = { delay(it) }, + ) + if ( + !shouldRemountSavedCardsDetector( + _deviceRecognition.value, + remountedDetectorForCheckoutGeneration, + checkoutSessionGeneration, + ) + ) { + return + } + remountedDetectorForCheckoutGeneration = checkoutSessionGeneration + _deviceRecognition.value = DeviceRecognitionState.Checking + _recognizedCardLabels.value = RecognizedCardLabelList() + _savedCardsDetectorKey.value = (_savedCardsDetectorKey.value.coerceAtLeast(0)) + 1 + } + + private fun treatsDeviceAsRecognized(): Boolean = + _deviceRecognition.value == DeviceRecognitionState.Recognized + + fun updateEmail(value: String) { + if (treatsDeviceAsRecognized() && value != _email.value) { + useDifferentEmail() + } + _email.value = value + refreshCustomerIdentityValidity() + } + + fun updateMerchantPrefill(transform: (ClickToPayMerchantPrefill) -> ClickToPayMerchantPrefill) { + _merchantPrefill.value = transform(_merchantPrefill.value) + } + + fun updateFirstName(value: String) { + updateMerchantPrefill { it.copy(firstName = value) } + if (_firstNameError.value != null) { + validateBillingNames() + } + } + + fun updateLastName(value: String) { + updateMerchantPrefill { it.copy(lastName = value) } + if (_lastNameError.value != null) { + validateBillingNames() + } + } + + fun updatePhoneCountryCode(value: String) { + if (treatsDeviceAsRecognized()) { + useDifferentEmail() + } + updateMerchantPrefill { it.copy(phoneCountryCode = value.filter { it.isDigit() }) } + refreshCustomerIdentityValidity() + } + + fun updatePhoneNumber(value: String) { + if (treatsDeviceAsRecognized()) { + useDifferentEmail() + } + updateMerchantPrefill { it.copy(phoneNumber = value.filter { it.isDigit() }) } + refreshCustomerIdentityValidity() + } + + fun updateAddressLine1(value: String) { + updateMerchantPrefill { it.copy(addressLine1 = value) } + } + + fun updateAddressLine2(value: String) { + updateMerchantPrefill { it.copy(addressLine2 = value) } + } + + fun updateCity(value: String) { + updateMerchantPrefill { it.copy(city = value) } + } + + fun updateState(value: String) { + updateMerchantPrefill { it.copy(state = value) } + } + + fun updateZip(value: String) { + updateMerchantPrefill { it.copy(zip = value) } + } + + fun updateCountry(value: String) { + updateMerchantPrefill { it.copy(country = value) } + } + + fun setCopyBillingToShipping(enabled: Boolean) { + updateMerchantPrefill { it.copy(copyBillingToShipping = enabled) } + } + + fun validateBillingNames(): Boolean { + val first = _merchantPrefill.value.firstName.trim() + val last = _merchantPrefill.value.lastName.trim() + _firstNameError.value = if (first.isEmpty()) "First name is required" else null + _lastNameError.value = if (last.isEmpty()) "Last name is required" else null + return first.isNotEmpty() && last.isNotEmpty() + } + + fun validateMerchantContactOnPay(): Boolean { + val namesValid = validateBillingNames() + val identityValid = validateCustomerIdentity() + return identityValid && namesValid + } + + fun clearEmailError() { + _emailError.value = null + } + + fun clearPhoneError() { + _phoneError.value = null + } + + fun validateCustomerIdentity(): Boolean { + val recognizedDevice = treatsDeviceAsRecognized() + val email = _email.value.trim() + val prefill = _merchantPrefill.value + val phone = prefill.phoneNumber.trim() + val countryCode = prefill.phoneCountryCode.trim() + + if (recognizedDevice) { + _phoneError.value = null + _emailError.value = + if (email.isNotEmpty() && !EmailValidator.isValid(email)) { + "Invalid email format" + } else { + null + } + return _emailError.value == null + } + + val hasValidEmail = email.isNotEmpty() && EmailValidator.isValid(email) + val hasPhoneLookup = phone.isNotEmpty() && countryCode.isNotEmpty() + val partialPhone = phone.isNotEmpty() xor countryCode.isNotEmpty() + + if (hasValidEmail || hasPhoneLookup) { + _emailError.value = null + _phoneError.value = null + return true + } + + _emailError.value = + when { + email.isNotEmpty() && !EmailValidator.isValid(email) -> "Invalid email format" + else -> null + } + _phoneError.value = + when { + partialPhone -> "Country code and mobile number are both required for phone lookup" + else -> "Email or phone with country code is required" + } + return false + } + + private fun refreshCustomerIdentityValidity() { + if (_emailError.value != null || _phoneError.value != null) { + validateCustomerIdentity() + } + } + + fun canStartPayment(): Boolean = + _stage.value == Stage.IDLE && _selectedProduct.value != null + + companion object { + fun canStartPayment( + stage: Stage, + selectedProduct: Product?, + deviceRecognition: DeviceRecognitionState, + ): Boolean = + stage == Stage.IDLE && + selectedProduct != null && + deviceRecognition != DeviceRecognitionState.Checking + + fun hasValidCustomerIdentity( + deviceRecognition: DeviceRecognitionState, + email: String, + phoneNumber: String, + phoneCountryCode: String, + ): Boolean { + val recognizedDevice = deviceRecognition == DeviceRecognitionState.Recognized + if (recognizedDevice) { + return email.isBlank() || EmailValidator.isValid(email) + } + return ClickToPayCustomer( + email = email.trim().ifBlank { null }, + phoneNumber = phoneNumber.trim().ifBlank { null }, + countryCode = phoneCountryCode.trim().ifBlank { null }, + ).let { customer -> + (email.isBlank() || EmailValidator.isValid(email)) && customer.isValidForLookup() + } + } + } + + fun clearMessages() { + _errorMessage.value = null + _successMessage.value = null + } + + private suspend fun initializeSdkIfNeeded(): Boolean { + if (spreedlySdk.isInitialized) return true + _isInitializing.value = true + return try { + sdkSessionManager.initializeSdk( + sdk = spreedlySdk, + context = context.applicationContext, + environmentKey = BuildConfig.ENVIRONMENT_KEY, + ).fold( + onSuccess = { true }, + onFailure = { + _errorMessage.value = "Failed to get auth params" + false + }, + ) + } finally { + _isInitializing.value = false + } + } + + private fun appendEvent(message: String) { + val updated = (_eventLog.value.lines + message).takeLast(6) + _eventLog.value = ClickToPayEventLog(updated) + } + + fun setUseCustomTheme(enabled: Boolean) { + themeConfiguration.setUseCustomTheme(enabled) + } + + fun setThemePreset(preset: SampleThemePreset) { + themeConfiguration.setPreset(preset) + } + + fun resetThemeConfiguration() { + themeConfiguration.setUseCustomTheme(false) + } + + fun applyThemeToSdk(isDarkMode: Boolean) { + themeConfiguration.applyGlobalTheme(spreedlySdk, isDarkMode) + } + + override fun onCleared() { + super.onCleared() + paymentResultJob?.cancel() + eventsJob?.cancel() + stateJob?.cancel() + remountJob?.cancel() + } +} + +@Immutable +data class ClickToPayEventLog(val lines: List = emptyList()) + +@Immutable +data class RecognizedCardLabelList(val items: List = emptyList()) diff --git a/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPaySandboxCatalog.kt b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPaySandboxCatalog.kt new file mode 100644 index 0000000..11153d1 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/clicktopay/ClickToPaySandboxCatalog.kt @@ -0,0 +1,71 @@ +package com.spreedly.example.screens.clicktopay + +import com.spreedly.clicktopay.ClickToPayCheckoutConfig +import com.spreedly.clicktopay.ClickToPayCustomer +import com.spreedly.clicktopay.ClickToPayInitConfig +import com.spreedly.clicktopay.ClickToPayMaskedCard + +object ClickToPaySandboxCatalog { + const val SANDBOX_SRC_DPA_ID = "83f255e3-7f82-4441-8782-b17737fa6e29" + const val LOCALE = "en_US" + const val DPA_PRESENTATION_NAME = "Spreedly C2P Sandbox" + const val DPA_NAME = "SpreedlyC2PSandbox" + + const val MERCHANT_PREFILL_HINT = + "Enter email or mobile with country code for lookup. " + + "Billing name is required on Pay; other address fields are optional and sent on tokenize when provided." + + fun labelForMaskedCard(card: ClickToPayMaskedCard): String { + val brand = card.brand.replace("-", " ").replaceFirstChar { it.uppercase() } + return "$brand •••• ${card.lastFour}" + } + + fun buildCheckoutConfig( + email: String, + doLookup: Boolean, + transactionAmount: Double = 100.0, + merchantPrefill: ClickToPayMerchantPrefill = ClickToPayMerchantPrefill(), + currencyCode: String = "USD", + ): ClickToPayCheckoutConfig { + val trimmedEmail = email.trim() + val trimmedPhone = merchantPrefill.phoneNumber.trim() + val countryCode = merchantPrefill.phoneCountryCode.trim() + return ClickToPayCheckoutConfig( + initConfig = + ClickToPayInitConfig( + transactionAmount = transactionAmount, + transactionCurrencyCode = currencyCode, + ), + srcDpaId = SANDBOX_SRC_DPA_ID, + locale = LOCALE, + isSandbox = true, + customer = + ClickToPayCustomer( + email = trimmedEmail.ifBlank { null }, + phoneNumber = trimmedPhone.ifBlank { null }, + countryCode = countryCode.ifBlank { null }, + ), + doLookup = doLookup, + tokenizeBilling = merchantPrefill.makeTokenizeBilling(trimmedEmail), + dpaPresentationName = DPA_PRESENTATION_NAME, + dpaName = DPA_NAME, + ) + } + + fun buildSavedCardsDetectorConfig(transactionAmount: Double = 100.0): ClickToPayCheckoutConfig = + ClickToPayCheckoutConfig( + initConfig = + ClickToPayInitConfig( + transactionAmount = transactionAmount, + transactionCurrencyCode = "USD", + ), + srcDpaId = SANDBOX_SRC_DPA_ID, + locale = LOCALE, + isSandbox = true, + customer = null, + doLookup = true, + merchantHostedCardList = true, + dpaPresentationName = DPA_PRESENTATION_NAME, + dpaName = DPA_NAME, + ) +} diff --git a/app/src/main/java/com/spreedly/example/screens/mainmenu/MainMenuScreen.kt b/app/src/main/java/com/spreedly/example/screens/mainmenu/MainMenuScreen.kt index 148dcd9..f41e387 100644 --- a/app/src/main/java/com/spreedly/example/screens/mainmenu/MainMenuScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/mainmenu/MainMenuScreen.kt @@ -202,6 +202,14 @@ fun MainMenuScreen(navController: NavHostController) { Spacer(Modifier.height(Spacing.md)) + MenuItemCard( + title = stringResource(R.string.menu_item_clicktopay_demo_title), + description = stringResource(R.string.menu_item_clicktopay_demo_description), + onClick = { navController.navigate("clicktopay_demo") }, + ) + + Spacer(Modifier.height(Spacing.md)) + MenuItemCard( title = stringResource(R.string.menu_item_design_system_title), description = stringResource(R.string.menu_item_design_system_description), diff --git a/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt b/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt index 3174364..649288f 100644 --- a/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt +++ b/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt @@ -13,6 +13,7 @@ import com.spreedly.example.screens.bankaccount.BankAccountViewModel import com.spreedly.example.screens.basiccheckout.BasicCheckoutViewModel import com.spreedly.example.screens.bottomsheet.BottomSheetPaymentViewModel import com.spreedly.example.screens.braintreepayment.BraintreePaymentViewModel +import com.spreedly.example.screens.clicktopay.ClickToPayPaymentViewModel import com.spreedly.example.screens.customcheckout.CheckoutWithAdditionalFieldsViewModel import com.spreedly.example.screens.customizedcheckout.CustomisedCheckoutViewModel import com.spreedly.example.screens.customtextfields.CustomTextFieldsViewModel @@ -174,3 +175,7 @@ fun braintreePaymentViewModel(): BraintreePaymentViewModel = viewModelWithContex BraintreePaymentViewModel(context) } +@Composable +fun clickToPayPaymentViewModel(): ClickToPayPaymentViewModel = viewModelWithContext { context -> + ClickToPayPaymentViewModel(context) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a36f8bd..d1f32e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,6 +100,8 @@ Braintree Payment Braintree PayPal and Venmo payments with native SDK checkout flow + Click to Pay + Mastercard Click to Pay — present(), events, and tokenize (physical device recommended for OTP) Java Offsite Payment diff --git a/app/src/test/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModelDetectorLifecycleTest.kt b/app/src/test/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModelDetectorLifecycleTest.kt new file mode 100644 index 0000000..04016eb --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/clicktopay/ClickToPayPaymentViewModelDetectorLifecycleTest.kt @@ -0,0 +1,401 @@ +package com.spreedly.example.screens.clicktopay + +import com.spreedly.clicktopay.ClickToPaySavedCardsDetectorFailure +import com.spreedly.clicktopay.ClickToPaySavedCardsDetectorResult +import com.spreedly.example.screens.common.Product +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ClickToPayPaymentViewModelDetectorLifecycleTest { + private val sampleProduct = + Product( + name = "Test", + description = "Test", + price = 100, + emoji = "🧪", + ) + + @Test + fun `canStartPayment returns true for DetectionFailed when idle and product selected`() { + assertTrue( + ClickToPayPaymentViewModel.canStartPayment( + stage = ClickToPayPaymentViewModel.Stage.IDLE, + selectedProduct = sampleProduct, + deviceRecognition = DeviceRecognitionState.DetectionFailed, + ), + ) + } + + @Test + fun `canStartPayment returns false while device recognition is checking`() { + assertFalse( + ClickToPayPaymentViewModel.canStartPayment( + stage = ClickToPayPaymentViewModel.Stage.IDLE, + selectedProduct = sampleProduct, + deviceRecognition = DeviceRecognitionState.Checking, + ), + ) + } + + @Test + fun `hasValidCustomerIdentity for DetectionFailed requires email or phone like not recognized`() { + assertFalse( + ClickToPayPaymentViewModel.hasValidCustomerIdentity( + deviceRecognition = DeviceRecognitionState.DetectionFailed, + email = "", + phoneNumber = "", + phoneCountryCode = "", + ), + ) + assertTrue( + ClickToPayPaymentViewModel.hasValidCustomerIdentity( + deviceRecognition = DeviceRecognitionState.DetectionFailed, + email = "shopper@example.com", + phoneNumber = "", + phoneCountryCode = "", + ), + ) + } + + @Test + fun `hasValidCustomerIdentity for Recognized allows blank contact fields`() { + assertTrue( + ClickToPayPaymentViewModel.hasValidCustomerIdentity( + deviceRecognition = DeviceRecognitionState.Recognized, + email = "", + phoneNumber = "", + phoneCountryCode = "", + ), + ) + } + + @Test + fun `awaitSavedCardsDetectorTearDown blocks until dispose when no controller`() = runTest { + val awaiter = SavedCardsDetectorTearDownAwaiter() + val tearDown = + async { + awaitSavedCardsDetectorTearDown( + hadActiveDetector = true, + controllerAwaitTearDown = null, + awaiter = awaiter, + ) + } + testScheduler.runCurrent() + assertFalse(tearDown.isCompleted) + + awaiter.signalDisposed() + assertTrue(tearDown.await()) + } + + @Test + fun `awaitSavedCardsDetectorTearDown completes when controller tear down finishes first`() = runTest { + val awaiter = SavedCardsDetectorTearDownAwaiter() + var controllerTearDownDone = false + + assertTrue( + awaitSavedCardsDetectorTearDown( + hadActiveDetector = true, + controllerAwaitTearDown = { + controllerTearDownDone = true + true + }, + awaiter = awaiter, + ), + ) + + assertTrue(controllerTearDownDone) + } + + @Test + fun `deviceRecognitionForDetectorResult maps saved cards to recognized`() { + assertEquals( + DeviceRecognitionState.Recognized, + deviceRecognitionForDetectorResult( + ClickToPaySavedCardsDetectorResult(hasSavedCards = true), + ), + ) + } + + @Test + fun `deviceRecognitionForDetectorResult maps empty success to not recognized`() { + assertEquals( + DeviceRecognitionState.NotRecognized, + deviceRecognitionForDetectorResult( + ClickToPaySavedCardsDetectorResult(hasSavedCards = false), + ), + ) + } + + @Test + fun `deviceRecognitionForDetectorResult maps failure to detection failed`() { + assertEquals( + DeviceRecognitionState.DetectionFailed, + deviceRecognitionForDetectorResult( + ClickToPaySavedCardsDetectorResult( + hasSavedCards = false, + failure = ClickToPaySavedCardsDetectorFailure.Timeout, + ), + ), + ) + assertEquals( + DeviceRecognitionState.DetectionFailed, + deviceRecognitionForDetectorResult( + ClickToPaySavedCardsDetectorResult( + hasSavedCards = false, + failure = ClickToPaySavedCardsDetectorFailure.InitFailed, + ), + ), + ) + assertEquals( + DeviceRecognitionState.DetectionFailed, + deviceRecognitionForDetectorResult( + ClickToPaySavedCardsDetectorResult( + hasSavedCards = false, + failure = ClickToPaySavedCardsDetectorFailure.Error, + ), + ), + ) + } + + @Test + fun `shouldRemountSavedCardsDetector allows remount when detection failed`() { + assertTrue( + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.DetectionFailed, + remountedDetectorForCheckoutGeneration = -1, + checkoutSessionGeneration = 1, + ), + ) + } + + @Test + fun `shouldRemountSavedCardsDetector returns false when using different email`() { + assertFalse( + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.UsingDifferentEmail, + remountedDetectorForCheckoutGeneration = -1, + checkoutSessionGeneration = 1, + ), + ) + } + + @Test + fun `shouldRemountSavedCardsDetector returns false when already remounted for session`() { + assertFalse( + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.Recognized, + remountedDetectorForCheckoutGeneration = 2, + checkoutSessionGeneration = 2, + ), + ) + } + + @Test + fun `shouldRemountSavedCardsDetector allows at most one remount per checkout session generation`() { + val generation = 3 + val first = + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.Recognized, + remountedDetectorForCheckoutGeneration = -1, + checkoutSessionGeneration = generation, + ) + assertTrue(first) + + val secondAfterAcquire = + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.Recognized, + remountedDetectorForCheckoutGeneration = generation, + checkoutSessionGeneration = generation, + ) + assertFalse(secondAfterAcquire) + } + + @Test + fun `awaitCheckoutInactiveForRemount polls until checkout inactive`() = runTest { + var checks = 0 + val inactive = + async { + awaitCheckoutInactiveForRemount( + isCheckoutActive = { + ++checks <= 2 + }, + delayMs = 50, + timeoutMs = 10_000, + delay = { kotlinx.coroutines.delay(it) }, + nowMs = { 0L }, + ) + } + testScheduler.runCurrent() + assertEquals(1, checks) + advanceTimeBy(50) + testScheduler.runCurrent() + assertEquals(2, checks) + advanceTimeBy(50) + testScheduler.runCurrent() + assertTrue(inactive.await()) + assertEquals(3, checks) + } + + @Test + fun `awaitCheckoutInactiveForRemount returns false when checkout still active after timeout`() = + runTest { + val inactive = + awaitCheckoutInactiveForRemount( + isCheckoutActive = { true }, + delayMs = 50, + timeoutMs = 200, + delay = { kotlinx.coroutines.delay(it) }, + nowMs = { testScheduler.currentTime }, + ) + assertFalse(inactive) + } + + @Test + fun `awaitCheckoutInactiveUntilRemount waits until checkout inactive`() = runTest { + var checks = 0 + val inactive = + async { + awaitCheckoutInactiveUntilRemount( + isCheckoutActive = { + ++checks <= 2 + }, + delayMs = 50, + delay = { kotlinx.coroutines.delay(it) }, + ) + } + testScheduler.runCurrent() + assertEquals(1, checks) + advanceTimeBy(50) + testScheduler.runCurrent() + assertEquals(2, checks) + advanceTimeBy(50) + testScheduler.runCurrent() + inactive.await() + assertEquals(3, checks) + } + + @Test + fun `awaitCheckoutInactiveUntilRemount keeps waiting past remount timeout window`() = runTest { + var checkoutActive = true + val inactive = + async { + awaitCheckoutInactiveUntilRemount( + isCheckoutActive = { checkoutActive }, + delayMs = 50, + delay = { kotlinx.coroutines.delay(it) }, + ) + } + testScheduler.runCurrent() + advanceTimeBy(CHECKOUT_INACTIVE_REMOUNT_TIMEOUT_MS + 200) + testScheduler.runCurrent() + assertFalse(inactive.isCompleted) + checkoutActive = false + advanceTimeBy(50) + testScheduler.runCurrent() + inactive.await() + } + + @Test + fun `awaitCheckoutInactiveUntilRemount stops when coroutine cancelled`() = runTest { + val inactive = + async { + awaitCheckoutInactiveUntilRemount( + isCheckoutActive = { true }, + delayMs = 50, + delay = { kotlinx.coroutines.delay(it) }, + ) + } + testScheduler.runCurrent() + inactive.cancel() + try { + inactive.await() + } catch (_: CancellationException) { + } + assertTrue(inactive.isCancelled) + } + + @Test + fun `awaitSavedCardsDetectorTearDown completes after timeout when dispose never signals`() = runTest { + val awaiter = SavedCardsDetectorTearDownAwaiter() + val tearDown = + async { + awaitSavedCardsDetectorTearDown( + hadActiveDetector = true, + controllerAwaitTearDown = null, + awaiter = awaiter, + timeoutMs = 5_000, + ) + } + testScheduler.runCurrent() + assertFalse(tearDown.isCompleted) + advanceTimeBy(5_000) + assertFalse(tearDown.await()) + } + + @Test + fun `awaitSavedCardsDetectorTearDown returns false when controller reports tear down timeout`() = + runTest { + val awaiter = SavedCardsDetectorTearDownAwaiter() + + assertFalse( + awaitSavedCardsDetectorTearDown( + hadActiveDetector = true, + controllerAwaitTearDown = { false }, + awaiter = awaiter, + ), + ) + } + + @Test + fun `SavedCardsDetectorTearDownAwaiter begin completes immediately when dispose signaled first`() = + runTest { + val awaiter = SavedCardsDetectorTearDownAwaiter() + awaiter.signalDisposed() + val deferred = awaiter.begin() + assertTrue(deferred.isCompleted) + deferred.await() + } + + @Test + fun `awaitSavedCardsDetectorTearDown does not hang when dispose signaled before begin`() = runTest { + val awaiter = SavedCardsDetectorTearDownAwaiter() + awaiter.signalDisposed() + assertTrue( + awaitSavedCardsDetectorTearDown( + hadActiveDetector = true, + controllerAwaitTearDown = null, + awaiter = awaiter, + timeoutMs = 5_000, + ), + ) + } + + @Test + fun `shouldRemountSavedCardsDetector allows remount again after new checkout session generation`() { + val priorGeneration = 4 + val newGeneration = 5 + assertFalse( + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.Recognized, + remountedDetectorForCheckoutGeneration = priorGeneration, + checkoutSessionGeneration = priorGeneration, + ), + ) + assertTrue( + shouldRemountSavedCardsDetector( + deviceRecognition = DeviceRecognitionState.Recognized, + remountedDetectorForCheckoutGeneration = priorGeneration, + checkoutSessionGeneration = newGeneration, + ), + ) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 07cf921..26e3a42 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the Spreedly Android SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-07-30 + +### Added + +- **Click to Pay** (`clicktopay`) — optional `:clicktopay` artifact with Mastercard WebView checkout (not present in the `1.2.0` artifact). Entry points: `SpreedlyClickToPayCheckout` (`present`, `cancel`, `events`, `state`, `tokenize`, lookup/OTP helpers), drop-in `SpreedlyClickToPayButton` / `ClickToPayBrandedButton`, and `ClickToPaySavedCardsDetector` for pre-checkout Remember-me recognition (tear down before `present()`). Sandbox new-user enrollment via `ClickToPayCheckoutConfig.sandboxEnrollmentCard` (in-memory only; rejected in production). Default UI uses MC `src-card-list` with native SPL CVV/pay; sheet chrome follows `Spreedly.setGlobalTheme()` via `SpreedlyAdaptiveGlobalTheme` (pay actions keep Mastercard SRC branding). WebView is hardened (Mastercard host allowlist, scheme deny-list, bridge method/size/forbidden-key guards, DCF popup policies); public `CheckoutComplete` carries metadata only (no PAN/CVV). See [Click to Pay Integration Guide](guides/click-to-pay.md). +- **Mandate passthrough on tokenization** (`payments-core`, `paymentsheet`, `hostedfields`) — optional `mandate` on tokenize APIs and drop-in sheets, forwarded verbatim to Spreedly at `payment_method.mandate` and omitted when null or empty. Accepts a `Map` (nested values preserved; pre-parsed `JsonObject` allowed). Spreedly owns schema validation; the SDK does not cap or validate mandate contents. Wire semantics follow ECMA-262 `JSON.stringify` (`NaN`/`Infinity` → `null`; `Date`/`Instant`/`UUID`/`URL`/`URI` → canonical string). Unrepresentable values or reference cycles fail tokenization with the offending key path. Mandate contents are never logged. Documented in express, ACH, and custom-form guides. + +### Breaking Changes + +- **`SpreedlyBottomSheet` / `SpreedlyBankAccountBottomSheet` Compose signatures** (`paymentsheet`) — optional trailing `mandate` changes the Compose-generated method name. Recompile consumers against the new `paymentsheet` AAR. Kotlin callers using defaults are source-compatible; Java `PaymentSheetJavaHelper.setupContent` keeps prior overload arities. +- **Tokenize APIs gain trailing `mandate`** (`payments-core`) — `Spreedly.createCreditCard` / `createBankAccount` / `createPaymentMethod` (and matching `SpreedlyPaymentManager` methods) take optional `mandate: Map?`. Kotlin defaults remain source-compatible; Java callers must pass `null` or a map. + ## [1.2.0] - 2026-07-22 ### Added diff --git a/docs/README.md b/docs/README.md index f3967f7..48aaed3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ | [Recaching](guides/recaching.md) | CVV recaching for saved payment methods | | [ACH Bank Account](guides/ach-bank-account.md) | Tokenize bank accounts with pre-built UI, custom layout, or headless flow | | [Offsite Payments](guides/offsite-payments.md) | PayPal, Pix, Boleto via Chrome Custom Tabs | +| [Click to Pay](guides/click-to-pay.md) | Mastercard Click to Pay / Unified Checkout via WebView | | [Stripe APM](guides/stripe-apm.md) | iDEAL, Bancontact, EPS, P24, SEPA via Stripe | | [Stripe Radar](guides/stripe-radar.md) | Device fingerprinting for fraud detection via Stripe Radar | | [Braintree APM](guides/braintree-apm.md) | PayPal and Venmo via Braintree | diff --git a/docs/guides/ach-bank-account.md b/docs/guides/ach-bank-account.md index 068c26f..980323d 100644 --- a/docs/guides/ach-bank-account.md +++ b/docs/guides/ach-bank-account.md @@ -90,6 +90,7 @@ SpreedlyBankAccountBottomSheet( | `metadata` | `Map` | `emptyMap()` | Metadata attached to the payment method | | `additionalFields` | `Map` | `emptyMap()` | Extra fields passed directly (e.g., address) | | `onPaymentResult` | `((PaymentResult) -> Unit)?` | `null` | Inline result callback | +| `mandate` | `Map?` | `null` | Opaque mandate object forwarded verbatim to Spreedly at `payment_method.mandate`. Omitted when null or empty. Spreedly validates its contents; the SDK does not | ### With Result Callback diff --git a/docs/guides/click-to-pay.md b/docs/guides/click-to-pay.md new file mode 100644 index 0000000..7c081c3 --- /dev/null +++ b/docs/guides/click-to-pay.md @@ -0,0 +1,705 @@ +# Click to Pay Integration Guide + +A practical guide for integrating Mastercard Click to Pay checkout into your Android app using the +Spreedly SDK `:clicktopay` module. + +## Table of Contents + +- [Introduction](#introduction) +- [Prerequisites](#prerequisites) +- [Project Setup](#project-setup) +- [How Click to Pay Works](#how-click-to-pay-works) +- [Kotlin Integration](#kotlin-integration) +- [Java Integration](#java-integration) +- [Auto-tokenize](#auto-tokenize) +- [Integration contract](#integration-contract) +- [WebView security](#webview-security) +- [PCI and sensitive data](#pci-and-sensitive-data) +- [Production checklist](#production-checklist) +- [Events and State](#events-and-state) +- [Error Handling](#error-handling) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) +- [API Reference](#api-reference) + +--- + +## Introduction + +Click to Pay (C2P) lets customers pay with saved Mastercard network cards after identity verification +(OTP). The Spreedly Android SDK hosts the Mastercard `lib.js` checkout in a hardened WebView and +bridges MC lifecycle events to your app through `SpreedlyClickToPayCheckout`. + +### Key characteristics + +- **Pattern B singleton** — call `SpreedlyClickToPayCheckout.present()` from your Activity; only one checkout session is active — a second `present()` finishes the prior activity +- **WebView + native bridge** — MC script runs in `c2p-host.html`; CVV for tokenize stays on the native side +- **Separate module** — MC WebView code is isolated in `:clicktopay`; include `:hostedfields` when using native SPL card fields +- **Optional auto-tokenize** — after MC checkout completes, the SDK can call `SpreedlyClickToPayCheckout.tokenize` and emit `PaymentMethodTokenized` + +--- + +## Prerequisites + +1. **Spreedly account** with Click to Pay enabled and a sandbox or production DPA ID (`srcDpaId`) +2. **Spreedly SDK initialized** via `Spreedly.init(options)` with fresh enhanced auth (nonce, signature, timestamp, certificate) +3. **Physical device recommended** for sandbox OTP and DCF (device cardholder flows) +4. See the [Compatibility table](../../README.md#compatibility) in the README for Android API level requirements + +--- + +## Project Setup + +### 1. Configure the Maven Repository + +Add the Spreedly GitHub Packages repository as described in [Getting Started — Install](getting-started.md#1-install). + +### 2. Add dependencies + +```kotlin +dependencies { + implementation("com.spreedly:checkout-clicktopay:$spreedlyVersion") + // Required when using native SPL card/CVV fields (default checkout UI) + implementation("com.spreedly:checkout-hostedfields:$spreedlyVersion") +} +``` + +`:clicktopay` transitively includes `payments-core` (`api` dependency). + +### 3. AndroidManifest (automatic) + +`ClickToPayCheckoutActivity` is declared in the module manifest and merged into your app. You do not +register it manually. + +### 4. No Click to Pay module = zero impact + +Without the `:clicktopay` artifact, no C2P classes or WebView assets are packaged in your APK. + +--- + +## How Click to Pay Works + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌──────────┐ +│ Merchant App │ │ SpreedlyClickToPay│ │ MC lib.js │ │ Spreedly │ +│ │ │ Checkout (WebView)│ │ (sandbox) │ │ API │ +└──────┬───────┘ └────────┬─────────┘ └──────┬──────┘ └────┬─────┘ + │ │ │ │ + │ present(config) │ │ │ + │────────────────────►│ load c2p-host.html │ │ + │ │────────────────────►│ │ + │ │◄── OTP / cards ─────│ │ + │◄── ClickToPayEvent ─│ │ │ + │ │ │ │ + │ (auto-tokenize) │ SpreedlyClickToPayCheckout.tokenize │ + │ │──────────────────────────────────────►│ + │◄── PaymentMethodTokenized / paymentResultFlow ──────────────│ +``` + +1. Merchant builds `ClickToPayCheckoutConfig` (DPA ID, customer email/phone, sandbox flag). +2. `present(config, activity)` opens `ClickToPayCheckoutActivity`. +3. WebView loads MC script; orchestrator handles lookup, OTP, card selection, and checkout. +4. Saved cards: MC `src-card-list` in the WebView; SPL CVV and pay stay native in the bottom bar. +5. On success, `ClickToPayEvent.CheckoutComplete` carries `ClickToPayMetadata` (no PAN). +6. The SDK auto-tokenizes with SPL CVV and emits `PaymentMethodTokenized`. + +### Lookup flow (iframe parity) + +When `doLookup = true` (default), the orchestrator runs: + +1. **getCards** — if the device has Remember-me cards, they load immediately. +2. **signOut** (internal) — when getCards is empty and `customer` has email or phone, the SDK clears any stale MC session before profile lookup so OTP targets the entered identity. +3. **idLookup** — MC resolves the consumer profile for that identity. +4. OTP and card list follow idLookup when `consumerPresent` is true; new-user enrollment when false. + +There is no `lookupStrategy` config — this sequence matches the Spreedly iFrame default (including sign-out before identity lookup). + +### Pre-checkout saved-card detector + +Use `ClickToPaySavedCardsDetector` when the merchant screen needs to know whether Remember-me cards exist **before** opening checkout. The primary integration pattern below unmounts the detector before `present()` — follow that same `savedCardsDetectorKey` gate in your screen. + +Rules: + +- The detector forces `merchantHostedCardList = true` so MC publishes masked metadata only (no `src-card-list` UI in the detector WebView). +- **Unmount the detector** (`savedCardsDetectorKey = -1` or equivalent) and await tear-down **before** `SpreedlyClickToPayCheckout.present(...)`. Two concurrent MC WebViews share process cookies and can race. +- Await tear-down whenever the detector was mounted (`savedCardsDetectorKey >= 0` before unmount), not only when `onControllerReady` has fired. Hold the controller from `onControllerReady` and call `awaitTearDown()` after unmount; if the controller is still null after unmount, await `onDetectorDisposed` (for example via a `CompletableDeferred` completed in that callback) so `present()` does not race ahead. Do **not** rely on `controller?.awaitTearDown()` alone — that no-ops when the controller is null and skips the wait entirely. Prefer `onDetectorDisposed` + `awaitTearDown()`; do not use `withFrameNanos` as the primary tear-down signal. `awaitTearDown()` returns `true` when dispose completed within 5s and `false` on timeout — **do not call `present()` when it returns `false`**; show an error or retry after the detector finishes unmounting. +- `SpreedlyClickToPayCheckout.present()` **rejects** (emits `C2P_INIT` error) while a detector composable is still mounted. Tear down first, then present. +- After checkout completes, cancels, or errors, remount the detector only when `!SpreedlyClickToPayCheckout.isActive` (bump `savedCardsDetectorKey`) so recognition can pick up Remember-me cards saved during that session. If the shopper chose "Not you?", keep the typed-email UI and skip remount until they leave that mode. +- Call `ClickToPaySavedCardsDetectorController.signOut()` when the shopper chooses a different identity ("Not you?"). +- Empty wallet: `hasSavedCards = false` and `failure = null`. Timeout / init / error: `hasSavedCards = false` and `failure` set (`Timeout`, `InitFailed`, or `Error`) — do not treat failure as an empty wallet. + +--- + +## Kotlin Integration + +### 1. Initialize Spreedly + +```kotlin +Spreedly.init( + context = applicationContext, + options = SpreedlySDKInitOptions( + environmentKey = "...", + // enhanced auth from your backend + nonce = nonce, + timestamp = timestamp, + certificateToken = certificateToken, + signature = signature, + ), +) +``` + +### 2. Subscribe to events + +```kotlin +lifecycleScope.launch { + SpreedlyClickToPayCheckout.events.collect { event -> + when (event) { + is ClickToPayEvent.CheckoutComplete -> { /* metadata; SDK auto-tokenizes */ } + is ClickToPayEvent.PaymentMethodTokenized -> { /* use event.token */ } + is ClickToPayEvent.Error -> { /* show event.message */ } + is ClickToPayEvent.CheckoutCancelled -> { } + else -> { /* OTP, lookup, lifecycle — see ClickToPayEvent KDoc */ } + } + } +} +``` + +### 3. Present checkout + +Build config once. On the **store checkout screen**, gate the saved-card detector with a key (`>= 0` mounted, `-1` unmounted) and tear it down before opening checkout: + +```kotlin +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.spreedly.clicktopay.ClickToPayButtonConfig +import com.spreedly.clicktopay.ui.ClickToPaySavedCardsDetector +import com.spreedly.clicktopay.ui.ClickToPaySavedCardsDetectorController +import com.spreedly.clicktopay.ui.SpreedlyClickToPayButton +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +val config = + ClickToPayCheckoutConfig( + initConfig = ClickToPayInitConfig(), + srcDpaId = "your-dpa-id", + isSandbox = true, + customer = ClickToPayCustomer(email = "shopper@example.com"), + dpaPresentationName = "Your Shop", + dpaName = "Your Shop", + ) + +var savedCardsDetectorKey by remember { mutableIntStateOf(0) } +var detectorController by remember { + mutableStateOf(null) +} +var disposeSignal by remember { mutableStateOf(CompletableDeferred()) } + +if (savedCardsDetectorKey >= 0) { + ClickToPaySavedCardsDetector( + config = config, // customer ignored; detector forces customer = null + detectorKey = savedCardsDetectorKey, + onControllerReady = { detectorController = it }, + onDetectorDisposed = { disposeSignal.complete(Unit) }, + onResult = { result -> + when { + result.hasSavedCards -> { /* Welcome back — result.savedCards */ } + result.failure != null -> { /* timeout/init/error — not an empty wallet */ } + else -> { /* no Remember-me cards */ } + } + }, + ) +} + +suspend fun tearDownSavedCardsDetectorIfMounted(): Boolean { + val wasMounted = savedCardsDetectorKey >= 0 + val controller = detectorController + val signal = disposeSignal + detectorController = null + if (!wasMounted) { + return true + } + disposeSignal = CompletableDeferred() + savedCardsDetectorKey = -1 + return if (controller != null) { + controller.awaitTearDown() + } else { + withTimeoutOrNull(5_000) { signal.await() } != null + } +} + +// Drop-in src-button (recommended) — await detector tear-down before present() +SpreedlyClickToPayButton( + checkoutConfig = config, + buttonConfig = ClickToPayButtonConfig(isDark = isDarkMode), // merchant-provided isDarkMode + prepareForPresentation = { + if (!refreshAuthAndValidate()) return@SpreedlyClickToPayButton false // merchant-provided + if (!tearDownSavedCardsDetectorIfMounted()) return@SpreedlyClickToPayButton false + true + }, + modifier = Modifier.fillMaxWidth(), +) +``` + +**Manual `present()`** — same unmount + `awaitTearDown()` / `onDetectorDisposed` before `present()`: + +```kotlin +suspend fun openClickToPay(activity: Activity): Boolean { + if (!refreshAuthAndValidate()) return false // merchant-provided + if (!tearDownSavedCardsDetectorIfMounted()) return false + SpreedlyClickToPayCheckout.present(config, activity) + return true +} +``` + +If you cannot wire `onDetectorDisposed`, a single-frame `withFrameNanos { }` after unmount is a last resort only — it does not guarantee WebView detach and can race on slow devices. + +### 3a. Merchant entry button (`src-button`) + +On your **store checkout screen** (before opening the C2P sheet), use the official Mastercard +[src-button](https://developer.mastercard.com/unified-checkout-solutions/documentation/ui-components/) +component — not a themed Material button. + +The [§3 sample](#3-present-checkout) is the safe copy-paste path: `SpreedlyClickToPayButton` with +`prepareForPresentation` that unmounts `ClickToPaySavedCardsDetector` when it was mounted and +awaits tear-down (`awaitTearDown()` and/or `onDetectorDisposed`) before checkout opens. + +**Legacy** — manual tap wiring (still await detector tear-down first): + +```kotlin +ClickToPayBrandedButton( + onClick = { + lifecycleScope.launch { + tearDownSavedCardsDetectorIfMounted() + SpreedlyClickToPayCheckout.present(config, activity) + } + }, + enabled = checkoutEnabled, + cardBrands = config.initConfig.cardBrands, + isSandbox = config.isSandbox, + locale = config.locale, + modifier = Modifier.fillMaxWidth(), +) +``` + +Java merchants can embed the drop-in via [ClickToPayButtonJavaHelper.setupContent]. When a +`ClickToPaySavedCardsDetector` is mounted, use the overload that takes +[ClickToPayPrepareForPresentation] and call `onReady.accept(true)` only after tear-down finishes: + +```java +ClickToPayButtonJavaHelper.setupContent( + composeView, + config, + buttonConfig, + activity, + onReady -> { + // Unmount detector, await tear-down on your controller, then on the main thread: + onReady.accept(true); + } +); +``` + +Do not use the deprecated `BooleanSupplier` overload for detector tear-down — it cannot await. + +Inside the C2P sheet, `src-card-list` and `src-otp-input` run in the checkout WebView. SPL CVV and +the pay action after card selection stay native (iframe parity). Manual card entry is triggered from +the MC card list — not a duplicate native link. + +### Global theme + +Click to Pay checkout applies your merchant global theme to sheet chrome (scaffold background, +typography, labels, identity fields). Call `Spreedly.setGlobalTheme()` **before** `present()` — there +is no per-present theme parameter on Click to Pay. + +```kotlin +Spreedly.setGlobalTheme( + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color(0xFF0052CC), + background = Color.White, + text = Color(0xFF18181B), + ), + ), +) + +SpreedlyClickToPayCheckout.present(config, activity) +``` + +The checkout activity wraps content in `SpreedlyAdaptiveGlobalTheme` (iOS +`View.spreedlyAdaptiveGlobalTheme()` parity). **Pay actions** inside the sheet use Mastercard SRC +branding (`ClickToPayPrimaryButton`, black background) — not your `primary` color. Use +[ClickToPayBrandedButton] on your store screen for the official `src-button` entry point. + +### Remember me + +When `ClickToPayOtpConfig.rememberMe` is `true`, the default checkout sheet shows a **Remember me** +toggle on the native saved-card CVV bar. The MC card-list remember-me control stays off to avoid +duplicate toggles. OTP-phase Remember me remains in the MC WebView when configured. + +```kotlin +ClickToPayCheckoutConfig( + // ... + otp = ClickToPayOtpConfig(rememberMe = true), +) +``` + +### 4. Cancel + +```kotlin +SpreedlyClickToPayCheckout.cancel() +``` + +--- + +## Java Integration + +Use `SpreedlyClickToPayCheckout` from Kotlin; for Java, collect events on the main thread: + +```java +CoroutineScope scope = LifecycleOwnerKt.getLifecycleScope(activity); +BuildersKt.launch(scope, EmptyCoroutineContext.INSTANCE, CoroutineStart.DEFAULT, + (scope1, continuation) -> { + FlowKt.collect( + SpreedlyClickToPayCheckout.INSTANCE.getEvents(), + event -> { + if (event instanceof ClickToPayEvent.PaymentMethodTokenized) { + String token = ((ClickToPayEvent.PaymentMethodTokenized) event).getToken(); + // handle token + } + return Unit.INSTANCE; + }, + continuation + ); + return Unit.INSTANCE; + }); +``` + +Prefer a thin Kotlin facade in mixed codebases. + +--- + +## Auto-tokenize + +After MC checkout COMPLETE, the SDK: + +1. Collects CVV on the native saved-card list (SPL field). +2. Calls `SpreedlyClickToPayCheckout.tokenize(metadata, verificationValue, billing)`. +3. Emits `ClickToPayEvent.PaymentMethodTokenized` on success. + +MC checkout can outlive your init nonce. Register a refresher before long sessions: + +```kotlin +SpreedlyClickToPayCheckout.setAutoTokenizeAuthRefresher { + // fetch fresh nonce/signature from your backend, call Spreedly.init(), return true on success + refreshSpreedlyAuth() +} +``` + +For merchant-hosted saved-card checkout, call `SpreedlyClickToPayCheckout.tokenize()` yourself after +`CheckoutComplete` (see below). + +### `CheckoutComplete` and PCI + +- `CheckoutComplete` carries `ClickToPayMetadata` only (no PAN, no CVV). +- The SDK retains SPL CVV internally until tokenize completes on the default checkout path. +- Merchant-hosted checkout supplies CVV at `checkoutSelectedCard()` / `tokenize()` call time. + +### Merchant-hosted saved-card list + +Set `merchantHostedCardList = true` on [ClickToPayCheckoutConfig] to hide the SDK native card list +and render masked cards from [ClickToPayEvent.DisplayCardsReady] in your own UI (iframe `displayCardsEl` parity). + +```kotlin +val config = + ClickToPayCheckoutConfig( + initConfig = ClickToPayInitConfig(), + srcDpaId = "your-src-dpa-id", + customer = ClickToPayCustomer(email = "shopper@example.com"), + merchantHostedCardList = true, + dpaPresentationName = "Your Store", + dpaName = "Your Store", + ) + +SpreedlyClickToPayCheckout.present(config, activity) + +// After DisplayCardsReady: +SpreedlyClickToPayCheckout.selectCard(card.srcDigitalCardId) +SpreedlyClickToPayCheckout.checkoutSelectedCard( + srcDigitalCardId = card.srcDigitalCardId, + verificationValue = cvvFromYourField, + rememberMe = false, +) +``` + +`checkoutSelectedCard` maps to iframe `c2pCheckout({ isCheckoutWithCard: true })`. CVV is never logged +or sent over the JS bridge. Call `SpreedlyClickToPayCheckout.tokenize()` after `CheckoutComplete` on +this path. + +--- + +## Integration contract + +Mastercard Unified Checkout Solutions (UCS) mobile integration requirements enforced by the SDK: + +| Topic | Value | +|-------|-------| +| Lib URL path | `/srci/integration/2/lib.js` (not legacy `/srci/merchant/2/lib.js`) | +| Sandbox base | `https://sandbox.src.mastercard.com` | +| Production base | `https://src.mastercard.com` | +| Query params | `srcDpaId`, `locale` | +| Init payload | `dpaTransactionOptions.paymentOptions[].dynamicDataType = "NONE"` | +| UAT signoff | Spreedly/MC must confirm lib URL and init payload on sandbox before production — track in your release ticket | + +Pinned references: + +- [UCS getting started](https://developer.mastercard.com/unified-checkout-solutions/documentation/gettingstarted/) +- [Click to Pay use cases](https://developer.mastercard.com/unified-checkout-solutions/documentation/use-cases/click-to-pay/) (recognized, lookup/OTP, first-time user) +- [UCS mobile tutorial](https://developer.mastercard.com/unified-checkout-solutions/tutorial/mobile/) +- [UCS tutorials and guides](https://developer.mastercard.com/unified-checkout-solutions/documentation/tutorials-guides/) +- [UCS mobile SDK reference](https://developer.mastercard.com/unified-checkout-solutions/documentation/sdk-reference/mobile/) +- [UCS UI components / src-button](https://developer.mastercard.com/unified-checkout-solutions/documentation/ui-components/) +- [Android web-native reference app](https://github.com/Mastercard/web-native-integration) (MC sample merchant WebView integration) + +Unit tests lock sandbox/prod lib URLs and `dynamicDataType` via `ClickToPayMcSpecFixtures`. + +--- + +## WebView security + +Mastercard’s mobile Click to Pay integration requires a WebView host. The SDK hardens that surface: + +| Control | Purpose | +|---------|---------| +| `SecureScreen()` | Blocks screenshots / screen recording during checkout | +| HTTPS navigation allowlist | Only `*.src.mastercard.com` hosts (see table below) | +| Explicit scheme deny-list | Blocks `content://`, `file://`, `android.resource://`, `javascript:`, and `intent://` in navigation and subresource loads | +| Inline scheme policy | `about:` and main-frame `data:` pass-through in `shouldInterceptRequest` for `loadDataWithBaseURL` bootstrap; subresource `data:`/`blob:` allowed for MC assets; top-level `data:`/`blob:` navigation blocked in `shouldOverrideUrlLoading`; main-frame `blob:` blocked in `shouldInterceptRequest` | +| `MIXED_CONTENT_NEVER_ALLOW` | Blocks mixed HTTP/HTTPS content | +| Bridge method allowlist + schemas | Rejects unknown `postMessage` methods and malformed payloads | +| Payload value sanitization | Redacts PAN/CVV patterns in accepted bridge string values before orchestrator handling | +| Payload size cap | Rejects oversized bridge messages | +| Forbidden sensitive keys | Bridge payloads cannot carry PAN/CVV keys from JS (normalized key match on objects and arrays) | +| No bridge param retention | Outbound bridge commands are not stored in production | +| `removeJavascriptInterface` on detach | Bridge removed when WebView is destroyed | +| `addJavascriptInterface` | Required by MC SDK for native↔JS communication | +| DCF popup handling | `onCreateWindow` opens a child WebView with the same URL policies and hardened settings | +| Branded button `WebMessageListener` | Mastercard-only `allowedOriginRules` (`https://src.mastercard.com`, `https://*.src.mastercard.com`); rejects non-main-frame and untrusted `sourceOrigin` | +| Branded button message cap | 16 KiB max on `C2pBrandedButtonBridge` postMessage payloads | + +### Host allowlist + +| Host pattern | Allowed | Notes | +|--------------|---------|-------| +| `sandbox.src.mastercard.com` | Yes | Sandbox MC script and checkout | +| `src.mastercard.com` | Yes | Production MC script and checkout | +| `*.src.mastercard.com` | Yes | MC subdomains (DCF, assets); suffix match rejects typosquat hosts like `evil.src.mastercard.com.evil.com` | +| Any other HTTPS host | No | Blocked in navigation and subresource loads | + +### DCF popup WebView + +Mastercard device cardholder (DCF) flows may open a child WebView via `onCreateWindow`. The SDK creates a popup with `ClickToPayMcWebViewClient`, `allowContentAccess=false`, and the same scheme deny-list as the host WebView. MC may attach the popup as a hidden window reference (`transport.webView = popup`) without adding it to your layout — this is an accepted MC pattern. Validate DCF completion on a physical device before go-live (see [Testing](#testing)). + +### Accepted risks + +`addJavascriptInterface` exposes the native bridge to **all frames** in the WebView, not only the +trusted MC origin. Phase guards, method allowlist, payload size cap, sensitive-key filter, value +sanitization, and `removeJavascriptInterface` on detach reduce abuse surface but do not eliminate +it. This is an **accepted risk** of the MC mobile integration model. + +Host HTML loads via `loadDataWithBaseURL` with an HTTPS Mastercard base URL. Main-frame `data:` +responses in `shouldInterceptRequest` are required for that bootstrap on device WebViews. +Subresource `data:` loads remain allowed for MC inline assets; top-level navigation to `data:` and +`blob:` stays blocked in `shouldOverrideUrlLoading`, and main-frame `blob:` is blocked in +`shouldInterceptRequest`. + +JavaScript, DOM storage, third-party cookies, and multiple windows are enabled because the MC +`lib.js` SDK and DCF flows require them. CVV for tokenize is collected in native SPL fields and +never sent over the JS bridge. + +Reference: [Mastercard Unified Checkout Solutions](https://developer.mastercard.com/unified-checkout-solutions/documentation/sdk-reference/mobile/). + +--- + +## PCI and sensitive data + +```mermaid +flowchart LR + subgraph merchant [Merchant app] + App[Your Activity] + end + subgraph sdk [Spreedly C2P SDK] + Singleton[SpreedlyClickToPayCheckout] + NativeCVV[Native SPL CVV field] + NativePAN[Native PAN holder] + Bridge[Hardened JS bridge] + end + subgraph mc [Mastercard WebView] + LibJs[lib.js on src.mastercard.com] + end + subgraph spreedly [Spreedly API] + Tokenize[createPaymentMethod] + end + App -->|present config| Singleton + Singleton --> LibJs + LibJs -->|metadata only| Bridge + Bridge --> Singleton + NativeCVV -->|tokenize only| Tokenize + NativePAN -->|encryptCard enrollNewUser| LibJs + Singleton -->|PaymentMethodTokenized| App +``` + +**PCI scope:** PCI scope depends on the merchant integration, deployment model, and assessor +interpretation. The saved-card path is designed to minimize merchant PAN exposure because +Mastercard hosts card data and the SDK only uses native CVV for tokenization, but this guide does +**not** assert SAQ-A eligibility. Merchants should confirm their applicable PCI scope with their +QSA, acquirer, and Spreedly compliance guidance. + +**Enrollment and new-card paths** briefly pass PAN/CVV through native memory into the trusted MC +WebView for `encryptCard` / `enrollNewUser`. Those paths require formal PCI scope review and +Spreedly/compliance signoff before production. + +**Sensitive data policy:** No PAN or CVV should be logged, persisted, parceled, or emitted in +public events. The saved-card flow still handles CVV transiently in native SPL fields; new-card and +enrollment flows handle PAN/CVV transiently before sending to the Mastercard WebView. + +During enrollment and new-card checkout the SDK briefly holds PAN/CVV in native memory and passes +them to the trusted Mastercard WebView host via `evaluateJavascript` for MC `encryptCard` / +`enrollNewUser`. The WebView host runs only on Mastercard HTTPS origins with hardened settings +(no file/content access, scheme deny-list, bridge method allowlist). Sensitive data is cleared on +checkout complete, cancel, failure, WebView detach, and MC checkout branch actions (`CANCEL`, +`CHANGE_CARD`, `ADD_CARD`, `SWITCH_CONSUMER`, missing/unknown action codes). + +| Data | Where it flows | Merchant exposure | +|------|----------------|-------------------| +| MC metadata (flow/correlation IDs) | `CheckoutComplete` event | Safe to persist | +| CVV | Native SPL field → internal tokenize path | Never on public events | +| Payment method token | `PaymentMethodTokenized` event / `paymentResultFlow` | Use `event.token`; never log the event (`toString` redacts token and nested `paymentMethod`) | +| Sandbox test PAN/CVV | `sandboxEnrollmentCard` config | Sandbox only; in-memory holder (not `Parcelable`); rejected in production | + +### Sensitive data flow by mode + +| Mode | Where PAN lives | Where CVV lives | Clearing trigger | +|------|-----------------|-----------------|------------------| +| MC WebView (saved card) | MC SDK only (never crosses bridge) | Native SPL field → tokenize | Checkout complete, cancel, fail, detach | +| MC WebView (new card + enroll) | Native memory → JS command to trusted MC WebView → `encryptCard` → MC | Native memory → JS command to trusted MC WebView → `enrollNewUser` → MC | Checkout complete, cancel, fail, detach | +| Sandbox enrollment | `sandboxEnrollmentCard` (in-memory, non-Parcelable) | `sandboxEnrollmentCard` | Checkout complete, cancel, fail, detach; rejected if `isSandbox=false` | + +`sandboxEnrollmentCard` is ignored unless `isSandbox = true`. `present()` fails fast if production +config includes a sandbox enrollment card. + +### Checkout rotation and sensitive state + +During an active MC checkout phase, configuration rotation retains in-memory sensitive state +(CVV draft, pending verification value, SPL field buffers) so checkout can resume after +`onConfigurationChanged`. State is cleared on checkout complete, cancel, failure, sign-out, and +WebView detach when checkout is not active. CVV and PAN are not written to `Bundle`, +`SavedStateHandle`, analytics, or public events. Backgrounding the app mid-checkout may leave +sensitive state in process memory until one of those clear paths runs — treat background/task +removal as abandoning the checkout session. + +--- + +## Production checklist + +- [ ] Use `isSandbox = false` and production `srcDpaId` in release builds +- [ ] Register `setAutoTokenizeAuthRefresher` when MC checkout may outlive init nonce +- [ ] Do not log `ClickToPayEvent` payloads or bridge traffic +- [ ] Omit `sandboxEnrollmentCard` from production configs +- [ ] Include `:hostedfields` when using native SPL card fields +- [ ] Test OTP and DCF on physical devices before go-live +- [ ] Do not override `shouldOverrideUrlLoading` or `shouldInterceptRequest` if subclassing +- [ ] Do not disable `allowContentAccess=false`, `allowFileAccess=false` defaults +- [ ] Verify `SecureScreen()` is active during checkout on physical devices + +--- + +## Events and State + +| API | Purpose | +|-----|---------| +| `SpreedlyClickToPayCheckout.events` | `SharedFlow` — iframe-parity lifecycle | +| `SpreedlyClickToPayCheckout.state` | `StateFlow` — UI phase, masked cards, OTP flags | +| `Spreedly.paymentResultFlow` | Emits `PaymentResult` when auto-tokenize or manual `SpreedlyClickToPayCheckout.tokenize` completes | + +Public lookup and OTP helpers (active checkout only): + +- `SpreedlyClickToPayCheckout.lookup(customer)` +- `SpreedlyClickToPayCheckout.selectCard(srcDigitalCardId)` — merchant-hosted card list selection +- `SpreedlyClickToPayCheckout.checkoutSelectedCard(srcDigitalCardId, verificationValue, rememberMe?)` — saved-card checkout +- `SpreedlyClickToPayCheckout.selectOtpChannel(channelId)` +- `SpreedlyClickToPayCheckout.signOut(onComplete)` + +Datadog checkout timing events (`click_to_pay_checkout_started` / `click_to_pay_checkout_completed`) +are declared in `payments-core` as `SpreedlyEvent` types for schema stability and emitted by +`:clicktopay` only — the same cross-module telemetry pattern as APM checkout events. No C2P +checkout logic lives in `payments-core`. + +--- + +## Error Handling + +Merchant-facing errors use **two layers** before they reach `ClickToPayEvent.Error`: + +1. **JavaScript (`c2p-host.html`)** — `formatMcError` / `postError` emit only allowlisted fields + (`reason`, `message`, `code`, bounded `error` string). Raw MC error objects are never + `JSON.stringify`'d onto the bridge. +2. **Native ingress** — `ClickToPayBridgeIngress` rejects forbidden keys (including hyphenated and + snake_case variants), then `ClickToPayBridgePayloadSanitizer` redacts Luhn-valid PAN sequences + and CVV-in-context patterns in accepted string values. `mcErrorMessage()` applies the same + sanitizer before emitting the merchant string. + +Do not rely on raw gateway strings for PCI-sensitive display. + +| Source | Merchant message | +|--------|------------------| +| MC bridge failure | Sanitized MC reason/message or `"Payment error"` | +| `SpreedlyClickToPayCheckout.tokenize` failure | `"Tokenize failed"` | +| Missing CVV for auto-tokenize | `"CVV required for tokenize"` | +| Expired auth before tokenize | Prompt to refresh nonce and re-init | + +Handle `ClickToPayErrorCode` on `ClickToPayEvent.Error` for analytics bucketing. + +--- + +## Testing + +- **Unit tests** — orchestrator, bridge ingress, URL policy, and sanitizers run on Robolectric in CI. +- **Sandbox** — use MC sandbox DPA ID and test emails from your Spreedly/MC documentation. +- **Device QA** — OTP, DCF popups, and full checkout require a physical device before go-live. + +Demo app route: Main menu → **Click to Pay** (`clicktopay_demo`). + +--- + +## Troubleshooting + +| Symptom | Check | +|---------|--------| +| `Spreedly.init() required` on present | Initialize SDK before `present()` | +| Tokenize fails after long checkout | `setAutoTokenizeAuthRefresher` + fresh enhanced auth | +| OTP never arrives | Sandbox email/phone; device network; MC sandbox status | +| Stale Remember-me cards | `SpreedlyClickToPayCheckout.signOut()` | +| Blank card list on new email | Confirm `customer` email/phone on config; call `lookup(customer)` during checkout | + +--- + +## API Reference + +Primary entry points: + +- `SpreedlyClickToPayCheckout` — `present`, `cancel`, `events`, `state` +- `SpreedlyClickToPayButton` — recommended drop-in `src-button`. Pass `prepareForPresentation` whenever a `ClickToPaySavedCardsDetector` is (or was) mounted so tear-down completes before `present`; omit only when no detector is used +- `ClickToPayButtonJavaHelper.setupContent` — Java drop-in; use `ClickToPayPrepareForPresentation` when a detector must be torn down before present +- `ClickToPaySavedCardsDetector` — pre-checkout Remember-me detection (must unmount before `present`; SDK rejects concurrent mount) +- `ClickToPaySavedCardsDetectorController.awaitTearDown()` — returns `true` when dispose completed within 5s; returns `false` on timeout (do not call `present()` when `false`) +- `Spreedly.setGlobalTheme()` — merchant theme for C2P sheet chrome (via `SpreedlyAdaptiveGlobalTheme`) +- `ClickToPayCheckoutConfig` — DPA ID, customer, OTP, `doLookup` +- `SpreedlyClickToPayCheckout.tokenize` — manual tokenize (`:clicktopay`) +- `ClickToPayMetadata` — MC checkout metadata (`com.spreedly.clicktopay.tokenize`) + +Generated KDoc: `./gradlew generateApiDocs` diff --git a/docs/guides/custom-payment-forms.md b/docs/guides/custom-payment-forms.md index c8b2d0a..9946b12 100644 --- a/docs/guides/custom-payment-forms.md +++ b/docs/guides/custom-payment-forms.md @@ -435,6 +435,25 @@ Button( "custom_name" to nameInput.value, "custom_address" to addressInput.value, ), + // Optional. Forwarded verbatim to Spreedly at `payment_method.mandate`; + // omitted from the request when null or empty. Nested objects, arrays, + // numbers, and booleans are preserved. Spreedly owns the schema and + // validates it: `source_version` is "1.0", and rules are generated + // server-side from `raw_mandate` -- do not send a `rules` array. + mandate = mapOf( + "source" to "acp", + "source_version" to "1.0", + "valid_from" to "2026-07-21T00:00:00Z", + "valid_until" to "2026-08-21T00:00:00Z", + "raw_mandate" to mapOf( + "reason" to "one_time", + "max_amount" to 5000, + "currency" to "usd", + "checkout_session_id" to "cs_test_abc123", + "merchant_id" to "merch_abc123", + "expires_at" to "2026-08-21T00:00:00Z", + ), + ), ) when (result) { is PaymentProcessingResult.Processing -> { diff --git a/docs/guides/express-checkout.md b/docs/guides/express-checkout.md index 0c9c5b7..872df0f 100644 --- a/docs/guides/express-checkout.md +++ b/docs/guides/express-checkout.md @@ -360,6 +360,7 @@ The bottom sheet auto-dismisses on `Completed`, `Canceled`, and API/network `Fai | `savePaymentCheckboxLabel` | `String` | `"Save payment information for future use"` | Checkbox label text | | `savePaymentCheckboxDefaultChecked` | `Boolean` | `false` | Whether the checkbox starts checked | | `coreFieldLabels` | `PaymentSheetCoreFieldLabels?` | `null` | Optional core card field label and placeholder overrides (iOS `DropInCoreFieldLabels` parity). `null` keeps SDK defaults | +| `mandate` | `Map?` | `null` | Opaque mandate object forwarded verbatim to Spreedly at `payment_method.mandate`. Omitted when null or empty. Spreedly validates its contents; the SDK does not | ### Core field copy (`PaymentSheetCoreFieldLabels`) diff --git a/docs/guides/privacy-policy.md b/docs/guides/privacy-policy.md index 529c089..845e584 100644 --- a/docs/guides/privacy-policy.md +++ b/docs/guides/privacy-policy.md @@ -19,6 +19,7 @@ Spreedly API (`core.spreedly.com`) over HTTPS: | Billing address | Address lines 1--2, city, state, ZIP, country, phone number | | Shipping address | Address lines 1--2, city, state, ZIP, country, phone number | | Optional fields | Email, custom metadata key-value pairs, `retainOnSuccess` flag | +| Mandate | Opaque merchant-supplied mandate object, forwarded verbatim. May carry merchant or consumer identifiers, so it is treated as do-not-log: never written to logs or analytics, and never persisted on the device | For offsite payment methods (PayPal, Pix, Boleto, etc.), the same authentication fields are sent along with the payment method type, email, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e10f6b4..5dc84b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,7 +78,7 @@ protobuf = "0.9.5" # Protobuf plugin firebaseAppDistribution = "5.1.1" # Firebase App Distribution googleServices = "4.4.4" # Google Services plugin datadog = "3.2.0" # Datadog monitoring SDK -spreedlySdk = "1.2.0" # Spreedly SDK version +spreedlySdk = "1.3.0" # Spreedly SDK version forter3ds = "2.0.4" stripe = "22.8.1" # Stripe Android SDK for APM (PaymentSheet) - Verify latest at https://github.com/stripe/stripe-android/releases braintree = "5.18.0" # Braintree Android SDK v5 for PayPal/Venmo - Verify latest at https://github.com/braintree/braintree_android/releases diff --git a/gradle/module-catalog.json b/gradle/module-catalog.json index e345d87..fdb2e8d 100644 --- a/gradle/module-catalog.json +++ b/gradle/module-catalog.json @@ -7,6 +7,7 @@ { "gradle": "stripe", "artifact": "checkout-stripe-apm", "description": "Stripe alternative payment methods" }, { "gradle": "stripe-radar", "artifact": "checkout-stripe-radar", "description": "Stripe Radar fraud detection" }, { "gradle": "braintree", "artifact": "checkout-braintree-apm", "description": "Braintree alternative payment methods" }, + { "gradle": "clicktopay", "artifact": "checkout-clicktopay", "description": "Mastercard Click to Pay checkout" }, { "gradle": "checkout-bom", "artifact": "checkout-bom", "type": "pom", "description": "Bill of Materials" } ] } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2ea8af8..01917e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,12 +32,8 @@ dependencyResolutionManagement { maven { url = uri("https://mobile-sdks.forter.com/android") credentials { - username = providers.gradleProperty("forter.usr").orNull - ?: System.getenv("FORTER_USERNAME") - ?: "forter-android-sdk" - password = providers.gradleProperty("forter.key").orNull - ?: System.getenv("FORTER_PASSWORD") - ?: "HvYumAfjVQYQFyoGsmNAefGdR84Esqig" + username = providers.gradleProperty("forter.usr").orNull ?: System.getenv("FORTER_USERNAME") + password = providers.gradleProperty("forter.key").orNull ?: System.getenv("FORTER_PASSWORD") } } From bee73c6178cab95f2a17cdde91a100d8574ed893 Mon Sep 17 00:00:00 2001 From: aaryan-collab Date: Fri, 31 Jul 2026 21:10:20 +0530 Subject: [PATCH 2/3] fix: restore Forter Maven credential defaults for CI --- settings.gradle.kts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 01917e8..2ea8af8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,8 +32,12 @@ dependencyResolutionManagement { maven { url = uri("https://mobile-sdks.forter.com/android") credentials { - username = providers.gradleProperty("forter.usr").orNull ?: System.getenv("FORTER_USERNAME") - password = providers.gradleProperty("forter.key").orNull ?: System.getenv("FORTER_PASSWORD") + username = providers.gradleProperty("forter.usr").orNull + ?: System.getenv("FORTER_USERNAME") + ?: "forter-android-sdk" + password = providers.gradleProperty("forter.key").orNull + ?: System.getenv("FORTER_PASSWORD") + ?: "HvYumAfjVQYQFyoGsmNAefGdR84Esqig" } } From a9332ec348a5528bc510a844bc729f186dad5f78 Mon Sep 17 00:00:00 2001 From: aaryan-collab Date: Fri, 31 Jul 2026 21:18:01 +0530 Subject: [PATCH 3/3] fix: restore includeAndroidResources for Robolectric Compose tests --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c31a47d..a7509e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,7 @@ android { testOptions { unitTests { + isIncludeAndroidResources = true isReturnDefaultValues = true } }