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
1 change: 1 addition & 0 deletions payments-core/res/layout/stripe_card_form_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
1 change: 1 addition & 0 deletions payments-core/res/values/attrs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<attr name="shouldShowPostalCode" format="boolean" />
<attr name="shouldRequirePostalCode" format="boolean" />
<attr name="shouldRequireUsZipCode" format="boolean" />
<attr name="isIntegratedInStripeView" format="boolean" />
</declare-styleable>

<declare-styleable name="BecsDebitWidget" tools:ignore="ResourceName">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>,
) {
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<String, String>,
)

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<String, String>,
) {
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"
}
}
Original file line number Diff line number Diff line change
@@ -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"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
Loading
Loading