Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal class EmbeddedContentPage(
}

fun clickOnLpm(code: String) {
composeTestRule.waitForIdle()
waitUntilVisible()

composeTestRule.onNode(hasTestTag("${TEST_TAG_NEW_PAYMENT_METHOD_ROW_BUTTON}_$code"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,9 @@ internal class EmbeddedPaymentElementAnalyticsTest {
managePage.waitUntilGone(card1.id)
managePage.clickDone()

validateAnalyticsRequest("mc_dismiss")
Espresso.pressBack()

testContext.markTestSucceeded()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ internal class DefaultEmbeddedContentHelper @Inject constructor(
},
invokeRowSelectionCallback = ::invokeRowSelectionCallback,
displaysMandatesInFormScreen = isImmediateAction && embeddedViewDisplaysMandateText,
shouldTrackIndividualPaymentMethods = false,
onInitiallyDisplayedPaymentMethodVisibilitySnapshot = { visiblePaymentMethods, hiddenPaymentMethods ->
eventReporter.onInitiallyDisplayedPaymentMethodVisibilitySnapshot(
visiblePaymentMethods = visiblePaymentMethods,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.stripe.android.paymentsheet.verticalmode

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.unit.IntSize

internal abstract class LayoutCoordinateInitialVisibilityTracker(
var expectedItems: List<String>,
val visibilityThreshold: Int,
) {

protected data class CoordinateSnapshot(
val positionInWindow: Offset,
val size: IntSize,
val boundsInWindow: Rect
)

var hasDispatched = false
protected set

fun updateVisibility(itemCode: String, coordinates: LayoutCoordinates) {
if (itemCode !in expectedItems || expectedItems.isEmpty()) return
if (hasDispatched) return // Only dispatch once per tracker instance
/**
* Capture only the relevant fields from [LayoutCoordinates] when we know the coordinates are attached.
* Whether previous coordinates is/isn't attached or not is irrelevant, we only need to know
* the previous coordinate's boundsInWindow, positionInWindow, and size.
*
* When implemented, [LayoutCoordinates.isAttached] provides a getter to a var, so we cannot rely on being able
* to call `positionInWindow` and `boundsInWindow` of saved [LayoutCoordinates].
*/
if (coordinates.isAttached) {
val coordinateSnapshot = CoordinateSnapshot(
positionInWindow = coordinates.positionInWindow(),
size = coordinates.size,
boundsInWindow = coordinates.boundsInWindow(),
)

val isVisible = calculateVisibility(
coordinates = coordinateSnapshot,
visibilityThresholdPercentage = visibilityThreshold,
)

updateVisibilityHelper(itemCode, coordinateSnapshot, isVisible)
} else {
return
}
}

fun updateExpectedItems(items: List<String>) {
if (this.expectedItems != items) {
// Reset to initial state with new items
this.expectedItems = items
reset()
}
}

abstract fun reset()

protected abstract fun updateVisibilityHelper(
itemCode: String,
coordinateSnapshot: CoordinateSnapshot,
isVisible: Boolean,
)

@Suppress("MagicNumber")
private fun calculateVisibility(
coordinates: CoordinateSnapshot,
visibilityThresholdPercentage: Int,
): Boolean {
val bounds = coordinates.boundsInWindow

// Check if completely out of bounds (hidden)
@Suppress("ComplexCondition")
if (bounds.left == 0f && bounds.top == 0f && bounds.right == 0f && bounds.bottom == 0f) {
return false
}

// Calculate visibility percentage
val widthInBounds = bounds.width
val heightInBounds = bounds.height
val totalArea = coordinates.size.height * coordinates.size.width
val areaInBounds = widthInBounds * heightInBounds

// 100 refers to percentages
val percentVisible = if (totalArea > 0) {
((areaInBounds / totalArea) * 100).toInt().coerceIn(0, 100)
} else {
0
}

return percentVisible >= visibilityThresholdPercentage
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.stripe.android.paymentsheet.DisplayableSavedPaymentMethod
import com.stripe.android.paymentsheet.PaymentSheet.Appearance.Embedded
import com.stripe.android.paymentsheet.PaymentSheet.Appearance.Embedded.RowStyle
import com.stripe.android.paymentsheet.R
import com.stripe.android.paymentsheet.verticalmode.PaymentMethodLayoutInitialVisibilityTracker.Companion.EMBEDDED_PAYMENT_METHOD_LAYOUT_NAME
import com.stripe.android.ui.core.elements.Mandate
import com.stripe.android.uicore.image.StripeImageLoader
import com.stripe.android.uicore.strings.resolve
Expand Down Expand Up @@ -137,7 +138,12 @@ internal fun PaymentMethodEmbeddedLayoutUI(
// Cancel tracking and any pending dispatches, when the paymentMethods used change
DisposableEffect(paymentMethodCodes) { onDispose { cancelPaymentMethodVisibilityTracking.invoke() } }

Column(modifier = modifier, verticalArrangement = arrangement) {
Column(
modifier = modifier.onGloballyPositioned {
updatePaymentMethodVisibility(EMBEDDED_PAYMENT_METHOD_LAYOUT_NAME, it)
},
verticalArrangement = arrangement
) {
if (appearance.style.topSeparatorEnabled()) OptionalEmbeddedDivider(appearance.style)

EmbeddedSavedPaymentMethodRowButton(
Expand All @@ -149,7 +155,6 @@ internal fun PaymentMethodEmbeddedLayoutUI(
onViewMorePaymentMethods = onViewMorePaymentMethods,
onManageOneSavedPaymentMethod = onManageOneSavedPaymentMethod,
onSelectSavedPaymentMethod = onSelectSavedPaymentMethod,
updatePaymentMethodVisibility = updatePaymentMethodVisibility,
appearance = appearance
)

Expand All @@ -159,7 +164,6 @@ internal fun PaymentMethodEmbeddedLayoutUI(
isEnabled = isEnabled,
imageLoader = imageLoader,
appearance = appearance,
updatePaymentMethodVisibility = updatePaymentMethodVisibility
)

if (appearance.style.bottomSeparatorEnabled()) OptionalEmbeddedDivider(appearance.style)
Expand Down Expand Up @@ -262,7 +266,6 @@ internal fun EmbeddedSavedPaymentMethodRowButton(
onViewMorePaymentMethods: () -> Unit,
onManageOneSavedPaymentMethod: (DisplayableSavedPaymentMethod) -> Unit,
onSelectSavedPaymentMethod: (DisplayableSavedPaymentMethod) -> Unit,
updatePaymentMethodVisibility: (String, LayoutCoordinates) -> Unit = { _, _ -> },
appearance: Embedded,
) {
if (displayedSavedPaymentMethod != null) {
Expand All @@ -278,9 +281,6 @@ internal fun EmbeddedSavedPaymentMethodRowButton(
onManageOneSavedPaymentMethod = { onManageOneSavedPaymentMethod(displayedSavedPaymentMethod) },
)
},
modifier = Modifier.onGloballyPositioned { coordinates ->
updatePaymentMethodVisibility("saved", coordinates)
},
onClick = { onSelectSavedPaymentMethod(displayedSavedPaymentMethod) },
appearance = appearance
)
Expand All @@ -296,7 +296,6 @@ internal fun EmbeddedNewPaymentMethodRowButtonsLayoutUi(
isEnabled: Boolean,
imageLoader: StripeImageLoader,
appearance: Embedded,
updatePaymentMethodVisibility: (String, LayoutCoordinates) -> Unit = { _, _ -> },
) {
val selectedIndex = remember(selection, paymentMethods) {
if (selection is PaymentMethodVerticalLayoutInteractor.Selection.New) {
Expand Down Expand Up @@ -327,9 +326,6 @@ internal fun EmbeddedNewPaymentMethodRowButtonsLayoutUi(
)
}
},
modifier = Modifier.onGloballyPositioned { coordinates ->
updatePaymentMethodVisibility(item.code, coordinates)
},
)
} else {
NewPaymentMethodRowButton(
Expand All @@ -338,9 +334,6 @@ internal fun EmbeddedNewPaymentMethodRowButtonsLayoutUi(
displayablePaymentMethod = item,
imageLoader = imageLoader,
appearance = appearance,
modifier = Modifier.onGloballyPositioned { coordinates ->
updatePaymentMethodVisibility(item.code, coordinates)
},
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
package com.stripe.android.paymentsheet.verticalmode

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand All @@ -26,36 +20,21 @@ import kotlin.coroutines.CoroutineContext
* Once stable, it dispatches a single analytics event.
*/
internal class PaymentMethodInitialVisibilityTracker(
private var expectedItems: List<String> = emptyList(),
private val renderedLpmCallback: (List<String>, List<String>) -> Unit,
expectedItems: List<String>,
dispatcher: CoroutineContext = Dispatchers.Default,
private val renderedLpmCallback: (List<String>, List<String>) -> Unit,
) : LayoutCoordinateInitialVisibilityTracker(
expectedItems = expectedItems,
visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD_PERCENT
) {
private data class CoordinateSnapshot(
val positionInWindow: Offset,
val size: IntSize,
val boundsInWindow: Rect
)

private val visibilityMap = mutableMapOf<String, Boolean>()
private val previousCoordinateSnapshots = mutableMapOf<String, CoordinateSnapshot>()
private val coordinateStabilityMap = mutableMapOf<String, Boolean>()
private var hasDispatched = false

private val coroutineScope = CoroutineScope(dispatcher)
private var dispatchEventJob: Job? = null

fun updateExpectedItems(items: List<String>) {
if (this.expectedItems != items) {
// Reset to initial state with new items
this.expectedItems = items
reset()
}
}

fun getHasDispatched(): Boolean {
return this.hasDispatched
}

/**
* When this function is called from onGloballyPositioned
* it is guaranteed to be called twice on any given stable coordinate
Expand All @@ -65,30 +44,11 @@ internal class PaymentMethodInitialVisibilityTracker(
* when coordinates have moved
* and when the composition is finalized and stable.
*/
fun updateVisibility(itemCode: String, coordinates: LayoutCoordinates) {
if (itemCode !in expectedItems || expectedItems.isEmpty()) return
if (hasDispatched) return // Only dispatch once per tracker instance

/**
* Capture only the relevant fields from [LayoutCoordinates] when we know the coordinates are attached.
* Whether previous coordinates is/isn't attached or not is irrelevant, we only need to know
* the previous coordinate's boundsInWindow, positionInWindow, and size.
*
* When implemented, [LayoutCoordinates.isAttached] provides a getter to a var, so we cannot rely on being able
* to call `positionInWindow` and `boundsInWindow` of saved [LayoutCoordinates].
*/
val coordinateSnapshot: CoordinateSnapshot
if (coordinates.isAttached) {
coordinateSnapshot = CoordinateSnapshot(
positionInWindow = coordinates.positionInWindow(),
size = coordinates.size,
boundsInWindow = coordinates.boundsInWindow(),
)
} else {
return
}

val newVisibility = calculateVisibility(coordinateSnapshot)
override fun updateVisibilityHelper(
itemCode: String,
coordinateSnapshot: CoordinateSnapshot,
isVisible: Boolean,
) {
val previousCoordinatesForItem = previousCoordinateSnapshots[itemCode]

// Check if coordinates are stable (haven't changed)
Expand All @@ -102,8 +62,8 @@ internal class PaymentMethodInitialVisibilityTracker(

// Update our tracking
this.previousCoordinateSnapshots[itemCode] = coordinateSnapshot
val wasVisibilityStable = visibilityMap[itemCode] == newVisibility
visibilityMap[itemCode] = newVisibility
val wasVisibilityStable = visibilityMap[itemCode] == isVisible
visibilityMap[itemCode] = isVisible

if (coordinatesAreStable && wasVisibilityStable) {
coordinateStabilityMap[itemCode] = true
Expand All @@ -116,32 +76,6 @@ internal class PaymentMethodInitialVisibilityTracker(
checkStabilityAndDispatch()
}

@Suppress("MagicNumber")
private fun calculateVisibility(coordinates: CoordinateSnapshot): Boolean {
val bounds = coordinates.boundsInWindow

// Check if completely out of bounds (hidden)
@Suppress("ComplexCondition")
if (bounds.left == 0f && bounds.top == 0f && bounds.right == 0f && bounds.bottom == 0f) {
return false
}

// Calculate visibility percentage
val widthInBounds = bounds.width
val heightInBounds = bounds.height
val totalArea = coordinates.size.height * coordinates.size.width
val areaInBounds = widthInBounds * heightInBounds

// 100 refers to percentages
val percentVisible = if (totalArea > 0) {
((areaInBounds / totalArea) * 100).toInt().coerceIn(0, 100)
} else {
0
}

return percentVisible >= DEFAULT_VISIBILITY_THRESHOLD_PERCENT
}

private fun checkStability(): Boolean {
if (expectedItems.size != coordinateStabilityMap.size || expectedItems.size != visibilityMap.size) {
return false
Expand Down Expand Up @@ -176,7 +110,7 @@ internal class PaymentMethodInitialVisibilityTracker(
}
}

fun reset() {
override fun reset() {
coordinateStabilityMap.clear()
previousCoordinateSnapshots.clear()
visibilityMap.clear()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.stripe.android.paymentsheet.verticalmode

internal class PaymentMethodLayoutInitialVisibilityTracker(
private val callback: () -> Unit,
) : LayoutCoordinateInitialVisibilityTracker(
expectedItems = listOf(EMBEDDED_PAYMENT_METHOD_LAYOUT_NAME),
visibilityThreshold = VISIBILITY_THRESHOLD_PERCENT_FOR_EMBEDDED_LAYOUT_TRACKING
) {

override fun updateVisibilityHelper(
itemCode: String,
coordinateSnapshot: CoordinateSnapshot,
isVisible: Boolean,
) {
if (isVisible) {
hasDispatched = true
callback()
}
}

override fun reset() {
hasDispatched = false
}

companion object {
const val EMBEDDED_PAYMENT_METHOD_LAYOUT_NAME = "embedded_payment_method_layout"

/**
* Arbitrary small non zero value to ensure that users see some of
* embedded payment method layout before dispatching event.
*
* It is assumed that users will scroll and see the entire
* embedded payment method layout before completing their purchase
*/
const val VISIBILITY_THRESHOLD_PERCENT_FOR_EMBEDDED_LAYOUT_TRACKING = 25
}
}
Loading
Loading