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() + } +}