Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add EmbeddedActivityLauncher #9919

Merged
merged 13 commits into from
Jan 22, 2025
24 changes: 24 additions & 0 deletions paymentsheet/api/paymentsheet.api
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,30 @@ public final class com/stripe/android/paymentelement/embedded/EmbeddedConfirmati
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentelement/embedded/FormContract$Args$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/embedded/FormContract$Args;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lcom/stripe/android/paymentelement/embedded/FormContract$Args;
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentelement/embedded/FormResult$Cancelled$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/embedded/FormResult$Cancelled;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lcom/stripe/android/paymentelement/embedded/FormResult$Cancelled;
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentelement/embedded/FormResult$Complete$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/embedded/FormResult$Complete;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lcom/stripe/android/paymentelement/embedded/FormResult$Complete;
public synthetic fun newArray (I)[Ljava/lang/Object;
}

public final class com/stripe/android/paymentsheet/BuildConfig {
public static final field BUILD_TYPE Ljava/lang/String;
public static final field DEBUG Z
Expand Down
3 changes: 3 additions & 0 deletions paymentsheet/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
<activity
android:name=".paymentdatacollection.cvcrecollection.CvcRecollectionActivity"
android:theme="@style/StripePaymentSheetDefaultTheme" />
<activity
android:name="com.stripe.android.paymentelement.embedded.FormActivity"
android:theme="@style/StripePaymentSheetDefaultTheme" />

<activity
android:name="com.stripe.android.link.LinkActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ import kotlinx.parcelize.Parcelize
class EmbeddedPaymentElement private constructor(
private val embeddedConfirmationHelper: EmbeddedConfirmationHelper,
private val sharedViewModel: SharedPaymentElementViewModel,
private val activityResultCaller: ActivityResultCaller,
) {

init {
sharedViewModel.initEmbeddedActivityLauncher(activityResultCaller)
}

/**
* Contains information about the customer's selected payment option.
* Use this to display the payment option in your own UI.
Expand Down Expand Up @@ -491,6 +497,7 @@ class EmbeddedPaymentElement private constructor(
override fun onDestroy(owner: LifecycleOwner) {
IntentConfirmationInterceptor.createIntentCallback = null
ExternalPaymentMethodInterceptor.externalPaymentMethodConfirmHandler = null
sharedViewModel.clearFormLauncher()
tjclawson-stripe marked this conversation as resolved.
Show resolved Hide resolved
}
}
)
Expand All @@ -503,6 +510,7 @@ class EmbeddedPaymentElement private constructor(
confirmationStateSupplier = { sharedViewModel.confirmationStateHolder.state },
),
sharedViewModel = sharedViewModel,
activityResultCaller = activityResultCaller,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.stripe.android.paymentelement.embedded

import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata

internal interface EmbeddedActivityLauncher {
val formLauncher: ((code: String, paymentMethodMetadata: PaymentMethodMetadata?) -> Unit)?
tjclawson-stripe marked this conversation as resolved.
Show resolved Hide resolved
}

internal class DefaultEmbeddedActivityLauncher(
activityResultCaller: ActivityResultCaller,
private val selectionHolder: EmbeddedSelectionHolder
) : EmbeddedActivityLauncher {

private var formActivityLauncher: ActivityResultLauncher<FormContract.Args> =
activityResultCaller.registerForActivityResult(FormContract()) { result ->
tjclawson-stripe marked this conversation as resolved.
Show resolved Hide resolved
if (result is FormResult.Complete) {
selectionHolder.set(result.selection)
}
}

override var formLauncher: ((code: String, paymentMethodMetadata: PaymentMethodMetadata?) -> Unit)? =
{ code, metadata ->
formActivityLauncher.launch(FormContract.Args(code, metadata))
}
private set
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has no effect.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed and made override a val as well

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ internal interface EmbeddedContentHelper {
rowStyle: Embedded.RowStyle,
embeddedViewDisplaysMandateText: Boolean,
)

fun setFormLauncher(formLauncher: ((code: String, paymentMethodMetaData: PaymentMethodMetadata?) -> Unit)?)
tjclawson-stripe marked this conversation as resolved.
Show resolved Hide resolved

fun clearFormLauncher()
}

internal fun interface EmbeddedContentHelperFactory {
Expand Down Expand Up @@ -80,6 +84,8 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
private val _embeddedContent = MutableStateFlow<EmbeddedContent?>(null)
override val embeddedContent: StateFlow<EmbeddedContent?> = _embeddedContent.asStateFlow()

private var formLauncher: ((code: String, paymentMethodMetaData: PaymentMethodMetadata?) -> Unit)? = null

init {
coroutineScope.launch {
state.collect { state ->
Expand Down Expand Up @@ -119,6 +125,16 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
)
}

override fun setFormLauncher(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have tests for this?

formLauncher: ((code: String, paymentMethodMetaData: PaymentMethodMetadata?) -> Unit)?
) {
this.formLauncher = formLauncher
}

override fun clearFormLauncher() {
formLauncher = null
}

private fun createInteractor(
coroutineScope: CoroutineScope,
paymentMethodMetadata: PaymentMethodMetadata,
Expand Down Expand Up @@ -161,6 +177,7 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
transitionToManageScreen = {
},
transitionToFormScreen = {
formLauncher?.invoke(it, state.value?.paymentMethodMetadata)
},
paymentMethods = customerStateHolder.paymentMethods,
mostRecentlySelectedSavedPaymentMethod = customerStateHolder.mostRecentlySelectedSavedPaymentMethod,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.stripe.android.paymentelement.embedded

import android.app.Activity
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Text
import com.stripe.android.common.ui.ElementsBottomSheetLayout
import com.stripe.android.uicore.StripeTheme
import com.stripe.android.uicore.elements.bottomsheet.rememberStripeBottomSheetState
import com.stripe.android.uicore.utils.fadeOut

internal class FormActivity : AppCompatActivity() {
private val args: FormContract.Args? by lazy {
FormContract.Args.fromIntent(intent)
}

@OptIn(ExperimentalMaterialApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

if (args == null) {
setFormResult(FormResult.Cancelled)
finish()
return
}

setContent {
StripeTheme {
val bottomSheetState = rememberStripeBottomSheetState()
ElementsBottomSheetLayout(
state = bottomSheetState,
onDismissed = {
setResult(
Activity.RESULT_OK,
FormResult.toIntent(intent, FormResult.Cancelled)
)
finish()
}
) {
args?.selectedPaymentMethodCode?.let { Text(it) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should know it's not null here. Can we store a local val?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has to either have null check or be cast to FormContract.Args as the compiler cannot smart cast

}
}
}
}

override fun finish() {
super.finish()
fadeOut()
}

private fun setFormResult(result: FormResult) {
setResult(
Activity.RESULT_OK,
FormResult.toIntent(intent, result)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.stripe.android.paymentelement.embedded

import android.content.Context
import android.content.Intent
import android.os.Parcelable
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.os.BundleCompat
import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata
import com.stripe.android.paymentsheet.model.PaymentSelection
import com.stripe.android.view.ActivityStarter
import kotlinx.parcelize.Parcelize

internal sealed interface FormResult : Parcelable {

@Parcelize
data class Complete(val selection: PaymentSelection) : FormResult

@Parcelize
object Cancelled : FormResult

companion object {
internal const val EXTRA_RESULT = ActivityStarter.Result.EXTRA

fun toIntent(intent: Intent, result: FormResult): Intent {
return intent.putExtra(EXTRA_RESULT, result)
}

fun fromIntent(intent: Intent?): FormResult {
val result = intent?.extras?.let { bundle ->
BundleCompat.getParcelable(bundle, EXTRA_RESULT, FormResult::class.java)
}

return result ?: Cancelled
}
}
}

internal class FormContract : ActivityResultContract<FormContract.Args, FormResult>() {
override fun createIntent(context: Context, input: Args): Intent {
return Intent(context, FormActivity::class.java)
.putExtra(EXTRA_ARGS, input)
}

override fun parseResult(resultCode: Int, intent: Intent?): FormResult {
return FormResult.fromIntent(intent)
}

@Parcelize
internal data class Args(
val selectedPaymentMethodCode: String,
val paymentMethodMetadata: PaymentMethodMetadata?,
) : Parcelable {
companion object {
fun fromIntent(intent: Intent): Args? {
return intent.extras?.let { bundle ->
BundleCompat.getParcelable(bundle, EXTRA_ARGS, Args::class.java)
}
}
}
}

internal companion object {
internal const val EXTRA_ARGS: String = "extra_activity_args"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.stripe.android.paymentelement.embedded

import android.content.Context
import android.content.res.Resources
import androidx.activity.result.ActivityResultCaller
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
Expand Down Expand Up @@ -143,6 +144,15 @@ internal class SharedPaymentElementViewModel @Inject constructor(
selectionHolder.set(null)
}

fun initEmbeddedActivityLauncher(activityResultCaller: ActivityResultCaller) {
jaynewstrom-stripe marked this conversation as resolved.
Show resolved Hide resolved
val launcher = DefaultEmbeddedActivityLauncher(activityResultCaller, selectionHolder)
embeddedContentHelper.setFormLauncher(launcher.formLauncher)
}

fun clearFormLauncher() {
embeddedContentHelper.clearFormLauncher()
}

class Factory(private val statusBarColor: Int?) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
val component = DaggerSharedPaymentElementViewModelComponent.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import com.stripe.android.model.PaymentMethodFixtures
import com.stripe.android.paymentelement.embedded.DefaultEmbeddedActivityLauncher
import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder
import com.stripe.android.paymentelement.embedded.FormContract
import com.stripe.android.paymentelement.embedded.FormResult
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Captor
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.any
import org.mockito.kotlin.capture
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class EmbeddedActivityLauncherTest {

private lateinit var activityResultCaller: ActivityResultCaller
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We typically write these tests with a scenario (see

) rather than lateinit vars.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

private lateinit var selectionHolder: EmbeddedSelectionHolder
private lateinit var launcher: DefaultEmbeddedActivityLauncher
private lateinit var formActivityLauncher: ActivityResultLauncher<FormContract.Args>

@Captor
private lateinit var contractCallbackCaptor: ArgumentCaptor<ActivityResultCallback<FormResult>>

@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These mocks don't feel like the type of tests we should write. Is it possible to write this with fakes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a task to refactor here

activityResultCaller = mock()
selectionHolder = mock()

formActivityLauncher = mock()

whenever(
activityResultCaller.registerForActivityResult(
any<FormContract>(),
capture(contractCallbackCaptor)
)
).thenReturn(formActivityLauncher)

launcher = DefaultEmbeddedActivityLauncher(activityResultCaller, selectionHolder)
}

@Test
fun `formLauncher launches activity with correct parameters`() {
val code = "test_code"
val expectedArgs = FormContract.Args(code, null)
launcher.formLauncher?.invoke(code, null)
verify(formActivityLauncher).launch(expectedArgs)
}

@Test
fun `formActivityLauncher callback updates selection holder on complete result`() {
val selection = PaymentMethodFixtures.CARD_PAYMENT_SELECTION
val result = FormResult.Complete(PaymentMethodFixtures.CARD_PAYMENT_SELECTION)
contractCallbackCaptor.value.onActivityResult(result)
verify(selectionHolder).set(selection)
}

@Test
fun `formActivityLauncher callback does not update selection holder on non-complete result`() {
val result = FormResult.Cancelled
contractCallbackCaptor.value.onActivityResult(result)
verify(selectionHolder, never()).set(any())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ internal class FakeEmbeddedContentHelper(
)
}

override fun setFormLauncher(
formLauncher: ((code: String, paymentMethodMetaData: PaymentMethodMetadata?) -> Unit)?
) {
// NO-OP
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have these do something, and validate it below.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added functionality to set class property. Do we want to add logic in the validation method since it's not set/clear aren't called during initialization?

}

override fun clearFormLauncher() {
// NO-OP
}

fun validate() {
dataLoadedTurbine.ensureAllEventsConsumed()
}
Expand Down
Loading
Loading