Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@ dependencies {
kapt "com.google.dagger:dagger-compiler:$dagger_version"
implementation 'com.jakewharton:process-phoenix:3.0.0'
implementation "com.jakewharton.timber:timber:5.0.1"
implementation 'com.stripe:stripe-android:23.17.1'
implementation 'com.stripe:stripe-android:23.20.0'
// Google Pay - to access and override `GooglePayAvailabilityClient`
implementation 'com.google.android.gms:play-services-wallet:20.0.0'

final okhttp_version = '5.1.0'
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/kickstarter/KSApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.kickstarter.libs.featureflag.StatsigClient
import com.kickstarter.libs.featureflag.StatsigException
import com.kickstarter.libs.utils.ApplicationLifecycleUtil
import com.kickstarter.libs.utils.Secrets
import com.kickstarter.utils.GooglePayAvailabilityUtil
import com.kickstarter.viewmodels.InitializationState
import io.reactivex.exceptions.OnErrorNotImplementedException
import io.reactivex.exceptions.UndeliverableException
Expand Down Expand Up @@ -70,6 +71,9 @@ open class KSApplication : MultiDexApplication(), IKSApplicationComponent, Image
@Inject
lateinit var statsigClient: StatsigClient

@Inject
lateinit var apiEndpoint: ApiEndpoint

/**
* - A CoroutineScope tied to the Application lifecycle
* used to initialize dependencies that require coroutines and early on network calls.
Expand Down Expand Up @@ -112,6 +116,10 @@ open class KSApplication : MultiDexApplication(), IKSApplicationComponent, Image
plant(Timber.DebugTree())
}

if (apiEndpoint != ApiEndpoint.PRODUCTION) {
GooglePayAvailabilityUtil.overrideGooglePayAvailabilityClient()
}

createErrorHandler()
initialize(applicationContext, ffClient) { this.initializeDependencies() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ enum class StatsigGateKey(val key: String) {
ANDROID_PRELAUNCH_SOCIAL_SHARE("android_social_share"),
ANDROID_PRELAUNCH_PROJECT_STORY("android_pre-launch_project_story"),
ANDROID_OPEN_CALLS("android_open_calls"),
ANDROID_REPORT_PROJECT("android_report_project")
ANDROID_REPORT_PROJECT("android_report_project"),
ANDROID_PAYMENTSHEET_GOOGLE_PAY("android_paymentsheet_google_pay"),
}

object StatsigExperiments {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.OptIn
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
Expand All @@ -22,6 +23,7 @@ import com.kickstarter.KSApplication
import com.kickstarter.R
import com.kickstarter.libs.Environment
import com.kickstarter.libs.featureflag.FlagKey
import com.kickstarter.libs.utils.Secrets
import com.kickstarter.libs.utils.WebUtils
import com.kickstarter.ui.SharedPreferenceKey
import com.kickstarter.ui.activities.AppThemes
Expand Down Expand Up @@ -195,19 +197,52 @@ fun Context.showAlertDialog(
/**
* Provides the configuration for the PaymentSheet, following the specs
* @see [link](https://stripe.com/docs/payments/accept-a-payment?platform=android&ui=elements#android-flowcontroller)
*
* @param googlePayCurrencyCode The three-letter ISO 4217 alphabetic currency code, e.g. "USD" or "EUR".

@Arkariang Arkariang Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth mentioning here the googlePayCurrencyCode is the merchant's ( Kickstarter ), not the project, took a moment to realize why on getGooglePayConfiguration "US" was hardcoded as country code and same with getPaymentSheetConfiguration "USD"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, I'll update the comments to add more clarification:

  • The countryCode set in the GooglePayConfiguration object is the country code of the business' (Kickstarter's) Stripe account, so that's hardcoded to "US". (Stripe docs, tech plan)
  • Noted in the PR details, the currencyCode for the GooglePayConfigurationObject is required for Setup Intents and should be set to the Project's currency. Only in Payment Method Settings, where there is no Project, it was decided to hardcode the value to "USD". Luke's comment here provides more context. So that's where you're seeing an explicit call to this.getPaymentSheetConfiguration(userEmail, googlePayEnabled, "USD") only in PaymentMethodSettingsActivity.flowControllerPresentPaymentOption(). On the other screens it's variable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, in a follow up message to Stripe reps, they responded today suggesting the countryCode may also need to be variable based on the Project (if I'm understanding their reply correctly). So if we need to make countryCode variable for Crowdfund & Late Pledge checkouts, I anticipate doing so in a separate PR but following the same pattern.

* Required in order to support Google Pay when processing a Setup Intent.
*/
fun Context.getPaymentSheetConfiguration(userEmail: String): PaymentSheet.Configuration {
fun Context.getPaymentSheetConfiguration(
userEmail: String,
googlePayEnabled: Boolean = false,
googlePayCurrencyCode: String? = null
): PaymentSheet.Configuration {
val stripeLinkEnabled = this.getEnvironment()?.featureFlagClient()?.getBoolean(FlagKey.ANDROID_STRIPE_LINK) ?: false
// TODO: Wait for stripe to devise a client-side option for turning off link
val googlePayConfiguration = this.getGooglePayConfiguration(googlePayEnabled, googlePayCurrencyCode)

return PaymentSheet.Configuration(
merchantDisplayName = getString(R.string.app_name),
allowsDelayedPaymentMethods = true,
appearance = this.getPaymentSheetAppearance(),
defaultBillingDetails = PaymentSheet.BillingDetails(email = userEmail)
defaultBillingDetails = PaymentSheet.BillingDetails(email = userEmail),
googlePay = googlePayConfiguration,
)
}

@VisibleForTesting
fun Context.getGooglePayConfiguration(
enabled: Boolean,
currencyCode: String?
): PaymentSheet.GooglePayConfiguration? {
val currencyCode = currencyCode?.trim()?.takeIf { it.isNotEmpty() }

val googlePayEnvironment =
if (this.getEnvironment()?.webEndpoint() == Secrets.WebEndpoint.PRODUCTION) {
PaymentSheet.GooglePayConfiguration.Environment.Production
} else {
PaymentSheet.GooglePayConfiguration.Environment.Test
}

// `currencyCode` is required to use Google Pay with Setup Intents
return if (enabled && currencyCode != null) {
PaymentSheet.GooglePayConfiguration(
environment = googlePayEnvironment,
countryCode = "US",
currencyCode = currencyCode
)
} else null
}

/**
* Provides the color configuration for the PaymentSheet, following the specs
* @see [link](https://stripe.com/docs/elements/appearance-api?platform=android#colors-android)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.stripe.android.paymentsheet.model.PaymentOption
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import timber.log.Timber

class PaymentMethodsSettingsActivity : AppCompatActivity() {

Expand Down Expand Up @@ -149,9 +150,11 @@ class PaymentMethodsSettingsActivity : AppCompatActivity() {
}

private fun flowControllerPresentPaymentOption(clientSecret: String, userEmail: String) {
val googlePayEnabled = viewModel.outputs.isPaymentSheetGooglePayEnabled()
Timber.d("googlePayEnabled: $googlePayEnabled")
flowController.configureWithSetupIntent(
setupIntentClientSecret = clientSecret,
configuration = this.getPaymentSheetConfiguration(userEmail),
configuration = this.getPaymentSheetConfiguration(userEmail, googlePayEnabled, "USD"),
callback = ::onConfigured
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.launch
import timber.log.Timber

const val REFRESH = "refresh"

Expand Down Expand Up @@ -718,7 +719,8 @@ class ProjectPageActivity :
latePledgeCheckoutViewModel.clientSecretForNewPaymentMethod.collect {
flowControllerPresentPaymentOption(
it,
latePledgeCheckoutUIState.userEmail
latePledgeCheckoutUIState.userEmail,
latePledgeCheckoutUIState.projectCurrency
Comment thread
tonyteate marked this conversation as resolved.
)
}
}
Expand Down Expand Up @@ -1439,10 +1441,12 @@ class ProjectPageActivity :
}
}

private fun flowControllerPresentPaymentOption(clientSecret: String, userEmail: String) {
private fun flowControllerPresentPaymentOption(clientSecret: String, userEmail: String, googlePayCurrencyCode: String? = null) {
val googlePayEnabled = latePledgeCheckoutViewModel.isPaymentSheetGooglePayEnabled()
Timber.d("googlePayEnabled: $googlePayEnabled, googlePayCurrencyCode: $googlePayCurrencyCode")
flowController.configureWithSetupIntent(
setupIntentClientSecret = clientSecret,
configuration = getPaymentSheetConfiguration(userEmail),
configuration = getPaymentSheetConfiguration(userEmail, googlePayEnabled, googlePayCurrencyCode),
callback = ::onConfigured
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ class CrowdfundCheckoutFragment : Fragment() {
viewModel.presentPaymentSheetStates.collectAsStateWithLifecycle().value
val setUpIntent = paymentSheetPresenter.setupClientId

val projectCurrencyCode = pledgeData?.projectData()?.project()?.currency()

configurePaymentSheet(paymentSheetPresenter.setupClientId)
LaunchedEffect(key1 = setUpIntent) {
if (setUpIntent.isNotEmpty() && email.isNotEmpty()) {
flowControllerPresentPaymentOption(setUpIntent, email)
flowControllerPresentPaymentOption(setUpIntent, email, projectCurrencyCode)
}
}

Expand Down Expand Up @@ -205,11 +207,13 @@ class CrowdfundCheckoutFragment : Fragment() {
return view
}

private fun flowControllerPresentPaymentOption(clientSecret: String, userEmail: String) {
private fun flowControllerPresentPaymentOption(clientSecret: String, userEmail: String, googlePayCurrencyCode: String? = null) {
context?.let {
val googlePayEnabled = viewModel.isPaymentSheetGooglePayEnabled()
Timber.d("googlePayEnabled: $googlePayEnabled, googlePayCurrencyCode: $googlePayCurrencyCode")
flowController.configureWithSetupIntent(
setupIntentClientSecret = clientSecret,
configuration = it.getPaymentSheetConfiguration(userEmail),
configuration = it.getPaymentSheetConfiguration(userEmail, googlePayEnabled, googlePayCurrencyCode),
callback = ::onConfigured
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.kickstarter.utils

import android.annotation.SuppressLint
import com.google.android.gms.wallet.IsReadyToPayRequest
import com.google.android.gms.wallet.PaymentsClient
import com.stripe.android.googlepaylauncher.GooglePayAvailabilityClient
import com.stripe.android.googlepaylauncher.GooglePayRepository
import kotlinx.coroutines.tasks.await
import org.json.JSONObject

object GooglePayAvailabilityUtil {
/* This is a workaround that allows Google Pay to work in `Test` mode, pulled directly from
* the associated Issue in the Stripe SDK: https://github.com/stripe/stripe-android/issues/13968 */
fun overrideGooglePayAvailabilityClient() {
@SuppressLint("RestrictedApi")
GooglePayRepository.googlePayAvailabilityClientFactory =
object : GooglePayAvailabilityClient.Factory {
override fun create(paymentsClient: PaymentsClient) =
object : GooglePayAvailabilityClient {
override suspend fun isReady(request: IsReadyToPayRequest): Boolean {
val relaxed = JSONObject(request.toJson())
.apply { remove("existingPaymentMethodRequired") }
return paymentsClient
.isReadyToPay(IsReadyToPayRequest.fromJson(relaxed.toString()))
.await()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.kickstarter.DeletePaymentSourceMutation
import com.kickstarter.libs.Environment
import com.kickstarter.libs.featureflag.StatsigGateKey
import com.kickstarter.libs.rx.transformers.Transformers.combineLatestPair
import com.kickstarter.libs.rx.transformers.Transformers.errorsV2
import com.kickstarter.libs.rx.transformers.Transformers.neverErrorV2
Expand Down Expand Up @@ -67,6 +68,8 @@ interface Outputs {

/** Emits in case SavePaymentMethod returns success output */
fun successSaving(): Observable<String>

fun isPaymentSheetGooglePayEnabled(): Boolean
}

class PaymentMethodsViewModel(environment: Environment) : ViewModel(), PaymentMethodsAdapter.Delegate, Inputs, Outputs {
Expand All @@ -89,6 +92,8 @@ class PaymentMethodsViewModel(environment: Environment) : ViewModel(), PaymentMe
private val loadingConfirmed = PublishSubject.create<Boolean>()

private val apolloClient = requireNotNull(environment.apolloClientV2())

private val statsigClient = requireNotNull(environment.statsigClient())
private val compositeDisposable = CompositeDisposable()

val inputs: Inputs = this
Expand Down Expand Up @@ -298,6 +303,9 @@ class PaymentMethodsViewModel(environment: Environment) : ViewModel(), PaymentMe
override fun successSaving(): Observable<String> =
this.successSaving

override fun isPaymentSheetGooglePayEnabled(): Boolean =
statsigClient.configReady.value && statsigClient.checkGate(StatsigGateKey.ANDROID_PAYMENTSHEET_GOOGLE_PAY.key)

class Factory(private val environment: Environment) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PaymentMethodsViewModel(environment) as T
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.kickstarter.libs.Environment
import com.kickstarter.libs.RefTag
import com.kickstarter.libs.featureflag.StatsigGateKey
import com.kickstarter.libs.utils.RefTagUtils
import com.kickstarter.libs.utils.RewardUtils
import com.kickstarter.libs.utils.ThirdPartyEventValues
Expand Down Expand Up @@ -78,6 +79,7 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? =
val cookieManager = requireNotNull(environment.cookieManager())
val sharedPreferences = requireNotNull(environment.sharedPreferences())
val ffClient = requireNotNull(environment.featureFlagClient())
val statsigClient = requireNotNull(environment.statsigClient())

private var pledgeData: PledgeData? = null
private var checkoutData: CheckoutData? = null // TOD potentially needs to change with user card input
Expand Down Expand Up @@ -581,6 +583,9 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? =
}
}

fun isPaymentSheetGooglePayEnabled(): Boolean =
statsigClient.configReady.value && statsigClient.checkGate(StatsigGateKey.ANDROID_PAYMENTSHEET_GOOGLE_PAY.key)

/**
* PaymentSheet has been presented to the user, stop loading until
* a new payment method is received. Will cover as well the case of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.kickstarter.libs.Environment
import com.kickstarter.libs.featureflag.StatsigGateKey
import com.kickstarter.libs.utils.RewardUtils
import com.kickstarter.libs.utils.extensions.checkoutTotalAmount
import com.kickstarter.libs.utils.extensions.isNotNull
Expand Down Expand Up @@ -60,7 +61,8 @@ data class LatePledgeCheckoutUIState(
val selectedRewards: List<Reward> = emptyList(),
val shippingAmount: Double = 0.0,
val checkoutTotal: Double = 0.0,
val isPledgeButtonEnabled: Boolean = true
val isPledgeButtonEnabled: Boolean = true,
val projectCurrency: String? = null
)

class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() {
Expand All @@ -79,6 +81,8 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() {

private var stripe: Stripe = requireNotNull(environment.stripe())

private val statsigClient = requireNotNull(environment.statsigClient())

private var clientSecretForNewCard: String = ""
private var newStoredCard: StoredCard? = null
private var errorAction: (message: String?) -> Unit = {}
Expand Down Expand Up @@ -434,6 +438,7 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() {
shippingAmount = this.pledgeData?.shippingCostIfShipping() ?: 0.0,
checkoutTotal = this.pledgeData?.checkoutTotalAmount() ?: 0.0,
isPledgeButtonEnabled = buttonEnabled && !isLoading,
projectCurrency = this.pledgeData?.projectData()?.project()?.currency()
)
)
}
Expand Down Expand Up @@ -549,6 +554,9 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() {
}
}

fun isPaymentSheetGooglePayEnabled(): Boolean =
statsigClient.configReady.value && statsigClient.checkGate(StatsigGateKey.ANDROID_PAYMENTSHEET_GOOGLE_PAY.key)

class Factory(private val environment: Environment) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class RewardsSelectionViewModel(private val environment: Environment, private va
emitCurrentState()
}

if (!refreshData) return
// if (!refreshData) return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is technically a quick fix for the bug that nondeterministically prevents the rewards carousel from loading for Late Pledge Projects. I will create a separate PR for it at some point soon.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A legitimate fix now provided by #2566


viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> Timber.e(throwable, "CoroutineExceptionHandler") }) {
mutableShippingUIState.update { previous ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kickstarter.libs.utils.extensions

import com.kickstarter.KSRobolectricTestCase
import org.junit.Test

class ContextExtTest : KSRobolectricTestCase() {
@Test
fun `test getGooglePayConfiguration()`() {
val context = application()
assertNull(context.getGooglePayConfiguration(false, "USD"))
assertNull(context.getGooglePayConfiguration(true, null))
assertNull(context.getGooglePayConfiguration(true, " "))
assertNotNull(context.getGooglePayConfiguration(true, "EUR"))
}
}
Loading
Loading