From 1d1842efeac12142549c0e60da737f251c3ac6aa Mon Sep 17 00:00:00 2001 From: Abdulraguman Date: Mon, 28 Sep 2026 22:52:30 +0530 Subject: [PATCH] chore: sync sample from v1.4.0 (1.4.0) --- README.md | 11 +- app/build.gradle.kts | 13 +- .../BasicCheckoutPaymentResultFilter.kt | 2 +- build.gradle.kts | 84 +-------- docs/CHANGELOG.md | 50 +++++- docs/guides/3ds-gateway-specific.md | 2 +- docs/guides/3ds-global.md | 13 +- docs/guides/ach-bank-account.md | 6 +- docs/guides/click-to-pay.md | 79 ++++++--- docs/guides/custom-payment-forms.md | 18 +- docs/guides/error-handling.md | 70 ++++---- docs/guides/express-checkout.md | 10 +- docs/guides/getting-started.md | 2 +- docs/guides/migration/from-legacy.md | 13 +- docs/guides/offsite-payments.md | 9 +- docs/guides/privacy-policy.md | 30 ++-- docs/guides/recaching.md | 14 +- docs/guides/security.md | 110 +++++++++--- docs/guides/stripe-apm.md | 9 +- gradle/kover-cli-checksums.txt | 37 ++++ gradle/kover-excludes.txt | 159 ++++++++++++++++++ gradle/libs.versions.toml | 10 +- gradle/test-shards.json | 34 ++++ 23 files changed, 550 insertions(+), 235 deletions(-) create mode 100644 gradle/kover-cli-checksums.txt create mode 100644 gradle/kover-excludes.txt create mode 100644 gradle/test-shards.json diff --git a/README.md b/README.md index 3d967b7..9855918 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.3.0** (tag `v1.3.0`). +This sample app demonstrates the [Spreedly Android Checkout SDK](https://github.com/spreedly/checkout-android-sdk) at version **1.4.0** (tag `v1.4.0`). ## Setup @@ -20,11 +20,10 @@ gpr.key=YOUR_GITHUB_TOKEN All SDK modules are resolved from GitHub Packages: ```kotlin -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") -implementation("com.spreedly:checkout-clicktopay:1.3.0") +implementation("com.spreedly:checkout-paymentsheet:1.4.0") +implementation("com.spreedly:checkout-braintree-apm:1.4.0") +implementation("com.spreedly:checkout-stripe-apm:1.4.0") +implementation("com.spreedly:checkout-threeds:1.4.0") ``` ## SDK Documentation diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7509e9..43a4166 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,7 @@ android { lint { checkReleaseBuilds = false + ignoreTestSources = true } compileOptions { @@ -144,12 +145,12 @@ kotlin { dependencies { // ✅ Use paymentsheet which includes payments-core and hosted-fields - 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("com.spreedly:checkout-paymentsheet:1.4.0") + implementation("com.spreedly:checkout-braintree-apm:1.4.0") + implementation("com.spreedly:checkout-stripe-apm:1.4.0") + implementation("com.spreedly:checkout-stripe-radar:1.4.0") + implementation("com.spreedly:checkout-threeds:1.4.0") + implementation("com.spreedly:checkout-clicktopay:1.4.0") implementation(libs.kotlinx.serialization.json) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt index 88ecf96..1093bba 100644 --- a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt +++ b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt @@ -3,7 +3,7 @@ package com.spreedly.example.screens.basiccheckout import com.spreedly.sdk.ui.PaymentResult internal const val CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE = - "All required fields are not valid" + "Payment form validation failed" internal fun PaymentResult.Failed.isClientSideFormValidationFailure(): Boolean = message == CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE diff --git a/build.gradle.kts b/build.gradle.kts index 46eb05d..2627793 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,87 +1,11 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask - +// Root build file for the public checkout-android-example repo. sync-sample.yml +// writes this in place of the SDK's root build.gradle.kts, which carries release +// plumbing (ABI baselines, japicmp, Kover CLI) the sample doesn't ship. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.compose.compiler) apply false - alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.spotless) apply false - alias(libs.plugins.hilt) apply false - alias(libs.plugins.ksp) apply false - alias(libs.plugins.room) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.binary.compatibility.validator) apply true - alias(libs.plugins.versions) apply true alias(libs.plugins.firebase.app.distribution) apply false alias(libs.plugins.google.services) apply false } - -tasks.withType().configureEach { - isReproducibleFileOrder = true - isPreserveFileTimestamps = false -} - -apiValidation { - ignoredProjects += listOf("app") - ignoredClasses += listOf("com.spreedly.sdk.BuildConfig") -} - -tasks.register("refreshLegacyAbiDumps") { - group = "verification" - description = - "Rebuild build/kotlin/abi-legacy dumps from compiled classes. " + - "Use with -PspreedlyRefreshAbiDumps so only dumps bypass the build cache (compile stays cached). " + - "To update committed api/*.api files, run updateLegacyAbi instead." - dependsOn( - subprojects - .filter { sub -> - sub.layout.projectDirectory.file("api/${sub.name}.api").asFile.exists() - } - .map { "${it.path}:dumpLegacyAbi" }, - ) -} - - -// Add Compose-specific lint task - - -// Configure dependencies so root project can aggregate coverage and documentation from all modules - -// Configure Dokka V2 multi-module documentation - -// Configure documentation settings for subprojects with Dokka applied - -// Map each Android submodule's prodDebug variant into the "custom" Kover variant -// so the root project can generate merged variant-specific reports. -// Also propagate the root-level report filters so per-module reports match the -// aggregated report (exclude Activity, UI, model, and generated classes). - -// Create a task to generate unified documentation using Dokka V2 - -// Add convenience task that's easier to remember - -// ============================================================================ -// Gradle Versions Plugin Configuration -// ============================================================================ -// Configure dependency update checking for automated updates -// ============================================================================ - -tasks.withType { - // Only show stable versions (no alpha, beta, rc, etc.) - rejectVersionIf { - isNonStable(candidate.version) && !isNonStable(currentVersion) - } - - // Output directory for reports - outputDir = "${project.layout.buildDirectory.get()}/dependencyUpdates" - reportfileName = "report" -} - -fun isNonStable(version: String): Boolean { - val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } - val regex = "^[0-9,.v-]+(-r)?$".toRegex() - val isStable = stableKeyword || regex.matches(version) - return isStable.not() -} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26e3a42..03ea02a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,12 +5,58 @@ 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.4.0] - 2026-09-16 + +### Changed + +- **Kotlin 2.1.20** — SDK compiles with Kotlin **2.1.20** (was 2.3.10) so React Native 0.82–0.86 hosts no longer need to pin Kotlin 2.3.10. AGP 8.13.2, Gradle 8.14.3, and Compose BOM are unchanged. +- **Custom loggers** (`payments-core`) — `setLogger` now receives already-sanitized tag, message, and throwable (no original exception type or PAN/CVV in log text). +- **Recache CVV** (`paymentsheet`) — CVV and the in-flight spinner are not restored after rotation. + +### Breaking Changes + +- **API error fields** (`payments-core`) — `SpreedlyApiErrorDetail` stored strings (`rawErrorBody`, messages, validation errors) are capped and redacted; they are no longer the raw HTTP body and may not be valid JSON. Use `statusCode` / `errorKey` / `safeDescription()`. Do not parse `rawErrorBody` as wire JSON. `AppNetworkError.API_ERROR` getters are still raw. +- **ACH account numbers** (`payments-core`, `paymentsheet`) — validation is ciphertext-only. A raw account number in `BankAccountSheetCallbacks.onAccountNumberChange` or `AccountNumberValidator` fails and will not tokenize. Use `SPLTextField(FormFieldType.ACCOUNT_NUMBER)`. +- **Card scheme** (`payments-core`) — plaintext digits on `onCardNumberChange` no longer set `cardScheme`. Drive PAN through `SPLTextField`. + +### Deprecated + +- **`SpreedlyEncryption` / `Encryptor` / `FormFieldType.shouldEncrypt()`** (`payments-core`) — lint-deprecated and restricted to the library group. Use `SPLTextField` for card and bank fields. `KEY` is a sentinel string, not key material. + +### Fixed + +- **Click to Pay** (`clicktopay`) — host ingress executor is shut down when the WebView detaches and when checkout or saved-cards detector sessions are cleared, so `detectorKey` re-runs no longer leak `c2p-host-ingress` threads. +- **Click to Pay** (`clicktopay`) — checkout with a saved card could fail tokenization with `"The requested information does not exist for any available network"`. The bridge payload sanitizer was running MC's `flowId` / `correlationId` / `merchantTransactionId` through the log-text PAN heuristic and redacting legitimate IDs to `"[REDACTED]"`. PAN and CVV redaction are unaffected; a plain control-character strip still guards these fields. +- **Click to Pay** (`clicktopay`) — host and branded-button `script-src` CSP was missing the sandbox Visa, Amex, and Discover per-network SRC adapter script hosts that MC's `lib.js` loads directly, outside `*.src.mastercard.com`. Checkout with a non-Mastercard-branded card could fail to load that network's adapter. +- **Click to Pay** (`clicktopay`) — entering an invalid email or phone on the identity form ended the checkout instead of letting the shopper correct it and try again. +- **Click to Pay** (`clicktopay`) — saved-card art didn't show up for a recognized device; the CSP was missing the host MC serves it from. +- **Click to Pay** (`clicktopay`) — Visa, Discover, and Amex cards couldn't be found or added at all. The checkout WebView only allowed Mastercard's own hosts, so the other networks' identity-lookup and enrollment iframes were blocked. Now matches iOS's allowlist. +- **Click to Pay** (`clicktopay`) — Discover checkout failed even after the above fix — its fingerprinting script runs outside the iframe sandbox the other networks use, and needed a few more hosts (its own domain, ThreatMetrix, and its tracking-pixel host) allowed. +- **Click to Pay** (`clicktopay`) — adding an American Express card failed with a generic error, then with a `jQuery not defined` error once the first cause was fixed. Amex's adapter script and its jQuery/js-cookie dependencies load from hosts that weren't on the allowlist. +- **Click to Pay** (`clicktopay`) — adding a new card from an already-recognized saved-cards session failed with "Enter an email or phone number to continue," even though the device didn't need one to be recognized. +- **Click to Pay** (`clicktopay`) — saved-card art still didn't load even after the `img-src` CSP fix above: the CSP allowed the asset host, but the native WebView allowlist independently blocked it, and that layer fails silently with no CSP violation to diagnose from. +- **Click to Pay** (`clicktopay`) — same class of bug as the item above, but for production: `script-src` only allowlisted the sandbox Visa/Amex adapter hosts, so a production checkout with those cards would have hit the same blocked-script failure the sandbox fix addressed. Confirmed against MC's published production `lib.js` and validated on live production checkouts. Discover's production hosts needed no change; the existing wildcards already covered them. The production equivalent of the Mastercard saved-card-art host is still unconfirmed — it isn't referenced anywhere in `lib.js`, so it can't be found the same way. +- **Click to Pay** (`clicktopay`) — a shopper recognized by Mastercard with no cards enrolled for this merchant's DPA got stuck on "OTP validated — loading cards" indefinitely after entering their OTP. The empty `getCards` response after OTP validation retried a profile lookup that had already run and was silently skipped instead of routing to card enrollment. +- **Click to Pay** (`clicktopay`) — a shopper whose new-card checkout was declined (e.g. Amex rejecting the card) and who had no other saved cards got stuck on an empty "Select a saved card" screen with no way forward but sign out. `CHANGE_CARD` always routed to the saved-card list regardless of whether any cards existed; it now routes to card enrollment when the shopper has none. + +### Security + +- **Logging / `toString()`** (`payments-core`) — `PaymentResult.Failed`, `ThreeDSChallengeResult.Failed`, and API error `toString()` are log-safe (`statusCode` / `errorType` only). Prefer `getDescription()` for UI. Logs redact labeled CVV JSON and 12–19 digit PAN-like runs (JSON timestamps can over-redact). +- **Click to Pay** (`clicktopay`) — tokenize failures publish a static `Tokenize failed` (no exception text). Host WebView uses origin-checked messaging instead of `JavascriptInterface`. UAT sandbox before production. +- **Screenshot flag** (`payments-core`, `clicktopay`) — overlapping Spreedly screens keep `FLAG_SECURE` until the last one closes; merchant-set `FLAG_SECURE` is preserved. +- **Submit fail-closed** (`payments-core`) — corrupt optional card/CVV/account ciphertext returns `ValidationFailed` instead of tokenizing empty values. +- **LogSanitizer** (`payments-core`) — redacts PAN-like digit runs of 12+ via one-pass candidate scanning (contiguous or separated by any number of space/dot/underscore/hyphen characters, including glued-after-letter and 20+ digit embeddings), and escaped-JSON labeled CVV (`\"cvv\":\"123\"`). Stored error strings also redact separator-formatted PAN fragments split across the 8 KiB cap. +- **Click to Pay** (`clicktopay`) — host and branded-button HTML `script-src` CSP uses a SHA-256 hash of the bootstrap script instead of `'unsafe-inline'`, and replaces the `*.src.mastercard.com` wildcard with an explicit host list: `src.mastercard.com` and `sandbox.src.mastercard.com`; the sandbox and production Visa (`secure.checkout.visa.com`) and Amex (`aexp-static.com`) SRC adapter hosts; Discover (`discover.com`, `discovercard.com` and their subdomains, `webapp.sandbox.src.discover.com`) and its ThreatMetrix fingerprinting hosts (`online-metrix.net` and subdomains); and `code.jquery.com` / `cdn.jsdelivr.net` for Amex's checkout-window dependencies. `WebMessageListener` origin checks remain the primary control. +- **Click to Pay** (`clicktopay`) — `isAllowedMastercardOrigin`, which gates the WebMessage bridge, had widened along with the navigation/resource allowlist and would accept a `sourceOrigin` from Visa, Discover, Amex, ThreatMetrix, or the third-party CDN hosts. `WebMessageListener`'s own `allowedOriginRules` already restricted this to Mastercard before the callback ran, so this wasn't independently exploitable; it's now narrowed back to Mastercard-only so the bridge origin check doesn't silently drift with future allowlist changes. Blocked-request debug logging also switched from a heuristic string sanitizer to a scheme/host/path-only representation, dropping query and fragment data outright instead of trying to redact it. +- **Click to Pay** (`clicktopay`) — `code.jquery.com`/`cdn.jsdelivr.net` no longer count as valid main-frame navigation targets, only as script resources. They're pure library CDNs with no `frame-src` CSP entry, so there was no legitimate navigation use for them; narrowing this doesn't change what Amex's checkout window can load. +- **Click to Pay** (`clicktopay`) — that navigation restriction wasn't fully enforced: `shouldOverrideUrlLoading` isn't called for POST navigation, so a resource-only host (the two CDNs above, or the Mastercard asset host) could still have replaced the main-frame document via a POST and only faced the broader resource policy. `shouldInterceptRequest` now enforces the navigation policy for main-frame requests directly, closing that gap regardless of HTTP method. + ## [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. +- **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 **values** are do-not-log (request `toString` redacts them); conversion diagnostics may include structural key paths. Documented in express, ACH, and custom-form guides. ### Breaking Changes @@ -654,4 +700,4 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html): For detailed integration guides and API documentation, visit our [documentation](https://developer.spreedly.com/docs/mobile-applications). -For support or questions, please contact our support team or create an issue in the repository. \ No newline at end of file +For support or questions, please contact our support team or create an issue in the repository. diff --git a/docs/guides/3ds-gateway-specific.md b/docs/guides/3ds-gateway-specific.md index 1abd42e..3f116bc 100644 --- a/docs/guides/3ds-gateway-specific.md +++ b/docs/guides/3ds-gateway-specific.md @@ -70,7 +70,7 @@ private fun setupSubscriptions() { } is ThreeDSChallengeResult.Failed -> { sdk.hideThreeDSChallenge() - showError(result.message ?: "3DS challenge failed") + showError(result.getDescription()) } is ThreeDSChallengeResult.Canceled -> { sdk.hideThreeDSChallenge() diff --git a/docs/guides/3ds-global.md b/docs/guides/3ds-global.md index 17a2672..f083114 100644 --- a/docs/guides/3ds-global.md +++ b/docs/guides/3ds-global.md @@ -349,8 +349,7 @@ class PaymentViewModel(val sdk: Spreedly) : ViewModel() { } is ThreeDSChallengeResult.Failed -> { - val message = result.message ?: "3DS Challenge failed" - _errorMessage.value = "Payment failed: $message" + _errorMessage.value = "Payment failed: ${result.getDescription()}" sdk.hideThreeDSChallenge() } @@ -615,7 +614,7 @@ class PaymentActivity : ComponentActivity() { handleSuccess() } is ThreeDSChallengeResult.Failed -> { - handleError(result.message ?: "Challenge failed") + handleError(result.getDescription()) } is ThreeDSChallengeResult.Canceled -> { handleCancellation() @@ -1048,8 +1047,8 @@ data class Failed( **Properties:** - `errorType` — Type of error (`FORTER_ERROR`, `NETWORK_ERROR`, `UNKNOWN_ERROR`) -- `message` — Human-readable error message -- `originalError` — Original exception (for debugging) +- `message` — Human-readable error message (safe for UI; do not log verbatim if it could echo vendor text) +- `originalError` — Unsanitized; do not log [Throwable.message] or the stack **When to expect:** - Authentication failed @@ -1057,8 +1056,8 @@ data class Failed( - Forter SDK encountered an error **What to do:** -- Show error message to user -- Log error details for debugging +- Show `getDescription()` to the user (not `message` on merchant-constructed failures) +- Log `errorType` and `toString()` only — not `message`, `originalError`, or `"$failed"` - Potentially retry or offer alternative payment method #### Canceled diff --git a/docs/guides/ach-bank-account.md b/docs/guides/ach-bank-account.md index 980323d..876185d 100644 --- a/docs/guides/ach-bank-account.md +++ b/docs/guides/ach-bank-account.md @@ -106,7 +106,7 @@ SpreedlyBankAccountBottomSheet( // result.token contains the payment method token } is PaymentResult.Failed -> { - // result.message contains the error + // result.getDescription() for UI; log result.toString() only } is PaymentResult.Canceled -> { // user dismissed the sheet (swipe / back) @@ -447,6 +447,7 @@ Results are delivered by different channels depending on the integration path. - **Drop-in** — every terminal (`Completed`, `Failed`, `Canceled`) is delivered to `onPaymentResult`, scoped to that sheet’s surface id. Shared-flow collectors still see `Completed`/`Failed`. **Cancellation is surface-only.** - **Headless / direct** — `Completed` and `Failed` on `paymentResultFlow`; no user-cancel concept. Unexpected SDK failures return immediate `PaymentProcessingResult.Failed(UNEXPECTED_ERROR)` while the sanitized `PaymentResult.Failed` is delivered asynchronously. ACH network/API failures never expose `originalError` or `rawErrorResponse` on public results. +- **Logging** — log `errorType`, `apiError`, `statusCode`, and `toString()` for debugging (see [Error Handling](error-handling.md#error-logging-for-debugging)). Use `getDescription()` for UI only. Never log `message`, `rawErrorResponse`, `originalError`, or `"$failed"` — merchant-built `Failed` getters may still hold unsanitized strings; `Failed.toString()` omits them. **Programmatic hide vs user-cancel**: setting `show = false` from the parent hides the sheet and clears SDK state, but does **not** call `onPaymentResult(Canceled)`. Only a swipe/back dismiss triggered by the user produces a `Canceled` result. If your UI needs to distinguish "user abandoned" from "parent closed the sheet", track that distinction in your own state rather than relying on `Canceled` alone. @@ -493,7 +494,7 @@ SpreedlyBankAccountBottomSheet( onPaymentResult = { result -> when (result) { is PaymentResult.Completed -> { /* result.token */ } - is PaymentResult.Failed -> { /* result.message */ } + is PaymentResult.Failed -> { /* result.getDescription() for UI */ } is PaymentResult.Canceled -> { /* user dismissed the sheet */ } PaymentResult.Initial -> {} } @@ -559,6 +560,7 @@ Card payment sheet still resets only on open (intentional difference). ## Security - Account numbers are encrypted in memory and auto-cleared after 3 minutes when the app is backgrounded (same as CVV). +- ACH validation is **ciphertext-only**: `isAchAccountNumberFieldValid` requires an SDK-encrypted account number in `BankAccountState`. `SPLTextField(FormFieldType.ACCOUNT_NUMBER)` already encrypts before `onAccountNumberChange`; do not pass raw account digits to `sdk.bankAccountCallbacks` or construct `AccountNumberValidator` with plaintext for production paths. - Routing numbers are not auto-cleared (they are semi-public identifiers). - `SpreedlyBankAccountBottomSheet` and `BankAccountSheet` both apply `SecureScreen` (FLAG_SECURE) to prevent screenshots. diff --git a/docs/guides/click-to-pay.md b/docs/guides/click-to-pay.md index 7c081c3..a69db9c 100644 --- a/docs/guides/click-to-pay.md +++ b/docs/guides/click-to-pay.md @@ -43,8 +43,9 @@ bridges MC lifecycle events to your app through `SpreedlyClickToPayCheckout`. 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 +3. **System WebView with `WEB_MESSAGE_LISTENER`** — checkout host and branded button require this AndroidX WebKit feature (Chrome/Android System WebView **82+** per [Android docs](https://developer.android.com/develop/ui/views/layout/webapps/native-api-access-jsbridge)). Unsupported devices fail closed: checkout emits `ClickToPayEvent.Error` / `C2P_INIT`; the branded button reports an error state without crashing composition. Merchants can usually recover by updating **Android System WebView** (or Chrome) from Google Play. Devices whose active WebView provider does not support `WEB_MESSAGE_LISTENER` are not supported for Click to Pay. Runtime support is checked via `WebViewFeature.isFeatureSupported()`; the SDK currently depends on `androidx.webkit:webkit:1.12.1` (bump tracked separately). +4. **Physical device recommended** for sandbox OTP and DCF (device cardholder flows) +5. See the [Compatibility table](../../README.md#compatibility) in the README for Android API level requirements --- @@ -121,6 +122,7 @@ Use `ClickToPaySavedCardsDetector` when the merchant screen needs to know whethe Rules: - The detector forces `merchantHostedCardList = true` so MC publishes masked metadata only (no `src-card-list` UI in the detector WebView). +- While mounted, the detector applies `SecureScreen()` / `FLAG_SECURE` for the host window for the full mount duration (released when the detector leaves composition, subject to overlapping Spreedly secure surfaces). - **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. @@ -453,6 +455,7 @@ Mastercard Unified Checkout Solutions (UCS) mobile integration requirements enfo | Production base | `https://src.mastercard.com` | | Query params | `srcDpaId`, `locale` | | Init payload | `dpaTransactionOptions.paymentOptions[].dynamicDataType = "NONE"` | +| Host / branded button bridge | Requires System WebView `WEB_MESSAGE_LISTENER` (Chrome/WebView 82+). Host/detector unsupported → `C2P_INIT`; branded button reports an error state without crashing (see [Prerequisites](#prerequisites)) | | UAT signoff | Spreedly/MC must confirm lib URL and init payload on sandbox before production — track in your release ticket | Pinned references: @@ -475,30 +478,42 @@ Mastercard’s mobile Click to Pay integration requires a WebView host. The SDK | Control | Purpose | |---------|---------| -| `SecureScreen()` | Blocks screenshots / screen recording during checkout | -| HTTPS navigation allowlist | Only `*.src.mastercard.com` hosts (see table below) | +| `SecureScreen()` | Prevents screenshots and non-secure display output; recording/capture protection varies by device | +| HTTPS navigation/resource allowlist | `*.src.mastercard.com` plus the federated-network, threat-intel, and third-party-library hosts MC's `lib.js` and per-network adapters load directly (see [Host allowlist](#host-allowlist)); the WebMessage bridge origin check stays Mastercard-only regardless | | 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 | +| Payload size cap | Host bridge rejects oversized messages (256 KB UTF-8); branded button uses a tighter 16 KiB UTF-8 cap | +| Host ingress structure caps | Rejects excessive JSON depth, array length, object keys, and non-opaque string length (`encryptedCard` / `dynamicData` exempt from string-length cap) | +| Host ingress threading | Host `validate` runs on a serialized background executor that is shut down when the host WebView detaches or the checkout / saved-cards detector session is cleared; delivery to the orchestrator is generation-gated on the main thread. Branded button keeps small allowlisted parse on the main thread | | 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` | +| Host `WebMessageListener` (`C2pHostBridge`) | Mastercard-only `allowedOriginRules` (`https://src.mastercard.com`, `https://*.src.mastercard.com`); the callback re-checks `sourceOrigin` against `isAllowedMastercardOrigin`, which is Mastercard-only regardless of what navigation/resource loading allows; rejects non-main-frame and untrusted `sourceOrigin`; requires `WEB_MESSAGE_LISTENER` | +| `removeWebMessageListener` on detach | Host and branded-button bridges removed when WebViews are destroyed | +| Host HTML CSP meta | Defence in depth only — `WebMessageListener` origin checks are the primary control. Bundled `c2p-host.html` sets `script-src` to `'self'`, a SHA-256 hash of the substituted bootstrap script, and the exact hosts in [Host allowlist](#host-allowlist) below (no wildcard beyond `*.src.mastercard.com` and the per-network subdomain wildcards listed there). `style-src` keeps `'unsafe-inline'`; `frame-src`/`connect-src`/`img-src`/`font-src` are scoped per host class, not a single wildcard. Sandbox hosts confirmed via on-device CSP violation logs; production Visa/Amex adapter and Discover hosts confirmed via MC's own published production `lib.js` (see [Host allowlist](#host-allowlist)). **The production Mastercard saved-card-art host is not — do not treat this policy as release-ready until it is** | +| DCF popup handling | `onCreateWindow` opens a child WebView with the same URL policies and hardened settings (no JS bridge on the popup) | +| Branded button `WebMessageListener` | Same Mastercard origin rules as the host bridge; rejects non-main-frame and untrusted `sourceOrigin` | +| Branded button HTML CSP meta | Same `script-src`/`frame-src`/`connect-src`/`img-src` policy as the host page. Host-page CSP does not apply to this WebView — this document has its own meta | | 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 | +Navigation and subresource loading use related but capability-specific allowlists, split into trust classes below. `isAllowedResourceUrl` permits everything `isAllowedNavigationUrl` does, plus resource-only hosts (the Mastercard asset host and the third-party library CDNs) that have no legitimate reason to be a navigation target — `ClickToPayMcWebViewClient` enforces the navigation policy for main-frame requests in both `shouldOverrideUrlLoading` and `shouldInterceptRequest`, since the latter is also reachable for POST navigation, which `shouldOverrideUrlLoading` is not called for. The WebMessage bridge origin check (`isAllowedMastercardOrigin`) is a **separate, narrower** policy still — only the Mastercard row is valid there, regardless of what this table allows for navigation/resources. + +| Host class | Hosts | Allowed for | Why | +|---|---|---|---| +| Mastercard | `sandbox.src.mastercard.com`, `src.mastercard.com`, `*.src.mastercard.com` | Navigation, resources, bridge origin | MC script, checkout, DCF; suffix match rejects typosquat hosts like `evil.src.mastercard.com.evil.com` | +| Mastercard assets | `sbx.assets.mastercard.com` | Resources only (not navigation, not bridge origin) | Saved-card art for a recognized device — an image host, not a script/checkout host | +| Federated networks | `visa.com`, `discover.com`, `discovercard.com`, `americanexpress.com`, `aexp-static.com` (+ subdomains) | Navigation, resources | MC's unified `lib.js` embeds each network's own communicator/fingerprint iframe and adapter script for identity lookup and enrollment; `aexp-static.com` is Amex's actual adapter host, not `americanexpress.com` | +| Threat intelligence | `online-metrix.net` (+ subdomains) | Navigation, resources | ThreatMetrix, pulled in by Discover's fingerprint script | +| Third-party libraries | `code.jquery.com`, `cdn.jsdelivr.net` (exact match, no subdomains) | Resources only (not navigation) | Amex's DCF checkout-window script depends on jQuery/js-cookie served from these public CDNs; pure script dependencies with no `frame-src` entry, so there's no legitimate navigation target here | +| Any other HTTPS host | — | Blocked everywhere | — | + +Production Visa (`secure.checkout.visa.com`) and Amex (`www.aexp-static.com`) adapter script hosts are now in `script-src` (HC-1828), confirmed both statically — MC's production `lib.js` (`src.mastercard.com/srci/integration/2/lib.js`) names these hosts directly in its network-adapter URL map, matching the sandbox bundle's structure — and against live production checkouts, with no CSP violation or CORS/404 for either host. Discover's production hosts (`webapp.src.discover.com`, `src.apis.discover.com`, `content.discovercard.com`) needed no change; they already fall under the `discover.com`/`discovercard.com` wildcards. Native-side, the federated-network suffix match already covered every one of these production hosts before this fix — only the CSP's exact-host `script-src` list was missing them. + +Still unverified: the production equivalent of `sbx.assets.mastercard.com` (Mastercard saved-card art) isn't statically referenced in `lib.js` — it's likely constructed at runtime from card data returned by MC's API — so it needs an actual production checkout with a recognized device to confirm, the same way the sandbox host originally was. Discover's fingerprint tag also injects an inline script whose content isn't stable across runs, so it can't be statically hash-allowlisted; that script is expected to keep failing CSP pending Discover/InfoSec input on whether it's required for fraud telemetry or affects checkout behavior. ### DCF popup WebView @@ -506,10 +521,7 @@ Mastercard device cardholder (DCF) flows may open a child WebView via `onCreateW ### 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 and branded-button traffic use origin-scoped `WebMessageListener` (not `addJavascriptInterface`), so child frames such as the DCF iframe cannot call the native bridge. Host ingress applies method allowlist, UTF-8 size + structure caps, sensitive-key filter, and value sanitization on a serialized background executor (`JSONObject` still fully parses under the byte cap). Branded button intentionally keeps light allowlisted parsing on the main thread with a UTF-8 size gate. 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. @@ -518,7 +530,10 @@ Subresource `data:` loads remain allowed for MC inline assets; top-level navigat `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 +`lib.js` SDK and DCF flows require them. Static analysis may still flag `javaScriptEnabled` and +`allowContentAccess` settings; both are intentional (`javaScriptEnabled` required by MC, content +access disabled on every load path) and are mitigated by the host allowlist, origin-scoped +listeners, ingress validation, and teardown. 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/). @@ -567,12 +582,22 @@ Spreedly/compliance signoff before production. 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). +During enrollment and new-card checkout, PAN/CVV are passed transiently from native memory into the +Mastercard-origin WebView via `evaluateJavascript`, and immediately supplied to MC `encryptCard` / +`enrollNewUser`. The SDK does not intentionally log, persist, place them on the navigation/resource +allowlist, or return them through the WebMessage bridge (whose origin check, +`isAllowedMastercardOrigin`, stays Mastercard-only regardless of what navigation/resources allow — +see [Host allowlist](#host-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). + +This host WebView also executes third-party scripts (per-network adapters, ThreatMetrix, and the +`code.jquery.com`/`cdn.jsdelivr.net` CDNs Amex's adapter depends on) in the same Mastercard-origin +document that temporarily holds PAN/CVV. Origin-scoped `WebMessageListener` rules stop untrusted +*frames* from reaching the native bridge, but they don't restrict what a script already loaded into +this document could do while it runs. That makes the hosts in [Host allowlist](#host-allowlist) part +of the PAN/CVV trust boundary, not just a network allowlist — vendor/InfoSec should sign off on that +model (particularly the two public CDN hosts) before this ships to production. | Data | Where it flows | Merchant exposure | |------|----------------|-------------------| @@ -681,6 +706,8 @@ Demo app route: Main menu → **Click to Pay** (`clicktopay_demo`). | Symptom | Check | |---------|--------| | `Spreedly.init() required` on present | Initialize SDK before `present()` | +| `C2P_INIT` / `WEB_MESSAGE_LISTENER support` | Update Android System WebView or Chrome from Play (82+); Click to Pay is unsupported without that feature | +| Branded button error / dimmed UI without crash | Same WebView floor; button reports error via bridge instead of crashing Compose | | 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()` | diff --git a/docs/guides/custom-payment-forms.md b/docs/guides/custom-payment-forms.md index 9946b12..9b72810 100644 --- a/docs/guides/custom-payment-forms.md +++ b/docs/guides/custom-payment-forms.md @@ -26,7 +26,9 @@ SPL fields enforce security automatically: - Card number fields block copy, cut, and text selection (paste is allowed) - CVV fields block all clipboard operations and text selection -**`SPLTextField` callbacks:** `onChange` receives **AES-encrypted** ciphertext for `FormFieldType.CARD`, `FormFieldType.CVV`, and `FormFieldType.ACCOUNT_NUMBER` only (`FormFieldType.shouldEncrypt()`); all other field types receive **raw** processed text in `onChange`. Do **not** log encrypted strings or treat them as display digits. Use **`onFieldStateChange(HostedFieldState)?`** for iframe-style observability: digit **counts** (`numberLength` / `cvvLength`), `cardScheme`, `isValid`, focus/blur via `HostedFieldEventType`, without parsing ciphertext. Use `onValidationChange` / `hasValidationError` / submit results for gating checkout. Kotlin/Java samples: [Migration from legacy](migration/from-legacy.md#hostedfieldstate--kotlin-samples-compose). +**`SPLTextField` callbacks:** `onChange` receives **AES-encrypted** ciphertext for `FormFieldType.CARD`, `FormFieldType.CVV`, and `FormFieldType.ACCOUNT_NUMBER` only (`FormFieldType.shouldEncrypt()` is deprecated / LIBRARY_GROUP-restricted — behavior unchanged); all other field types receive **raw** processed text in `onChange`. Do **not** log encrypted strings or treat them as display digits. Use **`onFieldStateChange(HostedFieldState)?`** for iframe-style observability: digit **counts** (`numberLength` / `cvvLength`), `cardScheme`, `isValid`, focus/blur via `HostedFieldEventType`, without parsing ciphertext. Use `onValidationChange` / `hasValidationError` / submit results for gating checkout. Kotlin/Java samples: [Migration from legacy](migration/from-legacy.md#hostedfieldstate--kotlin-samples-compose). + +**Card scheme detection (CARD field):** Scheme detection and CVV-length coupling run on **ciphertext stored by the SDK**, not on plaintext you pass from a custom `EditText`. Wire the PAN through **`SPLTextField(FormFieldType.CARD(...))`** so `onChange` delivers encrypted values to `sdk.callbacks.onCardNumberChange`. If you call `onCardNumberChange` with raw digit strings (headless demos, manual callback wiring), **`HostedFieldState.cardScheme` stays `null`** and CVV rules fall back to the unknown-scheme default until valid SDK ciphertext is stored — this is intentional after field-encryption hardening. See [security.md](security.md#card-scheme-only-storage). For initial SDK setup, see [getting-started.md](getting-started.md). @@ -417,6 +419,20 @@ ExpiryValidationUtils.isValidCombinedExpiry("", "") // true — blank dates allo Use `sdk.createCreditCard()` to submit the form. This is a suspend function and must be called from a coroutine scope. +### Submission validation (`formFields`) + +The `formFields` argument is the **submission validation manifest**: only field types you list are considered at tokenize time. + +| Rule | Behavior | +|------|----------| +| **Required** (`required = true`) | Always validated before tokenization. | +| **Optional encrypted CHD** (`CARD`, `CVV`, `ACCOUNT_NUMBER` with `required = false`) | Validated only when the SDK has **non-blank stored ciphertext** for that field. Corrupt or invalid ciphertext returns `ValidationFailed` and does **not** tokenize with empty decrypted values. | +| **Optional non-CHD** (for example `NAME`, `ZIP`, address fields with `required = false`) | **Not** submission-gated by `createCreditCard()` / `createBankAccount()` — same as pre–field-encryption-hardening behavior. | + +`ValidationFailed.invalidFields` lists every field that was validated and failed (including optional corrupt CHD). + +**Pre-submit UI vs submit:** `areAllFieldsValid(formFields)` runs validation on **every** listed field (including optional non-CHD). That can disable a Pay button even when `createCreditCard()` would proceed. For optional non-CHD in `formFields`, prefer field-level `HostedFieldState.isValid` / `onValidationChange` for UI, and treat `createCreditCard()` as the authoritative submit gate. See [ACH Bank Account](ach-bank-account.md) for the same `formFields` pattern on ACH. + ### Basic Submission ```kotlin diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 3bffea0..96ded6f 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -21,11 +21,11 @@ data class Failed( val errorType: ErrorType, // Type of error (API, Network, Unknown) val message: String?, // Primary error message val state: String?, // Transaction state (offsite payments) - val originalError: Throwable?, // Original exception (for debugging) + val originalError: Throwable?, // Unsanitized; do not log message or stack val apiError: SpreedlyApiError?, // Specific API error type val statusCode: Int?, // HTTP status code val validationErrors: List, // Field-specific errors - val rawErrorResponse: String? // Complete error response (debugging) + val rawErrorResponse: String? // Not wire JSON — do not parse. Sanitized via Failed.fromNetworkError ) ``` @@ -38,7 +38,7 @@ data class Failed( - Only allowlisted validation field names are included - Error messages are sanitized and length-bounded -Use `errorType`, `apiError`, `statusCode`, `message`, and `validationErrors` for merchant logging — not raw backend bodies. See [ACH Bank Account](ach-bank-account.md#handling-results). +Log `errorType`, `apiError`, `statusCode`, and `toString()` for debugging (see [Error Logging for Debugging](#2-error-logging-for-debugging)). Use `getDescription()` for UI only. Never log `message`, `rawErrorResponse`, `originalError`, or `"$failed"`. See [ACH Bank Account](ach-bank-account.md#handling-results). Immediate `createBankAccount()` return values: @@ -230,23 +230,25 @@ SpreedlyApiError.VALIDATION_ERROR -> { **What it means:** Connection issues, timeouts, or other network problems. +SDK-built failures set `errorType` to `NETWORK_ERROR` and sanitize `message` (for example +`Network error: NO_INTERNET` or `Network error: IO_ERROR`) from the internal network +error’s log-safe summary — not from raw exception text. **Do not** branch on substrings like +`"timeout"` or `"connection"` in `message`; those heuristics will not match. Use +`getDescription()` for UI and `toString()` for logs (see +[Error Logging for Debugging](#2-error-logging-for-debugging)). + **How to handle:** ```kotlin PaymentResult.Failed.ErrorType.NETWORK_ERROR -> { - when { - error.message?.contains("timeout") == true -> { - showRetryableError("Request timed out. Please try again.") - } - error.message?.contains("connection") == true -> { - showRetryableError("Connection failed. Please check your internet.") - } - else -> { - showRetryableError("Network error occurred. Please try again.") - } - } + // Retryable — show sanitized copy; log error.toString() only + showRetryableError(error.getDescription()) } ``` +For finer-grained copy (optional), map on **`errorType`** only, or inspect upstream +`SpreedlyNetworkError` before it is mapped to `PaymentResult.Failed` inside your own +network layer — not on `Failed.message` substring heuristics. + ## Field-Specific Error Handling ### Map Validation Errors to UI Fields @@ -390,7 +392,7 @@ For `PaymentResult.Failed` (from `paymentResultFlow`), use the built-in properti ```kotlin when (val result = paymentResult) { is PaymentResult.Failed -> { - val message = result.message ?: "Payment failed" + val message = result.getDescription() val apiError = result.apiError // SpreedlyApiError? for fine-grained handling } } @@ -423,34 +425,26 @@ private fun getHumanReadableError(apiError: SpreedlyApiError): String = when (ap ### 2. Error Logging for Debugging +Use `toString()` for logs and `getDescription()` for UI copy. Log +`errorType`, `apiError`, `statusCode`, and `toString()` only. Never log +`message`, `rawErrorResponse`, or `originalError` (including `originalError.message`) — +merchant-constructed `Failed(...)` is unsanitized, and `message` can still be +`[REDACTED]` or over-redacted on the SDK path. `PaymentResult.Failed.toString()` is +log-safe (omits `message`, bodies, and `originalError`). Do not log getters. + ```kotlin private fun logErrorForDebugging(error: PaymentResult.Failed) { - Log.e("PaymentError", buildString { - appendLine("Error Type: ${error.errorType}") - appendLine("API Error: ${error.apiError}") - appendLine("Status Code: ${error.statusCode}") - appendLine("Message: ${error.message}") - - if (error.hasValidationErrors()) { - appendLine("Validation Errors:") - error.validationErrors.forEach { validationError -> - appendLine(" ${validationError.fieldName}: ${validationError.errorMessage}") - } - } - - // rawErrorResponse is sanitized by the SDK but should only be logged in debug builds - if (BuildConfig.DEBUG) { - error.rawErrorResponse?.let { response -> - appendLine("Raw Response: $response") - } - error.originalError?.let { throwable -> - appendLine("Original Exception: ${throwable.message}") - } - } - }) + Log.e("PaymentError", error.toString()) } + +private fun userFacingMessage(error: PaymentResult.Failed): String = + error.getDescription() ``` +**3DS challenge failures** use the same logging contract on +`ThreeDSChallengeResult.Failed`: log `errorType` and `toString()` only; show +`getDescription()` for UI. See [3D Secure Global Integration](3ds-global.md#failed). + ### 3. Graceful Degradation ```kotlin diff --git a/docs/guides/express-checkout.md b/docs/guides/express-checkout.md index 872df0f..10cbb20 100644 --- a/docs/guides/express-checkout.md +++ b/docs/guides/express-checkout.md @@ -92,7 +92,7 @@ viewModelScope.launch { sdk.paymentResultFlow.collect { result -> when (result) { is PaymentResult.Completed -> sendTokenToBackend(result.token) - is PaymentResult.Failed -> showError(result.message) + is PaymentResult.Failed -> showError(result.getDescription()) is PaymentResult.Canceled -> { /* user dismissed */ } PaymentResult.Initial -> { /* waiting */ } } @@ -274,7 +274,7 @@ public class PaymentActivity extends AppCompatActivity { String token = ((PaymentResult.Completed) result).getToken(); // Send token to backend } else if (result instanceof PaymentResult.Failed) { - String message = ((PaymentResult.Failed) result).getMessage(); + String message = ((PaymentResult.Failed) result).getDescription(); // Show error } else if (result instanceof PaymentResult.Canceled) { // User dismissed @@ -312,7 +312,7 @@ This is what you collect. Emitted after the tokenization API call completes (or | `Initial` | -- | Default state before any payment | | `Completed` | `token`, `paymentMethodResponse`, `shouldRetain`, `state`, `nonce`, `deviceData` | Tokenization succeeded | | `Canceled` | -- | User dismissed the bottom sheet | -| `Failed` | `errorType`, `message`, `state`, `apiError`, `statusCode`, `validationErrors`, `rawErrorResponse` | Tokenization failed | +| `Failed` | `errorType`, `message`, `state`, `apiError`, `statusCode`, `validationErrors`, `rawErrorResponse` | Tokenization failed. SDK path: strings sanitized via `fromNetworkError`; do not log getters — use `toString()` (see [Error Handling](error-handling.md#error-logging-for-debugging)). | ```kotlin sdk.paymentResultFlow.collect { result -> @@ -605,9 +605,9 @@ is PaymentResult.Failed -> { val description = result.getDescription() val apiError = result.apiError // e.g., SpreedlyApiError.VALIDATION_ERROR - // Field-level validation errors from the API + // Field-level validation errors from the API — highlight in UI; do not log messages result.validationErrors.forEach { error -> - Log.d("Payment", "${error.fieldName}: ${error.errorMessage}") + showFieldError(error.fieldName, error.errorMessage) } } PaymentResult.Failed.ErrorType.NETWORK_ERROR -> { diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 31c8b49..fa3313f 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -91,7 +91,7 @@ lifecycleScope.launch { // Send token to your backend to complete the transaction } is PaymentResult.Failed -> { - val error = result.message + val error = result.getDescription() // Show error to user } is PaymentResult.Canceled -> { diff --git a/docs/guides/migration/from-legacy.md b/docs/guides/migration/from-legacy.md index f2349b5..2e09170 100644 --- a/docs/guides/migration/from-legacy.md +++ b/docs/guides/migration/from-legacy.md @@ -17,7 +17,7 @@ The Checkout Android SDK replaces both the old native SDK and WebView-based appr - Jetpack Compose UI with full theming and dark mode support - Server-side authentication (no secrets on-device) -- PCI scope reduction (sensitive data never touches merchant code) +- Designed to minimize PAN/CVV exposure in merchant UI code (confirm PCI scope with your QSA/acquirer) - ACH bank account tokenization - 3D Secure authentication (Forter global and gateway-specific) - Alternative payment methods (Stripe APM, Braintree APM) @@ -42,7 +42,7 @@ See the [README compatibility table](../../../README.md#compatibility) for minim | **UI** | XML Views (`SecureForm`, `SecureCreditCardField`) / WebView | Jetpack Compose (`SPLTextField`, `SpreedlyBottomSheet`) | | **Async model** | RxJava `Single` / JS callbacks | Coroutines (`suspend fun`) + `SharedFlow` | | **Authentication** | API secret stored on-device: `SpreedlyClient.newInstance("key", "secret", true)` | Server-generated signed params per session (nonce, signature, certificateToken, timestamp) -- no secret on device | -| **PCI scope** | Merchant code constructs `CreditCardInfo` with raw card data / WebView handles it in JS | Sensitive data flows exclusively through SDK secure components (`SPLTextField` / `sdk.callbacks`) -- never in merchant code | +| **PCI scope** | Merchant code constructs `CreditCardInfo` with raw card data / WebView handles it in JS | Sensitive fields are intended to flow through SDK secure components (`SPLTextField` / `sdk.callbacks`); confirm scope with your QSA/acquirer | | **Dependencies** | `com.spreedly:client` / `express` / `securewidgets` | Multi-module: `checkout-payments-core` / `checkout-hostedfields` / `checkout-paymentsheet` + optional `checkout-threeds`, `checkout-braintree-apm`, `checkout-stripe-apm`, `checkout-stripe-radar` | | **Result delivery** | `onActivityResult` with `EXTRA_PAYMENT_METHOD_TOKEN` / JS bridge | `sdk.paymentResultFlow: SharedFlow` | @@ -206,7 +206,7 @@ The old SDK let merchant code construct `CreditCardInfo` objects containing raw - **`SPLTextField`** composables write card data to internal encrypted state - **`sdk.callbacks`** methods update internal `PaymentSheetState` -The `createCreditCard()` and `createPaymentMethod()` methods read from that internal state. Your code never handles raw card numbers, CVVs, or account numbers directly. This is a PCI scope reduction by design. +The `createCreditCard()` and `createPaymentMethod()` methods read from that internal state. Prefer `SPLTextField` so merchant UI code does not handle raw card numbers, CVVs, or account numbers directly. Confirm PCI scope with your QSA/acquirer. ### Option A: Express Checkout (pre-built bottom sheet) @@ -510,7 +510,7 @@ is PaymentResult.Failed -> { // Connection, timeout, IO errors } PaymentResult.Failed.ErrorType.UNKNOWN_ERROR -> { - // Unexpected -- result.originalError has the Throwable + // Unexpected — use getDescription() for UI; do not log originalError } } } @@ -520,11 +520,14 @@ Key properties on `PaymentResult.Failed`: - `errorType` -- `API_ERROR`, `NETWORK_ERROR`, or `UNKNOWN_ERROR` - `apiError: SpreedlyApiError?` -- categorized API error type - `validationErrors: List` -- field-level errors with `fieldName`, `errorKey`, `errorMessage` -- `message: String?` -- primary error message +- `message: String?` -- primary error message (UI copy; do not log for debugging) - `statusCode: Int?` -- HTTP status code - `getDescription()` -- user-friendly error string - `hasValidationErrors()` -- whether field-level errors are present - `getValidationErrors(fieldName)` -- errors for a specific field +- `toString()` -- log-safe (`errorType`, plus `statusCode` / `apiError` / `state` when present); omits `message`, bodies, and validation lists + +**Logging:** log `errorType`, `apiError`, `statusCode`, and `toString()` for debugging. Use `getDescription()` for UI only — not `message`, `rawErrorResponse`, `originalError`, or `"$failed"`. See [Error Handling](../error-handling.md#error-logging-for-debugging). For recache results (`Result`), use `SpreedlyErrorMessages.getUserFriendlyMessage(error)` to get a display-ready string. diff --git a/docs/guides/offsite-payments.md b/docs/guides/offsite-payments.md index 3bb08cb..b7e4459 100644 --- a/docs/guides/offsite-payments.md +++ b/docs/guides/offsite-payments.md @@ -317,7 +317,7 @@ private fun observePaymentResults() { handleCompletion(state, token) } is PaymentResult.Failed -> { - val message = result.message ?: "Payment failed" + val message = result.getDescription() val state = result.state // e.g., "gateway_processing_failed" handleFailure(message, state) } @@ -1083,7 +1083,7 @@ sdk.paymentResultFlow.collect { result -> showError("Payment failed. Please try again.") } else -> { - showError(result.message ?: "Payment failed") + showError(result.getDescription()) } } } @@ -1356,7 +1356,7 @@ when (result) { // Soft success - customer will pay offline showSuccess("Payment initiated. Customer will complete payment offline.") } else { - showError(result.message) + showError(result.getDescription()) } } } @@ -1619,12 +1619,13 @@ data class Failed( val errorType: ErrorType, val message: String?, val state: String? = null, // Transaction state (e.g., "gateway_processing_failed") - val originalError: Throwable? = null, + val originalError: Throwable? = null, // Unsanitized; do not log message or stack // ... other fields ) ``` - `state` contains the transaction state when the failure was detected via status API +- **Logging:** log `errorType`, `apiError`, `statusCode`, and `toString()` for debugging. Use `getDescription()` for UI only. Never log `message`, `rawErrorResponse`, `originalError`, or `"$failed"`. See [Error Handling](error-handling.md#error-logging-for-debugging). --- diff --git a/docs/guides/privacy-policy.md b/docs/guides/privacy-policy.md index 845e584..55abd86 100644 --- a/docs/guides/privacy-policy.md +++ b/docs/guides/privacy-policy.md @@ -15,11 +15,12 @@ Spreedly API (`core.spreedly.com`) over HTTPS: |----------|--------| | Authentication | Environment key, nonce, timestamp, HMAC signature, certificate token | | Card details | Card number, CVV/CVC, expiry month and year | +| Bank account | Account number (encrypted in memory), routing number, account type, holder type, optional bank name | | Cardholder info | First name, last name, full name, company | | 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 | +| Mandate | Opaque merchant-supplied mandate object, forwarded verbatim. Mandate **values** are do-not-log and are never persisted on the device; request `toString` redacts them. Conversion diagnostics may include structural mandate **key paths**. Click to Pay unexpected local conversion failures expose only a static public failure message. | For offsite payment methods (PayPal, Pix, Boleto, etc.), the same authentication fields are sent along with the payment method type, email, @@ -86,15 +87,16 @@ The SDK does **not** collect: ### Payment data -1. Card numbers and CVV values are encrypted in memory using AES-128-GCM - with a per-instance random key that is never persisted to disk. +1. Card numbers, CVV values, and bank account numbers are encrypted in memory using AES-128-GCM + with a per-process random key (one `SecureRandom` 128-bit key for the process, not per SDK + instance) that is never persisted to disk. 2. Encrypted values are decrypted only when the SDK needs the plaintext for validation, card scheme detection, or API submission. 3. Data is transmitted over HTTPS to `core.spreedly.com` and is not stored locally in SharedPreferences, databases, or the file system. 4. Once the Spreedly API returns a payment method token, the raw card data is no longer needed and exists only in process memory until garbage - collected. + collected. Recache CVV is held in process memory only (not in saved instance state). ### Telemetry data @@ -102,8 +104,10 @@ The SDK does **not** collect: values that may have been included in error messages. 2. Sanitized logs are batched by the Datadog SDK and uploaded approximately every 5 seconds. -3. No card numbers, CVV values, cardholder names, or billing addresses are - ever included in telemetry. +3. SDK-controlled logging and telemetry paths run through `LogSanitizer` for supported + PAN/CVV/token/credential patterns. That is not a guarantee that every string a merchant + logs, or every residual public error getter, is free of sensitive data. See + [Security](security.md). ## Third-Party Services @@ -125,13 +129,14 @@ policies. The SDK implements multiple layers of protection for payment data: -- **In-memory encryption** -- AES-128-GCM for card numbers and CVV values +- **In-memory encryption** -- AES-128-GCM for card numbers, CVV, and bank account numbers - **No local persistence** -- sensitive data is never written to disk - **Clipboard blocking** -- copy/cut disabled on card and CVV fields; paste disabled on CVV only - **Screenshot prevention** -- `FLAG_SECURE` applied to payment UI - **CVV auto-clear** -- CVV field is cleared after 3 minutes in background -- **Log sanitization** -- card numbers, CVV, tokens, emails, and secrets - are redacted from all log output +- **Log sanitization** -- SDK-controlled log APIs redact supported card-number, labeled CVV, + token, email, and secret patterns before Logcat and Datadog. Residual surfaces are listed in + [Security](security.md). - **HTTPS only** -- no plaintext HTTP endpoints For full details, see the [Security](security.md) guide. @@ -158,11 +163,12 @@ policies: The SDK is designed to support PCI DSS compliance: -- Sensitive payment data (card numbers, CVV) is never logged -- All card data is encrypted in process memory +- SDK-controlled logging paths are sanitized for supported sensitive-data patterns +- Card and bank account CHD is encrypted in process memory - No card data is persisted to disk or local storage - Transmission uses HTTPS exclusively -- Log sanitization prevents accidental exposure in Logcat or Datadog +- Log sanitization reduces accidental exposure in Logcat or Datadog; it does not certify PCI DSS + scope. Merchants confirm scope with their QSA or acquirer. See the [Security](security.md) guide for the complete list of PCI compliance controls. diff --git a/docs/guides/recaching.md b/docs/guides/recaching.md index 139fd09..c0eec61 100644 --- a/docs/guides/recaching.md +++ b/docs/guides/recaching.md @@ -629,7 +629,7 @@ fun MyScreen(viewModel: MyViewModel) { try { val result = spreedly.recachePaymentMethod(token, config) } catch (e: IllegalStateException) { - Log.e("Recache", "SDK not initialized: ${e.message}") + Log.e("Recache", "SDK not initialized") // Initialize SDK first spreedly.init(options) } @@ -709,7 +709,8 @@ suspend fun recacheWithErrorHandling( result.data.transaction.paymentMethod.token } is Result.Error -> { - val errorMessage = when (val error = result.error) { + val error = result.error + val errorMessage = when (error) { is SpreedlyNetworkError.SpreedlyApiErrorDetail -> { when { error.statusCode == 404 -> "Card not found. It may have been deleted." @@ -722,15 +723,14 @@ suspend fun recacheWithErrorHandling( is SpreedlyNetworkError.IO_ERROR -> "Request timed out. Please try again." else -> "An unexpected error occurred. Please try again." } - showError(errorMessage) - logError("Recache failed", error) + logError("Recache", error.safeDescription()) null } } } catch (e: IllegalStateException) { showError("SDK not initialized. Please restart the app.") - logError("Recache exception", e) + logError("Recache", "SDK not initialized") return null } } @@ -884,8 +884,8 @@ RecacheConfig config = RecacheJavaHelper.createRecacheConfig( ); RecacheJavaHelper.recachePaymentMethod( this, sdk, paymentMethodToken, config, - (token, updatedAt) -> Log.d("Recache", "token=" + token + " updatedAt=" + updatedAt), - error -> Log.e("Recache", "Recache failed") + (token, updatedAt) -> Log.d("Recache", "Recache succeeded"), + error -> Log.e("Recache", error) ); ``` diff --git a/docs/guides/security.md b/docs/guides/security.md index db3e3f1..9d27a91 100644 --- a/docs/guides/security.md +++ b/docs/guides/security.md @@ -3,16 +3,26 @@ The Spreedly Android SDK is designed to handle sensitive payment data safely. It provides multiple layers of protection -- screenshot prevention, in-memory field encryption, clipboard blocking, automatic CVV expiry, and log -sanitization -- so that card details are never exposed through your -application code. +sanitization -- to **minimize** cardholder data exposure in merchant code. +Sensitive values still exist transiently on SDK-managed paths (validation, +tokenization, and residual public crypto helpers documented below); follow +this guide and [error-handling.md](error-handling.md) for safe integration. ## Screenshot and Screen Recording Prevention The SDK sets `FLAG_SECURE` on the host window whenever payment UI is visible. -This blocks screenshots and, on most Android versions, screen recording. +`FLAG_SECURE` prevents secure window content from appearing in screenshots +and non-secure displays. -The flag is applied when the composable enters composition and cleared -automatically when it leaves, so the rest of your app is unaffected. +Ownership is reference-counted per window: overlapping secure surfaces (for +example a payment sheet under a 3-D Secure challenge) keep the flag until the +last Spreedly owner leaves. If the host app already had `FLAG_SECURE` set, +Spreedly leaves it set after release. The rest of your app is otherwise +unaffected. + +Do not set or clear `FLAG_SECURE` yourself on a window while a Spreedly secure +surface is active on that window. Spreedly restores the flag state from the +first Spreedly acquire; concurrent merchant changes can race with that restore. ### Built-in coverage @@ -24,6 +34,7 @@ automatically when it leaves, so the rest of your app is unaffected. - `SpreedlyRecacheUI` - `ThreeDSChallengeBottomSheet` / `ThreeDSChallengeSheet` - `HostedFieldsJavaHelper` +- `ClickToPaySavedCardsDetector` (held for the detector mount duration) No extra work is needed if you use these components. @@ -60,32 +71,36 @@ fun MyPaymentScreen() { ### Limitations -| Android version | Screenshots | Screen recording | -|-----------------|-------------|------------------| -| 5 -- 9 | Blocked | Blocked by most recorders | -| 10+ | Blocked | System recorder **not** blocked (Android limitation) | -| Rooted devices | Can be bypassed | Can be bypassed | +`FLAG_SECURE` prevents secure window content from appearing in screenshots +and non-secure displays. Protection against screen recording, screen sharing, +overlays, rooted devices, and OEM-specific capture mechanisms can vary by +Android version, device, and capture mechanism. -For additional protection, consider server-side checks via the -Play Integrity API. +For additional protection, consider server-side checks via Play Integrity +App Access Risk. ## Field Encryption -Card numbers and CVV values are encrypted in memory using **AES-128-GCM** -(via `SpreedlyEncryption`). Each app instance generates a random 128-bit key -with `SecureRandom`; the key lives only in process memory and is never -persisted. +Card numbers, CVV, and bank account numbers (**CARD**, **CVV**, and **ACCOUNT_NUMBER**) +are encrypted in memory using **AES-128-GCM** (via `SpreedlyEncryption`). Encryption uses +a **per-process** lifetime key: each app process generates a random **128-bit** key with +`SecureRandom` (one `keyBytes` for the `SpreedlyEncryption` object, not per SDK instance); +the key lives only in process memory and is never persisted. `SpreedlyEncryption.KEY` is a +sentinel string (not key material) used to select that process key. Encryption is applied automatically through the `Encryptor` interface: -- `DefaultEncryptor.encryptValue()` encrypts CARD and CVV field types on +- `DefaultEncryptor.encryptValue()` encrypts **CARD, CVV, and ACCOUNT_NUMBER** field types on every keystroke. - `DefaultEncryptor.decryptValue()` decrypts only when the SDK itself needs the plaintext (validation, scheme detection, API submission). - All other field types (name, expiry, ZIP) pass through unencrypted. -This means your application code never has access to raw card numbers or -CVV values, even if you inspect the field state objects. +`@RestrictTo(LIBRARY_GROUP)` and `@Deprecated` on the encryption surfaces are +**lint-only** — symbols remain in the published ABI. Public `decryptAES` / `KEY` +still accept CHD for SDK field ciphertext; this release improves entropy and +fail-closed decrypt behavior. It is **not** an egress reduction for every API +capability (`getDisplayValue`, public `decryptAES`, and residual FieldUtils paths). ## PCI Compliance Controls @@ -120,23 +135,72 @@ transmitted directly to the Spreedly API over HTTPS. ## Log Sanitization -All SDK log output passes through `LogSanitizer` before reaching Logcat or -Datadog: +The SDK sanitizes log tags, messages, and throwables in `LoggerManager` before +any `SpreedlyLogger` implementation runs on the standard log APIs +(`verbose` / `debug` / `info` / `warn` / `error`) — including custom loggers +installed via `LoggerManager.setLogger(...)`. Structured events via +`LoggerManager.emitEvent` sanitize string attributes before Datadog and before +the base-logger copy (that path does not go through the log-API facade). +Implementations must not expect raw card data, CVV, or the original exception +instance. `LoggerManager.logger` is the log-API facade; it is not the same +instance passed to `setLogger`. + +Built-in sanitization covers: | Pattern | Action | |---------|--------| -| 16-digit card numbers (with or without separators) | Replaced with `[REDACTED]` | -| CVV / CVC / security code values | Replaced with `[REDACTED]` | +| ISO 7812 card numbers / PAN-like digit runs of **12 or more** digits, including contiguous runs and digits separated by any number of spaces, hyphens, dots, or underscores (covers double-/triple-/wide-spaced formatting, glued-after-letter forms such as `cardNumber4111…`, and 20+ digit embeddings). Candidates are detected in a single linear pass with no per-candidate character cap. 12-digit dotted IPv4 addresses and long numeric IDs may over-redact | Replaced with `[REDACTED]` | +| Labeled CVV / CVC / security code values (JSON `"cvv":"123"`, escaped-JSON `\"cvv\":\"123\"`, `verification_value`, prose `cvv: 123`; unlabeled 3–4 digits are not redacted) | Replaced with `[REDACTED]` | | API keys, tokens, secrets, passwords | Replaced with `[REDACTED]` | | Signatures and HMACs | Replaced with `[REDACTED]` | | Email addresses | Replaced with `[REDACTED]` | | Payment method / transaction tokens in URL paths | Replaced with `[REDACTED]` | | Log-forging control characters (`\r`, `\n`, null bytes) | Stripped | +`LogSanitizer` scrubs supported PAN/CVV/token/credential/value patterns and URL +tokens. Bare identifier-shaped path components (for example structural mandate +key paths in conversion diagnostics) are **not** automatically scrubbed. + +Mandate **values** are do-not-log; request `toString` redacts them as +`mandate=[REDACTED]`. Conversion diagnostics may contain structural mandate key +paths. Click to Pay unexpected local conversion failures publish only a static +public failure message (`Tokenize failed`). The SDK default `sdkScope` +`CoroutineExceptionHandler` logs only the exception class name (no throwable +payload) so custom loggers cannot receive raw source exception content from +that last-resort path. + Additional protections: - `PaymentMethodRequest.toString()` redacts `environmentKey`, `nonce`, `signature`, and `certificateToken`. +- `SpreedlyNetworkError.SpreedlyApiErrorDetail.toString()` matches + `safeDescription()` (`statusCode` and `errorKey` only). It does not + include `rawErrorBody`, `errorMessage`, or `validationErrors`. Getters + return values stored after `LogSanitizer.sanitizeStoredErrorString` (pattern + redaction, trailing separator-optional partial-digit redaction after the 8 KiB cap, log-forge + control characters → space, then `trim()`), including nested + `validationErrors` `fieldName` / `errorKey` / `errorMessage`, after an 8 KiB + length cap. That is not the verbatim HTTP body and is not a guarantee every + secret shape is removed. Prefer `safeDescription()` for logs; do **not** parse + `rawErrorBody` / `rawErrorResponse` as wire JSON (over-redaction can break + JSON, e.g. unlabeled 13-digit timestamps). Do not treat stored strings as a + source of truth for analytics or retries — use `statusCode` / `errorKey` / + `safeDescription()` instead. + `AppNetworkError.API_ERROR.toString()` also matches `safeDescription()`; + public `API_ERROR` getters remain raw. Direct `AppNetworkError` consumers are not + covered by this tokenize-path hardening. `RequestHandler` uses streaming + `HttpStatement.execute { }` (not the no-arg overload that buffers the full + body via `call.save()`), reads at most 8 KiB **bytes** from the HTTP error + body channel, and cancels the rest; success (2xx) bodies are not capped. + Cancel stops further channel reads on that path; it is not a socket-level + cutoff if the engine already buffered data. +- `PaymentResult.Failed.toString()` is log-safe (`errorType`, + `statusCode`, `apiError`, and `state` when present). It does not include + `message`, `rawErrorResponse`, `validationErrors`, or `originalError`. Prefer + `toString()` for logs and `getDescription()` for UI; never log getters + directly on merchant-constructed failures. +- `ThreeDSChallengeResult.Failed.toString()` is log-safe + (`errorType` only). It does not include `message` or `originalError`. - `ApiClientBuilder` sanitizes the `Authorization` header in HTTP logs. - `DatadogSpreedlyLogger` masks the environment key to its first 4 characters (`AbCd****`) in all Datadog attributes via `LogSanitizer.maskEnvironmentKey()`. diff --git a/docs/guides/stripe-apm.md b/docs/guides/stripe-apm.md index 68b820b..7f2a6fe 100644 --- a/docs/guides/stripe-apm.md +++ b/docs/guides/stripe-apm.md @@ -254,7 +254,7 @@ private fun observePaymentResults() { handleSuccess(message) } is PaymentResult.Failed -> { - handleFailure(result.message ?: "Payment failed") + handleFailure(result.getDescription()) } is PaymentResult.Canceled -> { handleCancellation() @@ -498,14 +498,15 @@ is blank, it publishes `PaymentResult.Failed` immediately: ```kotlin is PaymentResult.Failed -> { + val description = result.getDescription() when { - result.message?.contains("publishable key") == true -> { + description.contains("publishable key") -> { // Missing or blank Stripe publishable key } - result.message?.contains("Client secret") == true -> { + description.contains("Client secret") -> { // Missing or blank client secret } - result.message?.contains("Transaction token") == true -> { + description.contains("Transaction token") -> { // Missing or blank transaction token } } diff --git a/gradle/kover-cli-checksums.txt b/gradle/kover-cli-checksums.txt new file mode 100644 index 0000000..d6db8aa --- /dev/null +++ b/gradle/kover-cli-checksums.txt @@ -0,0 +1,37 @@ +# Pinned SHA-256 for the Kover CLI jar, keyed by version. +# +# The sharded coverage merge job runs `java -jar kover-cli.jar`, so this is an +# executable fetched at CI time and it gets pinned rather than trusted. Maven +# Central publishes only a .sha1 alongside the artifact, which is too weak to +# rely on for integrity, so the SHA-256 is recorded here instead. +# +# The version itself is NOT recorded here -- it is read from +# gradle/libs.versions.toml so the CLI always matches the Gradle plugin. This +# file only answers "what should that version's jar hash to". +# +# MAINTENANCE, when Kover is upgraded: +# 1. bump `kover` in gradle/libs.versions.toml +# 2. ./scripts/ci/fetch-kover-cli.sh --print-sha256 +# 3. add the new " " line below (keep old lines; they cost +# nothing and make a rollback verifiable) +# 4. confirm the value independently, e.g. from a second machine or by +# comparing against the .sha1 Maven Central publishes +# 5. python3 scripts/ci/test_kover_artifact_parser.py +# The merge job parses */build/kover/custom.artifact, which is Kover's +# internal format. A layout change there would otherwise surface as a +# wrong aggregate rather than an error, so the parser tests are part of +# the upgrade, not optional. +# 6. re-run the monolith-vs-CLI aggregate parity check +# Gradle Kover and the CLI must still agree exactly on every raw counter +# and the whole class set. A version skew between the plugin that writes +# the binary coverage and the CLI that reads it is precisely what this +# pin exists to prevent, and parity is the only proof it held. +# +# All three -- a pin, green parser tests, and exact parity -- are required +# together. A Kover bump with any one missing should not merge. +# +# A version present in libs.versions.toml but absent here fails the build +# rather than downloading unverified. +# +# +0.9.3 6c5ac0c25465c3ab05b7ca5d8eea08d9fec544ee78c26376c0c6d5b5b0507828 diff --git a/gradle/kover-excludes.txt b/gradle/kover-excludes.txt new file mode 100644 index 0000000..204daaa --- /dev/null +++ b/gradle/kover-excludes.txt @@ -0,0 +1,159 @@ +# Canonical Kover coverage exclusion policy. +# +# One representation, consumed by three places: +# - build.gradle.kts, the root aggregate report +# - build.gradle.kts, the per-module reports propagated to subprojects +# - scripts/ci/kover_cli_aggregate.py, the sharded merge job's Kover CLI call +# +# Everything is a CLASS wildcard pattern, because Gradle Kover `classes(...)` +# and Kover CLI `--exclude` both accept exactly that syntax. Package rules were +# converted to subtree patterns so no translation step exists: the shard +# prototype produced a report with 136 extra classes precisely because the CLI +# has no package filter and the translation had to be re-derived. +# +# `*` matches any characters including dots, so `a.b.*` covers subpackages. +# Every former package rule but one was already paired with its own `.*` form, +# making conversion exact. +# +# SUBTREE POLICY DECISION -- com.spreedly.sdk.models +# +# The old root config used `packages("com.spreedly.sdk.models")`, which in +# Gradle Kover excludes only classes directly in that package, while +# `com.spreedly.sdk.models.offsite` exists. The class-pattern form +# `com.spreedly.sdk.models.*` also reaches that subpackage. +# +# The intended policy is deliberately the SUBTREE: everything under +# com.spreedly.sdk.models is excluded, subpackages included. These are wire +# models and DTOs with no behaviour worth covering, the old config already +# named 13 of them individually alongside the package rule, and a per-subpackage +# opt-in list would be one more thing to forget when a model is added. +# +# This is a decision, not a side effect of wildcard semantics. It is currently +# inert -- the offsite subpackage contributes zero classes to the aggregate, +# verified by old-vs-new per-module comparison across all eight modules -- but +# it will silently cover future classes added under +# com.spreedly.sdk.models.offsite. If a subpackage there ever needs coverage, +# narrow this line rather than relying on the old package/class distinction. +# +# Blank lines and `#` comments are ignored. Keep it sorted. +*$$* +*$$serializer* +*$Companion* +*$WhenMappings* +*$setupContent$* +*$setupRecacheUI$* +*.AuthParamsResponse* +*.CardScheme* +*.CheckoutButton* +*.CreditCard* +*.CustomFieldsConfig +*.FormFieldType +*.FormFieldType* +*.Metadata* +*.PaymentMethodDetails* +*.PaymentMethodRequest* +*.PaymentMethodResponse* +*.PaymentSheet* +*.PaymentSheetCallbacks +*.RecacheConfig* +*.RecacheRequest* +*.RecacheResponse* +*.SPLTextField* +*.ScreenPresentationMode* +*.SpreedlyApiService +*.SpreedlyBottomSheet* +*.Transaction* +*.TransactionCompletionRequest* +*.TransactionCompletionResponse* +*.TransactionStatusRequest* +*.TransactionStatusResponse* +*.atoms.* +*.components.* +*.molecules.* +*.screen.* +*.theme.* +*.ui.* +*.visualtransformations.* +*Activity* +*Adapter* +*AuthParamsResponse* +*BinMetadata* +*BraintreePayPalHandlerKt* +*BraintreeVenmoHandlerKt* +*BuildConfig* +*CVVInputFieldKt* +*CardNumberError* +*CheckoutButton* +*ClickToPayBrandedButtonContentKt* +*ClickToPayBrandedButtonKt* +*ClickToPayBrandedButtonPresentation* +*ClickToPayButtonJavaHelper* +*ClickToPayCheckoutArgs* +*ClickToPayCheckoutFieldsKt* +*ClickToPayCheckoutViewModel* +*ClickToPayCheckoutViewModelFactory* +*ClickToPayDisplayCardsConfig* +*ClickToPayEnrollmentCard* +*ClickToPayEvent$* +*ClickToPayFieldError* +*ClickToPayGuestCheckoutConfig* +*ClickToPayInitConfig* +*ClickToPayMaskedCard* +*ClickToPayMcInitialization* +*ClickToPayMcPayload* +*ClickToPayNewCardFields* +*ClickToPayNewUserEnrollmentBarKt* +*ClickToPayOtpConfig* +*ClickToPayPopUpWebChromeClient* +*ClickToPayPrimaryButtonKt* +*ClickToPaySplEncryptedFields* +*ClickToPayTokenizeBilling* +*ClickToPayValidationChannel* +*ClickToPayWebViewHostKt* +*Composable* +*ComposableSingletons* +*CreditCard* +*CustomFieldsConfig* +*Dto* +*Entity* +*FieldComposeUtilsKt* +*FormFieldType* +*Fragment* +*Hilt_* +*HostedFieldsJavaHelper* +*Kt$* +*McOrchestratorPhase* +*McUiOverlay* +*Model* +*PaymentMethodRequest* +*PaymentMethodResponse* +*PaymentSheet* +*PaymentSheetCallbacks* +*Preview* +*R +*R$* +*RecacheBottomSheetKt* +*RecacheDialogKt* +*Response* +*SPLTextField* +*Screen* +*SpreedlyApiService* +*SpreedlyBottomSheet* +*SpreedlyClickToPayButtonKt* +*SpreedlyPaymentBottomSheetKt* +*SpreedlyRecacheUI* +*SpreedlyRecacheUIKt* +*Test* +*Theme* +*ViewHolder* +*_Factory* +*_HiltModules* +*_MembersInjector* +com.spreedly.hostedfields.ui.SaveCardCheckbox* +com.spreedly.paymentsheet.* +com.spreedly.sdk.SpreedlyFactory +com.spreedly.sdk.SpreedlyWithCustomApiService +com.spreedly.sdk.errors.* +com.spreedly.sdk.examples.SpreedlyInstantiationExamples +com.spreedly.sdk.models.* +com.spreedly.ui.* diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5dc84b9..d5d7623 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,8 @@ # Build Tools & Core # ============================================================================ agp = "8.13.2" # Android Gradle Plugin -kotlin = "2.3.10" # Kotlin language version (2.3.20 blocked by CodeQL; github.com/github/codeql/issues/21484) -ksp = "2.3.6" # Kotlin Symbol Processing (KSP2 line) +kotlin = "2.1.20" # Kotlin language version (toolchain aligned with merchant Kotlin 2.1.x) +ksp = "2.1.20-2.0.1" # KSP version must match Kotlin (KSP2 line) androidTools = "31.13.0" # Android build tools # ============================================================================ @@ -21,7 +21,7 @@ coreKtx = "1.17.0" # AndroidX Core KTX extensions appcompat = "1.7.1" # AndroidX AppCompat activity = "1.11.0" # AndroidX Activity browser = "1.9.0" # AndroidX Browser (Chrome Custom Tabs + Auth Tab) -webkit = "1.12.1" # AndroidX WebKit +webkit = "1.12.1" # AndroidX WebKit (pin; bump to latest stable in a dedicated PR with C2P smoke) cardview = "1.0.0" # AndroidX CardView constraintlayout = "2.2.1" # AndroidX ConstraintLayout lifecycle = "2.9.4" # AndroidX Lifecycle (runtime, viewmodel, compose) @@ -60,6 +60,7 @@ androidxTestCore = "1.6.1" # AndroidX Test Core (compatible with an espressoCore = "3.5.0" # Espresso UI testing (compatible with Compose BOM 2025.10.01) mockk = "1.14.6" # MockK mocking framework robolectric = "4.15.1" # Robolectric for unit testing with Android +orgJson = "20240303" # org.json for pure-JVM unit tests (Android stubs insufficient) # ============================================================================ # Code Quality & Documentation @@ -78,7 +79,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.3.0" # Spreedly SDK version +spreedlySdk = "1.4.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 @@ -176,6 +177,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +org-json = { group = "org.json", name = "json", version.ref = "orgJson" } # ---------------------------------------------------------------------------- # Code Quality & Build Tools diff --git a/gradle/test-shards.json b/gradle/test-shards.json new file mode 100644 index 0000000..8577d48 --- /dev/null +++ b/gradle/test-shards.json @@ -0,0 +1,34 @@ +{ + "_comment": [ + "Canonical test-shard composition. One source for the code-quality workflow", + "matrix, the preflight validator, and the staging scripts, so module", + "allocation cannot drift between them.", + "", + "Every non-POM module in gradle/module-catalog.json must appear exactly once.", + "scripts/ci/validate-shard-map.sh enforces that before any Test job runs, so", + "adding PPCP, Paze or Google Pay fails in seconds with a message naming the", + "module rather than after four Test jobs have spent runner-minutes.", + "", + "Composition is dependency-aware, not balanced on standalone test duration.", + "Measured CI shard walls were 241-297s where standalone test times spanned", + "5-32s, because checkout, Gradle setup and compiling payments-core dominate.", + "So modules are grouped by what a shard must compile anyway." + ], + "shards": [ + { + "name": "s1-clicktopay", + "modules": ["clicktopay", "hostedfields"], + "rationale": "clicktopay depends on hostedfields, so this shard compiles hostedfields regardless; testing it here costs no extra compile." + }, + { + "name": "s2-paymentsheet-core", + "modules": ["paymentsheet", "payments-core"], + "rationale": "paymentsheet compiles payments-core anyway, so payments-core's tests ride along instead of paying for a dedicated shard's setup and compile." + }, + { + "name": "s3-threeds-apm", + "modules": ["threeds", "stripe", "stripe-radar", "braintree"], + "rationale": "the four leaf modules that depend only on payments-core; they share one payments-core compile between them." + } + ] +}