diff --git a/payments-core/res/layout/stripe_card_form_view.xml b/payments-core/res/layout/stripe_card_form_view.xml
index df4e5b6fbd7..0035d3d093b 100644
--- a/payments-core/res/layout/stripe_card_form_view.xml
+++ b/payments-core/res/layout/stripe_card_form_view.xml
@@ -16,6 +16,7 @@
android:id="@+id/card_multiline_widget"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ app:isIntegratedInStripeView="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shouldShowPostalCode="false" />
diff --git a/payments-core/res/values/attrs.xml b/payments-core/res/values/attrs.xml
index ef771afed3a..bee8b1372f5 100644
--- a/payments-core/res/values/attrs.xml
+++ b/payments-core/res/values/attrs.xml
@@ -11,6 +11,7 @@
+
diff --git a/payments-core/src/main/java/com/stripe/android/networking/PaymentAnalyticsEvent.kt b/payments-core/src/main/java/com/stripe/android/networking/PaymentAnalyticsEvent.kt
index 8baf40a4f24..112b1e31595 100644
--- a/payments-core/src/main/java/com/stripe/android/networking/PaymentAnalyticsEvent.kt
+++ b/payments-core/src/main/java/com/stripe/android/networking/PaymentAnalyticsEvent.kt
@@ -113,7 +113,12 @@ enum class PaymentAnalyticsEvent(val code: String) : AnalyticsEvent {
CardMetadataMissingRange("card_metadata_missing_range"),
CardMetadataExpectedExtraDigitsButUserEntered16ThenSwitchedFields(
"card_metadata.expected_extra_digits_but_user_entered_16_then_switched_fields"
- );
+ ),
+
+ // Card Element
+ MobileCardElementShown("mobile_card_element_shown"),
+ MobileCardElementInteraction("mobile_card_element_interaction"),
+ MobileCardElementFormCompleted("mobile_card_element_form_completed");
@Keep
override fun toString(): String {
diff --git a/payments-core/src/main/java/com/stripe/android/view/CardElementAnalytics.kt b/payments-core/src/main/java/com/stripe/android/view/CardElementAnalytics.kt
new file mode 100644
index 00000000000..f9ded6b33ae
--- /dev/null
+++ b/payments-core/src/main/java/com/stripe/android/view/CardElementAnalytics.kt
@@ -0,0 +1,197 @@
+package com.stripe.android.view
+
+import android.content.Context
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import com.stripe.android.BuildConfig
+import com.stripe.android.core.Logger
+import com.stripe.android.core.networking.AnalyticsRequestV2Executor
+import com.stripe.android.core.networking.AnalyticsRequestV2Factory
+import com.stripe.android.core.networking.DefaultAnalyticsRequestV2Executor
+import com.stripe.android.core.networking.DefaultStripeNetworkClient
+import com.stripe.android.core.networking.RealAnalyticsRequestV2Storage
+import com.stripe.android.networking.PaymentAnalyticsEvent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+internal interface CardElementAnalytics {
+ fun reportShown(context: Context)
+
+ fun reportInteraction(context: Context)
+
+ fun reportFormCompleted(context: Context)
+
+ fun saveState(outState: Bundle)
+
+ fun restoreState(savedState: Bundle)
+}
+
+internal class DefaultCardElementAnalytics internal constructor(
+ private val widgetType: CardElementWidgetType,
+ private val cardElementAnalyticsRequestExecutorFactory: CardElementAnalyticsRequestExecutor.Factory,
+) : CardElementAnalytics {
+ internal constructor(
+ widgetType: CardElementWidgetType,
+ ) : this(
+ widgetType = widgetType,
+ cardElementAnalyticsRequestExecutorFactory = DefaultCardElementAnalyticsRequestExecutor.Factory,
+ )
+
+ private var hasReportedShown: Boolean = false
+ private var hasReportedInteraction: Boolean = false
+ private var hasReportedFormCompleted: Boolean = false
+
+ override fun reportShown(context: Context) {
+ if (hasReportedShown) {
+ return
+ }
+ hasReportedShown = true
+ fire(
+ context = context,
+ event = PaymentAnalyticsEvent.MobileCardElementShown,
+ params = mapOf(PARAM_WIDGET_TYPE to widgetType.analyticsValue),
+ )
+ }
+
+ override fun reportInteraction(context: Context) {
+ if (hasReportedInteraction) {
+ return
+ }
+ hasReportedInteraction = true
+ fire(
+ context = context,
+ event = PaymentAnalyticsEvent.MobileCardElementInteraction,
+ params = mapOf(PARAM_WIDGET_TYPE to widgetType.analyticsValue),
+ )
+ }
+
+ override fun reportFormCompleted(context: Context) {
+ if (hasReportedFormCompleted) {
+ return
+ }
+ hasReportedFormCompleted = true
+ fire(
+ context = context,
+ event = PaymentAnalyticsEvent.MobileCardElementFormCompleted,
+ params = mapOf(PARAM_WIDGET_TYPE to widgetType.analyticsValue),
+ )
+ }
+
+ override fun saveState(outState: Bundle) {
+ outState.putBundle(
+ STATE_CARD_ELEMENT_ANALYTICS,
+ bundleOf(
+ KEY_HAS_REPORTED_SHOWN to hasReportedShown,
+ KEY_HAS_REPORTED_INTERACTION to hasReportedInteraction,
+ KEY_HAS_REPORTED_FORM_COMPLETED to hasReportedFormCompleted,
+ )
+ )
+ }
+
+ override fun restoreState(savedState: Bundle) {
+ val state = savedState.getBundle(STATE_CARD_ELEMENT_ANALYTICS)
+
+ hasReportedShown = state?.getBoolean(KEY_HAS_REPORTED_SHOWN) ?: false
+ hasReportedInteraction = state?.getBoolean(KEY_HAS_REPORTED_INTERACTION) ?: false
+ hasReportedFormCompleted = state?.getBoolean(KEY_HAS_REPORTED_FORM_COMPLETED) ?: false
+ }
+
+ private fun fire(
+ context: Context,
+ event: PaymentAnalyticsEvent,
+ params: Map,
+ ) {
+ cardElementAnalyticsRequestExecutorFactory
+ .create(context)
+ .report(event, params)
+ }
+
+ internal companion object {
+ const val PARAM_WIDGET_TYPE = "widget_type"
+
+ const val STATE_CARD_ELEMENT_ANALYTICS = "stripe_card_element_analytics_state"
+
+ const val KEY_HAS_REPORTED_SHOWN = "stripe_card_element_has_reported_shown"
+ const val KEY_HAS_REPORTED_INTERACTION = "stripe_card_element_has_reported_interaction"
+ const val KEY_HAS_REPORTED_FORM_COMPLETED = "stripe_card_element_has_reported_form_completed"
+ }
+}
+
+internal object NoOpCardElementCardElementAnalytics : CardElementAnalytics {
+ override fun reportShown(context: Context) {
+ // No-op
+ }
+
+ override fun reportInteraction(context: Context) {
+ // No-op
+ }
+
+ override fun reportFormCompleted(context: Context) {
+ // No-op
+ }
+
+ override fun saveState(outState: Bundle) {
+ // No-op
+ }
+
+ override fun restoreState(savedState: Bundle) {
+ // No-op
+ }
+}
+
+internal interface CardElementAnalyticsRequestExecutor {
+ fun report(
+ event: PaymentAnalyticsEvent,
+ params: Map,
+ )
+
+ interface Factory {
+ fun create(context: Context): CardElementAnalyticsRequestExecutor
+ }
+}
+
+internal class DefaultCardElementAnalyticsRequestExecutor internal constructor(
+ private val analyticsFactory: AnalyticsRequestV2Factory,
+ private val analyticsExecutor: AnalyticsRequestV2Executor,
+ private val coroutineScope: CoroutineScope,
+) : CardElementAnalyticsRequestExecutor {
+ override fun report(
+ event: PaymentAnalyticsEvent,
+ params: Map,
+ ) {
+ coroutineScope.launch {
+ analyticsExecutor.enqueue(
+ analyticsFactory.createRequest(event.eventName, params),
+ )
+ }
+ }
+
+ object Factory : CardElementAnalyticsRequestExecutor.Factory {
+ override fun create(context: Context): CardElementAnalyticsRequestExecutor {
+ val applicationContext = context.applicationContext
+ val logger = Logger.getInstance(BuildConfig.DEBUG)
+
+ return DefaultCardElementAnalyticsRequestExecutor(
+ analyticsFactory = AnalyticsRequestV2Factory(
+ clientId = CLIENT_ID,
+ origin = ORIGIN,
+ context = applicationContext,
+ ),
+ analyticsExecutor = DefaultAnalyticsRequestV2Executor(
+ context = applicationContext,
+ logger = logger,
+ networkClient = DefaultStripeNetworkClient(
+ logger = logger,
+ ),
+ storage = RealAnalyticsRequestV2Storage(applicationContext),
+ isWorkManagerAvailable = { false },
+ ),
+ coroutineScope = CoroutineScope(Dispatchers.IO),
+ )
+ }
+
+ private const val CLIENT_ID = "stripe-mobile-sdk"
+ private const val ORIGIN = "stripe-mobile-sdk-android"
+ }
+}
diff --git a/payments-core/src/main/java/com/stripe/android/view/CardElementWidgetType.kt b/payments-core/src/main/java/com/stripe/android/view/CardElementWidgetType.kt
new file mode 100644
index 00000000000..d1236b58be7
--- /dev/null
+++ b/payments-core/src/main/java/com/stripe/android/view/CardElementWidgetType.kt
@@ -0,0 +1,7 @@
+package com.stripe.android.view
+
+internal enum class CardElementWidgetType(val analyticsValue: String) {
+ CardInputWidget("card_input_widget"),
+ CardFormView("card_form_view"),
+ CardMultilineWidget("card_multiline_widget"),
+}
diff --git a/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt b/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt
index 49058c7b89a..abb683692fa 100644
--- a/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt
+++ b/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt
@@ -43,10 +43,11 @@ import com.stripe.payments.model.R as PaymentsModelR
* To access the [CardParams], see details in [cardParams] property.
* To get notified if the current card params are valid, set a [CardValidCallback] object with [setCardValidCallback].
*/
-class CardFormView @JvmOverloads constructor(
+class CardFormView internal constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
+ defStyleAttr: Int = 0,
+ private val cardElementAnalytics: CardElementAnalytics,
) : LinearLayout(context, attrs, defStyleAttr) {
private val layoutInflater = LayoutInflater.from(context)
private val viewBinding = StripeCardFormViewBinding.inflate(layoutInflater, this)
@@ -93,10 +94,25 @@ class CardFormView @JvmOverloads constructor(
).toSet()
}
- private val cardValidTextWatcher = object : StripeTextWatcher() {
+ private val textFocusWatcher = OnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ cardElementAnalytics.reportInteraction(context)
+ }
+ }
+
+ private val textInputWatcher = object : StripeTextWatcher() {
override fun afterTextChanged(s: Editable?) {
super.afterTextChanged(s)
- cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields)
+
+ cardElementAnalytics.reportInteraction(context)
+
+ val isComplete = invalidFields.isEmpty()
+
+ if (isComplete) {
+ cardElementAnalytics.reportFormCompleted(context)
+ }
+
+ cardValidCallback?.onInputChanged(isComplete, invalidFields)
}
}
@@ -221,6 +237,19 @@ class CardFormView @JvmOverloads constructor(
}
}
+ @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ ) : this(
+ context = context,
+ attrs = attrs,
+ defStyleAttr = defStyleAttr,
+ cardElementAnalytics = DefaultCardElementAnalytics(
+ widgetType = CardElementWidgetType.CardFormView,
+ ),
+ )
+
init {
orientation = VERTICAL
@@ -245,6 +274,13 @@ class CardFormView @JvmOverloads constructor(
postalCodeContainer.setBackgroundColor(Color.TRANSPARENT)
}
+ allEditTextFields.forEach {
+ it.internalFocusChangeListeners.remove(textFocusWatcher)
+ it.removeTextChangedListener(textInputWatcher)
+ it.addTextChangedListener(textInputWatcher)
+ it.internalFocusChangeListeners.add(textFocusWatcher)
+ }
+
when (style) {
Style.Standard -> applyStandardStyle()
Style.Borderless -> applyBorderlessStyle()
@@ -253,12 +289,6 @@ class CardFormView @JvmOverloads constructor(
fun setCardValidCallback(callback: CardValidCallback?) {
this.cardValidCallback = callback
- allEditTextFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) }
-
- // only add the TextWatcher if it will be used
- if (callback != null) {
- allEditTextFields.forEach { it.addTextChangedListener(cardValidTextWatcher) }
- }
// call immediately after setting
cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields)
}
@@ -438,11 +468,14 @@ class CardFormView @JvmOverloads constructor(
STATE_SUPER_STATE to super.onSaveInstanceState(),
STATE_ENABLED to isEnabled,
STATE_ON_BEHALF_OF to onBehalfOf,
- )
+ ).apply {
+ cardElementAnalytics.saveState(this)
+ }
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is Bundle) {
+ cardElementAnalytics.restoreState(state)
super.onRestoreInstanceState(state.getParcelable(STATE_SUPER_STATE))
isEnabled = state.getBoolean(STATE_ENABLED)
onBehalfOf = state.getString(STATE_ON_BEHALF_OF)
@@ -453,6 +486,7 @@ class CardFormView @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
+ cardElementAnalytics.reportShown(context = context)
lifecycleOwnerDelegate.initLifecycle(this)
// Merchant could set onBehalfOf before view is attached to window.
// Check and set if needed.
diff --git a/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt b/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt
index 724201f92b9..a66f754f0f0 100644
--- a/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt
+++ b/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt
@@ -54,10 +54,11 @@ import kotlin.properties.Delegates
* The card number, cvc, and expiry date will always be left to right regardless of locale. Postal
* code layout direction will be set according to the locale.
*/
-class CardInputWidget @JvmOverloads constructor(
+class CardInputWidget internal constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
+ defStyleAttr: Int = 0,
+ private val cardElementAnalytics: CardElementAnalytics,
) : LinearLayout(context, attrs, defStyleAttr), CardWidget {
private var customCvcLabel: String? = null
private val viewBinding = StripeCardInputWidgetBinding.inflate(
@@ -91,10 +92,26 @@ class CardInputWidget @JvmOverloads constructor(
private var cardInputListener: CardInputListener? = null
private var cardValidCallback: CardValidCallback? = null
- private val cardValidTextWatcher = object : StripeTextWatcher() {
+
+ private val textFocusWatcher = OnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ cardElementAnalytics.reportInteraction(context)
+ }
+ }
+
+ private val textInputWatcher = object : StripeTextWatcher() {
override fun afterTextChanged(s: Editable?) {
super.afterTextChanged(s)
- cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields)
+
+ val isComplete = invalidFields.isEmpty()
+
+ cardElementAnalytics.reportInteraction(context)
+
+ if (isComplete) {
+ cardElementAnalytics.reportFormCompleted(context)
+ }
+
+ cardValidCallback?.onInputChanged(isComplete, invalidFields)
}
}
@@ -340,15 +357,18 @@ class CardInputWidget @JvmOverloads constructor(
cvcEditText.imeOptions = EditorInfo.IME_ACTION_NEXT
// First remove if it's already added, to make sure it's not added multiple times.
- postalCodeEditText.removeTextChangedListener(cardValidTextWatcher)
- postalCodeEditText.addTextChangedListener(cardValidTextWatcher)
+ postalCodeEditText.internalFocusChangeListeners.remove(textFocusWatcher)
+ postalCodeEditText.removeTextChangedListener(textInputWatcher)
+ postalCodeEditText.addTextChangedListener(textInputWatcher)
+ postalCodeEditText.internalFocusChangeListeners.add(textFocusWatcher)
} else {
postalCodeEditText.isEnabled = false
postalCodeTextInputLayout.visibility = View.GONE
cvcEditText.imeOptions = EditorInfo.IME_ACTION_DONE
- postalCodeEditText.removeTextChangedListener(cardValidTextWatcher)
+ postalCodeEditText.internalFocusChangeListeners.remove(textFocusWatcher)
+ postalCodeEditText.removeTextChangedListener(textInputWatcher)
}
updatePostalRequired()
}
@@ -426,6 +446,19 @@ class CardInputWidget @JvmOverloads constructor(
}
}
+ @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+ ) : this(
+ context = context,
+ attrs = attrs,
+ defStyleAttr = defStyleAttr,
+ cardElementAnalytics = DefaultCardElementAnalytics(
+ widgetType = CardElementWidgetType.CardInputWidget,
+ ),
+ )
+
init {
// This ensures that onRestoreInstanceState is called
// during rotations.
@@ -450,6 +483,7 @@ class CardInputWidget @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
+ cardElementAnalytics.reportShown(context = context)
lifecycleDelegate.initLifecycle(this)
doWithCardWidgetViewModel(viewModelStoreOwner) { viewModel ->
@@ -476,12 +510,6 @@ class CardInputWidget @JvmOverloads constructor(
override fun setCardValidCallback(callback: CardValidCallback?) {
this.cardValidCallback = callback
- requiredFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) }
-
- // only add the TextWatcher if it will be used
- if (callback != null) {
- requiredFields.forEach { it.addTextChangedListener(cardValidTextWatcher) }
- }
// call immediately after setting
cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields)
@@ -641,11 +669,14 @@ class CardInputWidget @JvmOverloads constructor(
STATE_CARD_VIEWED to isShowingFullCard,
STATE_POSTAL_CODE_ENABLED to postalCodeEnabled,
STATE_ON_BEHALF_OF to onBehalfOf
- )
+ ).apply {
+ cardElementAnalytics.saveState(this)
+ }
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state is Bundle) {
+ cardElementAnalytics.restoreState(state)
postalCodeEnabled = state.getBoolean(STATE_POSTAL_CODE_ENABLED, true)
isShowingFullCard = state.getBoolean(STATE_CARD_VIEWED, true)
onBehalfOf = state.getString(STATE_ON_BEHALF_OF)
@@ -797,6 +828,13 @@ class CardInputWidget @JvmOverloads constructor(
cvcEditText.setDeleteEmptyListener(BackUpFieldDeleteListener(expiryDateEditText))
postalCodeEditText.setDeleteEmptyListener(BackUpFieldDeleteListener(cvcEditText))
+ requiredFields.forEach {
+ it.internalFocusChangeListeners.remove(textFocusWatcher)
+ it.removeTextChangedListener(textInputWatcher)
+ it.addTextChangedListener(textInputWatcher)
+ it.internalFocusChangeListeners.add(textFocusWatcher)
+ }
+
cvcEditText.internalFocusChangeListeners.add { _, hasFocus ->
cardBrandView.shouldShowCvc = hasFocus
diff --git a/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt b/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt
index 2050e1d75c1..3db10d0213a 100644
--- a/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt
+++ b/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt
@@ -32,6 +32,7 @@ import com.stripe.android.model.CardParams
import com.stripe.android.model.ExpirationDate
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodCreateParams
+import kotlin.jvm.JvmOverloads
import kotlin.properties.Delegates
/**
@@ -43,11 +44,12 @@ import kotlin.properties.Delegates
* The card number, cvc, and expiry date will always be left to right regardless of locale. Postal
* code layout direction will be set according to the locale.
*/
-class CardMultilineWidget @JvmOverloads constructor(
+class CardMultilineWidget internal constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
- private var shouldShowPostalCode: Boolean = CardWidget.DEFAULT_POSTAL_CODE_ENABLED
+ private var shouldShowPostalCode: Boolean = CardWidget.DEFAULT_POSTAL_CODE_ENABLED,
+ private val cardElementAnalytics: CardElementAnalytics,
) : LinearLayout(context, attrs, defStyleAttr), CardWidget {
private val viewBinding = StripeCardMultilineWidgetBinding.inflate(
LayoutInflater.from(context),
@@ -92,12 +94,29 @@ class CardMultilineWidget @JvmOverloads constructor(
private var cardInputListener: CardInputListener? = null
private var cardValidCallback: CardValidCallback? = null
- private val cardValidTextWatcher = object : StripeTextWatcher() {
+
+ private val textFocusWatcher = OnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ cardElementAnalytics.reportInteraction(context)
+ }
+ }
+
+ private val textInputWatcher = object : StripeTextWatcher() {
override fun afterTextChanged(s: Editable?) {
super.afterTextChanged(s)
+
+ cardElementAnalytics.reportInteraction(context)
+
+ val isComplete = invalidFields.isEmpty()
+
+ if (isComplete) {
+ cardElementAnalytics.reportFormCompleted(context)
+ }
+
cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields)
}
}
+
internal val invalidFields: Set
get() {
return listOfNotNull(
@@ -371,6 +390,24 @@ class CardMultilineWidget @JvmOverloads constructor(
cardBrandView.merchantPreferredNetworks = preferredNetworks
}
+ @JvmOverloads
+ constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ shouldShowPostalCode: Boolean = CardWidget.DEFAULT_POSTAL_CODE_ENABLED,
+ ) : this(
+ context = context,
+ attrs = attrs,
+ defStyleAttr = defStyleAttr,
+ shouldShowPostalCode = shouldShowPostalCode,
+ cardElementAnalytics = if (isIntegratedWithinStripeView(context, attrs)) {
+ NoOpCardElementCardElementAnalytics
+ } else {
+ DefaultCardElementAnalytics(CardElementWidgetType.CardMultilineWidget)
+ },
+ )
+
init {
orientation = VERTICAL
@@ -459,6 +496,11 @@ class CardMultilineWidget @JvmOverloads constructor(
updateBrandUi()
allFields.forEach { field ->
+ field.internalFocusChangeListeners.remove(textFocusWatcher)
+ field.removeTextChangedListener(textInputWatcher)
+ field.addTextChangedListener(textInputWatcher)
+ field.internalFocusChangeListeners.add(textFocusWatcher)
+
field.doAfterTextChanged {
shouldShowErrorIcon = false
}
@@ -483,6 +525,7 @@ class CardMultilineWidget @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
+ cardElementAnalytics.reportShown(context)
// see https://github.com/stripe/stripe-android/pull/3154
cvcEditText.hint = null
@@ -529,12 +572,6 @@ class CardMultilineWidget @JvmOverloads constructor(
override fun setCardValidCallback(callback: CardValidCallback?) {
this.cardValidCallback = callback
- allFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) }
-
- // only add the TextWatcher if it will be used
- if (callback != null) {
- allFields.forEach { it.addTextChangedListener(cardValidTextWatcher) }
- }
// call immediately after setting
cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields)
@@ -583,13 +620,15 @@ class CardMultilineWidget @JvmOverloads constructor(
return bundleOf(
STATE_REMAINING_STATE to super.onSaveInstanceState(),
STATE_ON_BEHALF_OF to onBehalfOf
- )
+ ).apply {
+ cardElementAnalytics.saveState(this)
+ }
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state is Bundle) {
onBehalfOf = state.getString(STATE_ON_BEHALF_OF)
-
+ cardElementAnalytics.restoreState(state)
super.onRestoreInstanceState(state.getParcelable(STATE_REMAINING_STATE))
} else {
super.onRestoreInstanceState(state)
@@ -859,3 +898,23 @@ class CardMultilineWidget @JvmOverloads constructor(
private const val STATE_ON_BEHALF_OF = "state_on_behalf_of"
}
}
+
+private fun isIntegratedWithinStripeView(
+ context: Context,
+ attrs: AttributeSet?,
+): Boolean {
+ if (attrs == null) {
+ return false
+ }
+
+ val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CardElement)
+
+ return try {
+ typedArray.getBoolean(
+ R.styleable.CardElement_isIntegratedInStripeView,
+ false,
+ )
+ } finally {
+ typedArray.recycle()
+ }
+}
diff --git a/payments-core/src/test/java/com/stripe/android/view/CardFormViewTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardFormViewTest.kt
index 8892cc34a42..37fb6d2a218 100644
--- a/payments-core/src/test/java/com/stripe/android/view/CardFormViewTest.kt
+++ b/payments-core/src/test/java/com/stripe/android/view/CardFormViewTest.kt
@@ -29,6 +29,7 @@ import com.stripe.android.utils.CardElementTestHelper
import com.stripe.android.utils.TestUtils.idleLooper
import com.stripe.android.utils.createTestActivityRule
import com.stripe.android.view.CardFormViewTestActivity.Companion.VIEW_ID
+import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
import org.junit.Before
import org.junit.Rule
@@ -505,6 +506,91 @@ internal class CardFormViewTest {
}
}
+ @Test
+ fun `reportShown is called when card form attaches`() = runCardFormViewAnalyticsTest {
+ assertThat(analytics.awaitShown()).isNotNull()
+ assertThat(analytics.awaitInteraction()).isNotNull()
+ }
+
+ @Test
+ fun `reportInteraction called when postal focus`() = runCardFormViewAnalyticsTest {
+ assertThat(analytics.awaitShown()).isNotNull()
+
+ binding.postalCode.getParentOnFocusChangeListener()
+ .onFocusChange(binding.postalCode, true)
+
+ idleLooper()
+
+ repeat(2) { assertThat(analytics.awaitInteraction()).isNotNull() }
+ }
+
+ @Test
+ fun `reports form completed when form valid`() = runCardFormViewAnalyticsTest {
+ assertThat(analytics.awaitShown()).isNotNull()
+
+ binding.populate(
+ VISA_WITH_SPACES,
+ VALID_MONTH,
+ VALID_YEAR,
+ VALID_CVC,
+ VALID_US_ZIP,
+ )
+
+ idleLooper()
+
+ repeat(9) { assertThat(analytics.awaitInteraction()).isNotNull() }
+ assertThat(analytics.awaitFormCompleted()).isNotNull()
+ }
+
+ private data class AnalyticsScenario(
+ val analytics: RecordingCardElementAnalytics,
+ val cardFormView: CardFormView,
+ val binding: StripeCardFormViewBinding,
+ )
+
+ private fun runCardFormViewAnalyticsTest(
+ locale: Locale = Locale.US,
+ block: suspend AnalyticsScenario.() -> Unit,
+ ) {
+ val originalLocale = Locale.getDefault()
+ Locale.setDefault(locale)
+
+ ActivityScenario.launch(
+ Intent(context, CardFormViewTestActivity::class.java).apply {
+ putExtra(
+ "args",
+ CardFormViewTestActivity.Args(
+ isCbcEligible = false,
+ borderless = false,
+ useRecordingCardElementAnalytics = true,
+ ),
+ )
+ }
+ ).use { activityScenario ->
+ activityScenario.onActivity { activity ->
+ runBlocking {
+ val cardFormView = activity.findViewById(VIEW_ID)
+ val binding = StripeCardFormViewBinding.bind(cardFormView)
+ val analytics = activity.recordingCardElementAnalytics
+
+ idleLooper()
+
+ block(
+ AnalyticsScenario(
+ analytics = analytics,
+ cardFormView = cardFormView,
+ binding = binding,
+ )
+ )
+
+ analytics.ensureAllEventsConsumed()
+ }
+ }
+ }
+
+ Locale.setDefault(originalLocale)
+ }
+
private fun StripeCardFormViewBinding.populate(
visa: String,
month: String,
@@ -571,6 +657,7 @@ internal class CardFormViewTestActivity : AppCompatActivity() {
data class Args(
val isCbcEligible: Boolean,
val borderless: Boolean,
+ val useRecordingCardElementAnalytics: Boolean = false,
) : Parcelable
private val args: Args by lazy {
@@ -578,13 +665,26 @@ internal class CardFormViewTestActivity : AppCompatActivity() {
intent.getParcelableExtra("args")!!
}
+ val recordingCardElementAnalytics = RecordingCardElementAnalytics()
+
private val cardFormView: CardFormView by lazy {
val style = if (args.borderless) "1" else "0"
val attributes = Robolectric.buildAttributeSet()
.addAttribute(R.attr.cardFormStyle, style)
.build()
- CardFormView(this, attributes).apply {
+ val view = if (args.useRecordingCardElementAnalytics) {
+ CardFormView(
+ context = this,
+ attrs = attributes,
+ defStyleAttr = 0,
+ cardElementAnalytics = recordingCardElementAnalytics,
+ )
+ } else {
+ CardFormView(this, attributes)
+ }
+
+ view.apply {
id = VIEW_ID
val storeOwner = CardElementTestHelper.createViewModelStoreOwner(isCbcEligible = args.isCbcEligible)
diff --git a/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt
index 3172ce9b920..83c28baf82a 100644
--- a/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt
+++ b/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt
@@ -44,6 +44,7 @@ import com.stripe.android.utils.createTestActivityRule
import com.stripe.android.view.CardInputWidget.Companion.LOGGING_TOKEN
import com.stripe.android.view.CardInputWidget.Companion.shouldIconShowBrand
import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
import kotlinx.parcelize.Parcelize
import org.hamcrest.CoreMatchers.anything
import org.junit.Rule
@@ -1812,6 +1813,86 @@ internal class CardInputWidgetTest {
assertThat(cardBrandView.possibleBrands.size).isEqualTo(2)
}
+ @Test
+ fun `card element analytics reportShown when widget attaches`() = runCardInputWidgetAnalyticsTest {
+ assertThat(cardElementAnalytics.awaitShown()).isNotNull()
+ assertThat(cardElementAnalytics.awaitInteraction()).isNotNull()
+ }
+
+ @Test
+ fun `card element analytics records focus events as interactions`() =
+ runCardInputWidgetAnalyticsTest {
+ with(cardInputWidget) {
+ cardNumberEditText.getParentOnFocusChangeListener()
+ .onFocusChange(cardNumberEditText, true)
+ expiryDateEditText.getParentOnFocusChangeListener()
+ .onFocusChange(expiryDateEditText, true)
+ cvcEditText.getParentOnFocusChangeListener()
+ .onFocusChange(cvcEditText, true)
+ postalCodeEditText.getParentOnFocusChangeListener()
+ .onFocusChange(postalCodeEditText, true)
+ }
+
+ idleLooper()
+
+ assertThat(cardElementAnalytics.awaitShown()).isNotNull()
+ repeat(6) {
+ assertThat(cardElementAnalytics.awaitInteraction()).isNotNull()
+ }
+ }
+
+ @Test
+ fun `card element analytics reports form completed when all inputs valid`() =
+ runCardInputWidgetAnalyticsTest {
+ with(cardInputWidget) {
+ postalCodeEnabled = true
+ updateCardNumberAndIdle(VISA_WITH_SPACES)
+ expiryDateEditText.append("12")
+ expiryDateEditText.append("50")
+ cvcEditText.append("123")
+ postalCodeEditText.append("94103")
+ }
+
+ idleLooper()
+
+ assertThat(cardElementAnalytics.awaitShown()).isNotNull()
+ repeat(9) { assertThat(cardElementAnalytics.awaitInteraction()).isNotNull() }
+ repeat(2) { assertThat(cardElementAnalytics.awaitFormCompleted()).isNotNull() }
+ }
+
+ private fun runCardInputWidgetAnalyticsTest(
+ block: suspend AnalyticsScenario.() -> Unit,
+ ) {
+ ActivityScenario.launch(
+ Intent(context, CardInputWidgetTestActivity::class.java).apply {
+ putExtra(
+ "args",
+ CardInputWidgetTestActivity.Args(
+ isCbcEligible = false,
+ ),
+ )
+ }
+ ).use { scenario ->
+ scenario.onActivity { activity ->
+ runTest {
+ val widget = activity.findViewById(CardInputWidgetTestActivity.VIEW_ID)
+ val cardElementAnalytics = activity.recordingCardElementAnalytics
+
+ idleLooper()
+
+ block(
+ AnalyticsScenario(
+ cardInputWidget = widget,
+ cardElementAnalytics = cardElementAnalytics,
+ )
+ )
+
+ cardElementAnalytics.ensureAllEventsConsumed()
+ }
+ }
+ }
+ }
+
private fun runCardInputWidgetTest(
isCbcEligible: Boolean = false,
afterRecreation: (CardInputWidget.() -> Unit)? = null,
@@ -1846,6 +1927,11 @@ internal class CardInputWidgetTest {
activityScenario.close()
}
+ private class AnalyticsScenario(
+ val cardInputWidget: CardInputWidget,
+ val cardElementAnalytics: RecordingCardElementAnalytics,
+ )
+
private fun CardInputWidget.updateCardNumberAndIdle(cardNumber: String) {
cardNumberEditText.setText(cardNumber)
idleLooper()
@@ -1914,8 +2000,15 @@ internal class CardInputWidgetTestActivity : AppCompatActivity() {
intent.getParcelableExtra("args")!!
}
+ val recordingCardElementAnalytics = RecordingCardElementAnalytics()
+
private val cardInputWidget: CardInputWidget by lazy {
- CardInputWidget(this).apply {
+ CardInputWidget(
+ context = this,
+ attrs = null,
+ defStyleAttr = 0,
+ cardElementAnalytics = recordingCardElementAnalytics,
+ ).apply {
id = VIEW_ID
layoutWidthCalculator = CardInputWidget.LayoutWidthCalculator { text, _ -> text.length * 10 }
diff --git a/payments-core/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt
index abce803a4f6..ae8ad5c071b 100644
--- a/payments-core/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt
+++ b/payments-core/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt
@@ -44,6 +44,7 @@ import com.stripe.android.utils.CardElementTestHelper
import com.stripe.android.utils.TestUtils.idleLooper
import com.stripe.android.utils.createTestActivityRule
import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
import kotlinx.parcelize.Parcelize
import org.hamcrest.CoreMatchers.anything
import org.hamcrest.Matchers
@@ -1334,6 +1335,95 @@ internal class CardMultilineWidgetTest {
}
}
+ @Test
+ fun `reportShown is called on attach`() =
+ runCardMultilineWidgetAnalyticsTest {
+ assertThat(cardAnalytics.awaitShown()).isNotNull()
+ assertThat(cardAnalytics.awaitInteraction()).isNotNull()
+ }
+
+ @Test
+ fun `reportInteraction called on text focus`() =
+ runCardMultilineWidgetAnalyticsTest {
+ assertThat(cardAnalytics.awaitShown()).isNotNull()
+
+ with(widgetGroup) {
+ cardNumberEditText.getParentOnFocusChangeListener()
+ .onFocusChange(cardNumberEditText, true)
+ expiryDateEditText.getParentOnFocusChangeListener()
+ .onFocusChange(expiryDateEditText, true)
+ cvcEditText.getParentOnFocusChangeListener()
+ .onFocusChange(cvcEditText, true)
+ postalCodeEditText.getParentOnFocusChangeListener()
+ .onFocusChange(postalCodeEditText, true)
+ }
+
+ idleLooper()
+
+ repeat(5) { assertThat(cardAnalytics.awaitInteraction()).isNotNull() }
+ }
+
+ @Test
+ fun `reports form completed when multiline inputs valid`() =
+ runCardMultilineWidgetAnalyticsTest {
+ assertThat(cardAnalytics.awaitShown()).isNotNull()
+
+ with(widgetGroup) {
+ cardNumberEditText.setText(VISA_WITH_SPACES)
+ expiryDateEditText.append("12")
+ expiryDateEditText.append("50")
+ cvcEditText.append("123")
+ postalCodeEditText.append("94103")
+ }
+
+ idleLooper()
+
+ repeat(9) { assertThat(cardAnalytics.awaitInteraction()).isNotNull() }
+ repeat(2) { assertThat(cardAnalytics.awaitFormCompleted()).isNotNull() }
+ }
+
+ private data class AnalyticsScenario(
+ val activity: CardMultilineWidgetTestActivity,
+ val cardAnalytics: RecordingCardElementAnalytics,
+ val widgetGroup: WidgetControlGroup,
+ )
+
+ private fun runCardMultilineWidgetAnalyticsTest(
+ block: suspend AnalyticsScenario.() -> Unit,
+ ) {
+ ActivityScenario.launch(
+ Intent(context, CardMultilineWidgetTestActivity::class.java).apply {
+ putExtra(
+ "args",
+ CardMultilineWidgetTestActivity.Args(
+ isCbcEligible = false,
+ useRecordingCardElementAnalytics = true,
+ ),
+ )
+ }
+ ).use { activityScenario ->
+ activityScenario.onActivity { activity ->
+ runTest {
+ activity.setWorkContext(coroutineContext)
+
+ val cardElementAnalytics = activity.recordingFullCardElementAnalytics
+
+ idleLooper()
+
+ block(
+ AnalyticsScenario(
+ activity = activity,
+ cardAnalytics = cardElementAnalytics,
+ widgetGroup = activity.fullGroup,
+ )
+ )
+
+ cardElementAnalytics.ensureAllEventsConsumed()
+ }
+ }
+ }
+ }
+
private fun runCardMultilineWidgetTest(
isCbcEligible: Boolean = false,
block: TestContext.() -> Unit,
@@ -1400,6 +1490,7 @@ internal class CardMultilineWidgetTestActivity : AppCompatActivity() {
@Parcelize
data class Args(
val isCbcEligible: Boolean,
+ val useRecordingCardElementAnalytics: Boolean = false,
) : Parcelable
private val args: Args by lazy {
@@ -1407,8 +1498,24 @@ internal class CardMultilineWidgetTestActivity : AppCompatActivity() {
intent.getParcelableExtra("args")!!
}
+ val recordingFullCardElementAnalytics = RecordingCardElementAnalytics()
+
+ val recordingNoZipCardElementAnalytics = RecordingCardElementAnalytics()
+
private val cardMultilineWidget: CardMultilineWidget by lazy {
- CardMultilineWidget(this, shouldShowPostalCode = true).apply {
+ val widget = if (args.useRecordingCardElementAnalytics) {
+ CardMultilineWidget(
+ context = this,
+ attrs = null,
+ defStyleAttr = 0,
+ shouldShowPostalCode = true,
+ cardElementAnalytics = recordingFullCardElementAnalytics,
+ )
+ } else {
+ CardMultilineWidget(this, shouldShowPostalCode = true)
+ }
+
+ widget.apply {
id = VIEW_ID
val storeOwner = CardElementTestHelper.createViewModelStoreOwner(isCbcEligible = args.isCbcEligible)
@@ -1418,7 +1525,19 @@ internal class CardMultilineWidgetTestActivity : AppCompatActivity() {
}
private val noZipCardMultilineWidget: CardMultilineWidget by lazy {
- CardMultilineWidget(this, shouldShowPostalCode = false).apply {
+ val widget = if (args.useRecordingCardElementAnalytics) {
+ CardMultilineWidget(
+ context = this,
+ attrs = null,
+ defStyleAttr = 0,
+ shouldShowPostalCode = false,
+ cardElementAnalytics = recordingNoZipCardElementAnalytics,
+ )
+ } else {
+ CardMultilineWidget(this, shouldShowPostalCode = false)
+ }
+
+ widget.apply {
id = NO_ZIP_VIEW_ID
val storeOwner = CardElementTestHelper.createViewModelStoreOwner(isCbcEligible = args.isCbcEligible)
diff --git a/payments-core/src/test/java/com/stripe/android/view/DefaultCardElementAnalyticsTest.kt b/payments-core/src/test/java/com/stripe/android/view/DefaultCardElementAnalyticsTest.kt
new file mode 100644
index 00000000000..55d8ff17f08
--- /dev/null
+++ b/payments-core/src/test/java/com/stripe/android/view/DefaultCardElementAnalyticsTest.kt
@@ -0,0 +1,187 @@
+package com.stripe.android.view
+
+import android.content.Context
+import android.os.Bundle
+import androidx.test.core.app.ApplicationProvider
+import app.cash.turbine.Turbine
+import com.google.common.truth.Truth.assertThat
+import com.stripe.android.networking.PaymentAnalyticsEvent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class DefaultCardElementAnalyticsTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun `reportShown sends event once per session`() = test(
+ widgetType = CardElementWidgetType.CardInputWidget,
+ ) {
+ analytics.reportShown(context)
+ analytics.reportShown(context)
+
+ assertThat(executor.awaitReportCall()).isEqualTo(
+ FakeCardElementAnalyticsRequestExecutor.ReportCall(
+ event = PaymentAnalyticsEvent.MobileCardElementShown,
+ params = mapOf(
+ DefaultCardElementAnalytics.PARAM_WIDGET_TYPE to
+ CardElementWidgetType.CardInputWidget.analyticsValue,
+ ),
+ ),
+ )
+ executor.ensureAllEventsConsumed()
+ }
+
+ @Test
+ fun `reportInteraction sends event once per session`() = test {
+ analytics.reportInteraction(context)
+ analytics.reportInteraction(context)
+
+ assertThat(executor.awaitReportCall()).isEqualTo(
+ FakeCardElementAnalyticsRequestExecutor.ReportCall(
+ event = PaymentAnalyticsEvent.MobileCardElementInteraction,
+ params = mapOf(
+ DefaultCardElementAnalytics.PARAM_WIDGET_TYPE to
+ CardElementWidgetType.CardFormView.analyticsValue,
+ ),
+ ),
+ )
+ executor.ensureAllEventsConsumed()
+ }
+
+ @Test
+ fun `reportFormCompleted sends event once per session`() = test {
+ analytics.reportFormCompleted(context)
+ analytics.reportFormCompleted(context)
+
+ assertThat(executor.awaitReportCall()).isEqualTo(
+ FakeCardElementAnalyticsRequestExecutor.ReportCall(
+ event = PaymentAnalyticsEvent.MobileCardElementFormCompleted,
+ params = mapOf(
+ DefaultCardElementAnalytics.PARAM_WIDGET_TYPE to
+ CardElementWidgetType.CardFormView.analyticsValue,
+ ),
+ ),
+ )
+ executor.ensureAllEventsConsumed()
+ }
+
+ @Test
+ fun `reportShown uses multiline widget type`() = test(
+ widgetType = CardElementWidgetType.CardMultilineWidget,
+ ) {
+ analytics.reportShown(context)
+
+ assertThat(executor.awaitReportCall()).isEqualTo(
+ FakeCardElementAnalyticsRequestExecutor.ReportCall(
+ event = PaymentAnalyticsEvent.MobileCardElementShown,
+ params = mapOf(
+ DefaultCardElementAnalytics.PARAM_WIDGET_TYPE to
+ CardElementWidgetType.CardMultilineWidget.analyticsValue,
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `saveState and restoreState preserve dedupe across instances`() = runTest {
+ val executor = FakeCardElementAnalyticsRequestExecutor()
+ val factory = FakeCardElementAnalyticsRequestExecutorFactory(executor = executor)
+
+ val first = DefaultCardElementAnalytics(
+ widgetType = CardElementWidgetType.CardFormView,
+ cardElementAnalyticsRequestExecutorFactory = factory,
+ )
+
+ first.reportShown(context)
+ first.reportInteraction(context)
+ first.reportFormCompleted(context)
+
+ assertThat(executor.awaitReportCall().event)
+ .isEqualTo(PaymentAnalyticsEvent.MobileCardElementShown)
+ assertThat(executor.awaitReportCall().event)
+ .isEqualTo(PaymentAnalyticsEvent.MobileCardElementInteraction)
+ assertThat(executor.awaitReportCall().event)
+ .isEqualTo(PaymentAnalyticsEvent.MobileCardElementFormCompleted)
+
+ val bundle = Bundle()
+ first.saveState(bundle)
+
+ val second = DefaultCardElementAnalytics(
+ widgetType = CardElementWidgetType.CardFormView,
+ cardElementAnalyticsRequestExecutorFactory = factory,
+ )
+
+ second.restoreState(bundle)
+
+ second.reportShown(context)
+ second.reportInteraction(context)
+ second.reportFormCompleted(context)
+
+ executor.expectNoEvents()
+ }
+
+ private fun test(
+ widgetType: CardElementWidgetType = CardElementWidgetType.CardFormView,
+ block: suspend Scenario.() -> Unit,
+ ) = runTest {
+ val executor = FakeCardElementAnalyticsRequestExecutor()
+ val analytics = DefaultCardElementAnalytics(
+ widgetType = widgetType,
+ cardElementAnalyticsRequestExecutorFactory = FakeCardElementAnalyticsRequestExecutorFactory(
+ executor = executor,
+ ),
+ )
+
+ Scenario(
+ context = context,
+ executor = executor,
+ analytics = analytics,
+ ).block()
+
+ executor.ensureAllEventsConsumed()
+ }
+
+ private data class Scenario(
+ val context: Context,
+ val executor: FakeCardElementAnalyticsRequestExecutor,
+ val analytics: CardElementAnalytics,
+ )
+
+ private class FakeCardElementAnalyticsRequestExecutor : CardElementAnalyticsRequestExecutor {
+ private val reportCalls = Turbine()
+
+ suspend fun awaitReportCall(): ReportCall = reportCalls.awaitItem()
+
+ fun ensureAllEventsConsumed() {
+ reportCalls.ensureAllEventsConsumed()
+ }
+
+ fun expectNoEvents() {
+ reportCalls.expectNoEvents()
+ }
+
+ override fun report(
+ event: PaymentAnalyticsEvent,
+ params: Map,
+ ) {
+ reportCalls.add(ReportCall(event = event, params = params))
+ }
+
+ data class ReportCall(
+ val event: PaymentAnalyticsEvent,
+ val params: Map,
+ )
+ }
+
+ private class FakeCardElementAnalyticsRequestExecutorFactory(
+ private val executor: FakeCardElementAnalyticsRequestExecutor,
+ ) : CardElementAnalyticsRequestExecutor.Factory {
+ override fun create(context: Context): CardElementAnalyticsRequestExecutor {
+ return executor
+ }
+ }
+}
diff --git a/payments-core/src/test/java/com/stripe/android/view/RecordingCardElementAnalytics.kt b/payments-core/src/test/java/com/stripe/android/view/RecordingCardElementAnalytics.kt
new file mode 100644
index 00000000000..9bf21322637
--- /dev/null
+++ b/payments-core/src/test/java/com/stripe/android/view/RecordingCardElementAnalytics.kt
@@ -0,0 +1,49 @@
+package com.stripe.android.view
+
+import android.content.Context
+import android.os.Bundle
+import app.cash.turbine.Turbine
+
+internal class RecordingCardElementAnalytics : CardElementAnalytics {
+ private val shownCalls = Turbine()
+ private val interactionCalls = Turbine()
+ private val formCompletedCalls = Turbine()
+
+ override fun reportShown(context: Context) {
+ shownCalls.add(Unit)
+ }
+
+ override fun reportInteraction(context: Context) {
+ interactionCalls.add(Unit)
+ }
+
+ override fun reportFormCompleted(context: Context) {
+ formCompletedCalls.add(Unit)
+ }
+
+ override fun saveState(outState: Bundle) {
+ // No-op
+ }
+
+ override fun restoreState(savedState: Bundle) {
+ // No-op
+ }
+
+ suspend fun awaitInteraction() {
+ interactionCalls.awaitItem()
+ }
+
+ suspend fun awaitFormCompleted() {
+ formCompletedCalls.awaitItem()
+ }
+
+ suspend fun awaitShown() {
+ shownCalls.awaitItem()
+ }
+
+ fun ensureAllEventsConsumed() {
+ shownCalls.ensureAllEventsConsumed()
+ interactionCalls.ensureAllEventsConsumed()
+ formCompletedCalls.ensureAllEventsConsumed()
+ }
+}