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 @@ -41,6 +41,9 @@ internal class CheckoutControllerExampleViewModel(
savedStateHandle = savedStateHandle,
).resultCallback { result ->
Log.d(TAG, "Result: $result")
if (result is CheckoutController.Result.Completed) {
_sessionComplete.tryEmit(Unit)
}
}.build()

init {
Expand All @@ -50,9 +53,6 @@ internal class CheckoutControllerExampleViewModel(
viewModelScope.launch {
controller.session.collect { session ->
updateConfiguredState { it.copy(session = session) }
if (session?.status == Session.Status.Complete) {
_sessionComplete.tryEmit(Unit)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.stripe.android.checkout

import com.stripe.android.core.injection.ViewModelScope
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject

@OptIn(CheckoutSessionPreview::class)
internal class CheckoutConfirmationResultHandler @Inject constructor(
private val confirmationHandler: ConfirmationHandler,
private val resultCallback: CheckoutController.ResultCallback,
@ViewModelScope private val viewModelScope: CoroutineScope,
) {
private var isAwaitingProcessDeathResult = confirmationHandler.hasReloadedFromProcessDeath

/**
* Observes the shared [ConfirmationHandler] for the controller's lifetime and forwards each
* terminal confirmation result to [resultCallback]. This is registered exactly once (from
* [CheckoutController]'s init); the handler emits [ConfirmationHandler.State.Complete] at most once
* per confirmation and never restores a stale completion across process death, so this single
* collector delivers each result exactly once.
*/
fun register() {
confirmationHandler.state
.filterIsInstance<ConfirmationHandler.State.Complete>()
.onEach { handle(it.result) }
.launchIn(viewModelScope)
}

private fun handle(result: ConfirmationHandler.Result) {
val isProcessDeathResult = isAwaitingProcessDeathResult
isAwaitingProcessDeathResult = false

when (result) {
is ConfirmationHandler.Result.Succeeded ->
resultCallback.onResult(CheckoutController.Result.Completed())
is ConfirmationHandler.Result.Failed ->
resultCallback.onResult(CheckoutController.Result.Failed(result.cause))
is ConfirmationHandler.Result.Canceled -> handleCanceled(result, isProcessDeathResult)
}
}

private fun handleCanceled(
result: ConfirmationHandler.Result.Canceled,
isProcessDeathResult: Boolean,
) {
// None normally keeps the customer in the payment UI. After process death, however, it means
// the pending next-action result did not return, so checkout must report the interrupted flow.
val shouldInformMerchant =
result.action == ConfirmationHandler.Result.Canceled.Action.InformCancellation ||
result.action == ConfirmationHandler.Result.Canceled.Action.None && isProcessDeathResult

if (shouldInformMerchant) {
resultCallback.onResult(CheckoutController.Result.Canceled())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,32 @@ private val SERVER_UPDATE_TIMEOUT_MS = 20.seconds.inWholeMilliseconds
@Singleton
@CheckoutSessionPreview
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Suppress("TooManyFunctions", "UnusedParameter")
@Suppress("TooManyFunctions")
class CheckoutController @Inject internal constructor(
resultCallback: ResultCallback,
@ViewModelScope private val viewModelScope: CoroutineScope,
confirmationResultHandler: CheckoutConfirmationResultHandler,
private val checkoutSessionRepository: CheckoutSessionRepository,
private val checkoutStateLoader: CheckoutStateLoader,
private val stateHolder: CheckoutControllerStateHolder,
private val sheetStateHolder: SheetStateHolder,
private val checkoutPresenterSubcomponentFactory: CheckoutPresenterSubcomponent.Factory,
@PaymentElementCallbackIdentifier internal val paymentElementCallbackIdentifier: String,
) {
private val mutex = Mutex()
private val pendingMutations = AtomicInteger(0)

private val _isUpdating = MutableStateFlow(false)

init {
confirmationResultHandler.register()
}

/**
* The latest [Session] data, or `null` until [configure] has completed successfully.
*/
val session: StateFlow<Session?>
get() = stateHolder.session

private val mutex = Mutex()
private val pendingMutations = AtomicInteger(0)

private val _isUpdating = MutableStateFlow(false)

/**
* Whether a mutation is currently in progress.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package com.stripe.android.checkout

import app.cash.turbine.Turbine
import com.google.common.truth.Truth.assertThat
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.isInstanceOf
import com.stripe.android.model.PaymentIntentFixtures
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler
import com.stripe.android.paymentelement.confirmation.FakeConfirmationOption
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(CheckoutSessionPreview::class)
internal class CheckoutConfirmationResultHandlerTest {

@Test
fun `register handles current complete result`() {
runScenario(initialResult = succeeded()) {
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
}
}

@Test
fun `succeeded result invokes Completed`() = runScenario {
emit(succeeded())

assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
}

@Test
fun `each confirmation completion delivers its own callback`() = runScenario {
emit(succeeded())
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()

// A second confirmation returns to Confirming before completing again. The interleaved
// Confirming breaks the state flow's de-duplication, so an identical result still delivers.
emitConfirming()
emit(succeeded())

assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
}

@Test
fun `failed result invokes Failed with the confirmation cause`() = runScenario {
val cause = IllegalStateException("Failed")

emit(
ConfirmationHandler.Result.Failed(
cause = cause,
message = "Failed".resolvableString,
type = ConfirmationHandler.Result.Failed.ErrorType.Payment,
)
)

val result = callbackCalls.awaitItem()
assertThat(result).isInstanceOf<CheckoutController.Result.Failed>()
assertThat((result as CheckoutController.Result.Failed).error).isEqualTo(cause)
}

@Test
fun `canceled with InformCancellation invokes Canceled`() = runScenario {
emit(
ConfirmationHandler.Result.Canceled(
action = ConfirmationHandler.Result.Canceled.Action.InformCancellation,
)
)

assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Canceled>()
}

@Test
fun `canceled with ModifyPaymentDetails does not invoke callback`() = runScenario {
emit(
ConfirmationHandler.Result.Canceled(
action = ConfirmationHandler.Result.Canceled.Action.ModifyPaymentDetails,
)
)

callbackCalls.expectNoEvents()
}

@Test
fun `canceled with None does not invoke callback`() = runScenario {
emit(
ConfirmationHandler.Result.Canceled(
action = ConfirmationHandler.Result.Canceled.Action.None,
)
)

callbackCalls.expectNoEvents()
}

@Test
fun `canceled with None after process death invokes Canceled`() = runScenario(
hasReloadedFromProcessDeath = true,
) {
emit(
ConfirmationHandler.Result.Canceled(
action = ConfirmationHandler.Result.Canceled.Action.None,
)
)

assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Canceled>()
}

private fun runScenario(
initialResult: ConfirmationHandler.Result? = null,
hasReloadedFromProcessDeath: Boolean = false,
block: suspend Scenario.() -> Unit,
) = runTest {
val callbackCalls = Turbine<CheckoutController.Result>()
val initialConfirmationState: ConfirmationHandler.State =
initialResult?.let(ConfirmationHandler.State::Complete)
?: ConfirmationHandler.State.Idle
val confirmationHandler = FakeConfirmationHandler(
hasReloadedFromProcessDeath = hasReloadedFromProcessDeath,
state = MutableStateFlow(initialConfirmationState),
)
val handler = CheckoutConfirmationResultHandler(
confirmationHandler = confirmationHandler,
resultCallback = CheckoutController.ResultCallback(callbackCalls::add),
viewModelScope = backgroundScope,
)
handler.register()
testScheduler.runCurrent()

Scenario(
confirmationHandler = confirmationHandler,
callbackCalls = callbackCalls,
testScheduler = testScheduler,
).block()

confirmationHandler.validate()
callbackCalls.ensureAllEventsConsumed()
}

private fun succeeded(): ConfirmationHandler.Result.Succeeded {
return ConfirmationHandler.Result.Succeeded(PaymentIntentFixtures.PI_SUCCEEDED)
}

private class Scenario(
val confirmationHandler: FakeConfirmationHandler,
val callbackCalls: Turbine<CheckoutController.Result>,
val testScheduler: TestCoroutineScheduler,
) {
fun emit(result: ConfirmationHandler.Result) {
confirmationHandler.state.value = ConfirmationHandler.State.Complete(result)
testScheduler.runCurrent()
}

fun emitConfirming() {
confirmationHandler.state.value =
ConfirmationHandler.State.Confirming(FakeConfirmationOption())
testScheduler.runCurrent()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import com.stripe.android.networktesting.testBodyFromFile
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentelement.callbacks.PaymentElementCallbackReferences
import com.stripe.android.paymentelement.callbacks.PaymentElementCallbacks
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler
import com.stripe.android.paymentelement.embedded.content.SheetStateHolder
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.model.PaymentSelection
Expand All @@ -44,6 +46,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.robolectric.RobolectricTestRunner
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -337,6 +340,41 @@ internal class CheckoutControllerTest {
assertThat(controller.session.value).isNull()
}

@Test
fun `constructing the controller registers the confirmation result handler`() = runTest {
val confirmationHandler = FakeConfirmationHandler()
val results = mutableListOf<CheckoutController.Result>()
val stateHolder = CheckoutControllerStateFactory.createStateHolder(SavedStateHandle())
val resultHandler = CheckoutConfirmationResultHandler(
confirmationHandler = confirmationHandler,
resultCallback = CheckoutController.ResultCallback { results.add(it) },
viewModelScope = backgroundScope,
)

// Constructing the controller must start the collector in init; otherwise the emission below
// is dropped and no callback fires. Deps the constructor only stores are inert mocks.
CheckoutController(
viewModelScope = backgroundScope,
confirmationResultHandler = resultHandler,
checkoutSessionRepository = mock(),
checkoutStateLoader = mock(),
stateHolder = stateHolder,
sheetStateHolder = SheetStateHolder(SavedStateHandle()),
checkoutPresenterSubcomponentFactory = mock(),
paymentElementCallbackIdentifier = DEFAULT_INTEGRATION_NAME,
)

confirmationHandler.state.value = ConfirmationHandler.State.Complete(
ConfirmationHandler.Result.Canceled(
action = ConfirmationHandler.Result.Canceled.Action.InformCancellation,
)
)
testScheduler.runCurrent()

assertThat(results.single()).isInstanceOf(CheckoutController.Result.Canceled::class.java)
confirmationHandler.validate()
}

@Test
fun `clearPaymentOption clears paymentOptionDisplayData`() = runTest {
val savedStateHandle = SavedStateHandle()
Expand Down