From 1db07966532ed1e5ffed1d954be173e5ddf7b0d2 Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Mon, 3 Feb 2025 10:46:45 +0100 Subject: [PATCH] Add PayToDelegate and PayToComponent COAND-1058 --- .../internal/provider/ComponentProvider.kt | 2 + payto/api/payto.api | 21 +++ payto/build.gradle | 1 + .../adyen/checkout/payto/PayToComponent.kt | 103 ++++++++++++ .../provider/PayToComponentProvider.kt | 16 ++ .../payto/internal/ui/PayToDelegate.kt | 24 +++ .../checkout/payto/PayToComponentTest.kt | 159 ++++++++++++++++++ 7 files changed, 326 insertions(+) create mode 100644 payto/src/main/java/com/adyen/checkout/payto/PayToComponent.kt create mode 100644 payto/src/main/java/com/adyen/checkout/payto/internal/provider/PayToComponentProvider.kt create mode 100644 payto/src/main/java/com/adyen/checkout/payto/internal/ui/PayToDelegate.kt create mode 100644 payto/src/test/java/com/adyen/checkout/payto/PayToComponentTest.kt diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt index 6134622f1b..f60c42434c 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt @@ -439,6 +439,8 @@ internal fun getComponentFor( ) } + // TODO: Add PayTo here when component provider is created + checkCompileOnly { SepaComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { SepaComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, diff --git a/payto/api/payto.api b/payto/api/payto.api index 5912231f5d..018ce247a7 100644 --- a/payto/api/payto.api +++ b/payto/api/payto.api @@ -6,6 +6,24 @@ public final class com/adyen/checkout/payto/BuildConfig { public fun ()V } +public final class com/adyen/checkout/payto/PayToComponent : androidx/lifecycle/ViewModel, com/adyen/checkout/action/core/internal/ActionHandlingComponent, com/adyen/checkout/components/core/internal/ButtonComponent, com/adyen/checkout/components/core/internal/PaymentComponent, com/adyen/checkout/ui/core/internal/ui/ViewableComponent { + public static final field Companion Lcom/adyen/checkout/payto/PayToComponent$Companion; + public static final field PAYMENT_METHOD_TYPES Ljava/util/List; + public static final field PROVIDER Lcom/adyen/checkout/payto/internal/provider/PayToComponentProvider; + public fun canHandleAction (Lcom/adyen/checkout/components/core/action/Action;)Z + public fun getDelegate ()Lcom/adyen/checkout/components/core/internal/ui/ComponentDelegate; + public fun getViewFlow ()Lkotlinx/coroutines/flow/Flow; + public fun handleAction (Lcom/adyen/checkout/components/core/action/Action;Landroid/app/Activity;)V + public fun handleIntent (Landroid/content/Intent;)V + public fun isConfirmationRequired ()Z + public fun setInteractionBlocked (Z)V + public fun setOnRedirectListener (Lkotlin/jvm/functions/Function0;)V + public fun submit ()V +} + +public final class com/adyen/checkout/payto/PayToComponent$Companion { +} + public final class com/adyen/checkout/payto/PayToComponentState : com/adyen/checkout/components/core/PaymentComponentState { public fun (Lcom/adyen/checkout/components/core/PaymentComponentData;ZZ)V public final fun component1 ()Lcom/adyen/checkout/components/core/PaymentComponentData; @@ -22,3 +40,6 @@ public final class com/adyen/checkout/payto/PayToComponentState : com/adyen/chec public fun toString ()Ljava/lang/String; } +public final class com/adyen/checkout/payto/internal/provider/PayToComponentProvider { +} + diff --git a/payto/build.gradle b/payto/build.gradle index 017c9ef446..54140a37c9 100644 --- a/payto/build.gradle +++ b/payto/build.gradle @@ -50,4 +50,5 @@ dependencies { testImplementation testFixtures(project(':ui-core')) testImplementation libs.bundles.junit testImplementation libs.bundles.kotlin.coroutines.test + testImplementation libs.bundles.mockito } diff --git a/payto/src/main/java/com/adyen/checkout/payto/PayToComponent.kt b/payto/src/main/java/com/adyen/checkout/payto/PayToComponent.kt new file mode 100644 index 0000000000..0ab4cd9c3a --- /dev/null +++ b/payto/src/main/java/com/adyen/checkout/payto/PayToComponent.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by ararat on 3/2/2025. + */ + +package com.adyen.checkout.payto + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adyen.checkout.action.core.internal.ActionHandlingComponent +import com.adyen.checkout.action.core.internal.DefaultActionHandlingComponent +import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate +import com.adyen.checkout.components.core.PaymentMethodTypes +import com.adyen.checkout.components.core.internal.ButtonComponent +import com.adyen.checkout.components.core.internal.ComponentEventHandler +import com.adyen.checkout.components.core.internal.PaymentComponent +import com.adyen.checkout.components.core.internal.PaymentComponentEvent +import com.adyen.checkout.components.core.internal.toActionCallback +import com.adyen.checkout.components.core.internal.ui.ComponentDelegate +import com.adyen.checkout.core.AdyenLogLevel +import com.adyen.checkout.core.internal.util.adyenLog +import com.adyen.checkout.payto.internal.provider.PayToComponentProvider +import com.adyen.checkout.payto.internal.ui.PayToDelegate +import com.adyen.checkout.ui.core.internal.ui.ButtonDelegate +import com.adyen.checkout.ui.core.internal.ui.ComponentViewType +import com.adyen.checkout.ui.core.internal.ui.ViewableComponent +import com.adyen.checkout.ui.core.internal.util.mergeViewFlows +import kotlinx.coroutines.flow.Flow + +/** + * A [PaymentComponent] that supports the [PaymentMethodTypes.PAY_TO] payment method. + */ +class PayToComponent internal constructor( + private val payToDelegate: PayToDelegate, + private val genericActionDelegate: GenericActionDelegate, + private val actionHandlingComponent: DefaultActionHandlingComponent, + internal val componentEventHandler: ComponentEventHandler, +) : ViewModel(), + PaymentComponent, + ViewableComponent, + ButtonComponent, + ActionHandlingComponent by actionHandlingComponent { + + override val delegate: ComponentDelegate get() = actionHandlingComponent.activeDelegate + + override val viewFlow: Flow = mergeViewFlows( + viewModelScope, + payToDelegate.viewFlow, + genericActionDelegate.viewFlow, + ) + + init { + payToDelegate.initialize(viewModelScope) + genericActionDelegate.initialize(viewModelScope) + componentEventHandler.initialize(viewModelScope) + } + + internal fun observe( + lifecycleOwner: LifecycleOwner, + callback: (PaymentComponentEvent) -> Unit + ) { + payToDelegate.observe(lifecycleOwner, viewModelScope, callback) + genericActionDelegate.observe(lifecycleOwner, viewModelScope, callback.toActionCallback()) + } + + internal fun removeObserver() { + payToDelegate.removeObserver() + genericActionDelegate.removeObserver() + } + + override fun isConfirmationRequired(): Boolean = payToDelegate.isConfirmationRequired() + + override fun submit() { + (delegate as? ButtonDelegate)?.onSubmit() + ?: adyenLog(AdyenLogLevel.ERROR) { "Component is currently not submittable, ignoring." } + } + + override fun setInteractionBlocked(isInteractionBlocked: Boolean) { + (delegate as? PayToDelegate)?.setInteractionBlocked(isInteractionBlocked) + ?: adyenLog(AdyenLogLevel.ERROR) { "Payment component is not interactable, ignoring." } + } + + override fun onCleared() { + super.onCleared() + adyenLog(AdyenLogLevel.DEBUG) { "onCleared" } + payToDelegate.onCleared() + genericActionDelegate.onCleared() + componentEventHandler.onCleared() + } + + companion object { + + @JvmField + val PROVIDER = PayToComponentProvider() + + @JvmField + val PAYMENT_METHOD_TYPES = listOf(PaymentMethodTypes.PAY_TO) + } +} diff --git a/payto/src/main/java/com/adyen/checkout/payto/internal/provider/PayToComponentProvider.kt b/payto/src/main/java/com/adyen/checkout/payto/internal/provider/PayToComponentProvider.kt new file mode 100644 index 0000000000..b07d178a0a --- /dev/null +++ b/payto/src/main/java/com/adyen/checkout/payto/internal/provider/PayToComponentProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by ararat on 3/2/2025. + */ + +package com.adyen.checkout.payto.internal.provider + +import androidx.annotation.RestrictTo + +// TODO To be implemented +class PayToComponentProvider +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +constructor() diff --git a/payto/src/main/java/com/adyen/checkout/payto/internal/ui/PayToDelegate.kt b/payto/src/main/java/com/adyen/checkout/payto/internal/ui/PayToDelegate.kt new file mode 100644 index 0000000000..957247f104 --- /dev/null +++ b/payto/src/main/java/com/adyen/checkout/payto/internal/ui/PayToDelegate.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by ararat on 3/2/2025. + */ + +package com.adyen.checkout.payto.internal.ui + +import com.adyen.checkout.components.core.internal.ui.PaymentComponentDelegate +import com.adyen.checkout.payto.PayToComponentState +import com.adyen.checkout.ui.core.internal.ui.ButtonDelegate +import com.adyen.checkout.ui.core.internal.ui.UIStateDelegate +import com.adyen.checkout.ui.core.internal.ui.ViewProvidingDelegate + +internal interface PayToDelegate : + PaymentComponentDelegate, + ViewProvidingDelegate, + ButtonDelegate, + UIStateDelegate { + + fun setInteractionBlocked(isInteractionBlocked: Boolean) +} diff --git a/payto/src/test/java/com/adyen/checkout/payto/PayToComponentTest.kt b/payto/src/test/java/com/adyen/checkout/payto/PayToComponentTest.kt new file mode 100644 index 0000000000..ec182eb1fc --- /dev/null +++ b/payto/src/test/java/com/adyen/checkout/payto/PayToComponentTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by ararat on 3/2/2025. + */ + +package com.adyen.checkout.payto + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import com.adyen.checkout.action.core.internal.DefaultActionHandlingComponent +import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate +import com.adyen.checkout.components.core.internal.ComponentEventHandler +import com.adyen.checkout.components.core.internal.PaymentComponentEvent +import com.adyen.checkout.payto.internal.ui.PayToComponentViewType +import com.adyen.checkout.payto.internal.ui.PayToDelegate +import com.adyen.checkout.test.LoggingExtension +import com.adyen.checkout.test.TestDispatcherExtension +import com.adyen.checkout.test.extensions.invokeOnCleared +import com.adyen.checkout.ui.core.internal.ui.TestComponentViewType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExtendWith(MockitoExtension::class, TestDispatcherExtension::class, LoggingExtension::class) +internal class PayToComponentTest( + @Mock private val payToDelegate: PayToDelegate, + @Mock private val genericActionDelegate: GenericActionDelegate, + @Mock private val actionHandlingComponent: DefaultActionHandlingComponent, + @Mock private val componentEventHandler: ComponentEventHandler, +) { + + private lateinit var component: PayToComponent + + @BeforeEach + fun before() { + whenever(payToDelegate.viewFlow) doReturn MutableStateFlow(PayToComponentViewType) + whenever(genericActionDelegate.viewFlow) doReturn MutableStateFlow(null) + + component = PayToComponent( + payToDelegate, + genericActionDelegate, + actionHandlingComponent, + componentEventHandler, + ) + } + + @Test + fun `when component is created, then delegates are initialized`() { + verify(payToDelegate).initialize(component.viewModelScope) + verify(genericActionDelegate).initialize(component.viewModelScope) + verify(componentEventHandler).initialize(component.viewModelScope) + } + + @Test + fun `when component is cleared, then delegates are cleared`() { + component.invokeOnCleared() + + verify(payToDelegate).onCleared() + verify(genericActionDelegate).onCleared() + verify(componentEventHandler).onCleared() + } + + @Test + fun `when observe is called, then observe in delegates is called`() { + val lifecycleOwner = mock() + val callback: (PaymentComponentEvent) -> Unit = {} + + component.observe(lifecycleOwner, callback) + + verify(payToDelegate).observe(lifecycleOwner, component.viewModelScope, callback) + verify(genericActionDelegate).observe(eq(lifecycleOwner), eq(component.viewModelScope), any()) + } + + @Test + fun `when removeObserver is called, then removeObserver in delegates is called`() { + component.removeObserver() + + verify(payToDelegate).removeObserver() + verify(genericActionDelegate).removeObserver() + } + + @Test + fun `when component is initialized, then view flow should match delegate view flow`() = runTest { + component.viewFlow.test { + assertEquals(PayToComponentViewType, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `when delegate view flow emits a value, then component view flow should match that value`() = runTest { + val payToDelegateViewFlow = MutableStateFlow(TestComponentViewType.VIEW_TYPE_1) + whenever(payToDelegate.viewFlow) doReturn payToDelegateViewFlow + component = PayToComponent(payToDelegate, genericActionDelegate, actionHandlingComponent, componentEventHandler) + + component.viewFlow.test { + assertEquals(TestComponentViewType.VIEW_TYPE_1, awaitItem()) + + payToDelegateViewFlow.emit(TestComponentViewType.VIEW_TYPE_2) + assertEquals(TestComponentViewType.VIEW_TYPE_2, awaitItem()) + + expectNoEvents() + } + } + + @Test + fun `when action delegate view flow emits a value, then component view flow should match that value`() = runTest { + val actionDelegateViewFlow = MutableStateFlow(TestComponentViewType.VIEW_TYPE_1) + whenever(genericActionDelegate.viewFlow) doReturn actionDelegateViewFlow + component = PayToComponent(payToDelegate, genericActionDelegate, actionHandlingComponent, componentEventHandler) + + component.viewFlow.test { + // this value should match the value of the main delegate and not the action delegate + // and in practice the initial value of the action delegate view flow is always null so it should be ignored + assertEquals(PayToComponentViewType, awaitItem()) + + actionDelegateViewFlow.emit(TestComponentViewType.VIEW_TYPE_2) + assertEquals(TestComponentViewType.VIEW_TYPE_2, awaitItem()) + + expectNoEvents() + } + } + + @Test + fun `when isConfirmationRequired, then delegate is called`() { + component.isConfirmationRequired() + verify(payToDelegate).isConfirmationRequired() + } + + @Test + fun `when submit is called and active delegate is the payment delegate, then delegate onSubmit is called`() { + whenever(component.delegate).thenReturn(payToDelegate) + component.submit() + verify(payToDelegate).onSubmit() + } + + @Test + fun `when submit is called and active delegate is the action delegate, then delegate onSubmit is not called`() { + whenever(component.delegate).thenReturn(genericActionDelegate) + component.submit() + verify(payToDelegate, never()).onSubmit() + } +}