From de11873fd437384a170c56bf9b6e0a38bd81512e Mon Sep 17 00:00:00 2001 From: jreij Date: Thu, 17 Aug 2023 14:03:04 +0300 Subject: [PATCH 01/66] Combine flows in MainViewModel to improve view state updating COAND-788 --- .../checkout/example/ui/main/MainViewModel.kt | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt index 001f068f0d..be87f6b699 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt @@ -30,8 +30,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import javax.inject.Inject @@ -44,28 +43,37 @@ internal class MainViewModel @Inject constructor( private val checkoutConfigurationProvider: CheckoutConfigurationProvider, ) : ViewModel() { + private val lifecycleResumed: MutableSharedFlow = MutableSharedFlow() private val useSessions: MutableStateFlow = MutableStateFlow(keyValueStorage.useSessions()) private val showLoading: MutableStateFlow = MutableStateFlow(false) - private val _mainViewState: MutableStateFlow = MutableStateFlow(getViewState()) + private val _mainViewState: MutableStateFlow = MutableStateFlow(getInitialViewState()) val mainViewState: Flow = _mainViewState private val _eventFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) val eventFlow: Flow = _eventFlow init { - useSessions.onEach { - loadViewState() - }.launchIn(viewModelScope) + viewModelScope.launch { + combineViewStateFlows() + } + } - showLoading.onEach { - loadViewState() - }.launchIn(viewModelScope) + private suspend fun combineViewStateFlows() { + combine( + lifecycleResumed, + useSessions, + showLoading, + ) { _, useSessions, showLoading -> + getViewState(useSessions, showLoading) + }.collect { + loadViewState(it) + } } internal fun onResume() { viewModelScope.launch { - loadViewState() + lifecycleResumed.emit(Unit) } } @@ -203,13 +211,16 @@ internal class MainViewModel @Inject constructor( showLoading.emit(loading) } - private suspend fun loadViewState() { - _mainViewState.emit(getViewState()) - } - - private fun getViewState(): MainViewState { + private fun getInitialViewState(): MainViewState { val useSessions = useSessions.value val showLoading = showLoading.value + return getViewState(useSessions, showLoading) + } + + private fun getViewState( + useSessions: Boolean, + showLoading: Boolean, + ): MainViewState { return MainViewState( listItems = getListItems(useSessions), useSessions = useSessions, @@ -217,6 +228,10 @@ internal class MainViewModel @Inject constructor( ) } + private suspend fun loadViewState(mainViewState: MainViewState) { + _mainViewState.emit(mainViewState) + } + private fun getListItems(useSessions: Boolean): List { return if (useSessions) { ComponentItemProvider.getSessionItems() From 9115f429bb636332c612f0379c35be129e0f3557 Mon Sep 17 00:00:00 2001 From: jreij Date: Thu, 17 Aug 2023 17:24:20 +0300 Subject: [PATCH 02/66] Add entry for Google Pay in example app COAND-788 --- .../checkout/example/ui/bacs/BacsFragment.kt | 3 ++- .../example/ui/googlepay/GooglePayFragment.kt | 27 +++++++++++++++++++ .../ui/googlepay/GooglePayViewModel.kt | 11 ++++++++ .../checkout/example/ui/main/ComponentItem.kt | 1 + .../example/ui/main/ComponentItemProvider.kt | 1 + .../checkout/example/ui/main/MainActivity.kt | 3 +++ .../example/ui/main/MainNavigation.kt | 2 ++ .../checkout/example/ui/main/MainViewModel.kt | 2 ++ example-app/src/main/res/values/strings.xml | 1 + 9 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt index 9da64a0fea..a7836fbcc0 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.adyen.checkout.bacs.BacsDirectDebitComponent import com.adyen.checkout.example.databinding.FragmentBacsBinding +import com.adyen.checkout.example.extensions.getLogTag import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider import com.adyen.checkout.example.ui.instant.InstantFragment import com.adyen.checkout.redirect.RedirectComponent @@ -144,7 +145,7 @@ class BacsFragment : BottomSheetDialogFragment() { companion object { - private const val TAG = "BacsFragment" + private val TAG = getLogTag() internal const val RETURN_URL_EXTRA = "RETURN_URL_EXTRA" diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt new file mode 100644 index 0000000000..1eeb7f4a68 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 17/8/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +import androidx.fragment.app.FragmentManager +import com.adyen.checkout.example.extensions.getLogTag +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class GooglePayFragment : BottomSheetDialogFragment() { + + companion object { + + private val TAG = getLogTag() + + fun show(fragmentManager: FragmentManager) { + GooglePayFragment().show(fragmentManager, TAG) + } + } +} diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt new file mode 100644 index 0000000000..04551e506e --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 17/8/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +internal class GooglePayViewModel diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt index 6eb6a1a441..5a6e4e929a 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt @@ -31,5 +31,6 @@ internal sealed class ComponentItem { object CardWithSessionTakenOver : Entry(R.string.card_component_with_session_taken_over_entry) object GiftCard : Entry(R.string.gift_card_component_entry) object GiftCardWithSession : Entry(R.string.gift_card_with_session_component_entry) + object GooglePay : Entry(R.string.google_pay_component_entry) } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt index ff3d377acd..c49a179a0f 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt @@ -12,6 +12,7 @@ internal object ComponentItemProvider { ComponentItem.Entry.Blik, ComponentItem.Entry.Card, ComponentItem.Entry.GiftCard, + ComponentItem.Entry.GooglePay, ComponentItem.Entry.Klarna, ComponentItem.Entry.PayPal, ComponentItem.Entry.Instant(instantPaymentMethodType), diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt index e733e6fa24..91cd87368d 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt @@ -35,6 +35,7 @@ import com.adyen.checkout.example.ui.card.compose.SessionsCardActivity import com.adyen.checkout.example.ui.configuration.ConfigurationActivity import com.adyen.checkout.example.ui.giftcard.GiftCardActivity import com.adyen.checkout.example.ui.giftcard.SessionsGiftCardActivity +import com.adyen.checkout.example.ui.googlepay.GooglePayFragment import com.adyen.checkout.example.ui.instant.InstantFragment import com.adyen.checkout.redirect.RedirectComponent import dagger.hilt.android.AndroidEntryPoint @@ -200,6 +201,8 @@ class MainActivity : AppCompatActivity() { is MainNavigation.Instant -> { InstantFragment.show(supportFragmentManager, navigation.paymentMethodType) } + + is MainNavigation.GooglePay -> GooglePayFragment.show(supportFragmentManager) } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt index 94891df843..fe789f7cf8 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt @@ -30,6 +30,8 @@ internal sealed class MainNavigation { object CardWithSessionTakenOver : MainNavigation() + object GooglePay : MainNavigation() + data class DropIn( val paymentMethodsApiResponse: PaymentMethodsApiResponse, val dropInConfiguration: DropInConfiguration diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt index be87f6b699..3d2a3aa04a 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt @@ -77,6 +77,7 @@ internal class MainViewModel @Inject constructor( } } + @Suppress("CyclomaticComplexMethod") fun onComponentEntryClick(entry: ComponentItem.Entry) { when (entry) { is ComponentItem.Entry.Bacs -> _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.Bacs)) @@ -104,6 +105,7 @@ internal class MainViewModel @Inject constructor( is ComponentItem.Entry.GiftCardWithSession -> _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.GiftCardWithSession)) + is ComponentItem.Entry.GooglePay -> _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.GooglePay)) is ComponentItem.Entry.DropIn -> startDropInFlow() is ComponentItem.Entry.DropInWithSession -> startSessionDropInFlow(false) is ComponentItem.Entry.DropInWithCustomSession -> startSessionDropInFlow(true) diff --git a/example-app/src/main/res/values/strings.xml b/example-app/src/main/res/values/strings.xml index 16fe881743..38d07c13cd 100644 --- a/example-app/src/main/res/values/strings.xml +++ b/example-app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Klarna PayPal Instant (%s) + Google Pay Use sessions From f7ed56258678b752517286490d89ef7afb71048d Mon Sep 17 00:00:00 2001 From: josephj Date: Fri, 20 Oct 2023 16:52:08 +0200 Subject: [PATCH 03/66] Check google pay availability COAND-788 --- .../checkout/example/ui/bacs/BacsFragment.kt | 2 +- .../CheckoutConfigurationProvider.kt | 2 +- .../ui/googlepay/GooglePayComponentData.kt | 18 +++ .../example/ui/googlepay/GooglePayEvent.kt | 25 +++ .../example/ui/googlepay/GooglePayFragment.kt | 137 ++++++++++++++++ .../ui/googlepay/GooglePayViewModel.kt | 149 +++++++++++++++++- .../ui/googlepay/GooglePayViewState.kt | 22 +++ .../main/res/layout/fragment_google_pay.xml | 60 +++++++ example-app/src/main/res/values/strings.xml | 1 + 9 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewState.kt create mode 100644 example-app/src/main/res/layout/fragment_google_pay.xml diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt index a7836fbcc0..0f169c2d68 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt @@ -50,7 +50,7 @@ class BacsFragment : BottomSheetDialogFragment() { // Insert return url in extras, so we can access it in the ViewModel through SavedStateHandle val returnUrl = RedirectComponent.getReturnUrl(requireActivity().applicationContext) arguments = (arguments ?: bundleOf()).apply { - putString(InstantFragment.RETURN_URL_EXTRA, returnUrl) + putString(RETURN_URL_EXTRA, returnUrl) } _binding = FragmentBacsBinding.inflate(inflater) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/configuration/CheckoutConfigurationProvider.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/configuration/CheckoutConfigurationProvider.kt index 50202b5b94..db8117843b 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/configuration/CheckoutConfigurationProvider.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/configuration/CheckoutConfigurationProvider.kt @@ -129,7 +129,7 @@ internal class CheckoutConfigurationProvider @Inject constructor( .setAnalyticsConfiguration(getAnalyticsConfiguration()) .build() - private fun getGooglePayConfiguration(): GooglePayConfiguration = + fun getGooglePayConfiguration(): GooglePayConfiguration = GooglePayConfiguration.Builder(shopperLocale, environment, clientKey) .setCountryCode(keyValueStorage.getCountry()) .setAmount(amount) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt new file mode 100644 index 0000000000..bdb8564c84 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 17/8/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +import com.adyen.checkout.components.core.ComponentCallback +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.googlepay.GooglePayComponentState + +internal data class GooglePayComponentData( + val paymentMethod: PaymentMethod, + val callback: ComponentCallback, +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt new file mode 100644 index 0000000000..e19475caa4 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 17/8/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +import com.adyen.checkout.components.core.ComponentAvailableCallback +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.action.Action + +internal sealed class GooglePayEvent { + + data class CheckAvailability( + val paymentMethod: PaymentMethod, + val callback: ComponentAvailableCallback + ) : GooglePayEvent() + + data class PaymentResult(val result: String) : GooglePayEvent() + + data class AdditionalAction(val action: Action) : GooglePayEvent() +} diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt index 1eeb7f4a68..50c0b3b0a8 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -8,18 +8,155 @@ package com.adyen.checkout.example.ui.googlepay +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.adyen.checkout.components.core.ComponentAvailableCallback +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.internal.util.requireApplication +import com.adyen.checkout.example.databinding.FragmentGooglePayBinding import com.adyen.checkout.example.extensions.getLogTag +import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider +import com.adyen.checkout.googlepay.GooglePayComponent +import com.adyen.checkout.redirect.RedirectComponent import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + @AndroidEntryPoint class GooglePayFragment : BottomSheetDialogFragment() { + @Inject + internal lateinit var checkoutConfigurationProvider: CheckoutConfigurationProvider + + private var _binding: FragmentGooglePayBinding? = null + private val binding: FragmentGooglePayBinding get() = requireNotNull(_binding) + + private val viewModel: GooglePayViewModel by viewModels() + + private var googlePayComponent: GooglePayComponent? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + // Insert return url in extras, so we can access it in the ViewModel through SavedStateHandle + val returnUrl = RedirectComponent.getReturnUrl(requireActivity().applicationContext) + arguments = (arguments ?: bundleOf()).apply { + putString(RETURN_URL_EXTRA, returnUrl) + } + + _binding = FragmentGooglePayBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.googleComponentDataFlow + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { setupGooglePayComponent(it) } + .launchIn(lifecycleScope) + + viewModel.viewState + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { onViewState(it) } + .launchIn(lifecycleScope) + + viewModel.events + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { onEvent(it) } + .launchIn(lifecycleScope) + } + + private fun setupGooglePayComponent(googlePayComponentData: GooglePayComponentData) { + val googlePayComponent = GooglePayComponent.PROVIDER.get( + fragment = this, + paymentMethod = googlePayComponentData.paymentMethod, + configuration = checkoutConfigurationProvider.getGooglePayConfiguration(), + callback = googlePayComponentData.callback + ) + + this.googlePayComponent = googlePayComponent + + binding.componentView.attach(googlePayComponent, viewLifecycleOwner) + } + + private fun onViewState(state: GooglePayViewState) { + when (state) { + is GooglePayViewState.Error -> { + binding.errorView.isVisible = true + binding.errorView.text = getString(state.message) + binding.componentView.isVisible = false + binding.progressIndicator.isVisible = false + binding.googlePayButton.isVisible = false + } + + GooglePayViewState.Loading -> { + binding.errorView.isVisible = false + binding.componentView.isVisible = false + binding.progressIndicator.isVisible = true + binding.googlePayButton.isVisible = false + } + + GooglePayViewState.ShowButton -> { + binding.errorView.isVisible = false + binding.componentView.isVisible = false + binding.progressIndicator.isVisible = false + binding.googlePayButton.isVisible = true + } + + GooglePayViewState.ShowComponent -> { + binding.errorView.isVisible = false + binding.componentView.isVisible = true + binding.progressIndicator.isVisible = false + binding.googlePayButton.isVisible = false + } + } + } + + private fun onEvent(event: GooglePayEvent) { + when (event) { + is GooglePayEvent.CheckAvailability -> checkAvailability(event.paymentMethod, event.callback) + is GooglePayEvent.AdditionalAction -> googlePayComponent?.handleAction(event.action, requireActivity()) + is GooglePayEvent.PaymentResult -> onPaymentResult(event.result) + } + } + + private fun checkAvailability(paymentMethod: PaymentMethod, callback: ComponentAvailableCallback) { + GooglePayComponent.PROVIDER.isAvailable( + applicationContext = requireApplication(), + paymentMethod = paymentMethod, + configuration = checkoutConfigurationProvider.getGooglePayConfiguration(), + callback = callback, + ) + } + + private fun onPaymentResult(result: String) { + Toast.makeText(requireContext(), result, Toast.LENGTH_SHORT).show() + dismiss() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + googlePayComponent = null + } + companion object { private val TAG = getLogTag() + internal const val RETURN_URL_EXTRA = "RETURN_URL_EXTRA" + fun show(fragmentManager: FragmentManager) { GooglePayFragment().show(fragmentManager, TAG) } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt index 04551e506e..0c59ab2c85 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -8,4 +8,151 @@ package com.adyen.checkout.example.ui.googlepay -internal class GooglePayViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adyen.checkout.components.core.ActionComponentData +import com.adyen.checkout.components.core.ComponentAvailableCallback +import com.adyen.checkout.components.core.ComponentCallback +import com.adyen.checkout.components.core.ComponentError +import com.adyen.checkout.components.core.PaymentComponentData +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.example.R +import com.adyen.checkout.example.data.storage.KeyValueStorage +import com.adyen.checkout.example.repositories.PaymentsRepository +import com.adyen.checkout.example.service.createPaymentRequest +import com.adyen.checkout.example.service.getPaymentMethodRequest +import com.adyen.checkout.googlepay.GooglePayComponent +import com.adyen.checkout.googlepay.GooglePayComponentState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import javax.inject.Inject + +@HiltViewModel +internal class GooglePayViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val paymentsRepository: PaymentsRepository, + private val keyValueStorage: KeyValueStorage, +) : ViewModel(), + ComponentCallback, + ComponentAvailableCallback { + + private val _googleComponentDataFlow = MutableStateFlow(null) + val googleComponentDataFlow: Flow = _googleComponentDataFlow.filterNotNull() + + private val _viewState = MutableStateFlow(GooglePayViewState.Loading) + val viewState: Flow = _viewState + + private val _events = MutableSharedFlow() + val events: Flow = _events + + init { + viewModelScope.launch { fetchPaymentMethods() } + } + + private suspend fun fetchPaymentMethods() = withContext(Dispatchers.IO) { + val paymentMethodResponse = paymentsRepository.getPaymentMethods( + getPaymentMethodRequest( + merchantAccount = keyValueStorage.getMerchantAccount(), + shopperReference = keyValueStorage.getShopperReference(), + amount = keyValueStorage.getAmount(), + countryCode = keyValueStorage.getCountry(), + shopperLocale = keyValueStorage.getShopperLocale(), + splitCardFundingSources = keyValueStorage.isSplitCardFundingSources(), + ) + ) + + val paymentMethod = paymentMethodResponse + ?.paymentMethods + ?.firstOrNull { GooglePayComponent.PROVIDER.isPaymentMethodSupported(it) } + + if (paymentMethod == null) { + _viewState.emit(GooglePayViewState.Error(R.string.error_dialog_title)) + } else { + _events.emit(GooglePayEvent.CheckAvailability(paymentMethod, this@GooglePayViewModel)) + } + } + + override fun onAvailabilityResult(isAvailable: Boolean, paymentMethod: PaymentMethod) { + viewModelScope.launch { + if (isAvailable) { + _viewState.emit(GooglePayViewState.ShowButton) + } else { + _viewState.emit(GooglePayViewState.Error(R.string.google_pay_unavailable_error)) + } + } + } + + override fun onSubmit(state: GooglePayComponentState) { + makePayment(state.data) + } + + override fun onAdditionalDetails(actionComponentData: ActionComponentData) { + sendPaymentDetails(actionComponentData) + } + + override fun onError(componentError: ComponentError) { + onComponentError(componentError) + } + + private fun onComponentError(error: ComponentError) { + viewModelScope.launch { _events.emit(GooglePayEvent.PaymentResult("Failed: ${error.errorMessage}")) } + } + + private fun sendPaymentDetails(actionComponentData: ActionComponentData) { + viewModelScope.launch(Dispatchers.IO) { + val json = ActionComponentData.SERIALIZER.serialize(actionComponentData) + handlePaymentResponse(paymentsRepository.makeDetailsRequest(json)) + } + } + + private suspend fun handlePaymentResponse(json: JSONObject?) { + json?.let { + when { + json.has("action") -> { + val action = Action.SERIALIZER.deserialize(json.getJSONObject("action")) + handleAction(action) + } + + else -> _events.emit(GooglePayEvent.PaymentResult("Finished: ${json.optString("resultCode")}")) + } + } ?: _events.emit(GooglePayEvent.PaymentResult("Failed")) + } + + private suspend fun handleAction(action: Action) { + _viewState.emit(GooglePayViewState.ShowComponent) + _events.emit(GooglePayEvent.AdditionalAction(action)) + } + + private fun makePayment(data: PaymentComponentData<*>) { + _viewState.value = GooglePayViewState.Loading + + val paymentComponentData = PaymentComponentData.SERIALIZER.serialize(data) + + viewModelScope.launch(Dispatchers.IO) { + val paymentRequest = createPaymentRequest( + paymentComponentData = paymentComponentData, + shopperReference = keyValueStorage.getShopperReference(), + amount = keyValueStorage.getAmount(), + countryCode = keyValueStorage.getCountry(), + merchantAccount = keyValueStorage.getMerchantAccount(), + redirectUrl = savedStateHandle.get(GooglePayFragment.RETURN_URL_EXTRA) + ?: error("Return url should be set"), + isThreeds2Enabled = keyValueStorage.isThreeds2Enabled(), + isExecuteThreeD = keyValueStorage.isExecuteThreeD(), + shopperEmail = keyValueStorage.getShopperEmail(), + ) + + handlePaymentResponse(paymentsRepository.makePaymentsRequest(paymentRequest)) + } + } +} + diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewState.kt new file mode 100644 index 0000000000..d9bceb0940 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 17/8/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +import androidx.annotation.StringRes + +internal sealed class GooglePayViewState { + + data object Loading : GooglePayViewState() + + data object ShowButton : GooglePayViewState() + + data object ShowComponent : GooglePayViewState() + + data class Error(@StringRes val message: Int) : GooglePayViewState() +} diff --git a/example-app/src/main/res/layout/fragment_google_pay.xml b/example-app/src/main/res/layout/fragment_google_pay.xml new file mode 100644 index 0000000000..1f7d402dc2 --- /dev/null +++ b/example-app/src/main/res/layout/fragment_google_pay.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + diff --git a/example-app/src/main/res/values/strings.xml b/example-app/src/main/res/values/strings.xml index 38d07c13cd..1f4a1d195f 100644 --- a/example-app/src/main/res/values/strings.xml +++ b/example-app/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Your country code must be %s Your currency code must be %s + Google Pay is unavailable on this device Blik Waiting for approval From 0fc4a1db3f58f1edfb54022cffd0897096beb531 Mon Sep 17 00:00:00 2001 From: josephj Date: Fri, 20 Oct 2023 17:01:09 +0200 Subject: [PATCH 04/66] Launch google pay on button click COAND-788 --- .../example/ui/googlepay/GooglePayFragment.kt | 19 ++++++++++++++++++- .../ui/googlepay/GooglePayViewModel.kt | 3 +++ .../checkout/example/ui/main/MainActivity.kt | 8 ++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt index 50c0b3b0a8..9f345ce4a4 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -8,6 +8,7 @@ package com.adyen.checkout.example.ui.googlepay +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -75,6 +76,8 @@ class GooglePayFragment : BottomSheetDialogFragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .onEach { onEvent(it) } .launchIn(lifecycleScope) + + loadGooglePayButton() } private fun setupGooglePayComponent(googlePayComponentData: GooglePayComponentData) { @@ -145,6 +148,19 @@ class GooglePayFragment : BottomSheetDialogFragment() { dismiss() } + private fun loadGooglePayButton() { + binding.googlePayButton.setOnClickListener { + googlePayComponent?.startGooglePayScreen(requireActivity(), ACTIVITY_RESULT_CODE) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == ACTIVITY_RESULT_CODE) { + googlePayComponent?.handleActivityResult(resultCode, data) + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null @@ -153,9 +169,10 @@ class GooglePayFragment : BottomSheetDialogFragment() { companion object { - private val TAG = getLogTag() + internal val TAG = getLogTag() internal const val RETURN_URL_EXTRA = "RETURN_URL_EXTRA" + internal const val ACTIVITY_RESULT_CODE = 1 fun show(fragmentManager: FragmentManager) { GooglePayFragment().show(fragmentManager, TAG) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt index 0c59ab2c85..568c1d62d1 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -78,6 +78,9 @@ internal class GooglePayViewModel @Inject constructor( _viewState.emit(GooglePayViewState.Error(R.string.error_dialog_title)) } else { _events.emit(GooglePayEvent.CheckAvailability(paymentMethod, this@GooglePayViewModel)) + _googleComponentDataFlow.emit( + GooglePayComponentData(paymentMethod, this@GooglePayViewModel) + ) } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt index 91cd87368d..37e39f7049 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt @@ -218,6 +218,14 @@ class MainActivity : AppCompatActivity() { binding.switchSessions.isChecked = isChecked } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == GooglePayFragment.ACTIVITY_RESULT_CODE) { + (supportFragmentManager.findFragmentByTag(GooglePayFragment.TAG) as? GooglePayFragment) + ?.onActivityResult(requestCode, resultCode, data) + } + } + override fun onDestroy() { super.onDestroy() componentItemAdapter = null From b01a465e29c63deaf05f030513cd22500cf0ca10 Mon Sep 17 00:00:00 2001 From: josephj Date: Wed, 6 Dec 2023 12:12:42 +0100 Subject: [PATCH 05/66] Integrate the Google Pay button COAND-788 --- .../checkout/example/ui/bacs/BacsFragment.kt | 1 - .../example/ui/googlepay/GooglePayFragment.kt | 22 ++++++++++++++----- .../ui/googlepay/GooglePayViewModel.kt | 1 - .../main/res/layout/fragment_google_pay.xml | 7 +++--- .../src/main/res/values-night/styles.xml | 14 ++++++++++++ example-app/src/main/res/values/styles.xml | 4 ++++ 6 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 example-app/src/main/res/values-night/styles.xml diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt index 0f169c2d68..ca01899b46 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsFragment.kt @@ -24,7 +24,6 @@ import com.adyen.checkout.bacs.BacsDirectDebitComponent import com.adyen.checkout.example.databinding.FragmentBacsBinding import com.adyen.checkout.example.extensions.getLogTag import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider -import com.adyen.checkout.example.ui.instant.InstantFragment import com.adyen.checkout.redirect.RedirectComponent import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt index 9f345ce4a4..afa9d1d77a 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -22,19 +22,19 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.adyen.checkout.components.core.ComponentAvailableCallback import com.adyen.checkout.components.core.PaymentMethod -import com.adyen.checkout.components.core.internal.util.requireApplication import com.adyen.checkout.example.databinding.FragmentGooglePayBinding import com.adyen.checkout.example.extensions.getLogTag import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider import com.adyen.checkout.googlepay.GooglePayComponent import com.adyen.checkout.redirect.RedirectComponent +import com.google.android.gms.wallet.button.ButtonConstants.ButtonType +import com.google.android.gms.wallet.button.ButtonOptions import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject - @AndroidEntryPoint class GooglePayFragment : BottomSheetDialogFragment() { @@ -76,8 +76,6 @@ class GooglePayFragment : BottomSheetDialogFragment() { .flowWithLifecycle(viewLifecycleOwner.lifecycle) .onEach { onEvent(it) } .launchIn(lifecycleScope) - - loadGooglePayButton() } private fun setupGooglePayComponent(googlePayComponentData: GooglePayComponentData) { @@ -85,12 +83,14 @@ class GooglePayFragment : BottomSheetDialogFragment() { fragment = this, paymentMethod = googlePayComponentData.paymentMethod, configuration = checkoutConfigurationProvider.getGooglePayConfiguration(), - callback = googlePayComponentData.callback + callback = googlePayComponentData.callback, ) this.googlePayComponent = googlePayComponent binding.componentView.attach(googlePayComponent, viewLifecycleOwner) + + loadGooglePayButton() } private fun onViewState(state: GooglePayViewState) { @@ -136,7 +136,7 @@ class GooglePayFragment : BottomSheetDialogFragment() { private fun checkAvailability(paymentMethod: PaymentMethod, callback: ComponentAvailableCallback) { GooglePayComponent.PROVIDER.isAvailable( - applicationContext = requireApplication(), + applicationContext = requireActivity().application, paymentMethod = paymentMethod, configuration = checkoutConfigurationProvider.getGooglePayConfiguration(), callback = callback, @@ -149,11 +149,21 @@ class GooglePayFragment : BottomSheetDialogFragment() { } private fun loadGooglePayButton() { + val allowedPaymentMethods = googlePayComponent?.getGooglePayButtonParameters()?.allowedPaymentMethods.orEmpty() + val buttonOptions = ButtonOptions + .newBuilder() + .setButtonType(ButtonType.PAY) + .setAllowedPaymentMethods(allowedPaymentMethods) + .build() + binding.googlePayButton.initialize(buttonOptions) + binding.googlePayButton.setOnClickListener { googlePayComponent?.startGooglePayScreen(requireActivity(), ACTIVITY_RESULT_CODE) } } + // It is required to use onActivityResult with the Google Pay library (AutoResolveHelper). + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == ACTIVITY_RESULT_CODE) { diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt index 568c1d62d1..19ac858735 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -158,4 +158,3 @@ internal class GooglePayViewModel @Inject constructor( } } } - diff --git a/example-app/src/main/res/layout/fragment_google_pay.xml b/example-app/src/main/res/layout/fragment_google_pay.xml index 1f7d402dc2..fa75802986 100644 --- a/example-app/src/main/res/layout/fragment_google_pay.xml +++ b/example-app/src/main/res/layout/fragment_google_pay.xml @@ -16,11 +16,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - + + + + + diff --git a/example-app/src/main/res/values/styles.xml b/example-app/src/main/res/values/styles.xml index 002e6393fa..82f8242747 100644 --- a/example-app/src/main/res/values/styles.xml +++ b/example-app/src/main/res/values/styles.xml @@ -38,4 +38,8 @@ + From cc4ab4ea8d11687225d47dd292b9957c4c5dc624 Mon Sep 17 00:00:00 2001 From: Joseph Jreij Date: Wed, 20 Dec 2023 13:40:23 +0100 Subject: [PATCH 06/66] Update SECURITY.md Update the content and URLs inside SECURITY.md --- SECURITY.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8a471c25d0..e843ff246f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,3 @@ -# Reporting Security Issues +# Disclosing security issues -We welcome reports of possible vulnerabilities or issues as part of our responsible disclosure program. For more information go to -https://support.adyen.com/hc/en-us/articles/115001187330-How-do-I-report-a-possible-security-issue-to-Adyen +We welcome reports of possible vulnerabilities or issues as part of our [responsible disclosure policy](https://www.adyen.com/policies-and-disclaimer/responsible-disclosure). For more information check out this page on [how to disclose a security issue](https://help.adyen.com/en_US/knowledge/security/product-security/how-do-i-disclose-a-security-issue). From 2f0d72655e929f2342255becf8c401e7caf0e67c Mon Sep 17 00:00:00 2001 From: josephj Date: Wed, 13 Dec 2023 16:19:47 +0100 Subject: [PATCH 07/66] Send the GooglePayConfiguration from the view model to the fragment Instead of accessing it directly in the fragment COAND-788 --- .../ui/googlepay/GooglePayComponentData.kt | 2 ++ .../example/ui/googlepay/GooglePayEvent.kt | 2 ++ .../example/ui/googlepay/GooglePayFragment.kt | 34 ++++++++++--------- .../ui/googlepay/GooglePayViewModel.kt | 20 +++++++++-- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt index bdb8564c84..3f28bb0f43 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayComponentData.kt @@ -11,8 +11,10 @@ package com.adyen.checkout.example.ui.googlepay import com.adyen.checkout.components.core.ComponentCallback import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.googlepay.GooglePayComponentState +import com.adyen.checkout.googlepay.GooglePayConfiguration internal data class GooglePayComponentData( val paymentMethod: PaymentMethod, + val googlePayConfiguration: GooglePayConfiguration, val callback: ComponentCallback, ) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt index e19475caa4..6e00b16fea 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt @@ -11,11 +11,13 @@ package com.adyen.checkout.example.ui.googlepay import com.adyen.checkout.components.core.ComponentAvailableCallback import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.googlepay.GooglePayConfiguration internal sealed class GooglePayEvent { data class CheckAvailability( val paymentMethod: PaymentMethod, + val googlePayConfiguration: GooglePayConfiguration, val callback: ComponentAvailableCallback ) : GooglePayEvent() diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt index afa9d1d77a..b382a408c4 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -20,8 +20,6 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import com.adyen.checkout.components.core.ComponentAvailableCallback -import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.example.databinding.FragmentGooglePayBinding import com.adyen.checkout.example.extensions.getLogTag import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider @@ -79,12 +77,14 @@ class GooglePayFragment : BottomSheetDialogFragment() { } private fun setupGooglePayComponent(googlePayComponentData: GooglePayComponentData) { - val googlePayComponent = GooglePayComponent.PROVIDER.get( - fragment = this, - paymentMethod = googlePayComponentData.paymentMethod, - configuration = checkoutConfigurationProvider.getGooglePayConfiguration(), - callback = googlePayComponentData.callback, - ) + val googlePayComponent = with(googlePayComponentData) { + GooglePayComponent.PROVIDER.get( + fragment = this@GooglePayFragment, + paymentMethod = paymentMethod, + configuration = googlePayConfiguration, + callback = callback, + ) + } this.googlePayComponent = googlePayComponent @@ -128,19 +128,21 @@ class GooglePayFragment : BottomSheetDialogFragment() { private fun onEvent(event: GooglePayEvent) { when (event) { - is GooglePayEvent.CheckAvailability -> checkAvailability(event.paymentMethod, event.callback) + is GooglePayEvent.CheckAvailability -> checkAvailability(event) is GooglePayEvent.AdditionalAction -> googlePayComponent?.handleAction(event.action, requireActivity()) is GooglePayEvent.PaymentResult -> onPaymentResult(event.result) } } - private fun checkAvailability(paymentMethod: PaymentMethod, callback: ComponentAvailableCallback) { - GooglePayComponent.PROVIDER.isAvailable( - applicationContext = requireActivity().application, - paymentMethod = paymentMethod, - configuration = checkoutConfigurationProvider.getGooglePayConfiguration(), - callback = callback, - ) + private fun checkAvailability(event: GooglePayEvent.CheckAvailability) { + with(event) { + GooglePayComponent.PROVIDER.isAvailable( + applicationContext = requireActivity().application, + paymentMethod = paymentMethod, + configuration = googlePayConfiguration, + callback = callback, + ) + } } private fun onPaymentResult(result: String) { diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt index 19ac858735..5e6a100771 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -23,6 +23,7 @@ import com.adyen.checkout.example.data.storage.KeyValueStorage import com.adyen.checkout.example.repositories.PaymentsRepository import com.adyen.checkout.example.service.createPaymentRequest import com.adyen.checkout.example.service.getPaymentMethodRequest +import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider import com.adyen.checkout.googlepay.GooglePayComponent import com.adyen.checkout.googlepay.GooglePayComponentState import dagger.hilt.android.lifecycle.HiltViewModel @@ -41,10 +42,13 @@ internal class GooglePayViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val paymentsRepository: PaymentsRepository, private val keyValueStorage: KeyValueStorage, + checkoutConfigurationProvider: CheckoutConfigurationProvider, ) : ViewModel(), ComponentCallback, ComponentAvailableCallback { + private val googlePayConfiguration = checkoutConfigurationProvider.getGooglePayConfiguration() + private val _googleComponentDataFlow = MutableStateFlow(null) val googleComponentDataFlow: Flow = _googleComponentDataFlow.filterNotNull() @@ -67,7 +71,7 @@ internal class GooglePayViewModel @Inject constructor( countryCode = keyValueStorage.getCountry(), shopperLocale = keyValueStorage.getShopperLocale(), splitCardFundingSources = keyValueStorage.isSplitCardFundingSources(), - ) + ), ) val paymentMethod = paymentMethodResponse @@ -77,9 +81,19 @@ internal class GooglePayViewModel @Inject constructor( if (paymentMethod == null) { _viewState.emit(GooglePayViewState.Error(R.string.error_dialog_title)) } else { - _events.emit(GooglePayEvent.CheckAvailability(paymentMethod, this@GooglePayViewModel)) + _events.emit( + GooglePayEvent.CheckAvailability( + paymentMethod, + googlePayConfiguration, + this@GooglePayViewModel, + ), + ) _googleComponentDataFlow.emit( - GooglePayComponentData(paymentMethod, this@GooglePayViewModel) + GooglePayComponentData( + paymentMethod, + googlePayConfiguration, + this@GooglePayViewModel, + ), ) } } From 058267d07691a5b88e09e88d9b430b9ebf613ce2 Mon Sep 17 00:00:00 2001 From: josephj Date: Thu, 14 Dec 2023 16:02:57 +0100 Subject: [PATCH 08/66] Add dependency to the google pay compose button COAND-788 --- dependencies.gradle | 34 +++++++++++++++------------ example-app/build.gradle | 2 ++ googlepay/build.gradle | 2 +- gradle/verification-metadata.xml | 40 +++++++++++++++++++++++++------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index 108cb75f97..9b559384a6 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -52,6 +52,7 @@ ext { // External Dependencies cash_app_pay_version = '2.3.0' + google_pay_compose_button_version = '0.1.3' okhttp_version = "4.12.0" play_services_wallet_version = '19.2.1' wechat_pay_version = "6.8.0" @@ -78,8 +79,8 @@ ext { uiautomator_version = "2.2.0" libraries = [ - adyen3ds2 : "com.adyen.threeds:adyen-3ds2:$adyen3ds2_version", - androidx : [ + adyen3ds2 : "com.adyen.threeds:adyen-3ds2:$adyen3ds2_version", + androidx : [ annotation : "androidx.annotation:annotation:$annotation_version", appcompat : "androidx.appcompat:appcompat:$appcompat_version", browser : "androidx.browser:browser:$browser_version", @@ -92,8 +93,8 @@ ext { preference : "androidx.preference:preference-ktx:$preference_version", recyclerview : "androidx.recyclerview:recyclerview:$recyclerview_version" ], - cashAppPay : "app.cash.paykit:core:$cash_app_pay_version", - compose : [ + cashAppPay : "app.cash.paykit:core:$cash_app_pay_version", + compose : [ activity : "androidx.activity:activity-compose:$compose_activity_version", bom : "androidx.compose:compose-bom:$compose_bom_version", hilt : "androidx.hilt:hilt-navigation-compose:$compose_hilt_version", @@ -105,26 +106,29 @@ ext { ], viewmodel: "androidx.lifecycle:lifecycle-viewmodel-compose:$compose_viewmodel_version" ], - hilt : "com.google.dagger:hilt-android:$hilt_version", - hiltCompiler : "com.google.dagger:hilt-compiler:$hilt_version", - kotlinCoroutines : [ + googlePay : [ + composeButton : "com.google.pay.button:compose-pay-button:$google_pay_compose_button_version", + playServicesWallet: "com.google.android.gms:play-services-wallet:$play_services_wallet_version", + ], + hilt : "com.google.dagger:hilt-android:$hilt_version", + hiltCompiler : "com.google.dagger:hilt-compiler:$hilt_version", + kotlinCoroutines: [ "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" ], - leakCanary : "com.squareup.leakcanary:leakcanary-android:$leak_canary_version", - material : "com.google.android.material:material:$material_version", - moshi : [ + leakCanary : "com.squareup.leakcanary:leakcanary-android:$leak_canary_version", + material : "com.google.android.material:material:$material_version", + moshi : [ "com.squareup.moshi:moshi-adapters:$moshi_adapters_version", "com.squareup.moshi:moshi-kotlin:$moshi_kotlin_adapter_version" ], - okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version", - okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$okhttp_logging_version", - playServicesWallet: "com.google.android.gms:play-services-wallet:$play_services_wallet_version", - retrofit : [ + okhttp : "com.squareup.okhttp3:okhttp:$okhttp_version", + okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$okhttp_logging_version", + retrofit : [ "com.squareup.retrofit2:retrofit:$retrofit2_version", "com.squareup.retrofit2:converter-moshi:$retrofit2_version" ], - wechat : "com.tencent.mm.opensdk:wechat-sdk-android-without-mta:$wechat_pay_version" + wechat : "com.tencent.mm.opensdk:wechat-sdk-android-without-mta:$wechat_pay_version" ] testLibraries = [ diff --git a/example-app/build.gradle b/example-app/build.gradle index 61079a8e5e..5cddf7b64e 100644 --- a/example-app/build.gradle +++ b/example-app/build.gradle @@ -94,6 +94,8 @@ dependencies { implementation libraries.hilt kapt libraries.hiltCompiler + implementation libraries.googlePay.composeButton + debugImplementation libraries.leakCanary debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' diff --git a/googlepay/build.gradle b/googlepay/build.gradle index 93b0ba364e..f4fd4461b7 100644 --- a/googlepay/build.gradle +++ b/googlepay/build.gradle @@ -41,7 +41,7 @@ dependencies { api project(':sessions-core') // Dependencies - api libraries.playServicesWallet + api libraries.googlePay.playServicesWallet //Tests testImplementation project(':test-core') diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 46ba1da5bd..181853a004 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1271,6 +1271,14 @@ + + + + + + + + @@ -1326,6 +1334,14 @@ + + + + + + + + @@ -1664,6 +1680,14 @@ + + + + + + + + @@ -1680,14 +1704,6 @@ - - - - - - - - @@ -5515,6 +5531,14 @@ + + + + + + + + From 9d73466be6ce8f9da82295c40bdf9b7d5358828f Mon Sep 17 00:00:00 2001 From: josephj Date: Thu, 14 Dec 2023 16:24:51 +0100 Subject: [PATCH 09/66] Add entry for the Google Pay with sessions example COAND-788 --- example-app/src/main/AndroidManifest.xml | 26 +++++++++++ .../compose/SessionsGooglePayActivity.kt | 31 +++++++++++++ .../checkout/example/ui/main/ComponentItem.kt | 1 + .../example/ui/main/ComponentItemProvider.kt | 1 + .../checkout/example/ui/main/MainActivity.kt | 43 +++++++++++-------- .../example/ui/main/MainNavigation.kt | 20 +++++---- .../checkout/example/ui/main/MainViewModel.kt | 14 +++--- example-app/src/main/res/values/strings.xml | 1 + 8 files changed, 104 insertions(+), 33 deletions(-) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt diff --git a/example-app/src/main/AndroidManifest.xml b/example-app/src/main/AndroidManifest.xml index 0ad79d28d0..03f4ca2547 100644 --- a/example-app/src/main/AndroidManifest.xml +++ b/example-app/src/main/AndroidManifest.xml @@ -187,6 +187,32 @@ android:value=".ui.main.MainActivity" /> + + + + + + + + + + + + + + + diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt new file mode 100644 index 0000000000..cf90d1fdb9 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.adyen.checkout.redirect.RedirectComponent +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SessionsGooglePayActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Insert return url in extras, so we can access it in the ViewModel through SavedStateHandle + val returnUrl = RedirectComponent.getReturnUrl(applicationContext) + "/sessions/googlepay" + intent = (intent ?: Intent()).putExtra(RETURN_URL_EXTRA, returnUrl) + } + + companion object { + internal const val RETURN_URL_EXTRA = "RETURN_URL_EXTRA" + } +} diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt index 5a6e4e929a..3047762ba8 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItem.kt @@ -32,5 +32,6 @@ internal sealed class ComponentItem { object GiftCard : Entry(R.string.gift_card_component_entry) object GiftCardWithSession : Entry(R.string.gift_card_with_session_component_entry) object GooglePay : Entry(R.string.google_pay_component_entry) + object GooglePayWithSession : Entry(R.string.google_pay_with_session_component_entry) } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt index c49a179a0f..30c58a794e 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/ComponentItemProvider.kt @@ -25,6 +25,7 @@ internal object ComponentItemProvider { ComponentItem.Title(R.string.components_title), ComponentItem.Entry.CardWithSession, ComponentItem.Entry.CardWithSessionTakenOver, + ComponentItem.Entry.GooglePayWithSession, ComponentItem.Entry.GiftCardWithSession, ) } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt index 37e39f7049..c37ab01d6d 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt @@ -36,6 +36,7 @@ import com.adyen.checkout.example.ui.configuration.ConfigurationActivity import com.adyen.checkout.example.ui.giftcard.GiftCardActivity import com.adyen.checkout.example.ui.giftcard.SessionsGiftCardActivity import com.adyen.checkout.example.ui.googlepay.GooglePayFragment +import com.adyen.checkout.example.ui.googlepay.compose.SessionsGooglePayActivity import com.adyen.checkout.example.ui.instant.InstantFragment import com.adyen.checkout.redirect.RedirectComponent import dagger.hilt.android.AndroidEntryPoint @@ -136,6 +137,7 @@ class MainActivity : AppCompatActivity() { } } + @Suppress("LongMethod") private fun onNavigateTo(navigation: MainNavigation) { when (navigation) { is MainNavigation.DropIn -> { @@ -167,42 +169,45 @@ class MainActivity : AppCompatActivity() { ) } - is MainNavigation.Bacs -> BacsFragment.show(supportFragmentManager) + is MainNavigation.Bacs -> { + BacsFragment.show(supportFragmentManager) + } + is MainNavigation.Blik -> { - val intent = Intent(this, BlikActivity::class.java) - startActivity(intent) + startActivity(Intent(this, BlikActivity::class.java)) } - MainNavigation.Card -> { - val intent = Intent(this, CardActivity::class.java) - startActivity(intent) + is MainNavigation.Card -> { + startActivity(Intent(this, CardActivity::class.java)) } - MainNavigation.CardWithSession -> { - val intent = Intent(this, SessionsCardActivity::class.java) - startActivity(intent) + is MainNavigation.CardWithSession -> { + startActivity(Intent(this, SessionsCardActivity::class.java)) } - MainNavigation.GiftCard -> { - val intent = Intent(this, GiftCardActivity::class.java) - startActivity(intent) + is MainNavigation.GiftCard -> { + startActivity(Intent(this, GiftCardActivity::class.java)) } - MainNavigation.GiftCardWithSession -> { - val intent = Intent(this, SessionsGiftCardActivity::class.java) - startActivity(intent) + is MainNavigation.GiftCardWithSession -> { + startActivity(Intent(this, SessionsGiftCardActivity::class.java)) } - MainNavigation.CardWithSessionTakenOver -> { - val intent = Intent(this, SessionsCardTakenOverActivity::class.java) - startActivity(intent) + is MainNavigation.CardWithSessionTakenOver -> { + startActivity(Intent(this, SessionsCardTakenOverActivity::class.java)) } is MainNavigation.Instant -> { InstantFragment.show(supportFragmentManager, navigation.paymentMethodType) } - is MainNavigation.GooglePay -> GooglePayFragment.show(supportFragmentManager) + is MainNavigation.GooglePay -> { + GooglePayFragment.show(supportFragmentManager) + } + + is MainNavigation.GooglePayWithSession -> { + startActivity(Intent(this, SessionsGooglePayActivity::class.java)) + } } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt index fe789f7cf8..e72a8d6ee9 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainNavigation.kt @@ -14,23 +14,25 @@ import com.adyen.checkout.sessions.core.CheckoutSession internal sealed class MainNavigation { - object Bacs : MainNavigation() + data object Bacs : MainNavigation() - object Blik : MainNavigation() + data object Blik : MainNavigation() - object Card : MainNavigation() + data object Card : MainNavigation() - class Instant(val paymentMethodType: String) : MainNavigation() + data class Instant(val paymentMethodType: String) : MainNavigation() - object CardWithSession : MainNavigation() + data object CardWithSession : MainNavigation() - object GiftCard : MainNavigation() + data object GiftCard : MainNavigation() - object GiftCardWithSession : MainNavigation() + data object GiftCardWithSession : MainNavigation() - object CardWithSessionTakenOver : MainNavigation() + data object CardWithSessionTakenOver : MainNavigation() - object GooglePay : MainNavigation() + data object GooglePay : MainNavigation() + + data object GooglePayWithSession : MainNavigation() data class DropIn( val paymentMethodsApiResponse: PaymentMethodsApiResponse, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt index 3d2a3aa04a..980ebc28cf 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainViewModel.kt @@ -84,7 +84,7 @@ internal class MainViewModel @Inject constructor( is ComponentItem.Entry.Blik -> _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.Blik)) is ComponentItem.Entry.Card -> _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.Card)) is ComponentItem.Entry.Klarna -> _eventFlow.tryEmit( - MainEvent.NavigateTo(MainNavigation.Instant(PAYMENT_METHOD_KLARNA)) + MainEvent.NavigateTo(MainNavigation.Instant(PAYMENT_METHOD_KLARNA)), ) is ComponentItem.Entry.PayPal -> @@ -92,7 +92,7 @@ internal class MainViewModel @Inject constructor( is ComponentItem.Entry.Instant -> _eventFlow.tryEmit( - MainEvent.NavigateTo(MainNavigation.Instant(keyValueStorage.getInstantPaymentMethodType())) + MainEvent.NavigateTo(MainNavigation.Instant(keyValueStorage.getInstantPaymentMethodType())), ) is ComponentItem.Entry.CardWithSession -> @@ -106,6 +106,10 @@ internal class MainViewModel @Inject constructor( _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.GiftCardWithSession)) is ComponentItem.Entry.GooglePay -> _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.GooglePay)) + is ComponentItem.Entry.GooglePayWithSession -> { + _eventFlow.tryEmit(MainEvent.NavigateTo(MainNavigation.GooglePayWithSession)) + } + is ComponentItem.Entry.DropIn -> startDropInFlow() is ComponentItem.Entry.DropInWithSession -> startSessionDropInFlow(false) is ComponentItem.Entry.DropInWithCustomSession -> startSessionDropInFlow(true) @@ -160,7 +164,7 @@ internal class MainViewModel @Inject constructor( countryCode = keyValueStorage.getCountry(), shopperLocale = keyValueStorage.getShopperLocale(), splitCardFundingSources = keyValueStorage.isSplitCardFundingSources(), - ) + ), ) private suspend fun getSession(dropInConfiguration: DropInConfiguration): CheckoutSession? { @@ -178,8 +182,8 @@ internal class MainViewModel @Inject constructor( ?: error("Return url should be set"), shopperEmail = keyValueStorage.getShopperEmail(), installmentOptions = getSettingsInstallmentOptionsMode(keyValueStorage.getInstallmentOptionsMode()), - showInstallmentAmount = keyValueStorage.isInstallmentAmountShown() - ) + showInstallmentAmount = keyValueStorage.isInstallmentAmountShown(), + ), ) ?: return null return getCheckoutSession(sessionModel, dropInConfiguration) diff --git a/example-app/src/main/res/values/strings.xml b/example-app/src/main/res/values/strings.xml index 1f4a1d195f..d182683477 100644 --- a/example-app/src/main/res/values/strings.xml +++ b/example-app/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ PayPal Instant (%s) Google Pay + Google Pay Use sessions From b4c82c15170c1d1b71eed4b7c30f3da7fbed15c4 Mon Sep 17 00:00:00 2001 From: josephj Date: Thu, 14 Dec 2023 16:29:07 +0100 Subject: [PATCH 10/66] Extract GooglePayAvailabilityData to separate data class COAND-788 --- .../ui/googlepay/GooglePayAvailabilityData.kt | 19 +++++++++++++++++++ .../example/ui/googlepay/GooglePayEvent.kt | 9 +-------- .../example/ui/googlepay/GooglePayFragment.kt | 6 +++--- .../ui/googlepay/GooglePayViewModel.kt | 8 +++++--- 4 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt new file mode 100644 index 0000000000..36febfcfa5 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +import com.adyen.checkout.components.core.ComponentAvailableCallback +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.googlepay.GooglePayConfiguration + +internal data class GooglePayAvailabilityData( + val paymentMethod: PaymentMethod, + val googlePayConfiguration: GooglePayConfiguration, + val callback: ComponentAvailableCallback +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt index 6e00b16fea..af441889b3 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt @@ -8,18 +8,11 @@ package com.adyen.checkout.example.ui.googlepay -import com.adyen.checkout.components.core.ComponentAvailableCallback -import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.action.Action -import com.adyen.checkout.googlepay.GooglePayConfiguration internal sealed class GooglePayEvent { - data class CheckAvailability( - val paymentMethod: PaymentMethod, - val googlePayConfiguration: GooglePayConfiguration, - val callback: ComponentAvailableCallback - ) : GooglePayEvent() + data class CheckAvailability(val googlePayAvailabilityData: GooglePayAvailabilityData) : GooglePayEvent() data class PaymentResult(val result: String) : GooglePayEvent() diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt index b382a408c4..a48860f047 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -128,14 +128,14 @@ class GooglePayFragment : BottomSheetDialogFragment() { private fun onEvent(event: GooglePayEvent) { when (event) { - is GooglePayEvent.CheckAvailability -> checkAvailability(event) + is GooglePayEvent.CheckAvailability -> checkAvailability(event.googlePayAvailabilityData) is GooglePayEvent.AdditionalAction -> googlePayComponent?.handleAction(event.action, requireActivity()) is GooglePayEvent.PaymentResult -> onPaymentResult(event.result) } } - private fun checkAvailability(event: GooglePayEvent.CheckAvailability) { - with(event) { + private fun checkAvailability(googlePayAvailabilityData: GooglePayAvailabilityData) { + with(googlePayAvailabilityData) { GooglePayComponent.PROVIDER.isAvailable( applicationContext = requireActivity().application, paymentMethod = paymentMethod, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt index 5e6a100771..99885deea2 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -83,9 +83,11 @@ internal class GooglePayViewModel @Inject constructor( } else { _events.emit( GooglePayEvent.CheckAvailability( - paymentMethod, - googlePayConfiguration, - this@GooglePayViewModel, + GooglePayAvailabilityData( + paymentMethod, + googlePayConfiguration, + this@GooglePayViewModel, + ), ), ) _googleComponentDataFlow.emit( From 6f5b17d2ee547b8881cf86dcc36184281053e475 Mon Sep 17 00:00:00 2001 From: josephj Date: Thu, 14 Dec 2023 16:29:59 +0100 Subject: [PATCH 11/66] Add missing Deprecated annotation to MainActivity onActivityResult --- .../java/com/adyen/checkout/example/ui/main/MainActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt index c37ab01d6d..8d50b61db1 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/main/MainActivity.kt @@ -223,6 +223,8 @@ class MainActivity : AppCompatActivity() { binding.switchSessions.isChecked = isChecked } + // It is required to use onActivityResult with the Google Pay library (AutoResolveHelper). + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == GooglePayFragment.ACTIVITY_RESULT_CODE) { From b7d1bba93599543ae1958ae706218b83a54c18c6 Mon Sep 17 00:00:00 2001 From: josephj Date: Thu, 14 Dec 2023 16:32:27 +0100 Subject: [PATCH 12/66] Add compose example screen for Google Pay with sessions COAND-788 --- .../ui/googlepay/GooglePayActivityResult.kt | 16 ++ .../compose/SessionsGooglePayActivity.kt | 50 +++++ .../compose/SessionsGooglePayComponentData.kt | 22 ++ .../compose/SessionsGooglePayScreen.kt | 207 +++++++++++++++++ .../compose/SessionsGooglePayState.kt | 25 +++ .../compose/SessionsGooglePayUIState.kt | 27 +++ .../compose/SessionsGooglePayViewModel.kt | 209 ++++++++++++++++++ 7 files changed, 556 insertions(+) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayComponentData.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt new file mode 100644 index 0000000000..83553cb935 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay + +import android.content.Intent + +internal data class GooglePayActivityResult( + val resultCode: Int, + val data: Intent?, +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt index cf90d1fdb9..8128229305 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt @@ -10,22 +10,72 @@ package com.adyen.checkout.example.ui.googlepay.compose import android.content.Intent import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.core.view.WindowCompat +import com.adyen.checkout.example.ui.theme.ExampleTheme +import com.adyen.checkout.example.ui.theme.NightTheme +import com.adyen.checkout.example.ui.theme.NightThemeRepository import com.adyen.checkout.redirect.RedirectComponent import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class SessionsGooglePayActivity : AppCompatActivity() { + @Inject + internal lateinit var nightThemeRepository: NightThemeRepository + + private val sessionsGooglePayViewModel: SessionsGooglePayViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Helps to resize the view port when the keyboard is displayed. + WindowCompat.setDecorFitsSystemWindows(window, false) + // Insert return url in extras, so we can access it in the ViewModel through SavedStateHandle val returnUrl = RedirectComponent.getReturnUrl(applicationContext) + "/sessions/googlepay" intent = (intent ?: Intent()).putExtra(RETURN_URL_EXTRA, returnUrl) + + setContent { + val useDarkTheme = when (nightThemeRepository.theme) { + NightTheme.DAY -> false + NightTheme.NIGHT -> true + NightTheme.SYSTEM -> isSystemInDarkTheme() + } + ExampleTheme(useDarkTheme) { + SessionsGooglePayScreen( + useDarkTheme = useDarkTheme, + onBackPressed = { onBackPressedDispatcher.onBackPressed() }, + viewModel = sessionsGooglePayViewModel, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + val data = intent.data + if (data != null && data.toString().startsWith(RedirectComponent.REDIRECT_RESULT_SCHEME)) { + sessionsGooglePayViewModel.onNewIntent(intent) + } + } + + // It is required to use onActivityResult with the Google Pay library (AutoResolveHelper). + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == ACTIVITY_RESULT_CODE) { + sessionsGooglePayViewModel.onActivityResult(resultCode, data) + } } companion object { internal const val RETURN_URL_EXTRA = "RETURN_URL_EXTRA" + internal const val ACTIVITY_RESULT_CODE = 1 } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayComponentData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayComponentData.kt new file mode 100644 index 0000000000..8e46b43d97 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayComponentData.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.googlepay.GooglePayComponentState +import com.adyen.checkout.googlepay.GooglePayConfiguration +import com.adyen.checkout.sessions.core.CheckoutSession +import com.adyen.checkout.sessions.core.SessionComponentCallback + +internal data class SessionsGooglePayComponentData( + val checkoutSession: CheckoutSession, + val googlePayConfiguration: GooglePayConfiguration, + val paymentMethod: PaymentMethod, + val callback: SessionComponentCallback +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt new file mode 100644 index 0000000000..852036610a --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.adyen.checkout.example.ui.googlepay.compose + +import android.app.Activity +import android.app.Application +import android.content.Intent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.adyen.checkout.components.compose.AdyenComponent +import com.adyen.checkout.components.compose.get +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.example.ui.compose.ResultContent +import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult +import com.adyen.checkout.example.ui.googlepay.GooglePayAvailabilityData +import com.adyen.checkout.googlepay.GooglePayComponent +import com.google.pay.button.ButtonTheme +import com.google.pay.button.ButtonType +import com.google.pay.button.PayButton + +@Composable +internal fun SessionsGooglePayScreen( + useDarkTheme: Boolean, + onBackPressed: () -> Unit, + viewModel: SessionsGooglePayViewModel, +) { + Scaffold( + modifier = Modifier.windowInsetsPadding(WindowInsets.ime), + topBar = { + TopAppBar( + title = { Text(text = "Google Pay with sessions") }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon(Icons.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { innerPadding -> + val googlePayState by viewModel.googlePayState.collectAsState() + SessionsGooglePayContent( + googlePayState = googlePayState, + onButtonClicked = viewModel::onButtonClicked, + useDarkTheme = useDarkTheme, + modifier = Modifier.padding(innerPadding), + ) + + with(googlePayState) { + CheckGooglePayAvailability(checkAvailability, viewModel::onAvailabilityChecked) + HandleActivityResult(activityResult, componentData, viewModel::onActivityResultHandled) + HandleAction(action, componentData, viewModel::onActionConsumed) + HandleNewIntent(newIntent, componentData, viewModel::onNewIntentHandled) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun SessionsGooglePayContent( + googlePayState: SessionsGooglePayState, + onButtonClicked: () -> Unit, + useDarkTheme: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when (val uiState = googlePayState.uiState) { + SessionsGooglePayUIState.Loading -> { + CircularProgressIndicator() + } + + SessionsGooglePayUIState.ShowButton -> { + val activity = LocalContext.current as Activity + val googlePayComponent = getGooglePayComponent(componentData = googlePayState.componentData) + PayButton( + onClick = { + googlePayComponent.startGooglePayScreen( + activity, + SessionsGooglePayActivity.ACTIVITY_RESULT_CODE, + ) + onButtonClicked() + }, + allowedPaymentMethods = googlePayComponent.getGooglePayButtonParameters().allowedPaymentMethods, + theme = if (useDarkTheme) ButtonTheme.Light else ButtonTheme.Dark, + type = ButtonType.Pay, + ) + } + + SessionsGooglePayUIState.ShowComponent -> { + val googlePayComponent = getGooglePayComponent(componentData = googlePayState.componentData) + AdyenComponent( + googlePayComponent, + modifier, + ) + } + + is SessionsGooglePayUIState.FinalResult -> { + ResultContent(uiState.finalResult) + } + } + } +} + +@Composable +private fun CheckGooglePayAvailability( + checkAvailability: GooglePayAvailabilityData?, + onAvailabilityChecked: () -> Unit +) { + if (checkAvailability == null) return + val application = LocalContext.current.applicationContext as Application + LaunchedEffect(checkAvailability) { + GooglePayComponent.PROVIDER.isAvailable( + application, + checkAvailability.paymentMethod, + checkAvailability.googlePayConfiguration, + checkAvailability.callback, + ) + onAvailabilityChecked() + } +} + +@Composable +private fun HandleActivityResult( + activityResult: GooglePayActivityResult?, + componentData: SessionsGooglePayComponentData?, + onActivityResultHandled: () -> Unit +) { + if (activityResult == null) return + val googlePayComponent = getGooglePayComponent(componentData = componentData) + LaunchedEffect(activityResult) { + googlePayComponent.handleActivityResult(activityResult.resultCode, activityResult.data) + onActivityResultHandled() + } +} + +@Composable +private fun HandleAction( + action: Action?, + componentData: SessionsGooglePayComponentData?, + onActionConsumed: () -> Unit +) { + if (action == null) return + val activity = LocalContext.current as Activity + val googlePayComponent = getGooglePayComponent(componentData = componentData) + LaunchedEffect(action) { + googlePayComponent.handleAction(action, activity) + onActionConsumed() + } +} + +@Composable +private fun HandleNewIntent( + newIntent: Intent?, + componentData: SessionsGooglePayComponentData?, + onNewIntentHandled: () -> Unit +) { + if (newIntent == null) return + val googlePayComponent = getGooglePayComponent(componentData = componentData) + LaunchedEffect(newIntent) { + googlePayComponent.handleIntent(newIntent) + onNewIntentHandled() + } +} + +@Composable +private fun getGooglePayComponent(componentData: SessionsGooglePayComponentData?): GooglePayComponent { + requireNotNull(componentData) { "Component data should not be null" } + return with(componentData) { + GooglePayComponent.PROVIDER.get( + checkoutSession, + paymentMethod, + googlePayConfiguration, + callback, + hashCode().toString(), + ) + } +} diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt new file mode 100644 index 0000000000..39c020a071 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import android.content.Intent +import androidx.compose.runtime.Immutable +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult +import com.adyen.checkout.example.ui.googlepay.GooglePayAvailabilityData + +@Immutable +internal data class SessionsGooglePayState( + val uiState: SessionsGooglePayUIState, + val componentData: SessionsGooglePayComponentData? = null, + val checkAvailability: GooglePayAvailabilityData? = null, + val activityResult: GooglePayActivityResult? = null, + val action: Action? = null, + val newIntent: Intent? = null, +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt new file mode 100644 index 0000000000..806ca6f04e --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import androidx.compose.runtime.Immutable +import com.adyen.checkout.example.ui.compose.ResultState + +internal sealed class SessionsGooglePayUIState { + + @Immutable + data object Loading : SessionsGooglePayUIState() + + @Immutable + data object ShowButton : SessionsGooglePayUIState() + + @Immutable + data object ShowComponent : SessionsGooglePayUIState() + + @Immutable + data class FinalResult(val finalResult: ResultState) : SessionsGooglePayUIState() +} diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt new file mode 100644 index 0000000000..0106923502 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 13/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import android.content.Intent +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adyen.checkout.components.core.ComponentAvailableCallback +import com.adyen.checkout.components.core.ComponentError +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.PaymentMethodTypes +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.example.data.storage.KeyValueStorage +import com.adyen.checkout.example.extensions.getLogTag +import com.adyen.checkout.example.repositories.PaymentsRepository +import com.adyen.checkout.example.service.getSessionRequest +import com.adyen.checkout.example.service.getSettingsInstallmentOptionsMode +import com.adyen.checkout.example.ui.compose.ResultState +import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider +import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult +import com.adyen.checkout.example.ui.googlepay.GooglePayAvailabilityData +import com.adyen.checkout.googlepay.GooglePayComponentState +import com.adyen.checkout.googlepay.GooglePayConfiguration +import com.adyen.checkout.sessions.core.CheckoutSession +import com.adyen.checkout.sessions.core.CheckoutSessionProvider +import com.adyen.checkout.sessions.core.CheckoutSessionResult +import com.adyen.checkout.sessions.core.SessionComponentCallback +import com.adyen.checkout.sessions.core.SessionModel +import com.adyen.checkout.sessions.core.SessionPaymentResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@Suppress("TooManyFunctions") +@HiltViewModel +internal class SessionsGooglePayViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val paymentsRepository: PaymentsRepository, + private val keyValueStorage: KeyValueStorage, + checkoutConfigurationProvider: CheckoutConfigurationProvider, +) : ViewModel(), + SessionComponentCallback, + ComponentAvailableCallback { + + private val googlePayConfiguration = checkoutConfigurationProvider.getGooglePayConfiguration() + + private val _googlePayState = MutableStateFlow(SessionsGooglePayState(SessionsGooglePayUIState.Loading)) + val googlePayState: StateFlow = _googlePayState.asStateFlow() + + init { + viewModelScope.launch { fetchSession() } + } + + private suspend fun fetchSession() = withContext(Dispatchers.IO) { + val paymentMethodType = PaymentMethodTypes.GOOGLE_PAY + val checkoutSession = getSession(paymentMethodType) + if (checkoutSession == null) { + Log.e(TAG, "Failed to fetch session") + onError() + return@withContext + } + val paymentMethod = checkoutSession.getPaymentMethod(paymentMethodType) + if (paymentMethod == null) { + Log.e(TAG, "Session does not contain SCHEME payment method") + onError() + return@withContext + } + + val componentData = SessionsGooglePayComponentData( + checkoutSession, + googlePayConfiguration, + paymentMethod, + this@SessionsGooglePayViewModel, + ) + + val checkAvailability = GooglePayAvailabilityData( + paymentMethod, + googlePayConfiguration, + this@SessionsGooglePayViewModel, + ) + + updateState { + it.copy( + componentData = componentData, + checkAvailability = checkAvailability, + ) + } + } + + private suspend fun getSession(paymentMethodType: String): CheckoutSession? { + val sessionModel = paymentsRepository.createSession( + getSessionRequest( + merchantAccount = keyValueStorage.getMerchantAccount(), + shopperReference = keyValueStorage.getShopperReference(), + amount = keyValueStorage.getAmount(), + countryCode = keyValueStorage.getCountry(), + shopperLocale = keyValueStorage.getShopperLocale(), + splitCardFundingSources = keyValueStorage.isSplitCardFundingSources(), + isExecuteThreeD = keyValueStorage.isExecuteThreeD(), + isThreeds2Enabled = keyValueStorage.isThreeds2Enabled(), + redirectUrl = savedStateHandle.get(SessionsGooglePayActivity.RETURN_URL_EXTRA) + ?: error("Return url should be set"), + shopperEmail = keyValueStorage.getShopperEmail(), + allowedPaymentMethods = listOf(paymentMethodType), + installmentOptions = getSettingsInstallmentOptionsMode(keyValueStorage.getInstallmentOptionsMode()), + showInstallmentAmount = keyValueStorage.isInstallmentAmountShown(), + ), + ) ?: return null + + return getCheckoutSession(sessionModel, googlePayConfiguration) + } + + private suspend fun getCheckoutSession( + sessionModel: SessionModel, + googlePayConfiguration: GooglePayConfiguration, + ): CheckoutSession? { + return when (val result = CheckoutSessionProvider.createSession(sessionModel, googlePayConfiguration)) { + is CheckoutSessionResult.Success -> result.checkoutSession + is CheckoutSessionResult.Error -> null + } + } + + override fun onAvailabilityResult(isAvailable: Boolean, paymentMethod: PaymentMethod) { + viewModelScope.launch { + if (isAvailable) { + updateState { it.copy(uiState = SessionsGooglePayUIState.ShowButton) } + } else { + onError() + } + } + } + + override fun onAction(action: Action) { + updateState { it.copy(action = action) } + } + + override fun onError(componentError: ComponentError) { + Log.e(TAG, "Component error occurred") + onError() + } + + override fun onFinished(result: SessionPaymentResult) { + updateState { + it.copy(uiState = SessionsGooglePayUIState.FinalResult(getFinalResultState(result))) + } + } + + private fun getFinalResultState(result: SessionPaymentResult): ResultState = when (result.resultCode) { + "Authorised" -> ResultState.SUCCESS + "Pending", + "Received" -> ResultState.PENDING + + else -> ResultState.FAILURE + } + + private fun onError() { + updateState { it.copy(uiState = SessionsGooglePayUIState.FinalResult(ResultState.FAILURE)) } + } + + private fun updateState(block: (SessionsGooglePayState) -> SessionsGooglePayState) { + _googlePayState.update(block) + } + + fun onAvailabilityChecked() { + updateState { it.copy(checkAvailability = null) } + } + + fun onButtonClicked() { + updateState { it.copy(uiState = SessionsGooglePayUIState.ShowComponent) } + } + + fun onActionConsumed() { + updateState { it.copy(action = null) } + } + + fun onActivityResult(resultCode: Int, data: Intent?) { + updateState { it.copy(activityResult = GooglePayActivityResult(resultCode, data)) } + } + + fun onActivityResultHandled() { + updateState { it.copy(activityResult = null) } + } + + fun onNewIntent(intent: Intent) { + updateState { it.copy(newIntent = intent) } + } + + fun onNewIntentHandled() { + updateState { it.copy(newIntent = null) } + } + + companion object { + private val TAG = getLogTag() + } +} From 6570e9ba410c99a0c6ff3776b5ec488d3c375dbe Mon Sep 17 00:00:00 2001 From: josephj Date: Mon, 18 Dec 2023 14:45:31 +0100 Subject: [PATCH 13/66] Add helpers to return whether the current display is in dark mode or not COAND-788 --- .../ui/card/compose/SessionsCardActivity.kt | 10 ++---- .../compose/SessionsGooglePayActivity.kt | 12 ++----- .../example/ui/theme/NightThemeRepository.kt | 35 ++++++++++++++++++- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/card/compose/SessionsCardActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/card/compose/SessionsCardActivity.kt index 64c7bcf984..4bf47716b4 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/card/compose/SessionsCardActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/card/compose/SessionsCardActivity.kt @@ -12,10 +12,8 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.isSystemInDarkTheme import androidx.core.view.WindowCompat import com.adyen.checkout.example.ui.theme.ExampleTheme -import com.adyen.checkout.example.ui.theme.NightTheme import com.adyen.checkout.example.ui.theme.NightThemeRepository import com.adyen.checkout.redirect.RedirectComponent import dagger.hilt.android.AndroidEntryPoint @@ -38,12 +36,8 @@ class SessionsCardActivity : AppCompatActivity() { intent = (intent ?: Intent()).putExtra(RETURN_URL_EXTRA, returnUrl) setContent { - val useDarkTheme = when (nightThemeRepository.theme) { - NightTheme.DAY -> false - NightTheme.NIGHT -> true - NightTheme.SYSTEM -> isSystemInDarkTheme() - } - ExampleTheme(useDarkTheme) { + val isDarkTheme = nightThemeRepository.isDarkTheme() + ExampleTheme(isDarkTheme) { SessionsCardScreen(onBackPressed = { onBackPressedDispatcher.onBackPressed() }) } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt index 8128229305..e24a539111 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt @@ -13,10 +13,8 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.isSystemInDarkTheme import androidx.core.view.WindowCompat import com.adyen.checkout.example.ui.theme.ExampleTheme -import com.adyen.checkout.example.ui.theme.NightTheme import com.adyen.checkout.example.ui.theme.NightThemeRepository import com.adyen.checkout.redirect.RedirectComponent import dagger.hilt.android.AndroidEntryPoint @@ -41,14 +39,10 @@ class SessionsGooglePayActivity : AppCompatActivity() { intent = (intent ?: Intent()).putExtra(RETURN_URL_EXTRA, returnUrl) setContent { - val useDarkTheme = when (nightThemeRepository.theme) { - NightTheme.DAY -> false - NightTheme.NIGHT -> true - NightTheme.SYSTEM -> isSystemInDarkTheme() - } - ExampleTheme(useDarkTheme) { + val isDarkTheme = nightThemeRepository.isDarkTheme() + ExampleTheme(isDarkTheme) { SessionsGooglePayScreen( - useDarkTheme = useDarkTheme, + useDarkTheme = isDarkTheme, onBackPressed = { onBackPressedDispatcher.onBackPressed() }, viewModel = sessionsGooglePayViewModel, ) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/theme/NightThemeRepository.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/theme/NightThemeRepository.kt index b1e443d483..866fdc6355 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/theme/NightThemeRepository.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/theme/NightThemeRepository.kt @@ -8,8 +8,12 @@ package com.adyen.checkout.example.ui.theme +import android.content.Context import android.content.SharedPreferences +import android.content.res.Configuration import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable import androidx.core.content.edit import javax.inject.Inject import javax.inject.Singleton @@ -19,6 +23,11 @@ internal interface NightThemeRepository { var theme: NightTheme fun initialize() + + @Composable + fun isDarkTheme(): Boolean + + fun isDarkTheme(context: Context): Boolean } @Singleton @@ -42,6 +51,30 @@ internal class DefaultNightThemeRepository @Inject constructor( return NightTheme.findByPreferenceValue(preference) } + @Composable + override fun isDarkTheme(): Boolean { + return when (theme) { + NightTheme.DAY -> false + NightTheme.NIGHT -> true + NightTheme.SYSTEM -> isSystemInDarkTheme() + } + } + + override fun isDarkTheme(context: Context): Boolean { + return when (theme) { + NightTheme.DAY -> false + NightTheme.NIGHT -> true + NightTheme.SYSTEM -> { + when (context.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) { + Configuration.UI_MODE_NIGHT_YES -> true + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_UNDEFINED -> false + else -> false + } + } + } + } + companion object { // Should be same as R.string.night_theme_key private const val PREF_KEY_NIGHT_THEME = "night_theme_key" @@ -59,6 +92,6 @@ internal enum class NightTheme( companion object { fun findByPreferenceValue(value: String?): NightTheme = - values().find { it.preferenceValue == value } ?: SYSTEM + entries.find { it.preferenceValue == value } ?: SYSTEM } } From 3462db7b62a478ea9c980661df3379fc51244106 Mon Sep 17 00:00:00 2001 From: josephj Date: Tue, 19 Dec 2023 13:22:33 +0100 Subject: [PATCH 14/66] Move GooglePay availability check into the view models COAND-788 --- .../ui/googlepay/GooglePayAvailabilityData.kt | 19 -------- .../example/ui/googlepay/GooglePayEvent.kt | 2 - .../example/ui/googlepay/GooglePayFragment.kt | 12 ----- .../ui/googlepay/GooglePayViewModel.kt | 44 ++++++++++++------- .../compose/SessionsGooglePayScreen.kt | 21 --------- .../compose/SessionsGooglePayState.kt | 2 - .../compose/SessionsGooglePayViewModel.kt | 31 +++++++------ 7 files changed, 44 insertions(+), 87 deletions(-) delete mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt deleted file mode 100644 index 36febfcfa5..0000000000 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayAvailabilityData.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2023 Adyen N.V. - * - * This file is open source and available under the MIT license. See the LICENSE file for more info. - * - * Created by josephj on 13/12/2023. - */ - -package com.adyen.checkout.example.ui.googlepay - -import com.adyen.checkout.components.core.ComponentAvailableCallback -import com.adyen.checkout.components.core.PaymentMethod -import com.adyen.checkout.googlepay.GooglePayConfiguration - -internal data class GooglePayAvailabilityData( - val paymentMethod: PaymentMethod, - val googlePayConfiguration: GooglePayConfiguration, - val callback: ComponentAvailableCallback -) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt index af441889b3..1216561e2d 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayEvent.kt @@ -12,8 +12,6 @@ import com.adyen.checkout.components.core.action.Action internal sealed class GooglePayEvent { - data class CheckAvailability(val googlePayAvailabilityData: GooglePayAvailabilityData) : GooglePayEvent() - data class PaymentResult(val result: String) : GooglePayEvent() data class AdditionalAction(val action: Action) : GooglePayEvent() diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt index a48860f047..0184f0a707 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayFragment.kt @@ -128,23 +128,11 @@ class GooglePayFragment : BottomSheetDialogFragment() { private fun onEvent(event: GooglePayEvent) { when (event) { - is GooglePayEvent.CheckAvailability -> checkAvailability(event.googlePayAvailabilityData) is GooglePayEvent.AdditionalAction -> googlePayComponent?.handleAction(event.action, requireActivity()) is GooglePayEvent.PaymentResult -> onPaymentResult(event.result) } } - private fun checkAvailability(googlePayAvailabilityData: GooglePayAvailabilityData) { - with(googlePayAvailabilityData) { - GooglePayComponent.PROVIDER.isAvailable( - applicationContext = requireActivity().application, - paymentMethod = paymentMethod, - configuration = googlePayConfiguration, - callback = callback, - ) - } - } - private fun onPaymentResult(result: String) { Toast.makeText(requireContext(), result, Toast.LENGTH_SHORT).show() dismiss() diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt index 99885deea2..92d4b6d03c 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayViewModel.kt @@ -8,6 +8,7 @@ package com.adyen.checkout.example.ui.googlepay +import android.app.Application import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +27,7 @@ import com.adyen.checkout.example.service.getPaymentMethodRequest import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider import com.adyen.checkout.googlepay.GooglePayComponent import com.adyen.checkout.googlepay.GooglePayComponentState +import com.adyen.checkout.googlepay.GooglePayConfiguration import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -37,9 +39,11 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import javax.inject.Inject +@Suppress("TooManyFunctions") @HiltViewModel internal class GooglePayViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val application: Application, private val paymentsRepository: PaymentsRepository, private val keyValueStorage: KeyValueStorage, checkoutConfigurationProvider: CheckoutConfigurationProvider, @@ -80,24 +84,30 @@ internal class GooglePayViewModel @Inject constructor( if (paymentMethod == null) { _viewState.emit(GooglePayViewState.Error(R.string.error_dialog_title)) - } else { - _events.emit( - GooglePayEvent.CheckAvailability( - GooglePayAvailabilityData( - paymentMethod, - googlePayConfiguration, - this@GooglePayViewModel, - ), - ), - ) - _googleComponentDataFlow.emit( - GooglePayComponentData( - paymentMethod, - googlePayConfiguration, - this@GooglePayViewModel, - ), - ) + return@withContext } + + _googleComponentDataFlow.emit( + GooglePayComponentData( + paymentMethod, + googlePayConfiguration, + this@GooglePayViewModel, + ), + ) + + checkGooglePayAvailability(paymentMethod, googlePayConfiguration) + } + + private fun checkGooglePayAvailability( + paymentMethod: PaymentMethod, + googlePayConfiguration: GooglePayConfiguration, + ) { + GooglePayComponent.PROVIDER.isAvailable( + application, + paymentMethod, + googlePayConfiguration, + this, + ) } override fun onAvailabilityResult(isAvailable: Boolean, paymentMethod: PaymentMethod) { diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt index 852036610a..ab4f624bce 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt @@ -11,7 +11,6 @@ package com.adyen.checkout.example.ui.googlepay.compose import android.app.Activity -import android.app.Application import android.content.Intent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets @@ -40,7 +39,6 @@ import com.adyen.checkout.components.compose.get import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.example.ui.compose.ResultContent import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult -import com.adyen.checkout.example.ui.googlepay.GooglePayAvailabilityData import com.adyen.checkout.googlepay.GooglePayComponent import com.google.pay.button.ButtonTheme import com.google.pay.button.ButtonType @@ -74,7 +72,6 @@ internal fun SessionsGooglePayScreen( ) with(googlePayState) { - CheckGooglePayAvailability(checkAvailability, viewModel::onAvailabilityChecked) HandleActivityResult(activityResult, componentData, viewModel::onActivityResultHandled) HandleAction(action, componentData, viewModel::onActionConsumed) HandleNewIntent(newIntent, componentData, viewModel::onNewIntentHandled) @@ -131,24 +128,6 @@ private fun SessionsGooglePayContent( } } -@Composable -private fun CheckGooglePayAvailability( - checkAvailability: GooglePayAvailabilityData?, - onAvailabilityChecked: () -> Unit -) { - if (checkAvailability == null) return - val application = LocalContext.current.applicationContext as Application - LaunchedEffect(checkAvailability) { - GooglePayComponent.PROVIDER.isAvailable( - application, - checkAvailability.paymentMethod, - checkAvailability.googlePayConfiguration, - checkAvailability.callback, - ) - onAvailabilityChecked() - } -} - @Composable private fun HandleActivityResult( activityResult: GooglePayActivityResult?, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt index 39c020a071..1e30fd0fba 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt @@ -12,13 +12,11 @@ import android.content.Intent import androidx.compose.runtime.Immutable import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult -import com.adyen.checkout.example.ui.googlepay.GooglePayAvailabilityData @Immutable internal data class SessionsGooglePayState( val uiState: SessionsGooglePayUIState, val componentData: SessionsGooglePayComponentData? = null, - val checkAvailability: GooglePayAvailabilityData? = null, val activityResult: GooglePayActivityResult? = null, val action: Action? = null, val newIntent: Intent? = null, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt index 0106923502..e050db7d14 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt @@ -8,6 +8,7 @@ package com.adyen.checkout.example.ui.googlepay.compose +import android.app.Application import android.content.Intent import android.util.Log import androidx.lifecycle.SavedStateHandle @@ -26,7 +27,7 @@ import com.adyen.checkout.example.service.getSettingsInstallmentOptionsMode import com.adyen.checkout.example.ui.compose.ResultState import com.adyen.checkout.example.ui.configuration.CheckoutConfigurationProvider import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult -import com.adyen.checkout.example.ui.googlepay.GooglePayAvailabilityData +import com.adyen.checkout.googlepay.GooglePayComponent import com.adyen.checkout.googlepay.GooglePayComponentState import com.adyen.checkout.googlepay.GooglePayConfiguration import com.adyen.checkout.sessions.core.CheckoutSession @@ -49,6 +50,7 @@ import javax.inject.Inject @HiltViewModel internal class SessionsGooglePayViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val application: Application, private val paymentsRepository: PaymentsRepository, private val keyValueStorage: KeyValueStorage, checkoutConfigurationProvider: CheckoutConfigurationProvider, @@ -87,17 +89,10 @@ internal class SessionsGooglePayViewModel @Inject constructor( this@SessionsGooglePayViewModel, ) - val checkAvailability = GooglePayAvailabilityData( - paymentMethod, - googlePayConfiguration, - this@SessionsGooglePayViewModel, - ) + checkGooglePayAvailability(paymentMethod, googlePayConfiguration) updateState { - it.copy( - componentData = componentData, - checkAvailability = checkAvailability, - ) + it.copy(componentData = componentData) } } @@ -134,6 +129,18 @@ internal class SessionsGooglePayViewModel @Inject constructor( } } + private fun checkGooglePayAvailability( + paymentMethod: PaymentMethod, + googlePayConfiguration: GooglePayConfiguration, + ) { + GooglePayComponent.PROVIDER.isAvailable( + application, + paymentMethod, + googlePayConfiguration, + this, + ) + } + override fun onAvailabilityResult(isAvailable: Boolean, paymentMethod: PaymentMethod) { viewModelScope.launch { if (isAvailable) { @@ -175,10 +182,6 @@ internal class SessionsGooglePayViewModel @Inject constructor( _googlePayState.update(block) } - fun onAvailabilityChecked() { - updateState { it.copy(checkAvailability = null) } - } - fun onButtonClicked() { updateState { it.copy(uiState = SessionsGooglePayUIState.ShowComponent) } } From 50a2be861644f680bcbbb7df90dfa1476ab07638 Mon Sep 17 00:00:00 2001 From: josephj Date: Wed, 20 Dec 2023 17:06:16 +0100 Subject: [PATCH 15/66] Add event to start google pay from view model COAND-788 --- .../compose/SessionsGooglePayActivity.kt | 5 +--- .../compose/SessionsGooglePayScreen.kt | 28 +++++++++++++------ .../compose/SessionsGooglePayState.kt | 1 + .../compose/SessionsGooglePayViewModel.kt | 15 ++++++++-- .../compose/SessionsStartGooglePayData.kt | 13 +++++++++ 5 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt index e24a539111..756fb23a5a 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayActivity.kt @@ -63,13 +63,10 @@ class SessionsGooglePayActivity : AppCompatActivity() { @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - if (requestCode == ACTIVITY_RESULT_CODE) { - sessionsGooglePayViewModel.onActivityResult(resultCode, data) - } + sessionsGooglePayViewModel.onActivityResult(requestCode, resultCode, data) } companion object { internal const val RETURN_URL_EXTRA = "RETURN_URL_EXTRA" - internal const val ACTIVITY_RESULT_CODE = 1 } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt index ab4f624bce..f22adc0173 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt @@ -72,6 +72,7 @@ internal fun SessionsGooglePayScreen( ) with(googlePayState) { + HandleStartGooglePay(startGooglePay, componentData, viewModel::onGooglePayStarted) HandleActivityResult(activityResult, componentData, viewModel::onActivityResultHandled) HandleAction(action, componentData, viewModel::onActionConsumed) HandleNewIntent(newIntent, componentData, viewModel::onNewIntentHandled) @@ -97,16 +98,9 @@ private fun SessionsGooglePayContent( } SessionsGooglePayUIState.ShowButton -> { - val activity = LocalContext.current as Activity val googlePayComponent = getGooglePayComponent(componentData = googlePayState.componentData) PayButton( - onClick = { - googlePayComponent.startGooglePayScreen( - activity, - SessionsGooglePayActivity.ACTIVITY_RESULT_CODE, - ) - onButtonClicked() - }, + onClick = onButtonClicked, allowedPaymentMethods = googlePayComponent.getGooglePayButtonParameters().allowedPaymentMethods, theme = if (useDarkTheme) ButtonTheme.Light else ButtonTheme.Dark, type = ButtonType.Pay, @@ -128,6 +122,24 @@ private fun SessionsGooglePayContent( } } +@Composable +private fun HandleStartGooglePay( + startGooglePayData: SessionsStartGooglePayData?, + componentData: SessionsGooglePayComponentData?, + onGooglePayStarted: () -> Unit +) { + if (startGooglePayData == null) return + val activity = LocalContext.current as Activity + val googlePayComponent = getGooglePayComponent(componentData = componentData) + LaunchedEffect(startGooglePayData) { + googlePayComponent.startGooglePayScreen( + activity, + startGooglePayData.requestCode, + ) + onGooglePayStarted() + } +} + @Composable private fun HandleActivityResult( activityResult: GooglePayActivityResult?, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt index 1e30fd0fba..01d81c0c0c 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt @@ -17,6 +17,7 @@ import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult internal data class SessionsGooglePayState( val uiState: SessionsGooglePayUIState, val componentData: SessionsGooglePayComponentData? = null, + val startGooglePay: SessionsStartGooglePayData? = null, val activityResult: GooglePayActivityResult? = null, val action: Action? = null, val newIntent: Intent? = null, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt index e050db7d14..ca5b71a38c 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt @@ -183,14 +183,24 @@ internal class SessionsGooglePayViewModel @Inject constructor( } fun onButtonClicked() { - updateState { it.copy(uiState = SessionsGooglePayUIState.ShowComponent) } + updateState { + it.copy( + uiState = SessionsGooglePayUIState.ShowComponent, + startGooglePay = SessionsStartGooglePayData(ACTIVITY_RESULT_CODE), + ) + } + } + + fun onGooglePayStarted() { + updateState { it.copy(startGooglePay = null) } } fun onActionConsumed() { updateState { it.copy(action = null) } } - fun onActivityResult(resultCode: Int, data: Intent?) { + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != ACTIVITY_RESULT_CODE) return updateState { it.copy(activityResult = GooglePayActivityResult(resultCode, data)) } } @@ -208,5 +218,6 @@ internal class SessionsGooglePayViewModel @Inject constructor( companion object { private val TAG = getLogTag() + private const val ACTIVITY_RESULT_CODE = 1 } } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt new file mode 100644 index 0000000000..aecb996e34 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 20/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +internal data class SessionsStartGooglePayData( + val requestCode: Int, +) From 6a1ee467ff61aaacd907d4087ed9801ff36850ad Mon Sep 17 00:00:00 2001 From: josephj Date: Wed, 20 Dec 2023 17:23:20 +0100 Subject: [PATCH 16/66] Refactor state class to ensure component data is not nullable COAND-788 --- .../ui/googlepay/GooglePayActivityResult.kt | 2 + .../compose/SessionsGooglePayAction.kt | 16 ++++++ .../compose/SessionsGooglePayIntent.kt | 16 ++++++ .../compose/SessionsGooglePayScreen.kt | 57 ++++++++----------- .../compose/SessionsGooglePayState.kt | 9 +-- .../compose/SessionsGooglePayUIState.kt | 4 +- .../compose/SessionsGooglePayViewModel.kt | 28 ++++----- .../compose/SessionsStartGooglePayData.kt | 1 + 8 files changed, 79 insertions(+), 54 deletions(-) create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayAction.kt create mode 100644 example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayIntent.kt diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt index 83553cb935..019178c53e 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/GooglePayActivityResult.kt @@ -9,8 +9,10 @@ package com.adyen.checkout.example.ui.googlepay import android.content.Intent +import com.adyen.checkout.example.ui.googlepay.compose.SessionsGooglePayComponentData internal data class GooglePayActivityResult( + val componentData: SessionsGooglePayComponentData, val resultCode: Int, val data: Intent?, ) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayAction.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayAction.kt new file mode 100644 index 0000000000..7abb0433eb --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayAction.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 20/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import com.adyen.checkout.components.core.action.Action + +internal data class SessionsGooglePayAction( + val componentData: SessionsGooglePayComponentData, + val action: Action, +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayIntent.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayIntent.kt new file mode 100644 index 0000000000..92b71e8488 --- /dev/null +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayIntent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by josephj on 20/12/2023. + */ + +package com.adyen.checkout.example.ui.googlepay.compose + +import android.content.Intent + +internal data class SessionsGooglePayIntent( + val componentData: SessionsGooglePayComponentData, + val intent: Intent, +) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt index f22adc0173..d21bb52097 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayScreen.kt @@ -11,7 +11,6 @@ package com.adyen.checkout.example.ui.googlepay.compose import android.app.Activity -import android.content.Intent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -36,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.adyen.checkout.components.compose.AdyenComponent import com.adyen.checkout.components.compose.get -import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.example.ui.compose.ResultContent import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult import com.adyen.checkout.googlepay.GooglePayComponent @@ -72,10 +70,10 @@ internal fun SessionsGooglePayScreen( ) with(googlePayState) { - HandleStartGooglePay(startGooglePay, componentData, viewModel::onGooglePayStarted) - HandleActivityResult(activityResult, componentData, viewModel::onActivityResultHandled) - HandleAction(action, componentData, viewModel::onActionConsumed) - HandleNewIntent(newIntent, componentData, viewModel::onNewIntentHandled) + HandleStartGooglePay(startGooglePay, viewModel::onGooglePayStarted) + HandleActivityResult(activityResultToHandle, viewModel::onActivityResultHandled) + HandleAction(actionToHandle, viewModel::onActionConsumed) + HandleNewIntent(intentToHandle, viewModel::onNewIntentHandled) } } } @@ -97,8 +95,8 @@ private fun SessionsGooglePayContent( CircularProgressIndicator() } - SessionsGooglePayUIState.ShowButton -> { - val googlePayComponent = getGooglePayComponent(componentData = googlePayState.componentData) + is SessionsGooglePayUIState.ShowButton -> { + val googlePayComponent = getGooglePayComponent(componentData = uiState.componentData) PayButton( onClick = onButtonClicked, allowedPaymentMethods = googlePayComponent.getGooglePayButtonParameters().allowedPaymentMethods, @@ -107,8 +105,8 @@ private fun SessionsGooglePayContent( ) } - SessionsGooglePayUIState.ShowComponent -> { - val googlePayComponent = getGooglePayComponent(componentData = googlePayState.componentData) + is SessionsGooglePayUIState.ShowComponent -> { + val googlePayComponent = getGooglePayComponent(componentData = uiState.componentData) AdyenComponent( googlePayComponent, modifier, @@ -125,12 +123,11 @@ private fun SessionsGooglePayContent( @Composable private fun HandleStartGooglePay( startGooglePayData: SessionsStartGooglePayData?, - componentData: SessionsGooglePayComponentData?, onGooglePayStarted: () -> Unit ) { if (startGooglePayData == null) return val activity = LocalContext.current as Activity - val googlePayComponent = getGooglePayComponent(componentData = componentData) + val googlePayComponent = getGooglePayComponent(componentData = startGooglePayData.componentData) LaunchedEffect(startGooglePayData) { googlePayComponent.startGooglePayScreen( activity, @@ -142,50 +139,46 @@ private fun HandleStartGooglePay( @Composable private fun HandleActivityResult( - activityResult: GooglePayActivityResult?, - componentData: SessionsGooglePayComponentData?, + activityResultToHandle: GooglePayActivityResult?, onActivityResultHandled: () -> Unit ) { - if (activityResult == null) return - val googlePayComponent = getGooglePayComponent(componentData = componentData) - LaunchedEffect(activityResult) { - googlePayComponent.handleActivityResult(activityResult.resultCode, activityResult.data) + if (activityResultToHandle == null) return + val googlePayComponent = getGooglePayComponent(componentData = activityResultToHandle.componentData) + LaunchedEffect(activityResultToHandle) { + googlePayComponent.handleActivityResult(activityResultToHandle.resultCode, activityResultToHandle.data) onActivityResultHandled() } } @Composable private fun HandleAction( - action: Action?, - componentData: SessionsGooglePayComponentData?, + actionToHandle: SessionsGooglePayAction?, onActionConsumed: () -> Unit ) { - if (action == null) return + if (actionToHandle == null) return val activity = LocalContext.current as Activity - val googlePayComponent = getGooglePayComponent(componentData = componentData) - LaunchedEffect(action) { - googlePayComponent.handleAction(action, activity) + val googlePayComponent = getGooglePayComponent(componentData = actionToHandle.componentData) + LaunchedEffect(actionToHandle) { + googlePayComponent.handleAction(actionToHandle.action, activity) onActionConsumed() } } @Composable private fun HandleNewIntent( - newIntent: Intent?, - componentData: SessionsGooglePayComponentData?, + intentToHandle: SessionsGooglePayIntent?, onNewIntentHandled: () -> Unit ) { - if (newIntent == null) return - val googlePayComponent = getGooglePayComponent(componentData = componentData) - LaunchedEffect(newIntent) { - googlePayComponent.handleIntent(newIntent) + if (intentToHandle == null) return + val googlePayComponent = getGooglePayComponent(componentData = intentToHandle.componentData) + LaunchedEffect(intentToHandle) { + googlePayComponent.handleIntent(intentToHandle.intent) onNewIntentHandled() } } @Composable -private fun getGooglePayComponent(componentData: SessionsGooglePayComponentData?): GooglePayComponent { - requireNotNull(componentData) { "Component data should not be null" } +private fun getGooglePayComponent(componentData: SessionsGooglePayComponentData): GooglePayComponent { return with(componentData) { GooglePayComponent.PROVIDER.get( checkoutSession, diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt index 01d81c0c0c..af6755c00c 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayState.kt @@ -8,17 +8,14 @@ package com.adyen.checkout.example.ui.googlepay.compose -import android.content.Intent import androidx.compose.runtime.Immutable -import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.example.ui.googlepay.GooglePayActivityResult @Immutable internal data class SessionsGooglePayState( val uiState: SessionsGooglePayUIState, - val componentData: SessionsGooglePayComponentData? = null, val startGooglePay: SessionsStartGooglePayData? = null, - val activityResult: GooglePayActivityResult? = null, - val action: Action? = null, - val newIntent: Intent? = null, + val activityResultToHandle: GooglePayActivityResult? = null, + val actionToHandle: SessionsGooglePayAction? = null, + val intentToHandle: SessionsGooglePayIntent? = null, ) diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt index 806ca6f04e..9e9b9e0b99 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayUIState.kt @@ -17,10 +17,10 @@ internal sealed class SessionsGooglePayUIState { data object Loading : SessionsGooglePayUIState() @Immutable - data object ShowButton : SessionsGooglePayUIState() + data class ShowButton(val componentData: SessionsGooglePayComponentData) : SessionsGooglePayUIState() @Immutable - data object ShowComponent : SessionsGooglePayUIState() + data class ShowComponent(val componentData: SessionsGooglePayComponentData) : SessionsGooglePayUIState() @Immutable data class FinalResult(val finalResult: ResultState) : SessionsGooglePayUIState() diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt index ca5b71a38c..8b7d184ef6 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsGooglePayViewModel.kt @@ -63,6 +63,10 @@ internal class SessionsGooglePayViewModel @Inject constructor( private val _googlePayState = MutableStateFlow(SessionsGooglePayState(SessionsGooglePayUIState.Loading)) val googlePayState: StateFlow = _googlePayState.asStateFlow() + private var _componentData: SessionsGooglePayComponentData? = null + private val componentData: SessionsGooglePayComponentData + get() = requireNotNull(_componentData) { "component data should not be null" } + init { viewModelScope.launch { fetchSession() } } @@ -82,7 +86,7 @@ internal class SessionsGooglePayViewModel @Inject constructor( return@withContext } - val componentData = SessionsGooglePayComponentData( + _componentData = SessionsGooglePayComponentData( checkoutSession, googlePayConfiguration, paymentMethod, @@ -90,10 +94,6 @@ internal class SessionsGooglePayViewModel @Inject constructor( ) checkGooglePayAvailability(paymentMethod, googlePayConfiguration) - - updateState { - it.copy(componentData = componentData) - } } private suspend fun getSession(paymentMethodType: String): CheckoutSession? { @@ -144,7 +144,7 @@ internal class SessionsGooglePayViewModel @Inject constructor( override fun onAvailabilityResult(isAvailable: Boolean, paymentMethod: PaymentMethod) { viewModelScope.launch { if (isAvailable) { - updateState { it.copy(uiState = SessionsGooglePayUIState.ShowButton) } + updateState { it.copy(uiState = SessionsGooglePayUIState.ShowButton(componentData)) } } else { onError() } @@ -152,7 +152,7 @@ internal class SessionsGooglePayViewModel @Inject constructor( } override fun onAction(action: Action) { - updateState { it.copy(action = action) } + updateState { it.copy(actionToHandle = SessionsGooglePayAction(componentData, action)) } } override fun onError(componentError: ComponentError) { @@ -185,8 +185,8 @@ internal class SessionsGooglePayViewModel @Inject constructor( fun onButtonClicked() { updateState { it.copy( - uiState = SessionsGooglePayUIState.ShowComponent, - startGooglePay = SessionsStartGooglePayData(ACTIVITY_RESULT_CODE), + uiState = SessionsGooglePayUIState.ShowComponent(componentData), + startGooglePay = SessionsStartGooglePayData(componentData, ACTIVITY_RESULT_CODE), ) } } @@ -196,24 +196,24 @@ internal class SessionsGooglePayViewModel @Inject constructor( } fun onActionConsumed() { - updateState { it.copy(action = null) } + updateState { it.copy(actionToHandle = null) } } fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode != ACTIVITY_RESULT_CODE) return - updateState { it.copy(activityResult = GooglePayActivityResult(resultCode, data)) } + updateState { it.copy(activityResultToHandle = GooglePayActivityResult(componentData, resultCode, data)) } } fun onActivityResultHandled() { - updateState { it.copy(activityResult = null) } + updateState { it.copy(activityResultToHandle = null) } } fun onNewIntent(intent: Intent) { - updateState { it.copy(newIntent = intent) } + updateState { it.copy(intentToHandle = SessionsGooglePayIntent(componentData, intent)) } } fun onNewIntentHandled() { - updateState { it.copy(newIntent = null) } + updateState { it.copy(intentToHandle = null) } } companion object { diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt index aecb996e34..700a248e62 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/googlepay/compose/SessionsStartGooglePayData.kt @@ -9,5 +9,6 @@ package com.adyen.checkout.example.ui.googlepay.compose internal data class SessionsStartGooglePayData( + val componentData: SessionsGooglePayComponentData, val requestCode: Int, ) From c2a6f8ddc2ac873f5d7478f91a0be85addf699fe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 09:41:01 +0000 Subject: [PATCH 17/66] Update dependency androidx.browser:browser to v1.7.0 --- dependencies.gradle | 2 +- gradle/verification-metadata.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 9b559384a6..060c32f3c2 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -33,7 +33,7 @@ ext { // Android Dependencies annotation_version = "1.7.0" appcompat_version = "1.6.1" - browser_version = "1.6.0" + browser_version = "1.7.0" coroutines_version = "1.6.4" fragment_version = "1.6.2" lifecycle_version = "2.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 181853a004..c7642e85e3 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -402,6 +402,14 @@ + + + + + + + + From aa340d5ccf23c7b2f4c9b030cdb7420673a4e90d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 09:43:10 +0000 Subject: [PATCH 18/66] Update dependency gradle to v8.5 --- gradle/verification-metadata.xml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 63721 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c7642e85e3..fc3d5a23ce 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -6,7 +6,7 @@ ~ ~ Created by oscars on 16/5/2023. --> - + true false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7f93135c49b765f8051ef9d0a6055ff8e46073d8..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 zcma&NWl&^owk(X(xVyW%ySuwf;qI=D6|RlDJ2cR^yEKh!@I- zp9QeisK*rlxC>+~7Dk4IxIRsKBHqdR9b3+fyL=ynHmIDe&|>O*VlvO+%z5;9Z$|DJ zb4dO}-R=MKr^6EKJiOrJdLnCJn>np?~vU-1sSFgPu;pthGwf}bG z(1db%xwr#x)r+`4AGu$j7~u2MpVs3VpLp|mx&;>`0p0vH6kF+D2CY0fVdQOZ@h;A` z{infNyvmFUiu*XG}RNMNwXrbec_*a3N=2zJ|Wh5z* z5rAX$JJR{#zP>KY**>xHTuw?|-Rg|o24V)74HcfVT;WtQHXlE+_4iPE8QE#DUm%x0 zEKr75ur~W%w#-My3Tj`hH6EuEW+8K-^5P62$7Sc5OK+22qj&Pd1;)1#4tKihi=~8C zHiQSst0cpri6%OeaR`PY>HH_;CPaRNty%WTm4{wDK8V6gCZlG@U3$~JQZ;HPvDJcT1V{ z?>H@13MJcCNe#5z+MecYNi@VT5|&UiN1D4ATT+%M+h4c$t;C#UAs3O_q=GxK0}8%8 z8J(_M9bayxN}69ex4dzM_P3oh@ZGREjVvn%%r7=xjkqxJP4kj}5tlf;QosR=%4L5y zWhgejO=vao5oX%mOHbhJ8V+SG&K5dABn6!WiKl{|oPkq(9z8l&Mm%(=qGcFzI=eLu zWc_oCLyf;hVlB@dnwY98?75B20=n$>u3b|NB28H0u-6Rpl((%KWEBOfElVWJx+5yg z#SGqwza7f}$z;n~g%4HDU{;V{gXIhft*q2=4zSezGK~nBgu9-Q*rZ#2f=Q}i2|qOp z!!y4p)4o=LVUNhlkp#JL{tfkhXNbB=Ox>M=n6soptJw-IDI|_$is2w}(XY>a=H52d z3zE$tjPUhWWS+5h=KVH&uqQS=$v3nRs&p$%11b%5qtF}S2#Pc`IiyBIF4%A!;AVoI zXU8-Rpv!DQNcF~(qQnyyMy=-AN~U>#&X1j5BLDP{?K!%h!;hfJI>$mdLSvktEr*89 zdJHvby^$xEX0^l9g$xW-d?J;L0#(`UT~zpL&*cEh$L|HPAu=P8`OQZV!-}l`noSp_ zQ-1$q$R-gDL)?6YaM!=8H=QGW$NT2SeZlb8PKJdc=F-cT@j7Xags+Pr*jPtlHFnf- zh?q<6;)27IdPc^Wdy-mX%2s84C1xZq9Xms+==F4);O`VUASmu3(RlgE#0+#giLh-& zcxm3_e}n4{%|X zJp{G_j+%`j_q5}k{eW&TlP}J2wtZ2^<^E(O)4OQX8FDp6RJq!F{(6eHWSD3=f~(h} zJXCf7=r<16X{pHkm%yzYI_=VDP&9bmI1*)YXZeB}F? z(%QsB5fo*FUZxK$oX~X^69;x~j7ms8xlzpt-T15e9}$4T-pC z6PFg@;B-j|Ywajpe4~bk#S6(fO^|mm1hKOPfA%8-_iGCfICE|=P_~e;Wz6my&)h_~ zkv&_xSAw7AZ%ThYF(4jADW4vg=oEdJGVOs>FqamoL3Np8>?!W#!R-0%2Bg4h?kz5I zKV-rKN2n(vUL%D<4oj@|`eJ>0i#TmYBtYmfla;c!ATW%;xGQ0*TW@PTlGG><@dxUI zg>+3SiGdZ%?5N=8uoLA|$4isK$aJ%i{hECP$bK{J#0W2gQ3YEa zZQ50Stn6hqdfxJ*9#NuSLwKFCUGk@c=(igyVL;;2^wi4o30YXSIb2g_ud$ zgpCr@H0qWtk2hK8Q|&wx)}4+hTYlf;$a4#oUM=V@Cw#!$(nOFFpZ;0lc!qd=c$S}Z zGGI-0jg~S~cgVT=4Vo)b)|4phjStD49*EqC)IPwyeKBLcN;Wu@Aeph;emROAwJ-0< z_#>wVm$)ygH|qyxZaet&(Vf%pVdnvKWJn9`%DAxj3ot;v>S$I}jJ$FLBF*~iZ!ZXE zkvui&p}fI0Y=IDX)mm0@tAd|fEHl~J&K}ZX(Mm3cm1UAuwJ42+AO5@HwYfDH7ipIc zmI;1J;J@+aCNG1M`Btf>YT>~c&3j~Qi@Py5JT6;zjx$cvOQW@3oQ>|}GH?TW-E z1R;q^QFjm5W~7f}c3Ww|awg1BAJ^slEV~Pk`Kd`PS$7;SqJZNj->it4DW2l15}xP6 zoCl$kyEF%yJni0(L!Z&14m!1urXh6Btj_5JYt1{#+H8w?5QI%% zo-$KYWNMJVH?Hh@1n7OSu~QhSswL8x0=$<8QG_zepi_`y_79=nK=_ZP_`Em2UI*tyQoB+r{1QYZCpb?2OrgUw#oRH$?^Tj!Req>XiE#~B|~ z+%HB;=ic+R@px4Ld8mwpY;W^A%8%l8$@B@1m5n`TlKI6bz2mp*^^^1mK$COW$HOfp zUGTz-cN9?BGEp}5A!mDFjaiWa2_J2Iq8qj0mXzk; z66JBKRP{p%wN7XobR0YjhAuW9T1Gw3FDvR5dWJ8ElNYF94eF3ebu+QwKjtvVu4L zI9ip#mQ@4uqVdkl-TUQMb^XBJVLW(-$s;Nq;@5gr4`UfLgF$adIhd?rHOa%D);whv z=;krPp~@I+-Z|r#s3yCH+c1US?dnm+C*)r{m+86sTJusLdNu^sqLrfWed^ndHXH`m zd3#cOe3>w-ga(Dus_^ppG9AC>Iq{y%%CK+Cro_sqLCs{VLuK=dev>OL1dis4(PQ5R zcz)>DjEkfV+MO;~>VUlYF00SgfUo~@(&9$Iy2|G0T9BSP?&T22>K46D zL*~j#yJ?)^*%J3!16f)@Y2Z^kS*BzwfAQ7K96rFRIh>#$*$_Io;z>ux@}G98!fWR@ zGTFxv4r~v)Gsd|pF91*-eaZ3Qw1MH$K^7JhWIdX%o$2kCbvGDXy)a?@8T&1dY4`;L z4Kn+f%SSFWE_rpEpL9bnlmYq`D!6F%di<&Hh=+!VI~j)2mfil03T#jJ_s?}VV0_hp z7T9bWxc>Jm2Z0WMU?`Z$xE74Gu~%s{mW!d4uvKCx@WD+gPUQ zV0vQS(Ig++z=EHN)BR44*EDSWIyT~R4$FcF*VEY*8@l=218Q05D2$|fXKFhRgBIEE zdDFB}1dKkoO^7}{5crKX!p?dZWNz$m>1icsXG2N+((x0OIST9Zo^DW_tytvlwXGpn zs8?pJXjEG;T@qrZi%#h93?FP$!&P4JA(&H61tqQi=opRzNpm zkrG}$^t9&XduK*Qa1?355wd8G2CI6QEh@Ua>AsD;7oRUNLPb76m4HG3K?)wF~IyS3`fXuNM>${?wmB zpVz;?6_(Fiadfd{vUCBM*_kt$+F3J+IojI;9L(gc9n3{sEZyzR9o!_mOwFC#tQ{Q~ zP3-`#uK#tP3Q7~Q;4H|wjZHO8h7e4IuBxl&vz2w~D8)w=Wtg31zpZhz%+kzSzL*dV zwp@{WU4i;hJ7c2f1O;7Mz6qRKeASoIv0_bV=i@NMG*l<#+;INk-^`5w@}Dj~;k=|}qM1vq_P z|GpBGe_IKq|LNy9SJhKOQ$c=5L{Dv|Q_lZl=-ky*BFBJLW9&y_C|!vyM~rQx=!vun z?rZJQB5t}Dctmui5i31C_;_}CEn}_W%>oSXtt>@kE1=JW*4*v4tPp;O6 zmAk{)m!)}34pTWg8{i>($%NQ(Tl;QC@J@FfBoc%Gr&m560^kgSfodAFrIjF}aIw)X zoXZ`@IsMkc8_=w%-7`D6Y4e*CG8k%Ud=GXhsTR50jUnm+R*0A(O3UKFg0`K;qp1bl z7``HN=?39ic_kR|^R^~w-*pa?Vj#7|e9F1iRx{GN2?wK!xR1GW!qa=~pjJb-#u1K8 zeR?Y2i-pt}yJq;SCiVHODIvQJX|ZJaT8nO+(?HXbLefulKKgM^B(UIO1r+S=7;kLJ zcH}1J=Px2jsh3Tec&v8Jcbng8;V-`#*UHt?hB(pmOipKwf3Lz8rG$heEB30Sg*2rx zV<|KN86$soN(I!BwO`1n^^uF2*x&vJ$2d$>+`(romzHP|)K_KkO6Hc>_dwMW-M(#S zK(~SiXT1@fvc#U+?|?PniDRm01)f^#55;nhM|wi?oG>yBsa?~?^xTU|fX-R(sTA+5 zaq}-8Tx7zrOy#3*JLIIVsBmHYLdD}!0NP!+ITW+Thn0)8SS!$@)HXwB3tY!fMxc#1 zMp3H?q3eD?u&Njx4;KQ5G>32+GRp1Ee5qMO0lZjaRRu&{W<&~DoJNGkcYF<5(Ab+J zgO>VhBl{okDPn78<%&e2mR{jwVCz5Og;*Z;;3%VvoGo_;HaGLWYF7q#jDX=Z#Ml`H z858YVV$%J|e<1n`%6Vsvq7GmnAV0wW4$5qQ3uR@1i>tW{xrl|ExywIc?fNgYlA?C5 zh$ezAFb5{rQu6i7BSS5*J-|9DQ{6^BVQ{b*lq`xS@RyrsJN?-t=MTMPY;WYeKBCNg z^2|pN!Q^WPJuuO4!|P@jzt&tY1Y8d%FNK5xK(!@`jO2aEA*4 zkO6b|UVBipci?){-Ke=+1;mGlND8)6+P;8sq}UXw2hn;fc7nM>g}GSMWu&v&fqh

iViYT=fZ(|3Ox^$aWPp4a8h24tD<|8-!aK0lHgL$N7Efw}J zVIB!7=T$U`ao1?upi5V4Et*-lTG0XvExbf!ya{cua==$WJyVG(CmA6Of*8E@DSE%L z`V^$qz&RU$7G5mg;8;=#`@rRG`-uS18$0WPN@!v2d{H2sOqP|!(cQ@ zUHo!d>>yFArLPf1q`uBvY32miqShLT1B@gDL4XoVTK&@owOoD)OIHXrYK-a1d$B{v zF^}8D3Y^g%^cnvScOSJR5QNH+BI%d|;J;wWM3~l>${fb8DNPg)wrf|GBP8p%LNGN# z3EaIiItgwtGgT&iYCFy9-LG}bMI|4LdmmJt@V@% zb6B)1kc=T)(|L@0;wr<>=?r04N;E&ef+7C^`wPWtyQe(*pD1pI_&XHy|0gIGHMekd zF_*M4yi6J&Z4LQj65)S zXwdM{SwUo%3SbPwFsHgqF@V|6afT|R6?&S;lw=8% z3}@9B=#JI3@B*#4s!O))~z zc>2_4Q_#&+5V`GFd?88^;c1i7;Vv_I*qt!_Yx*n=;rj!82rrR2rQ8u5(Ejlo{15P% zs~!{%XJ>FmJ})H^I9bn^Re&38H{xA!0l3^89k(oU;bZWXM@kn$#aoS&Y4l^-WEn-fH39Jb9lA%s*WsKJQl?n9B7_~P z-XM&WL7Z!PcoF6_D>V@$CvUIEy=+Z&0kt{szMk=f1|M+r*a43^$$B^MidrT0J;RI` z(?f!O<8UZkm$_Ny$Hth1J#^4ni+im8M9mr&k|3cIgwvjAgjH z8`N&h25xV#v*d$qBX5jkI|xOhQn!>IYZK7l5#^P4M&twe9&Ey@@GxYMxBZq2e7?`q z$~Szs0!g{2fGcp9PZEt|rdQ6bhAgpcLHPz?f-vB?$dc*!9OL?Q8mn7->bFD2Si60* z!O%y)fCdMSV|lkF9w%x~J*A&srMyYY3{=&$}H zGQ4VG_?$2X(0|vT0{=;W$~icCI{b6W{B!Q8xdGhF|D{25G_5_+%s(46lhvNLkik~R z>nr(&C#5wwOzJZQo9m|U<;&Wk!_#q|V>fsmj1g<6%hB{jGoNUPjgJslld>xmODzGjYc?7JSuA?A_QzjDw5AsRgi@Y|Z0{F{!1=!NES-#*f^s4l0Hu zz468))2IY5dmD9pa*(yT5{EyP^G>@ZWumealS-*WeRcZ}B%gxq{MiJ|RyX-^C1V=0 z@iKdrGi1jTe8Ya^x7yyH$kBNvM4R~`fbPq$BzHum-3Zo8C6=KW@||>zsA8-Y9uV5V z#oq-f5L5}V<&wF4@X@<3^C%ptp6+Ce)~hGl`kwj)bsAjmo_GU^r940Z-|`<)oGnh7 zFF0Tde3>ui?8Yj{sF-Z@)yQd~CGZ*w-6p2U<8}JO-sRsVI5dBji`01W8A&3$?}lxBaC&vn0E$c5tW* zX>5(zzZ=qn&!J~KdsPl;P@bmA-Pr8T*)eh_+Dv5=Ma|XSle6t(k8qcgNyar{*ReQ8 zTXwi=8vr>!3Ywr+BhggHDw8ke==NTQVMCK`$69fhzEFB*4+H9LIvdt-#IbhZvpS}} zO3lz;P?zr0*0$%-Rq_y^k(?I{Mk}h@w}cZpMUp|ucs55bcloL2)($u%mXQw({Wzc~ z;6nu5MkjP)0C(@%6Q_I_vsWrfhl7Zpoxw#WoE~r&GOSCz;_ro6i(^hM>I$8y>`!wW z*U^@?B!MMmb89I}2(hcE4zN2G^kwyWCZp5JG>$Ez7zP~D=J^LMjSM)27_0B_X^C(M z`fFT+%DcKlu?^)FCK>QzSnV%IsXVcUFhFdBP!6~se&xxrIxsvySAWu++IrH;FbcY$ z2DWTvSBRfLwdhr0nMx+URA$j3i7_*6BWv#DXfym?ZRDcX9C?cY9sD3q)uBDR3uWg= z(lUIzB)G$Hr!){>E{s4Dew+tb9kvToZp-1&c?y2wn@Z~(VBhqz`cB;{E4(P3N2*nJ z_>~g@;UF2iG{Kt(<1PyePTKahF8<)pozZ*xH~U-kfoAayCwJViIrnqwqO}7{0pHw$ zs2Kx?s#vQr7XZ264>5RNKSL8|Ty^=PsIx^}QqOOcfpGUU4tRkUc|kc7-!Ae6!+B{o~7nFpm3|G5^=0#Bnm6`V}oSQlrX(u%OWnC zoLPy&Q;1Jui&7ST0~#+}I^&?vcE*t47~Xq#YwvA^6^} z`WkC)$AkNub|t@S!$8CBlwbV~?yp&@9h{D|3z-vJXgzRC5^nYm+PyPcgRzAnEi6Q^gslXYRv4nycsy-SJu?lMps-? zV`U*#WnFsdPLL)Q$AmD|0`UaC4ND07+&UmOu!eHruzV|OUox<+Jl|Mr@6~C`T@P%s zW7sgXLF2SSe9Fl^O(I*{9wsFSYb2l%-;&Pi^dpv!{)C3d0AlNY6!4fgmSgj_wQ*7Am7&$z;Jg&wgR-Ih;lUvWS|KTSg!&s_E9_bXBkZvGiC6bFKDWZxsD$*NZ#_8bl zG1P-#@?OQzED7@jlMJTH@V!6k;W>auvft)}g zhoV{7$q=*;=l{O>Q4a@ ziMjf_u*o^PsO)#BjC%0^h>Xp@;5$p{JSYDt)zbb}s{Kbt!T*I@Pk@X0zds6wsefuU zW$XY%yyRGC94=6mf?x+bbA5CDQ2AgW1T-jVAJbm7K(gp+;v6E0WI#kuACgV$r}6L? zd|Tj?^%^*N&b>Dd{Wr$FS2qI#Ucs1yd4N+RBUQiSZGujH`#I)mG&VKoDh=KKFl4=G z&MagXl6*<)$6P}*Tiebpz5L=oMaPrN+caUXRJ`D?=K9!e0f{@D&cZLKN?iNP@X0aF zE(^pl+;*T5qt?1jRC=5PMgV!XNITRLS_=9{CJExaQj;lt!&pdzpK?8p>%Mb+D z?yO*uSung=-`QQ@yX@Hyd4@CI^r{2oiu`%^bNkz+Nkk!IunjwNC|WcqvX~k=><-I3 zDQdbdb|!v+Iz01$w@aMl!R)koD77Xp;eZwzSl-AT zr@Vu{=xvgfq9akRrrM)}=!=xcs+U1JO}{t(avgz`6RqiiX<|hGG1pmop8k6Q+G_mv zJv|RfDheUp2L3=^C=4aCBMBn0aRCU(DQwX-W(RkRwmLeuJYF<0urcaf(=7)JPg<3P zQs!~G)9CT18o!J4{zX{_e}4eS)U-E)0FAt}wEI(c0%HkxgggW;(1E=>J17_hsH^sP z%lT0LGgbUXHx-K*CI-MCrP66UP0PvGqM$MkeLyqHdbgP|_Cm!7te~b8p+e6sQ_3k| zVcwTh6d83ltdnR>D^)BYQpDKlLk3g0Hdcgz2}%qUs9~~Rie)A-BV1mS&naYai#xcZ z(d{8=-LVpTp}2*y)|gR~;qc7fp26}lPcLZ#=JpYcn3AT9(UIdOyg+d(P5T7D&*P}# zQCYplZO5|7+r19%9e`v^vfSS1sbX1c%=w1;oyruXB%Kl$ACgKQ6=qNWLsc=28xJjg zwvsI5-%SGU|3p>&zXVl^vVtQT3o-#$UT9LI@Npz~6=4!>mc431VRNN8od&Ul^+G_kHC`G=6WVWM z%9eWNyy(FTO|A+@x}Ou3CH)oi;t#7rAxdIXfNFwOj_@Y&TGz6P_sqiB`Q6Lxy|Q{`|fgmRG(k+!#b*M+Z9zFce)f-7;?Km5O=LHV9f9_87; zF7%R2B+$?@sH&&-$@tzaPYkw0;=i|;vWdI|Wl3q_Zu>l;XdIw2FjV=;Mq5t1Q0|f< zs08j54Bp`3RzqE=2enlkZxmX6OF+@|2<)A^RNQpBd6o@OXl+i)zO%D4iGiQNuXd+zIR{_lb96{lc~bxsBveIw6umhShTX+3@ZJ=YHh@ zWY3(d0azg;7oHn>H<>?4@*RQbi>SmM=JrHvIG(~BrvI)#W(EAeO6fS+}mxxcc+X~W6&YVl86W9WFSS}Vz-f9vS?XUDBk)3TcF z8V?$4Q)`uKFq>xT=)Y9mMFVTUk*NIA!0$?RP6Ig0TBmUFrq*Q-Agq~DzxjStQyJ({ zBeZ;o5qUUKg=4Hypm|}>>L=XKsZ!F$yNTDO)jt4H0gdQ5$f|d&bnVCMMXhNh)~mN z@_UV6D7MVlsWz+zM+inZZp&P4fj=tm6fX)SG5H>OsQf_I8c~uGCig$GzuwViK54bcgL;VN|FnyQl>Ed7(@>=8$a_UKIz|V6CeVSd2(P z0Uu>A8A+muM%HLFJQ9UZ5c)BSAv_zH#1f02x?h9C}@pN@6{>UiAp>({Fn(T9Q8B z^`zB;kJ5b`>%dLm+Ol}ty!3;8f1XDSVX0AUe5P#@I+FQ-`$(a;zNgz)4x5hz$Hfbg z!Q(z26wHLXko(1`;(BAOg_wShpX0ixfWq3ponndY+u%1gyX)_h=v1zR#V}#q{au6; z!3K=7fQwnRfg6FXtNQmP>`<;!N137paFS%y?;lb1@BEdbvQHYC{976l`cLqn;b8lp zIDY>~m{gDj(wfnK!lpW6pli)HyLEiUrNc%eXTil|F2s(AY+LW5hkKb>TQ3|Q4S9rr zpDs4uK_co6XPsn_z$LeS{K4jFF`2>U`tbgKdyDne`xmR<@6AA+_hPNKCOR-Zqv;xk zu5!HsBUb^!4uJ7v0RuH-7?l?}b=w5lzzXJ~gZcxRKOovSk@|#V+MuX%Y+=;14i*%{)_gSW9(#4%)AV#3__kac1|qUy!uyP{>?U#5wYNq}y$S9pCc zFc~4mgSC*G~j0u#qqp9 z${>3HV~@->GqEhr_Xwoxq?Hjn#=s2;i~g^&Hn|aDKpA>Oc%HlW(KA1?BXqpxB;Ydx)w;2z^MpjJ(Qi(X!$5RC z*P{~%JGDQqojV>2JbEeCE*OEu!$XJ>bWA9Oa_Hd;y)F%MhBRi*LPcdqR8X`NQ&1L# z5#9L*@qxrx8n}LfeB^J{%-?SU{FCwiWyHp682F+|pa+CQa3ZLzBqN1{)h4d6+vBbV zC#NEbQLC;}me3eeYnOG*nXOJZEU$xLZ1<1Y=7r0(-U0P6-AqwMAM`a(Ed#7vJkn6plb4eI4?2y3yOTGmmDQ!z9`wzbf z_OY#0@5=bnep;MV0X_;;SJJWEf^E6Bd^tVJ9znWx&Ks8t*B>AM@?;D4oWUGc z!H*`6d7Cxo6VuyS4Eye&L1ZRhrRmN6Lr`{NL(wDbif|y&z)JN>Fl5#Wi&mMIr5i;x zBx}3YfF>>8EC(fYnmpu~)CYHuHCyr5*`ECap%t@y=jD>!_%3iiE|LN$mK9>- zHdtpy8fGZtkZF?%TW~29JIAfi2jZT8>OA7=h;8T{{k?c2`nCEx9$r zS+*&vt~2o^^J+}RDG@+9&M^K*z4p{5#IEVbz`1%`m5c2};aGt=V?~vIM}ZdPECDI)47|CWBCfDWUbxBCnmYivQ*0Nu_xb*C>~C9(VjHM zxe<*D<#dQ8TlpMX2c@M<9$w!RP$hpG4cs%AI){jp*Sj|*`m)5(Bw*A0$*i-(CA5#%>a)$+jI2C9r6|(>J8InryENI z$NohnxDUB;wAYDwrb*!N3noBTKPpPN}~09SEL18tkG zxgz(RYU_;DPT{l?Q$+eaZaxnsWCA^ds^0PVRkIM%bOd|G2IEBBiz{&^JtNsODs;5z zICt_Zj8wo^KT$7Bg4H+y!Df#3mbl%%?|EXe!&(Vmac1DJ*y~3+kRKAD=Ovde4^^%~ zw<9av18HLyrf*_>Slp;^i`Uy~`mvBjZ|?Ad63yQa#YK`4+c6;pW4?XIY9G1(Xh9WO8{F-Aju+nS9Vmv=$Ac0ienZ+p9*O%NG zMZKy5?%Z6TAJTE?o5vEr0r>f>hb#2w2U3DL64*au_@P!J!TL`oH2r*{>ffu6|A7tv zL4juf$DZ1MW5ZPsG!5)`k8d8c$J$o;%EIL0va9&GzWvkS%ZsGb#S(?{!UFOZ9<$a| zY|a+5kmD5N&{vRqkgY>aHsBT&`rg|&kezoD)gP0fsNYHsO#TRc_$n6Lf1Z{?+DLziXlHrq4sf(!>O{?Tj;Eh@%)+nRE_2VxbN&&%%caU#JDU%vL3}Cb zsb4AazPI{>8H&d=jUaZDS$-0^AxE@utGs;-Ez_F(qC9T=UZX=>ok2k2 ziTn{K?y~a5reD2A)P${NoI^>JXn>`IeArow(41c-Wm~)wiryEP(OS{YXWi7;%dG9v zI?mwu1MxD{yp_rrk!j^cKM)dc4@p4Ezyo%lRN|XyD}}>v=Xoib0gOcdXrQ^*61HNj z=NP|pd>@yfvr-=m{8$3A8TQGMTE7g=z!%yt`8`Bk-0MMwW~h^++;qyUP!J~ykh1GO z(FZ59xuFR$(WE;F@UUyE@Sp>`aVNjyj=Ty>_Vo}xf`e7`F;j-IgL5`1~-#70$9_=uBMq!2&1l zomRgpD58@)YYfvLtPW}{C5B35R;ZVvB<<#)x%srmc_S=A7F@DW8>QOEGwD6suhwCg z>Pa+YyULhmw%BA*4yjDp|2{!T98~<6Yfd(wo1mQ!KWwq0eg+6)o1>W~f~kL<-S+P@$wx*zeI|1t7z#Sxr5 zt6w+;YblPQNplq4Z#T$GLX#j6yldXAqj>4gAnnWtBICUnA&-dtnlh=t0Ho_vEKwV` z)DlJi#!@nkYV#$!)@>udAU*hF?V`2$Hf=V&6PP_|r#Iv*J$9)pF@X3`k;5})9^o4y z&)~?EjX5yX12O(BsFy-l6}nYeuKkiq`u9145&3Ssg^y{5G3Pse z9w(YVa0)N-fLaBq1`P!_#>SS(8fh_5!f{UrgZ~uEdeMJIz7DzI5!NHHqQtm~#CPij z?=N|J>nPR6_sL7!f4hD_|KH`vf8(Wpnj-(gPWH+ZvID}%?~68SwhPTC3u1_cB`otq z)U?6qo!ZLi5b>*KnYHWW=3F!p%h1;h{L&(Q&{qY6)_qxNfbP6E3yYpW!EO+IW3?@J z);4>g4gnl^8klu7uA>eGF6rIGSynacogr)KUwE_R4E5Xzi*Qir@b-jy55-JPC8c~( zo!W8y9OGZ&`xmc8;=4-U9=h{vCqfCNzYirONmGbRQlR`WWlgnY+1wCXbMz&NT~9*| z6@FrzP!LX&{no2!Ln_3|I==_4`@}V?4a;YZKTdw;vT<+K+z=uWbW(&bXEaWJ^W8Td z-3&1bY^Z*oM<=M}LVt>_j+p=2Iu7pZmbXrhQ_k)ysE9yXKygFNw$5hwDn(M>H+e1&9BM5!|81vd%r%vEm zqxY3?F@fb6O#5UunwgAHR9jp_W2zZ}NGp2%mTW@(hz7$^+a`A?mb8|_G*GNMJ) zjqegXQio=i@AINre&%ofexAr95aop5C+0MZ0m-l=MeO8m3epm7U%vZB8+I+C*iNFM z#T3l`gknX;D$-`2XT^Cg*vrv=RH+P;_dfF++cP?B_msQI4j+lt&rX2)3GaJx%W*Nn zkML%D{z5tpHH=dksQ*gzc|}gzW;lwAbxoR07VNgS*-c3d&8J|;@3t^ zVUz*J*&r7DFRuFVDCJDK8V9NN5hvpgGjwx+5n)qa;YCKe8TKtdnh{I7NU9BCN!0dq zczrBk8pE{{@vJa9ywR@mq*J=v+PG;?fwqlJVhijG!3VmIKs>9T6r7MJpC)m!Tc#>g zMtVsU>wbwFJEfwZ{vB|ZlttNe83)$iz`~#8UJ^r)lJ@HA&G#}W&ZH*;k{=TavpjWE z7hdyLZPf*X%Gm}i`Y{OGeeu^~nB8=`{r#TUrM-`;1cBvEd#d!kPqIgYySYhN-*1;L z^byj%Yi}Gx)Wnkosi337BKs}+5H5dth1JA{Ir-JKN$7zC)*}hqeoD(WfaUDPT>0`- z(6sa0AoIqASwF`>hP}^|)a_j2s^PQn*qVC{Q}htR z5-)duBFXT_V56-+UohKXlq~^6uf!6sA#ttk1o~*QEy_Y-S$gAvq47J9Vtk$5oA$Ct zYhYJ@8{hsC^98${!#Ho?4y5MCa7iGnfz}b9jE~h%EAAv~Qxu)_rAV;^cygV~5r_~?l=B`zObj7S=H=~$W zPtI_m%g$`kL_fVUk9J@>EiBH zOO&jtn~&`hIFMS5S`g8w94R4H40mdNUH4W@@XQk1sr17b{@y|JB*G9z1|CrQjd+GX z6+KyURG3;!*BQrentw{B2R&@2&`2}n(z-2&X7#r!{yg@Soy}cRD~j zj9@UBW+N|4HW4AWapy4wfUI- zZ`gSL6DUlgj*f1hSOGXG0IVH8HxK?o2|3HZ;KW{K+yPAlxtb)NV_2AwJm|E)FRs&& z=c^e7bvUsztY|+f^k7NXs$o1EUq>cR7C0$UKi6IooHWlK_#?IWDkvywnzg&ThWo^? z2O_N{5X39#?eV9l)xI(>@!vSB{DLt*oY!K1R8}_?%+0^C{d9a%N4 zoxHVT1&Lm|uDX%$QrBun5e-F`HJ^T$ zmzv)p@4ZHd_w9!%Hf9UYNvGCw2TTTbrj9pl+T9%-_-}L(tES>Or-}Z4F*{##n3~L~TuxjirGuIY#H7{%$E${?p{Q01 zi6T`n;rbK1yIB9jmQNycD~yZq&mbIsFWHo|ZAChSFPQa<(%d8mGw*V3fh|yFoxOOiWJd(qvVb!Z$b88cg->N=qO*4k~6;R==|9ihg&riu#P~s4Oap9O7f%crSr^rljeIfXDEg>wi)&v*a%7zpz<9w z*r!3q9J|390x`Zk;g$&OeN&ctp)VKRpDSV@kU2Q>jtok($Y-*x8_$2piTxun81@vt z!Vj?COa0fg2RPXMSIo26T=~0d`{oGP*eV+$!0I<(4azk&Vj3SiG=Q!6mX0p$z7I}; z9BJUFgT-K9MQQ-0@Z=^7R<{bn2Fm48endsSs`V7_@%8?Bxkqv>BDoVcj?K#dV#uUP zL1ND~?D-|VGKe3Rw_7-Idpht>H6XRLh*U7epS6byiGvJpr%d}XwfusjH9g;Z98H`x zyde%%5mhGOiL4wljCaWCk-&uE4_OOccb9c!ZaWt4B(wYl!?vyzl%7n~QepN&eFUrw zFIOl9c({``6~QD+43*_tzP{f2x41h(?b43^y6=iwyB)2os5hBE!@YUS5?N_tXd=h( z)WE286Fbd>R4M^P{!G)f;h<3Q>Fipuy+d2q-)!RyTgt;wr$(?9ox3;q+{E*ZQHhOn;lM`cjnu9 zXa48ks-v(~b*;MAI<>YZH(^NV8vjb34beE<_cwKlJoR;k6lJNSP6v}uiyRD?|0w+X@o1ONrH8a$fCxXpf? z?$DL0)7|X}Oc%h^zrMKWc-NS9I0Utu@>*j}b@tJ=ixQSJ={4@854wzW@E>VSL+Y{i z#0b=WpbCZS>kUCO_iQz)LoE>P5LIG-hv9E+oG}DtlIDF>$tJ1aw9^LuhLEHt?BCj& z(O4I8v1s#HUi5A>nIS-JK{v!7dJx)^Yg%XjNmlkWAq2*cv#tHgz`Y(bETc6CuO1VkN^L-L3j_x<4NqYb5rzrLC-7uOv z!5e`GZt%B782C5-fGnn*GhDF$%(qP<74Z}3xx+{$4cYKy2ikxI7B2N+2r07DN;|-T->nU&!=Cm#rZt%O_5c&1Z%nlWq3TKAW0w zQqemZw_ue--2uKQsx+niCUou?HjD`xhEjjQd3%rrBi82crq*~#uA4+>vR<_S{~5ce z-2EIl?~s z1=GVL{NxP1N3%=AOaC}j_Fv=ur&THz zyO!d9kHq|c73kpq`$+t+8Bw7MgeR5~`d7ChYyGCBWSteTB>8WAU(NPYt2Dk`@#+}= zI4SvLlyk#pBgVigEe`?NG*vl7V6m+<}%FwPV=~PvvA)=#ths==DRTDEYh4V5}Cf$z@#;< zyWfLY_5sP$gc3LLl2x+Ii)#b2nhNXJ{R~vk`s5U7Nyu^3yFg&D%Txwj6QezMX`V(x z=C`{76*mNb!qHHs)#GgGZ_7|vkt9izl_&PBrsu@}L`X{95-2jf99K)0=*N)VxBX2q z((vkpP2RneSIiIUEnGb?VqbMb=Zia+rF~+iqslydE34cSLJ&BJW^3knX@M;t*b=EA zNvGzv41Ld_T+WT#XjDB840vovUU^FtN_)G}7v)1lPetgpEK9YS^OWFkPoE{ovj^=@ zO9N$S=G$1ecndT_=5ehth2Lmd1II-PuT~C9`XVePw$y8J#dpZ?Tss<6wtVglm(Ok7 z3?^oi@pPio6l&!z8JY(pJvG=*pI?GIOu}e^EB6QYk$#FJQ%^AIK$I4epJ+9t?KjqA+bkj&PQ*|vLttme+`9G=L% ziadyMw_7-M)hS(3E$QGNCu|o23|%O+VN7;Qggp?PB3K-iSeBa2b}V4_wY`G1Jsfz4 z9|SdB^;|I8E8gWqHKx!vj_@SMY^hLEIbSMCuE?WKq=c2mJK z8LoG-pnY!uhqFv&L?yEuxo{dpMTsmCn)95xanqBrNPTgXP((H$9N${Ow~Is-FBg%h z53;|Y5$MUN)9W2HBe2TD`ct^LHI<(xWrw}$qSoei?}s)&w$;&!14w6B6>Yr6Y8b)S z0r71`WmAvJJ`1h&poLftLUS6Ir zC$bG9!Im_4Zjse)#K=oJM9mHW1{%l8sz$1o?ltdKlLTxWWPB>Vk22czVt|1%^wnN@*!l)}?EgtvhC>vlHm^t+ogpgHI1_$1ox9e;>0!+b(tBrmXRB`PY1vp-R**8N7 zGP|QqI$m(Rdu#=(?!(N}G9QhQ%o!aXE=aN{&wtGP8|_qh+7a_j_sU5|J^)vxq;# zjvzLn%_QPHZZIWu1&mRAj;Sa_97p_lLq_{~j!M9N^1yp3U_SxRqK&JnR%6VI#^E12 z>CdOVI^_9aPK2eZ4h&^{pQs}xsijXgFYRIxJ~N7&BB9jUR1fm!(xl)mvy|3e6-B3j zJn#ajL;bFTYJ2+Q)tDjx=3IklO@Q+FFM}6UJr6km7hj7th9n_&JR7fnqC!hTZoM~T zBeaVFp%)0cbPhejX<8pf5HyRUj2>aXnXBqDJe73~J%P(2C?-RT{c3NjE`)om! zl$uewSgWkE66$Kb34+QZZvRn`fob~Cl9=cRk@Es}KQm=?E~CE%spXaMO6YmrMl%9Q zlA3Q$3|L1QJ4?->UjT&CBd!~ru{Ih^in&JXO=|<6J!&qp zRe*OZ*cj5bHYlz!!~iEKcuE|;U4vN1rk$xq6>bUWD*u(V@8sG^7>kVuo(QL@Ki;yL zWC!FT(q{E8#on>%1iAS0HMZDJg{Z{^!De(vSIq&;1$+b)oRMwA3nc3mdTSG#3uYO_ z>+x;7p4I;uHz?ZB>dA-BKl+t-3IB!jBRgdvAbW!aJ(Q{aT>+iz?91`C-xbe)IBoND z9_Xth{6?(y3rddwY$GD65IT#f3<(0o#`di{sh2gm{dw*#-Vnc3r=4==&PU^hCv$qd zjw;>i&?L*Wq#TxG$mFIUf>eK+170KG;~+o&1;Tom9}}mKo23KwdEM6UonXgc z!6N(@k8q@HPw{O8O!lAyi{rZv|DpgfU{py+j(X_cwpKqcalcqKIr0kM^%Br3SdeD> zHSKV94Yxw;pjzDHo!Q?8^0bb%L|wC;4U^9I#pd5O&eexX+Im{ z?jKnCcsE|H?{uGMqVie_C~w7GX)kYGWAg%-?8|N_1#W-|4F)3YTDC+QSq1s!DnOML3@d`mG%o2YbYd#jww|jD$gotpa)kntakp#K;+yo-_ZF9qrNZw<%#C zuPE@#3RocLgPyiBZ+R_-FJ_$xP!RzWm|aN)S+{$LY9vvN+IW~Kf3TsEIvP+B9Mtm! zpfNNxObWQpLoaO&cJh5>%slZnHl_Q~(-Tfh!DMz(dTWld@LG1VRF`9`DYKhyNv z2pU|UZ$#_yUx_B_|MxUq^glT}O5Xt(Vm4Mr02><%C)@v;vPb@pT$*yzJ4aPc_FZ3z z3}PLoMBIM>q_9U2rl^sGhk1VUJ89=*?7|v`{!Z{6bqFMq(mYiA?%KbsI~JwuqVA9$H5vDE+VocjX+G^%bieqx->s;XWlKcuv(s%y%D5Xbc9+ zc(_2nYS1&^yL*ey664&4`IoOeDIig}y-E~_GS?m;D!xv5-xwz+G`5l6V+}CpeJDi^ z%4ed$qowm88=iYG+(`ld5Uh&>Dgs4uPHSJ^TngXP_V6fPyl~>2bhi20QB%lSd#yYn zO05?KT1z@?^-bqO8Cg`;ft>ilejsw@2%RR7;`$Vs;FmO(Yr3Fp`pHGr@P2hC%QcA|X&N2Dn zYf`MqXdHi%cGR@%y7Rg7?d3?an){s$zA{!H;Ie5exE#c~@NhQUFG8V=SQh%UxUeiV zd7#UcYqD=lk-}sEwlpu&H^T_V0{#G?lZMxL7ih_&{(g)MWBnCZxtXg znr#}>U^6!jA%e}@Gj49LWG@*&t0V>Cxc3?oO7LSG%~)Y5}f7vqUUnQ;STjdDU}P9IF9d9<$;=QaXc zL1^X7>fa^jHBu_}9}J~#-oz3Oq^JmGR#?GO7b9a(=R@fw@}Q{{@`Wy1vIQ#Bw?>@X z-_RGG@wt|%u`XUc%W{J z>iSeiz8C3H7@St3mOr_mU+&bL#Uif;+Xw-aZdNYUpdf>Rvu0i0t6k*}vwU`XNO2he z%miH|1tQ8~ZK!zmL&wa3E;l?!!XzgV#%PMVU!0xrDsNNZUWKlbiOjzH-1Uoxm8E#r`#2Sz;-o&qcqB zC-O_R{QGuynW14@)7&@yw1U}uP(1cov)twxeLus0s|7ayrtT8c#`&2~Fiu2=R;1_4bCaD=*E@cYI>7YSnt)nQc zohw5CsK%m?8Ack)qNx`W0_v$5S}nO|(V|RZKBD+btO?JXe|~^Qqur%@eO~<8-L^9d z=GA3-V14ng9L29~XJ>a5k~xT2152zLhM*@zlp2P5Eu}bywkcqR;ISbas&#T#;HZSf z2m69qTV(V@EkY(1Dk3`}j)JMo%ZVJ*5eB zYOjIisi+igK0#yW*gBGj?@I{~mUOvRFQR^pJbEbzFxTubnrw(Muk%}jI+vXmJ;{Q6 zrSobKD>T%}jV4Ub?L1+MGOD~0Ir%-`iTnWZN^~YPrcP5y3VMAzQ+&en^VzKEb$K!Q z<7Dbg&DNXuow*eD5yMr+#08nF!;%4vGrJI++5HdCFcGLfMW!KS*Oi@=7hFwDG!h2< zPunUEAF+HncQkbfFj&pbzp|MU*~60Z(|Ik%Tn{BXMN!hZOosNIseT?R;A`W?=d?5X zK(FB=9mZusYahp|K-wyb={rOpdn=@;4YI2W0EcbMKyo~-#^?h`BA9~o285%oY zfifCh5Lk$SY@|2A@a!T2V+{^!psQkx4?x0HSV`(w9{l75QxMk!)U52Lbhn{8ol?S) zCKo*7R(z!uk<6*qO=wh!Pul{(qq6g6xW;X68GI_CXp`XwO zxuSgPRAtM8K7}5E#-GM!*ydOOG_{A{)hkCII<|2=ma*71ci_-}VPARm3crFQjLYV! z9zbz82$|l01mv`$WahE2$=fAGWkd^X2kY(J7iz}WGS z@%MyBEO=A?HB9=^?nX`@nh;7;laAjs+fbo!|K^mE!tOB>$2a_O0y-*uaIn8k^6Y zSbuv;5~##*4Y~+y7Z5O*3w4qgI5V^17u*ZeupVGH^nM&$qmAk|anf*>r zWc5CV;-JY-Z@Uq1Irpb^O`L_7AGiqd*YpGUShb==os$uN3yYvb`wm6d=?T*it&pDk zo`vhw)RZX|91^^Wa_ti2zBFyWy4cJu#g)_S6~jT}CC{DJ_kKpT`$oAL%b^!2M;JgT zM3ZNbUB?}kP(*YYvXDIH8^7LUxz5oE%kMhF!rnPqv!GiY0o}NR$OD=ITDo9r%4E>E0Y^R(rS^~XjWyVI6 zMOR5rPXhTp*G*M&X#NTL`Hu*R+u*QNoiOKg4CtNPrjgH>c?Hi4MUG#I917fx**+pJfOo!zFM&*da&G_x)L(`k&TPI*t3e^{crd zX<4I$5nBQ8Ax_lmNRa~E*zS-R0sxkz`|>7q_?*e%7bxqNm3_eRG#1ae3gtV9!fQpY z+!^a38o4ZGy9!J5sylDxZTx$JmG!wg7;>&5H1)>f4dXj;B+@6tMlL=)cLl={jLMxY zbbf1ax3S4>bwB9-$;SN2?+GULu;UA-35;VY*^9Blx)Jwyb$=U!D>HhB&=jSsd^6yw zL)?a|>GxU!W}ocTC(?-%z3!IUhw^uzc`Vz_g>-tv)(XA#JK^)ZnC|l1`@CdX1@|!| z_9gQ)7uOf?cR@KDp97*>6X|;t@Y`k_N@)aH7gY27)COv^P3ya9I{4z~vUjLR9~z1Z z5=G{mVtKH*&$*t0@}-i_v|3B$AHHYale7>E+jP`ClqG%L{u;*ff_h@)al?RuL7tOO z->;I}>%WI{;vbLP3VIQ^iA$4wl6@0sDj|~112Y4OFjMs`13!$JGkp%b&E8QzJw_L5 zOnw9joc0^;O%OpF$Qp)W1HI!$4BaXX84`%@#^dk^hFp^pQ@rx4g(8Xjy#!X%+X5Jd@fs3amGT`}mhq#L97R>OwT5-m|h#yT_-v@(k$q7P*9X~T*3)LTdzP!*B} z+SldbVWrrwQo9wX*%FyK+sRXTa@O?WM^FGWOE?S`R(0P{<6p#f?0NJvnBia?k^fX2 zNQs7K-?EijgHJY}&zsr;qJ<*PCZUd*x|dD=IQPUK_nn)@X4KWtqoJNHkT?ZWL_hF? zS8lp2(q>;RXR|F;1O}EE#}gCrY~#n^O`_I&?&z5~7N;zL0)3Tup`%)oHMK-^r$NT% zbFg|o?b9w(q@)6w5V%si<$!U<#}s#x@0aX-hP>zwS#9*75VXA4K*%gUc>+yzupTDBOKH8WR4V0pM(HrfbQ&eJ79>HdCvE=F z|J>s;;iDLB^3(9}?biKbxf1$lI!*Z%*0&8UUq}wMyPs_hclyQQi4;NUY+x2qy|0J; zhn8;5)4ED1oHwg+VZF|80<4MrL97tGGXc5Sw$wAI#|2*cvQ=jB5+{AjMiDHmhUC*a zlmiZ`LAuAn_}hftXh;`Kq0zblDk8?O-`tnilIh|;3lZp@F_osJUV9`*R29M?7H{Fy z`nfVEIDIWXmU&YW;NjU8)EJpXhxe5t+scf|VXM!^bBlwNh)~7|3?fWwo_~ZFk(22% zTMesYw+LNx3J-_|DM~`v93yXe=jPD{q;li;5PD?Dyk+b? zo21|XpT@)$BM$%F=P9J19Vi&1#{jM3!^Y&fr&_`toi`XB1!n>sbL%U9I5<7!@?t)~ z;&H%z>bAaQ4f$wIzkjH70;<8tpUoxzKrPhn#IQfS%9l5=Iu))^XC<58D!-O z{B+o5R^Z21H0T9JQ5gNJnqh#qH^na|z92=hONIM~@_iuOi|F>jBh-?aA20}Qx~EpDGElELNn~|7WRXRFnw+Wdo`|# zBpU=Cz3z%cUJ0mx_1($X<40XEIYz(`noWeO+x#yb_pwj6)R(__%@_Cf>txOQ74wSJ z0#F3(zWWaR-jMEY$7C*3HJrohc79>MCUu26mfYN)f4M~4gD`}EX4e}A!U}QV8!S47 z6y-U-%+h`1n`*pQuKE%Av0@)+wBZr9mH}@vH@i{v(m-6QK7Ncf17x_D=)32`FOjjo zg|^VPf5c6-!FxN{25dvVh#fog=NNpXz zfB$o+0jbRkHH{!TKhE709f+jI^$3#v1Nmf80w`@7-5$1Iv_`)W^px8P-({xwb;D0y z7LKDAHgX<84?l!I*Dvi2#D@oAE^J|g$3!)x1Ua;_;<@#l1fD}lqU2_tS^6Ht$1Wl} zBESo7o^)9-Tjuz$8YQSGhfs{BQV6zW7dA?0b(Dbt=UnQs&4zHfe_sj{RJ4uS-vQpC zX;Bbsuju4%!o8?&m4UZU@~ZZjeFF6ex2ss5_60_JS_|iNc+R0GIjH1@Z z=rLT9%B|WWgOrR7IiIwr2=T;Ne?30M!@{%Qf8o`!>=s<2CBpCK_TWc(DX51>e^xh8 z&@$^b6CgOd7KXQV&Y4%}_#uN*mbanXq(2=Nj`L7H7*k(6F8s6{FOw@(DzU`4-*77{ zF+dxpv}%mFpYK?>N_2*#Y?oB*qEKB}VoQ@bzm>ptmVS_EC(#}Lxxx730trt0G)#$b zE=wVvtqOct1%*9}U{q<)2?{+0TzZzP0jgf9*)arV)*e!f`|jgT{7_9iS@e)recI#z zbzolURQ+TOzE!ymqvBY7+5NnAbWxvMLsLTwEbFqW=CPyCsmJ}P1^V30|D5E|p3BC5 z)3|qgw@ra7aXb-wsa|l^in~1_fm{7bS9jhVRkYVO#U{qMp z)Wce+|DJ}4<2gp8r0_xfZpMo#{Hl2MfjLcZdRB9(B(A(f;+4s*FxV{1F|4d`*sRNd zp4#@sEY|?^FIJ;tmH{@keZ$P(sLh5IdOk@k^0uB^BWr@pk6mHy$qf&~rI>P*a;h0C{%oA*i!VjWn&D~O#MxN&f@1Po# zKN+ zrGrkSjcr?^R#nGl<#Q722^wbYcgW@{+6CBS<1@%dPA8HC!~a`jTz<`g_l5N1M@9wn9GOAZ>nqNgq!yOCbZ@1z`U_N`Z>}+1HIZxk*5RDc&rd5{3qjRh8QmT$VyS;jK z;AF+r6XnnCp=wQYoG|rT2@8&IvKq*IB_WvS%nt%e{MCFm`&W*#LXc|HrD?nVBo=(8*=Aq?u$sDA_sC_RPDUiQ+wnIJET8vx$&fxkW~kP9qXKt zozR)@xGC!P)CTkjeWvXW5&@2?)qt)jiYWWBU?AUtzAN}{JE1I)dfz~7$;}~BmQF`k zpn11qmObXwRB8&rnEG*#4Xax3XBkKlw(;tb?Np^i+H8m(Wyz9k{~ogba@laiEk;2! zV*QV^6g6(QG%vX5Um#^sT&_e`B1pBW5yVth~xUs#0}nv?~C#l?W+9Lsb_5)!71rirGvY zTIJ$OPOY516Y|_014sNv+Z8cc5t_V=i>lWV=vNu#!58y9Zl&GsMEW#pPYPYGHQ|;vFvd*9eM==$_=vc7xnyz0~ zY}r??$<`wAO?JQk@?RGvkWVJlq2dk9vB(yV^vm{=NVI8dhsX<)O(#nr9YD?I?(VmQ z^r7VfUBn<~p3()8yOBjm$#KWx!5hRW)5Jl7wY@ky9lNM^jaT##8QGVsYeaVywmpv>X|Xj7gWE1Ezai&wVLt3p)k4w~yrskT-!PR!kiyQlaxl(( zXhF%Q9x}1TMt3~u@|#wWm-Vq?ZerK={8@~&@9r5JW}r#45#rWii};t`{5#&3$W)|@ zbAf2yDNe0q}NEUvq_Quq3cTjcw z@H_;$hu&xllCI9CFDLuScEMg|x{S7GdV8<&Mq=ezDnRZAyX-8gv97YTm0bg=d)(>N z+B2FcqvI9>jGtnK%eO%y zoBPkJTk%y`8TLf4)IXPBn`U|9>O~WL2C~C$z~9|0m*YH<-vg2CD^SX#&)B4ngOSG$ zV^wmy_iQk>dfN@Pv(ckfy&#ak@MLC7&Q6Ro#!ezM*VEh`+b3Jt%m(^T&p&WJ2Oqvj zs-4nq0TW6cv~(YI$n0UkfwN}kg3_fp?(ijSV#tR9L0}l2qjc7W?i*q01=St0eZ=4h zyGQbEw`9OEH>NMuIe)hVwYHsGERWOD;JxEiO7cQv%pFCeR+IyhwQ|y@&^24k+|8fD zLiOWFNJ2&vu2&`Jv96_z-Cd5RLgmeY3*4rDOQo?Jm`;I_(+ejsPM03!ly!*Cu}Cco zrQSrEDHNyzT(D5s1rZq!8#?f6@v6dB7a-aWs(Qk>N?UGAo{gytlh$%_IhyL7h?DLXDGx zgxGEBQoCAWo-$LRvM=F5MTle`M})t3vVv;2j0HZY&G z22^iGhV@uaJh(XyyY%} zd4iH_UfdV#T=3n}(Lj^|n;O4|$;xhu*8T3hR1mc_A}fK}jfZ7LX~*n5+`8N2q#rI$ z@<_2VANlYF$vIH$ zl<)+*tIWW78IIINA7Rr7i{<;#^yzxoLNkXL)eSs=%|P>$YQIh+ea_3k z_s7r4%j7%&*NHSl?R4k%1>Z=M9o#zxY!n8sL5>BO-ZP;T3Gut>iLS@U%IBrX6BA3k z)&@q}V8a{X<5B}K5s(c(LQ=%v1ocr`t$EqqY0EqVjr65usa=0bkf|O#ky{j3)WBR(((L^wmyHRzoWuL2~WTC=`yZ zn%VX`L=|Ok0v7?s>IHg?yArBcync5rG#^+u)>a%qjES%dRZoIyA8gQ;StH z1Ao7{<&}6U=5}4v<)1T7t!J_CL%U}CKNs-0xWoTTeqj{5{?Be$L0_tk>M9o8 zo371}S#30rKZFM{`H_(L`EM9DGp+Mifk&IP|C2Zu_)Ghr4Qtpmkm1osCf@%Z$%t+7 zYH$Cr)Ro@3-QDeQJ8m+x6%;?YYT;k6Z0E-?kr>x33`H%*ueBD7Zx~3&HtWn0?2Wt} zTG}*|v?{$ajzt}xPzV%lL1t-URi8*Zn)YljXNGDb>;!905Td|mpa@mHjIH%VIiGx- zd@MqhpYFu4_?y5N4xiHn3vX&|e6r~Xt> zZG`aGq|yTNjv;9E+Txuoa@A(9V7g?1_T5FzRI;!=NP1Kqou1z5?%X~Wwb{trRfd>i z8&y^H)8YnKyA_Fyx>}RNmQIczT?w2J4SNvI{5J&}Wto|8FR(W;Qw#b1G<1%#tmYzQ zQ2mZA-PAdi%RQOhkHy9Ea#TPSw?WxwL@H@cbkZwIq0B!@ns}niALidmn&W?!Vd4Gj zO7FiuV4*6Mr^2xlFSvM;Cp_#r8UaqIzHJQg_z^rEJw&OMm_8NGAY2)rKvki|o1bH~ z$2IbfVeY2L(^*rMRU1lM5Y_sgrDS`Z??nR2lX;zyR=c%UyGb*%TC-Dil?SihkjrQy~TMv6;BMs7P8il`H7DmpVm@rJ;b)hW)BL)GjS154b*xq-NXq2cwE z^;VP7ua2pxvCmxrnqUYQMH%a%nHmwmI33nJM(>4LznvY*k&C0{8f*%?zggpDgkuz&JBx{9mfb@wegEl2v!=}Sq2Gaty0<)UrOT0{MZtZ~j5y&w zXlYa_jY)I_+VA-^#mEox#+G>UgvM!Ac8zI<%JRXM_73Q!#i3O|)lOP*qBeJG#BST0 zqohi)O!|$|2SeJQo(w6w7%*92S})XfnhrH_Z8qe!G5>CglP=nI7JAOW?(Z29;pXJ9 zR9`KzQ=WEhy*)WH>$;7Cdz|>*i>=##0bB)oU0OR>>N<21e4rMCHDemNi2LD>Nc$;& zQRFthpWniC1J6@Zh~iJCoLOxN`oCKD5Q4r%ynwgUKPlIEd#?QViIqovY|czyK8>6B zSP%{2-<;%;1`#0mG^B(8KbtXF;Nf>K#Di72UWE4gQ%(_26Koiad)q$xRL~?pN71ZZ zujaaCx~jXjygw;rI!WB=xrOJO6HJ!!w}7eiivtCg5K|F6$EXa)=xUC za^JXSX98W`7g-tm@uo|BKj39Dl;sg5ta;4qjo^pCh~{-HdLl6qI9Ix6f$+qiZ$}s= zNguKrU;u+T@ko(Vr1>)Q%h$?UKXCY>3se%&;h2osl2D zE4A9bd7_|^njDd)6cI*FupHpE3){4NQ*$k*cOWZ_?CZ>Z4_fl@n(mMnYK62Q1d@+I zr&O))G4hMihgBqRIAJkLdk(p(D~X{-oBUA+If@B}j& zsHbeJ3RzTq96lB7d($h$xTeZ^gP0c{t!Y0c)aQE;$FY2!mACg!GDEMKXFOPI^)nHZ z`aSPJpvV0|bbrzhWWkuPURlDeN%VT8tndV8?d)eN*i4I@u zVKl^6{?}A?P)Fsy?3oi#clf}L18t;TjNI2>eI&(ezDK7RyqFxcv%>?oxUlonv(px) z$vnPzRH`y5A(x!yOIfL0bmgeMQB$H5wenx~!ujQK*nUBW;@Em&6Xv2%s(~H5WcU2R z;%Nw<$tI)a`Ve!>x+qegJnQsN2N7HaKzrFqM>`6R*gvh%O*-%THt zrB$Nk;lE;z{s{r^PPm5qz(&lM{sO*g+W{sK+m3M_z=4=&CC>T`{X}1Vg2PEfSj2x_ zmT*(x;ov%3F?qoEeeM>dUn$a*?SIGyO8m806J1W1o+4HRhc2`9$s6hM#qAm zChQ87b~GEw{ADfs+5}FJ8+|bIlIv(jT$Ap#hSHoXdd9#w<#cA<1Rkq^*EEkknUd4& zoIWIY)sAswy6fSERVm&!SO~#iN$OgOX*{9@_BWFyJTvC%S++ilSfCrO(?u=Dc?CXZ zzCG&0yVR{Z`|ZF0eEApWEo#s9osV>F{uK{QA@BES#&;#KsScf>y zvs?vIbI>VrT<*!;XmQS=bhq%46-aambZ(8KU-wOO2=en~D}MCToB_u;Yz{)1ySrPZ z@=$}EvjTdzTWU7c0ZI6L8=yP+YRD_eMMos}b5vY^S*~VZysrkq<`cK3>>v%uy7jgq z0ilW9KjVDHLv0b<1K_`1IkbTOINs0=m-22c%M~l=^S}%hbli-3?BnNq?b`hx^HX2J zIe6ECljRL0uBWb`%{EA=%!i^4sMcj+U_TaTZRb+~GOk z^ZW!nky0n*Wb*r+Q|9H@ml@Z5gU&W`(z4-j!OzC1wOke`TRAYGZVl$PmQ16{3196( zO*?`--I}Qf(2HIwb2&1FB^!faPA2=sLg(@6P4mN)>Dc3i(B0;@O-y2;lM4akD>@^v z=u>*|!s&9zem70g7zfw9FXl1bpJW(C#5w#uy5!V?Q(U35A~$dR%LDVnq@}kQm13{} zd53q3N(s$Eu{R}k2esbftfjfOITCL;jWa$}(mmm}d(&7JZ6d3%IABCapFFYjdEjdK z&4Edqf$G^MNAtL=uCDRs&Fu@FXRgX{*0<(@c3|PNHa>L%zvxWS={L8%qw`STm+=Rd zA}FLspESSIpE_^41~#5yI2bJ=9`oc;GIL!JuW&7YetZ?0H}$$%8rW@*J37L-~Rsx!)8($nI4 zZhcZ2^=Y+p4YPl%j!nFJA|*M^gc(0o$i3nlphe+~-_m}jVkRN{spFs(o0ajW@f3K{ zDV!#BwL322CET$}Y}^0ixYj2w>&Xh12|R8&yEw|wLDvF!lZ#dOTHM9pK6@Nm-@9Lnng4ZHBgBSrr7KI8YCC9DX5Kg|`HsiwJHg2(7#nS;A{b3tVO?Z% za{m5b3rFV6EpX;=;n#wltDv1LE*|g5pQ+OY&*6qCJZc5oDS6Z6JD#6F)bWxZSF@q% z+1WV;m!lRB!n^PC>RgQCI#D1br_o^#iPk>;K2hB~0^<~)?p}LG%kigm@moD#q3PE+ zA^Qca)(xnqw6x>XFhV6ku9r$E>bWNrVH9fum0?4s?Rn2LG{Vm_+QJHse6xa%nzQ?k zKug4PW~#Gtb;#5+9!QBgyB@q=sk9=$S{4T>wjFICStOM?__fr+Kei1 z3j~xPqW;W@YkiUM;HngG!;>@AITg}vAE`M2Pj9Irl4w1fo4w<|Bu!%rh%a(Ai^Zhi zs92>v5;@Y(Zi#RI*ua*h`d_7;byQSa*v9E{2x$<-_=5Z<7{%)}4XExANcz@rK69T0x3%H<@frW>RA8^swA+^a(FxK| zFl3LD*ImHN=XDUkrRhp6RY5$rQ{bRgSO*(vEHYV)3Mo6Jy3puiLmU&g82p{qr0F?ohmbz)f2r{X2|T2 z$4fdQ=>0BeKbiVM!e-lIIs8wVTuC_m7}y4A_%ikI;Wm5$9j(^Y z(cD%U%k)X>_>9~t8;pGzL6L-fmQO@K; zo&vQzMlgY95;1BSkngY)e{`n0!NfVgf}2mB3t}D9@*N;FQ{HZ3Pb%BK6;5#-O|WI( zb6h@qTLU~AbVW#_6?c!?Dj65Now7*pU{h!1+eCV^KCuPAGs28~3k@ueL5+u|Z-7}t z9|lskE`4B7W8wMs@xJa{#bsCGDFoRSNSnmNYB&U7 zVGKWe%+kFB6kb)e;TyHfqtU6~fRg)f|>=5(N36)0+C z`hv65J<$B}WUc!wFAb^QtY31yNleq4dzmG`1wHTj=c*=hay9iD071Hc?oYoUk|M*_ zU1GihAMBsM@5rUJ(qS?9ZYJ6@{bNqJ`2Mr+5#hKf?doa?F|+^IR!8lq9)wS3tF_9n zW_?hm)G(M+MYb?V9YoX^_mu5h-LP^TL^!Q9Z7|@sO(rg_4+@=PdI)WL(B7`!K^ND- z-uIuVDCVEdH_C@c71YGYT^_Scf_dhB8Z2Xy6vGtBSlYud9vggOqv^L~F{BraSE_t} zIkP+Hp2&nH^-MNEs}^`oMLy11`PQW$T|K(`Bu*(f@)mv1-qY(_YG&J2M2<7k;;RK~ zL{Fqj9yCz8(S{}@c)S!65aF<=&eLI{hAMErCx&>i7OeDN>okvegO87OaG{Jmi<|}D zaT@b|0X{d@OIJ7zvT>r+eTzgLq~|Dpu)Z&db-P4z*`M$UL51lf>FLlq6rfG)%doyp z)3kk_YIM!03eQ8Vu_2fg{+osaEJPtJ-s36R+5_AEG12`NG)IQ#TF9c@$99%0iye+ zUzZ57=m2)$D(5Nx!n)=5Au&O0BBgwxIBaeI(mro$#&UGCr<;C{UjJVAbVi%|+WP(a zL$U@TYCxJ=1{Z~}rnW;7UVb7+ZnzgmrogDxhjLGo>c~MiJAWs&&;AGg@%U?Y^0JhL ze(x6Z74JG6FlOFK(T}SXQfhr}RIFl@QXKnIcXYF)5|V~e-}suHILKT-k|<*~Ij|VF zC;t@=uj=hot~*!C68G8hTA%8SzOfETOXQ|3FSaIEjvBJp(A)7SWUi5!Eu#yWgY+;n zlm<$+UDou*V+246_o#V4kMdto8hF%%Lki#zPh}KYXmMf?hrN0;>Mv%`@{0Qn`Ujp) z=lZe+13>^Q!9zT);H<(#bIeRWz%#*}sgUX9P|9($kexOyKIOc`dLux}c$7It4u|Rl z6SSkY*V~g_B-hMPo_ak>>z@AVQ(_N)VY2kB3IZ0G(iDUYw+2d7W^~(Jq}KY=JnWS( z#rzEa&0uNhJ>QE8iiyz;n2H|SV#Og+wEZv=f2%1ELX!SX-(d3tEj$5$1}70Mp<&eI zCkfbByL7af=qQE@5vDVxx1}FSGt_a1DoE3SDI+G)mBAna)KBG4p8Epxl9QZ4BfdAN zFnF|Y(umr;gRgG6NLQ$?ZWgllEeeq~z^ZS7L?<(~O&$5|y)Al^iMKy}&W+eMm1W z7EMU)u^ke(A1#XCV>CZ71}P}0x)4wtHO8#JRG3MA-6g=`ZM!FcICCZ{IEw8Dm2&LQ z1|r)BUG^0GzI6f946RrBlfB1Vs)~8toZf~7)+G;pv&XiUO(%5bm)pl=p>nV^o*;&T z;}@oZSibzto$arQgfkp|z4Z($P>dTXE{4O=vY0!)kDO* zGF8a4wq#VaFpLfK!iELy@?-SeRrdz%F*}hjKcA*y@mj~VD3!it9lhRhX}5YOaR9$} z3mS%$2Be7{l(+MVx3 z(4?h;P!jnRmX9J9sYN#7i=iyj_5q7n#X(!cdqI2lnr8T$IfOW<_v`eB!d9xY1P=2q&WtOXY=D9QYteP)De?S4}FK6#6Ma z=E*V+#s8>L;8aVroK^6iKo=MH{4yEZ_>N-N z`(|;aOATba1^asjxlILk<4}f~`39dBFlxj>Dw(hMYKPO3EEt1@S`1lxFNM+J@uB7T zZ8WKjz7HF1-5&2=l=fqF-*@>n5J}jIxdDwpT?oKM3s8Nr`x8JnN-kCE?~aM1H!hAE z%%w(3kHfGwMnMmNj(SU(w42OrC-euI>Dsjk&jz3ts}WHqmMpzQ3vZrsXrZ|}+MHA7 z068obeXZTsO*6RS@o3x80E4ok``rV^Y3hr&C1;|ZZ0|*EKO`$lECUYG2gVFtUTw)R z4Um<0ZzlON`zTdvVdL#KFoMFQX*a5wM0Czp%wTtfK4Sjs)P**RW&?lP$(<}q%r68Z zS53Y!d@&~ne9O)A^tNrXHhXBkj~$8j%pT1%%mypa9AW5E&s9)rjF4@O3ytH{0z6riz|@< zB~UPh*wRFg2^7EbQrHf0y?E~dHlkOxof_a?M{LqQ^C!i2dawHTPYUE=X@2(3<=OOxs8qn_(y>pU>u^}3y&df{JarR0@VJn0f+U%UiF=$Wyq zQvnVHESil@d|8&R<%}uidGh7@u^(%?$#|&J$pvFC-n8&A>utA=n3#)yMkz+qnG3wd zP7xCnF|$9Dif@N~L)Vde3hW8W!UY0BgT2v(wzp;tlLmyk2%N|0jfG$%<;A&IVrOI< z!L)o>j>;dFaqA3pL}b-Je(bB@VJ4%!JeX@3x!i{yIeIso^=n?fDX`3bU=eG7sTc%g%ye8$v8P@yKE^XD=NYxTb zbf!Mk=h|otpqjFaA-vs5YOF-*GwWPc7VbaOW&stlANnCN8iftFMMrUdYNJ_Bnn5Vt zxfz@Ah|+4&P;reZxp;MmEI7C|FOv8NKUm8njF7Wb6Gi7DeODLl&G~}G4be&*Hi0Qw z5}77vL0P+7-B%UL@3n1&JPxW^d@vVwp?u#gVcJqY9#@-3X{ok#UfW3<1fb%FT`|)V~ggq z(3AUoUS-;7)^hCjdT0Kf{i}h)mBg4qhtHHBti=~h^n^OTH5U*XMgDLIR@sre`AaB$ zg)IGBET_4??m@cx&c~bA80O7B8CHR7(LX7%HThkeC*@vi{-pL%e)yXp!B2InafbDF zjPXf1mko3h59{lT6EEbxKO1Z5GF71)WwowO6kY|6tjSVSWdQ}NsK2x{>i|MKZK8%Q zfu&_0D;CO-Jg0#YmyfctyJ!mRJp)e#@O0mYdp|8x;G1%OZQ3Q847YWTyy|%^cpA;m zze0(5p{tMu^lDkpe?HynyO?a1$_LJl2L&mpeKu%8YvgRNr=%2z${%WThHG=vrWY@4 zsA`OP#O&)TetZ>s%h!=+CE15lOOls&nvC~$Qz0Ph7tHiP;O$i|eDwpT{cp>+)0-|; zY$|bB+Gbel>5aRN3>c0x)4U=|X+z+{ zn*_p*EQoquRL+=+p;=lm`d71&1NqBz&_ph)MXu(Nv6&XE7(RsS)^MGj5Q?Fwude-(sq zjJ>aOq!7!EN>@(fK7EE#;i_BGvli`5U;r!YA{JRodLBc6-`n8K+Fjgwb%sX;j=qHQ z7&Tr!)!{HXoO<2BQrV9Sw?JRaLXV8HrsNevvnf>Y-6|{T!pYLl7jp$-nEE z#X!4G4L#K0qG_4Z;Cj6=;b|Be$hi4JvMH!-voxqx^@8cXp`B??eFBz2lLD8RRaRGh zn7kUfy!YV~p(R|p7iC1Rdgt$_24i0cd-S8HpG|`@my70g^y`gu%#Tf_L21-k?sRRZHK&at(*ED0P8iw{7?R$9~OF$Ko;Iu5)ur5<->x!m93Eb zFYpIx60s=Wxxw=`$aS-O&dCO_9?b1yKiPCQmSQb>T)963`*U+Ydj5kI(B(B?HNP8r z*bfSBpSu)w(Z3j7HQoRjUG(+d=IaE~tv}y14zHHs|0UcN52fT8V_<@2ep_ee{QgZG zmgp8iv4V{k;~8@I%M3<#B;2R>Ef(Gg_cQM7%}0s*^)SK6!Ym+~P^58*wnwV1BW@eG z4sZLqsUvBbFsr#8u7S1r4teQ;t)Y@jnn_m5jS$CsW1um!p&PqAcc8!zyiXHVta9QC zY~wCwCF0U%xiQPD_INKtTb;A|Zf29(mu9NI;E zc-e>*1%(LSXB`g}kd`#}O;veb<(sk~RWL|f3ljxCnEZDdNSTDV6#Td({6l&y4IjKF z^}lIUq*ZUqgTPumD)RrCN{M^jhY>E~1pn|KOZ5((%F)G|*ZQ|r4zIbrEiV%42hJV8 z3xS)=!X1+=olbdGJ=yZil?oXLct8FM{(6ikLL3E%=q#O6(H$p~gQu6T8N!plf!96| z&Q3=`L~>U0zZh;z(pGR2^S^{#PrPxTRHD1RQOON&f)Siaf`GLj#UOk&(|@0?zm;Sx ztsGt8=29-MZs5CSf1l1jNFtNt5rFNZxJPvkNu~2}7*9468TWm>nN9TP&^!;J{-h)_ z7WsHH9|F%I`Pb!>KAS3jQWKfGivTVkMJLO-HUGM_a4UQ_%RgL6WZvrW+Z4ujZn;y@ zz9$=oO!7qVTaQAA^BhX&ZxS*|5dj803M=k&2%QrXda`-Q#IoZL6E(g+tN!6CA!CP* zCpWtCujIea)ENl0liwVfj)Nc<9mV%+e@=d`haoZ*`B7+PNjEbXBkv=B+Pi^~L#EO$D$ZqTiD8f<5$eyb54-(=3 zh)6i8i|jp(@OnRrY5B8t|LFXFQVQ895n*P16cEKTrT*~yLH6Z4e*bZ5otpRDri&+A zfNbK1D5@O=sm`fN=WzWyse!za5n%^+6dHPGX#8DyIK>?9qyX}2XvBWVqbP%%D)7$= z=#$WulZlZR<{m#gU7lwqK4WS1Ne$#_P{b17qe$~UOXCl>5b|6WVh;5vVnR<%d+Lnp z$uEmML38}U4vaW8>shm6CzB(Wei3s#NAWE3)a2)z@i{4jTn;;aQS)O@l{rUM`J@K& l00vQ5JBs~;vo!vr%%-k{2_Fq1Mn4QF81S)AQ99zk{{c4yR+0b! literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3fa8f862f7..1af9e0930b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 535037c8761e93bece0d778d1d4d21e367e1d711 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 10:58:56 +0000 Subject: [PATCH 19/66] Update hilt_version to v2.49 --- dependencies.gradle | 2 +- gradle/verification-metadata.xml | 106 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 060c32f3c2..c0d891f132 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -23,7 +23,7 @@ ext { kotlin_version = '1.9.10' detekt_gradle_plugin_version = "1.23.3" dokka_version = "1.9.10" - hilt_version = "2.48.1" + hilt_version = "2.49" compose_compiler_version = '1.5.3' // Code quality diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index fc3d5a23ce..269f363906 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -240,6 +240,14 @@ + + + + + + + + @@ -5018,6 +5026,14 @@ + + + + + + + + @@ -5050,6 +5066,14 @@ + + + + + + + + @@ -5082,6 +5106,14 @@ + + + + + + + + @@ -5146,6 +5178,14 @@ + + + + + + + + @@ -5178,6 +5218,14 @@ + + + + + + + + @@ -5210,6 +5258,14 @@ + + + + + + + + @@ -5242,6 +5298,14 @@ + + + + + + + + @@ -5274,6 +5338,14 @@ + + + + + + + + @@ -5294,6 +5366,11 @@ + + + + + @@ -5310,6 +5387,14 @@ + + + + + + + + @@ -8898,6 +8983,11 @@ + + + + + @@ -9722,6 +9812,17 @@ + + + + + + + + + + + @@ -9828,6 +9929,11 @@ + + + + + From 9d5b2cd0556646173222587aafd21adffa409d5f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 12:49:56 +0000 Subject: [PATCH 20/66] Update plugin io.gitlab.arturbosch.detekt to v1.23.4 --- dependencies.gradle | 2 +- gradle/verification-metadata.xml | 242 +++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index c0d891f132..0f9e9fbf48 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -21,7 +21,7 @@ ext { // Build Script android_gradle_plugin_version = '8.1.2' kotlin_version = '1.9.10' - detekt_gradle_plugin_version = "1.23.3" + detekt_gradle_plugin_version = "1.23.4" dokka_version = "1.9.10" hilt_version = "2.49" compose_compiler_version = '1.5.3' diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 269f363906..547bede2a0 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -6921,6 +6921,14 @@ + + + + + + + + @@ -6953,6 +6961,14 @@ + + + + + + + + @@ -6985,6 +7001,14 @@ + + + + + + + + @@ -7049,6 +7073,14 @@ + + + + + + + + @@ -7081,6 +7113,14 @@ + + + + + + + + @@ -7113,6 +7153,14 @@ + + + + + + + + @@ -7145,6 +7193,14 @@ + + + + + + + + @@ -7177,6 +7233,14 @@ + + + + + + + + @@ -7209,6 +7273,14 @@ + + + + + + + + @@ -7241,6 +7313,14 @@ + + + + + + + + @@ -7273,6 +7353,14 @@ + + + + + + + + @@ -7305,6 +7393,14 @@ + + + + + + + + @@ -7337,6 +7433,14 @@ + + + + + + + + @@ -7369,6 +7473,14 @@ + + + + + + + + @@ -7401,6 +7513,14 @@ + + + + + + + + @@ -7433,6 +7553,14 @@ + + + + + + + + @@ -7465,6 +7593,14 @@ + + + + + + + + @@ -7497,6 +7633,14 @@ + + + + + + + + @@ -7529,6 +7673,14 @@ + + + + + + + + @@ -7561,6 +7713,14 @@ + + + + + + + + @@ -7593,6 +7753,14 @@ + + + + + + + + @@ -7625,6 +7793,14 @@ + + + + + + + + @@ -7657,6 +7833,14 @@ + + + + + + + + @@ -7689,6 +7873,14 @@ + + + + + + + + @@ -7709,6 +7901,11 @@ + + + + + @@ -9052,6 +9249,14 @@ + + + + + + + + @@ -9148,6 +9353,14 @@ + + + + + + + + @@ -9565,6 +9778,14 @@ + + + + + + + + @@ -9613,6 +9834,14 @@ + + + + + + + + @@ -9823,6 +10052,14 @@ + + + + + + + + @@ -9934,6 +10171,11 @@ + + + + + From dee2580190cf1fbb01a05d973ece7b9cb2424504 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 14:14:27 +0000 Subject: [PATCH 21/66] Update detekt_version to v1.23.4 --- dependencies.gradle | 2 +- gradle/verification-metadata.xml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 0f9e9fbf48..ad38597132 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -27,7 +27,7 @@ ext { compose_compiler_version = '1.5.3' // Code quality - detekt_version = "1.23.3" + detekt_version = "1.23.4" ktlint_version = '1.0.1' // Android Dependencies diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 547bede2a0..40dbdcaea9 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7041,6 +7041,14 @@ + + + + + + + + From 4f0f74443f3545fa4ee12ab179cb0460dd89a438 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 14:07:51 +0000 Subject: [PATCH 22/66] Update android_gradle_plugin_version to v8.2.0 --- dependencies.gradle | 2 +- gradle/verification-metadata.xml | 377 +++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index ad38597132..4def188aae 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -19,7 +19,7 @@ ext { version_name = "5.1.0" // Build Script - android_gradle_plugin_version = '8.1.2' + android_gradle_plugin_version = '8.2.0' kotlin_version = '1.9.10' detekt_gradle_plugin_version = "1.23.4" dokka_version = "1.9.10" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 40dbdcaea9..01ddda99f6 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1451,6 +1451,14 @@ + + + + + + + + @@ -1491,6 +1499,14 @@ + + + + + + + + @@ -1531,6 +1547,14 @@ + + + + + + + + @@ -2801,6 +2825,14 @@ + + + + + + + + @@ -2841,6 +2873,14 @@ + + + + + + + + @@ -2866,6 +2906,11 @@ + + + + + @@ -2906,6 +2951,14 @@ + + + + + + + + @@ -2931,6 +2984,11 @@ + + + + + @@ -2971,6 +3029,14 @@ + + + + + + + + @@ -3011,6 +3077,14 @@ + + + + + + + + @@ -3051,6 +3125,14 @@ + + + + + + + + @@ -3131,6 +3213,14 @@ + + + + + + + + @@ -3171,6 +3261,14 @@ + + + + + + + + @@ -3211,6 +3309,14 @@ + + + + + + + + @@ -3251,6 +3357,14 @@ + + + + + + + + @@ -3291,6 +3405,14 @@ + + + + + + + + @@ -3331,6 +3453,14 @@ + + + + + + + + @@ -3371,6 +3501,14 @@ + + + + + + + + @@ -3463,6 +3601,14 @@ + + + + + + + + @@ -3503,6 +3649,14 @@ + + + + + + + + @@ -3543,6 +3697,14 @@ + + + + + + + + @@ -3583,6 +3745,14 @@ + + + + + + + + @@ -3623,6 +3793,14 @@ + + + + + + + + @@ -3663,6 +3841,14 @@ + + + + + + + + @@ -3703,6 +3889,14 @@ + + + + + + + + @@ -3719,6 +3913,14 @@ + + + + + + + + @@ -3759,6 +3961,14 @@ + + + + + + + + @@ -3799,6 +4009,14 @@ + + + + + + + + @@ -3839,6 +4057,14 @@ + + + + + + + + @@ -3879,6 +4105,14 @@ + + + + + + + + @@ -3943,6 +4177,14 @@ + + + + + + + + @@ -4103,6 +4345,14 @@ + + + + + + + + @@ -4303,6 +4553,14 @@ + + + + + + + + @@ -4343,6 +4601,14 @@ + + + + + + + + @@ -4383,6 +4649,14 @@ + + + + + + + + @@ -4423,6 +4697,14 @@ + + + + + + + + @@ -4463,6 +4745,14 @@ + + + + + + + + @@ -4487,6 +4777,14 @@ + + + + + + + + @@ -4527,6 +4825,14 @@ + + + + + + + + @@ -4551,6 +4857,14 @@ + + + + + + + + @@ -4591,6 +4905,14 @@ + + + + + + + + @@ -4631,6 +4953,14 @@ + + + + + + + + @@ -4671,6 +5001,14 @@ + + + + + + + + @@ -4942,6 +5280,14 @@ + + + + + + + + @@ -4963,6 +5309,11 @@ + + + + + @@ -8297,6 +8648,14 @@ + + + + + + + + @@ -8310,6 +8669,11 @@ + + + + + @@ -8320,6 +8684,11 @@ + + + + + @@ -8338,6 +8707,14 @@ + + + + + + + + From 2558c4df0be5c7fb177167a38d85eebe95eba8fb Mon Sep 17 00:00:00 2001 From: jreij Date: Tue, 2 Jan 2024 14:27:24 +0000 Subject: [PATCH 23/66] Update verification metadata --- gradle/verification-metadata.xml | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 01ddda99f6..f47d464f76 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3173,6 +3173,14 @@ + + + + + + + + @@ -3561,6 +3569,17 @@ + + + + + + + + + + + @@ -4225,6 +4244,14 @@ + + + + + + + + @@ -4265,6 +4292,14 @@ + + + + + + + + @@ -4305,6 +4340,14 @@ + + + + + + + + @@ -4393,6 +4436,14 @@ + + + + + + + + @@ -4433,6 +4484,14 @@ + + + + + + + + @@ -4473,6 +4532,14 @@ + + + + + + + + @@ -4513,6 +4580,14 @@ + + + + + + + + From fbe6e1b61eab38a1db987416dee071a41c80510f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 19:42:59 +0000 Subject: [PATCH 24/66] Update dependency androidx.activity:activity-compose to v1.8.1 --- dependencies.gradle | 2 +- gradle/verification-metadata.xml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 4def188aae..f094bed550 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -42,7 +42,7 @@ ext { constraintlayout_version = '2.1.4' // Compose Dependencies - compose_activity_version = '1.8.0' + compose_activity_version = '1.8.1' compose_bom_version = '2023.10.01' compose_hilt_version = '1.1.0' compose_viewmodel_version = '2.6.2' diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f47d464f76..196c7da74c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -81,6 +81,14 @@ + + + + + + + + @@ -105,6 +113,14 @@ + + + + + + + + @@ -163,6 +179,14 @@ + + + + + + + + From ccf5263daf016476d352c91c9ab78d05663f2813 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Jan 2024 08:56:24 +0000 Subject: [PATCH 25/66] Update JamesIves/github-pages-deploy-action action to v4.5.0 --- .github/workflows/publish_docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_docs.yml b/.github/workflows/publish_docs.yml index ada8db7a53..a9785155b0 100644 --- a/.github/workflows/publish_docs.yml +++ b/.github/workflows/publish_docs.yml @@ -34,7 +34,7 @@ jobs: # Deploy to GitHub Pages - name: Deploy GitHub Pages - uses: JamesIves/github-pages-deploy-action@v4.4.3 + uses: JamesIves/github-pages-deploy-action@v4.5.0 with: BRANCH: gh-pages FOLDER: build/docs/ From e73fe2971175a1caf9f2e3802b313c52d102139f Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 3 Jan 2024 10:35:52 +0100 Subject: [PATCH 26/66] Group Compose and Kotlin for Renovate --- renovate.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/renovate.json b/renovate.json index 9da6203e07..36980018cc 100644 --- a/renovate.json +++ b/renovate.json @@ -5,6 +5,18 @@ ], "labels": ["Dependencies"], "packageRules" : [ + { + "matchPackagePatterns": [ + "androidx.compose.compiler:compiler" + ], + "groupName": "kotlin" + }, + { + "matchPackagePatterns": [ + "org.jetbrains.kotlin.*" + ], + "groupName": "kotlin" + }, { "matchPackagePatterns" : ["*"], "minimumReleaseAge" : "30 days", From 2b275937ee735e4fbcdc46487d1906444019251a Mon Sep 17 00:00:00 2001 From: josephj Date: Wed, 3 Jan 2024 11:04:26 +0100 Subject: [PATCH 27/66] Update org.jetbrains.kotlin.android to v1.9.21 and composeOptions to v1.5.7 --- dependencies.gradle | 4 +- gradle/verification-metadata.xml | 275 +++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 2 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index f094bed550..65ce3e82e2 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -20,11 +20,11 @@ ext { // Build Script android_gradle_plugin_version = '8.2.0' - kotlin_version = '1.9.10' + kotlin_version = '1.9.21' detekt_gradle_plugin_version = "1.23.4" dokka_version = "1.9.10" hilt_version = "2.49" - compose_compiler_version = '1.5.3' + compose_compiler_version = '1.5.7' // Code quality detekt_version = "1.23.4" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 196c7da74c..5cb211387f 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -568,6 +568,14 @@ + + + + + + + + @@ -9596,6 +9604,14 @@ + + + + + + + + @@ -9620,6 +9636,14 @@ + + + + + + + + @@ -9644,6 +9668,14 @@ + + + + + + + + @@ -9669,6 +9701,14 @@ + + + + + + + + @@ -9677,6 +9717,14 @@ + + + + + + + + @@ -9685,6 +9733,14 @@ + + + + + + + + @@ -9765,6 +9821,14 @@ + + + + + + + + @@ -9789,6 +9853,14 @@ + + + + + + + + @@ -9869,6 +9941,14 @@ + + + + + + + + @@ -9893,6 +9973,14 @@ + + + + + + + + @@ -9926,6 +10014,14 @@ + + + + + + + + @@ -9950,6 +10046,14 @@ + + + + + + + + @@ -9974,6 +10078,14 @@ + + + + + + + + @@ -9998,6 +10110,14 @@ + + + + + + + + @@ -10022,6 +10142,14 @@ + + + + + + + + @@ -10046,6 +10174,14 @@ + + + + + + + + @@ -10070,6 +10206,14 @@ + + + + + + + + @@ -10094,6 +10238,14 @@ + + + + + + + + @@ -10118,6 +10270,14 @@ + + + + + + + + @@ -10142,6 +10302,14 @@ + + + + + + + + @@ -10166,6 +10334,14 @@ + + + + + + + + @@ -10350,6 +10526,14 @@ + + + + + + + + @@ -10374,6 +10558,14 @@ + + + + + + + + @@ -10398,6 +10590,14 @@ + + + + + + + + @@ -10422,6 +10622,14 @@ + + + + + + + + @@ -10537,6 +10745,9 @@ + + + @@ -10660,6 +10871,14 @@ + + + + + + + + @@ -10668,6 +10887,14 @@ + + + + + + + + @@ -10748,6 +10975,14 @@ + + + + + + + + @@ -10757,10 +10992,18 @@ + + + + + + + + @@ -10865,6 +11108,14 @@ + + + + + + + + @@ -10889,6 +11140,14 @@ + + + + + + + + @@ -10913,6 +11172,14 @@ + + + + + + + + @@ -10928,6 +11195,11 @@ + + + + + @@ -11015,6 +11287,9 @@ + + + From 2ed496f3d04d8506891627d385fce71787dbefbd Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 27 Sep 2023 15:02:37 +0200 Subject: [PATCH 28/66] Replace current encryption with JWE COAND-655 --- .../ACHDirectDebitComponentProvider.kt | 16 +- .../ui/DefaultACHDirectDebitDelegate.kt | 6 +- .../ui/DefaultACHDirectDebitDelegateTest.kt | 10 +- .../provider/BcmcComponentProvider.kt | 19 +- .../api/DefaultDetectCardTypeRepository.kt | 4 +- .../provider/CardComponentProvider.kt | 44 ++-- .../card/internal/ui/DefaultCardDelegate.kt | 8 +- .../card/internal/ui/StoredCardDelegate.kt | 4 +- .../internal/ui/DefaultCardDelegateTest.kt | 4 +- .../internal/ui/StoredCardDelegateTest.kt | 2 +- .../com/adyen/checkout/cse/CardEncrypter.kt | 18 +- .../adyen/checkout/cse/GenericEncrypter.kt | 15 +- .../cse/internal/CardEncryptorFactory.kt | 22 ++ .../cse/internal/ClientSideEncrypter.kt | 155 ------------- .../checkout/cse/internal/CompositeKey.kt | 50 +++++ .../checkout/cse/internal/DateGenerator.kt | 4 +- .../cse/internal/DefaultCardEncrypter.kt | 6 +- .../cse/internal/DefaultGenericEncrypter.kt | 9 +- .../cse/internal/GenericEncryptorFactory.kt | 21 ++ .../checkout/cse/internal/JSONWebEncryptor.kt | 212 ++++++++++++++++++ .../adyen/checkout/cse/internal/JWEObject.kt | 17 ++ .../cse/internal/DefaultCardEncrypterTest.kt | 8 +- .../internal/DefaultGenericEncrypterTest.kt | 6 +- .../provider/GiftCardComponentProvider.kt | 19 +- .../internal/ui/DefaultGiftCardDelegate.kt | 4 +- .../ui/DefaultGiftCardDelegateTest.kt | 8 +- 26 files changed, 401 insertions(+), 290 deletions(-) create mode 100644 cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt delete mode 100644 cse/src/main/java/com/adyen/checkout/cse/internal/ClientSideEncrypter.kt create mode 100644 cse/src/main/java/com/adyen/checkout/cse/internal/CompositeKey.kt create mode 100644 cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt create mode 100644 cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt create mode 100644 cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt index 10593a7678..ff33193113 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt @@ -43,9 +43,7 @@ import com.adyen.checkout.components.core.internal.util.get import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.data.api.HttpClientFactory -import com.adyen.checkout.cse.internal.ClientSideEncrypter -import com.adyen.checkout.cse.internal.DateGenerator -import com.adyen.checkout.cse.internal.DefaultGenericEncrypter +import com.adyen.checkout.cse.internal.GenericEncryptorFactory import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionComponentCallback import com.adyen.checkout.sessions.core.internal.SessionComponentEventHandler @@ -117,9 +115,7 @@ constructor( val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) + val genericEncrypter = GenericEncryptorFactory.provide() val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( application = application, @@ -139,7 +135,7 @@ constructor( publicKeyRepository = publicKeyRepository, addressRepository = addressRepository, submitHandler = SubmitHandler(savedStateHandle), - genericEncrypter = genericEncrypter, + genericEncryptor = genericEncrypter, componentParams = componentParams, order = order ) @@ -191,9 +187,7 @@ constructor( val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) + val genericEncryptor = GenericEncryptorFactory.provide() val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( application = application, @@ -214,7 +208,7 @@ constructor( publicKeyRepository = publicKeyRepository, addressRepository = addressRepository, submitHandler = SubmitHandler(savedStateHandle), - genericEncrypter = genericEncrypter, + genericEncryptor = genericEncryptor, componentParams = componentParams, order = checkoutSession.order ) diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt index c300fcc50d..6ce53c504a 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt @@ -66,7 +66,7 @@ internal class DefaultACHDirectDebitDelegate( private val publicKeyRepository: PublicKeyRepository, private val addressRepository: AddressRepository, private val submitHandler: SubmitHandler, - private val genericEncrypter: BaseGenericEncrypter, + private val genericEncryptor: BaseGenericEncrypter, override val componentParams: ACHDirectDebitComponentParams, private val order: Order? ) : ACHDirectDebitDelegate, ButtonDelegate, UIStateDelegate { @@ -277,12 +277,12 @@ internal class DefaultACHDirectDebitDelegate( } try { - val encryptedBankAccountNumber = genericEncrypter.encryptField( + val encryptedBankAccountNumber = genericEncryptor.encryptField( fieldKeyToEncrypt = ENCRYPTION_KEY_FOR_BANK_ACCOUNT_NUMBER, fieldValueToEncrypt = outputData.bankAccountNumber.value, publicKey = publicKey ) - val encryptedBankLocationId = genericEncrypter.encryptField( + val encryptedBankLocationId = genericEncryptor.encryptField( fieldKeyToEncrypt = ENCRYPTION_KEY_FOR_BANK_LOCATION_ID, fieldValueToEncrypt = outputData.bankLocationId.value, publicKey = publicKey diff --git a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt index abd8899460..5ae73c698c 100644 --- a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt +++ b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt @@ -72,14 +72,14 @@ internal class DefaultACHDirectDebitDelegateTest( private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var addressRepository: TestAddressRepository - private lateinit var genericEncrypter: TestGenericEncrypter + private lateinit var genericEncryptor: TestGenericEncrypter private lateinit var delegate: DefaultACHDirectDebitDelegate @BeforeEach fun setUp() { publicKeyRepository = TestPublicKeyRepository() addressRepository = TestAddressRepository() - genericEncrypter = TestGenericEncrypter() + genericEncryptor = TestGenericEncrypter() delegate = createAchDelegate() } @@ -325,7 +325,7 @@ internal class DefaultACHDirectDebitDelegateTest( @Test fun `encryption fails, then component state should be invalid`() = runTest { - genericEncrypter.shouldThrowException = true + genericEncryptor.shouldThrowException = true delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -676,7 +676,7 @@ internal class DefaultACHDirectDebitDelegateTest( analyticsRepository: AnalyticsRepository = this.analyticsRepository, publicKeyRepository: PublicKeyRepository = this.publicKeyRepository, addressRepository: AddressRepository = this.addressRepository, - genericEncrypter: BaseGenericEncrypter = this.genericEncrypter, + genericEncryptor: BaseGenericEncrypter = this.genericEncryptor, submitHandler: SubmitHandler = this.submitHandler, configuration: ACHDirectDebitConfiguration = getAchConfigurationBuilder().build(), order: OrderRequest? = TEST_ORDER, @@ -687,7 +687,7 @@ internal class DefaultACHDirectDebitDelegateTest( publicKeyRepository = publicKeyRepository, addressRepository = addressRepository, submitHandler = submitHandler, - genericEncrypter = genericEncrypter, + genericEncryptor = genericEncryptor, componentParams = ACHDirectDebitComponentParamsMapper(null, null).mapToParams(configuration, null), order = order ) diff --git a/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt b/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt index b651e49510..3c55d67055 100644 --- a/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt +++ b/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt @@ -43,10 +43,7 @@ import com.adyen.checkout.components.core.internal.util.get import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.data.api.HttpClientFactory -import com.adyen.checkout.cse.internal.ClientSideEncrypter -import com.adyen.checkout.cse.internal.DateGenerator -import com.adyen.checkout.cse.internal.DefaultCardEncrypter -import com.adyen.checkout.cse.internal.DefaultGenericEncrypter +import com.adyen.checkout.cse.internal.CardEncryptorFactory import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionComponentCallback import com.adyen.checkout.sessions.core.internal.SessionComponentEventHandler @@ -101,10 +98,7 @@ constructor( val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val cardValidationMapper = CardValidationMapper() - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val cardEncryptor = CardEncryptorFactory.provide() val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) val binLookupService = BinLookupService(httpClient) @@ -132,7 +126,7 @@ constructor( addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncryptor, genericEncrypter = genericEncrypter, submitHandler = SubmitHandler(savedStateHandle) ) @@ -183,10 +177,7 @@ constructor( val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val cardValidationMapper = CardValidationMapper() - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val cardEncryptor = CardEncryptorFactory.provide() val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) val binLookupService = BinLookupService(httpClient) @@ -215,7 +206,7 @@ constructor( addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncryptor, genericEncrypter = genericEncrypter, submitHandler = SubmitHandler(savedStateHandle) ) diff --git a/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt b/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt index a0ba09c501..506136478e 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt @@ -31,7 +31,7 @@ import java.util.UUID @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class DefaultDetectCardTypeRepository( - private val cardEncrypter: BaseCardEncrypter, + private val cardEncryptor: BaseCardEncrypter, private val binLookupService: BinLookupService, ) : DetectCardTypeRepository { @@ -169,7 +169,7 @@ class DefaultDetectCardTypeRepository( type: String? ): BinLookupResponse? { return runSuspendCatching { - val encryptedBin = cardEncrypter.encryptBin(cardNumber, publicKey) + val encryptedBin = cardEncryptor.encryptBin(cardNumber, publicKey) val cardBrands = supportedCardBrands.map { it.txVariant } val request = BinLookupRequest(encryptedBin, UUID.randomUUID().toString(), cardBrands, type) diff --git a/card/src/main/java/com/adyen/checkout/card/internal/provider/CardComponentProvider.kt b/card/src/main/java/com/adyen/checkout/card/internal/provider/CardComponentProvider.kt index 883bdf88fb..ced949a235 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/provider/CardComponentProvider.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/provider/CardComponentProvider.kt @@ -46,10 +46,8 @@ import com.adyen.checkout.components.core.internal.util.get import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.data.api.HttpClientFactory -import com.adyen.checkout.cse.internal.ClientSideEncrypter -import com.adyen.checkout.cse.internal.DateGenerator -import com.adyen.checkout.cse.internal.DefaultCardEncrypter -import com.adyen.checkout.cse.internal.DefaultGenericEncrypter +import com.adyen.checkout.cse.internal.CardEncryptorFactory +import com.adyen.checkout.cse.internal.GenericEncryptorFactory import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionComponentCallback import com.adyen.checkout.sessions.core.internal.SessionComponentEventHandler @@ -123,12 +121,10 @@ constructor( null, ) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val genericEncryptor = GenericEncryptorFactory.provide() + val cardEncryptor = CardEncryptorFactory.provide() val binLookupService = BinLookupService(httpClient) - val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncrypter, binLookupService) + val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncryptor, binLookupService) val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val addressService = AddressService(httpClient) @@ -157,8 +153,8 @@ constructor( addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, - cardEncrypter = cardEncrypter, - genericEncrypter = genericEncrypter, + cardEncryptor = cardEncryptor, + genericEncryptor = genericEncryptor, submitHandler = SubmitHandler(savedStateHandle) ) @@ -204,12 +200,10 @@ constructor( sessionParams = SessionParamsFactory.create(checkoutSession), ) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val genericEncryptor = GenericEncryptorFactory.provide() + val cardEncryptor = CardEncryptorFactory.provide() val binLookupService = BinLookupService(httpClient) - val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncrypter, binLookupService) + val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncryptor, binLookupService) val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val addressService = AddressService(httpClient) @@ -239,8 +233,8 @@ constructor( addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, - cardEncrypter = cardEncrypter, - genericEncrypter = genericEncrypter, + cardEncryptor = cardEncryptor, + genericEncryptor = genericEncryptor, submitHandler = SubmitHandler(savedStateHandle) ) @@ -301,10 +295,7 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val cardEncryptor = CardEncryptorFactory.provide() val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( @@ -324,7 +315,7 @@ constructor( order = order, componentParams = componentParams, analyticsRepository = analyticsRepository, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncryptor, publicKeyRepository = publicKeyRepository, submitHandler = SubmitHandler(savedStateHandle) ) @@ -372,10 +363,7 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) - val dateGenerator = DateGenerator() - val clientSideEncrypter = ClientSideEncrypter() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val cardEncryptor = CardEncryptorFactory.provide() val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( @@ -396,7 +384,7 @@ constructor( order = checkoutSession.order, componentParams = componentParams, analyticsRepository = analyticsRepository, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncryptor, publicKeyRepository = publicKeyRepository, submitHandler = SubmitHandler(savedStateHandle) ) diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt index b247f24cb1..c354f7d6a1 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt @@ -97,8 +97,8 @@ class DefaultCardDelegate( private val addressRepository: AddressRepository, private val detectCardTypeRepository: DetectCardTypeRepository, private val cardValidationMapper: CardValidationMapper, - private val cardEncrypter: BaseCardEncrypter, - private val genericEncrypter: BaseGenericEncrypter, + private val cardEncryptor: BaseCardEncrypter, + private val genericEncryptor: BaseGenericEncrypter, private val submitHandler: SubmitHandler, ) : CardDelegate { @@ -435,7 +435,7 @@ class DefaultCardDelegate( ) } - cardEncrypter.encryptFields(unencryptedCardBuilder.build(), publicKey) + cardEncryptor.encryptFields(unencryptedCardBuilder.build(), publicKey) } catch (e: EncryptionException) { exceptionChannel.trySend(e) @@ -673,7 +673,7 @@ class DefaultCardDelegate( if (isKCPAuthRequired()) { publicKey?.let { publicKey -> - encryptedPassword = genericEncrypter.encryptField( + encryptedPassword = genericEncryptor.encryptField( ENCRYPTION_KEY_FOR_KCP_PASSWORD, stateOutputData.kcpCardPasswordState.value, publicKey diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt index 5f9919b0e2..694180dc56 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt @@ -70,7 +70,7 @@ internal class StoredCardDelegate( private val order: OrderRequest?, override val componentParams: CardComponentParams, private val analyticsRepository: AnalyticsRepository, - private val cardEncrypter: BaseCardEncrypter, + private val cardEncryptor: BaseCardEncrypter, private val publicKeyRepository: PublicKeyRepository, private val submitHandler: SubmitHandler, ) : CardDelegate { @@ -278,7 +278,7 @@ internal class StoredCardDelegate( ) } - cardEncrypter.encryptFields(unencryptedCardBuilder.build(), publicKey) + cardEncryptor.encryptFields(unencryptedCardBuilder.build(), publicKey) } catch (e: EncryptionException) { exceptionChannel.trySend(e) return CardComponentState( diff --git a/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt b/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt index 3b941ed63f..1a9794379a 100644 --- a/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt +++ b/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt @@ -1182,11 +1182,11 @@ internal class DefaultCardDelegateTest( order = order, publicKeyRepository = publicKeyRepository, componentParams = componentParams, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncrypter, addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, - genericEncrypter = genericEncrypter, + genericEncryptor = genericEncrypter, analyticsRepository = analyticsRepository, submitHandler = submitHandler, ) diff --git a/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt b/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt index 219801313c..55625f8ced 100644 --- a/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt +++ b/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt @@ -464,7 +464,7 @@ internal class StoredCardDelegateTest( storedPaymentMethod = storedPaymentMethod, publicKeyRepository = publicKeyRepository, componentParams = componentParams, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncrypter, analyticsRepository = analyticsRepository, submitHandler = submitHandler, order = order, diff --git a/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt index 2676729570..b956c25626 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt @@ -8,11 +8,7 @@ package com.adyen.checkout.cse -import com.adyen.checkout.cse.internal.BaseCardEncrypter -import com.adyen.checkout.cse.internal.ClientSideEncrypter -import com.adyen.checkout.cse.internal.DateGenerator -import com.adyen.checkout.cse.internal.DefaultCardEncrypter -import com.adyen.checkout.cse.internal.DefaultGenericEncrypter +import com.adyen.checkout.cse.internal.CardEncryptorFactory /** * Allows the encryption of card data to be sent to Adyen's APIs. @@ -20,7 +16,7 @@ import com.adyen.checkout.cse.internal.DefaultGenericEncrypter */ object CardEncrypter { - private val encrypter = provideCardEncrypter() + private val encryptor = CardEncryptorFactory.provide() /** * Encrypts the available card data from [UnencryptedCard] into individual encrypted blocks. @@ -35,7 +31,7 @@ object CardEncrypter { unencryptedCard: UnencryptedCard, publicKey: String ): EncryptedCard { - return encrypter.encryptFields( + return encryptor.encryptFields( unencryptedCard = unencryptedCard, publicKey = publicKey ) @@ -54,7 +50,7 @@ object CardEncrypter { unencryptedCard: UnencryptedCard, publicKey: String ): String { - return encrypter.encrypt( + return encryptor.encrypt( unencryptedCard = unencryptedCard, publicKey = publicKey ) @@ -70,13 +66,9 @@ object CardEncrypter { */ @Throws(EncryptionException::class) fun encryptBin(bin: String, publicKey: String): String { - return encrypter.encryptBin( + return encryptor.encryptBin( bin = bin, publicKey = publicKey ) } - - private fun provideCardEncrypter(): BaseCardEncrypter { - return DefaultCardEncrypter(DefaultGenericEncrypter(ClientSideEncrypter(), DateGenerator())) - } } diff --git a/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt index 5ab4de5d06..6c6b596111 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt @@ -8,10 +8,7 @@ package com.adyen.checkout.cse -import com.adyen.checkout.cse.internal.BaseGenericEncrypter -import com.adyen.checkout.cse.internal.ClientSideEncrypter -import com.adyen.checkout.cse.internal.DateGenerator -import com.adyen.checkout.cse.internal.DefaultGenericEncrypter +import com.adyen.checkout.cse.internal.GenericEncryptorFactory /** * Allows the encryption of any type of data to be sent to Adyen's APIs. @@ -19,7 +16,7 @@ import com.adyen.checkout.cse.internal.DefaultGenericEncrypter */ object GenericEncrypter { - private val encrypter = provideGenericEncrypter() + private val encryptor = GenericEncryptorFactory.provide() /** * Encrypts a single field into a block of content. @@ -36,7 +33,7 @@ object GenericEncrypter { fieldValueToEncrypt: Any?, publicKey: String, ): String { - return encrypter.encryptField( + return encryptor.encryptField( fieldKeyToEncrypt = fieldKeyToEncrypt, fieldValueToEncrypt = fieldValueToEncrypt, publicKey = publicKey, @@ -56,13 +53,9 @@ object GenericEncrypter { publicKey: String, vararg fieldsToEncrypt: Pair, ): String { - return encrypter.encryptFields( + return encryptor.encryptFields( fieldsToEncrypt = fieldsToEncrypt, publicKey = publicKey, ) } - - private fun provideGenericEncrypter(): BaseGenericEncrypter { - return DefaultGenericEncrypter(ClientSideEncrypter(), DateGenerator()) - } } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt new file mode 100644 index 0000000000..a427c379e7 --- /dev/null +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by oscars on 27/9/2023. + */ + +package com.adyen.checkout.cse.internal + +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object CardEncryptorFactory { + + fun provide(): BaseCardEncrypter { + val dateGenerator = DateGenerator() + val jsonWebEncryptor = JSONWebEncryptor() + val genericEncrypter = DefaultGenericEncrypter(dateGenerator, jsonWebEncryptor) + return DefaultCardEncrypter(genericEncrypter) + } +} diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/ClientSideEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/ClientSideEncrypter.kt deleted file mode 100644 index 272daac7fa..0000000000 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/ClientSideEncrypter.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (c) 2021 Adyen N.V. - * - * This file is open source and available under the MIT license. See the LICENSE file for more info. - * - * Created by caiof on 14/1/2021. - */ -package com.adyen.checkout.cse.internal - -import android.util.Base64 -import androidx.annotation.RestrictTo -import com.adyen.checkout.cse.EncryptionException -import java.math.BigInteger -import java.security.InvalidAlgorithmParameterException -import java.security.InvalidKeyException -import java.security.KeyFactory -import java.security.NoSuchAlgorithmException -import java.security.PublicKey -import java.security.SecureRandom -import java.security.spec.InvalidKeySpecException -import java.security.spec.RSAPublicKeySpec -import java.util.Locale -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.IllegalBlockSizeException -import javax.crypto.KeyGenerator -import javax.crypto.NoSuchPaddingException -import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec -import kotlin.text.Charsets.UTF_8 - -/** - * Created by andrei on 8/8/16. - */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class ClientSideEncrypter { - - @Throws(EncryptionException::class) - @Suppress("ThrowsCount", "LongMethod", "CyclomaticComplexMethod") - fun encrypt(publicKeyString: String, plainText: String): String { - if (!ValidationUtils.isPublicKeyValid(publicKeyString)) { - throw EncryptionException("Invalid public key: $publicKeyString", null) - } - - val aesCipher = try { - Cipher.getInstance("AES/CCM/NoPadding") - } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("Problem instantiation AES Cipher Algorithm", e) - } catch (e: NoSuchPaddingException) { - throw EncryptionException("Problem instantiation AES Cipher Padding", e) - } - - val secureRandom = SecureRandom() - val keyComponents = publicKeyString.split("|").toTypedArray() - - // The bytes can be converted back to a public key object - val keyFactory: KeyFactory = try { - KeyFactory.getInstance("RSA") - } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("RSA KeyFactory not found.", e) - } - val pubKeySpec = RSAPublicKeySpec( - BigInteger(keyComponents[1].lowercase(Locale.getDefault()), RADIX), - BigInteger(keyComponents[0].lowercase(Locale.getDefault()), RADIX) - ) - val pubKey: PublicKey = try { - keyFactory.generatePublic(pubKeySpec) - } catch (e: InvalidKeySpecException) { - throw EncryptionException("Problem reading public key: $publicKeyString", e) - } - - val rsaCipher = try { - Cipher.getInstance("RSA/None/PKCS1Padding").apply { - init(Cipher.ENCRYPT_MODE, pubKey) - } - } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("Problem instantiation RSA Cipher Algorithm", e) - } catch (e: NoSuchPaddingException) { - throw EncryptionException("Problem instantiation RSA Cipher Padding", e) - } catch (e: InvalidKeyException) { - throw EncryptionException("Invalid public key: $publicKeyString", e) - } - - val aesKey = generateAesKey() - val iv = generateIV(secureRandom) - val encrypted: ByteArray = try { - aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, IvParameterSpec(iv)) - aesCipher.doFinal(plainText.toByteArray(UTF_8)) - } catch (e: IllegalBlockSizeException) { - throw EncryptionException("Incorrect AES Block Size", e) - } catch (e: BadPaddingException) { - throw EncryptionException("Incorrect AES Padding", e) - } catch (e: InvalidKeyException) { - throw EncryptionException("Invalid AES Key", e) - } catch (e: InvalidAlgorithmParameterException) { - throw EncryptionException("Invalid AES Parameters", e) - } - val result = ByteArray(iv.size + encrypted.size) - // copy IV to result - System.arraycopy(iv, 0, result, 0, iv.size) - // copy encrypted to result - System.arraycopy(encrypted, 0, result, iv.size, encrypted.size) - val encryptedAesKey: ByteArray - return try { - encryptedAesKey = rsaCipher.doFinal(aesKey.encoded) - String.format( - Locale.ROOT, - "%s%s%s%s%s%s", - PREFIX, - VERSION, - SEPARATOR, - Base64.encodeToString(encryptedAesKey, Base64.NO_WRAP), - SEPARATOR, - Base64.encodeToString(result, Base64.NO_WRAP) - ) - } catch (e: IllegalBlockSizeException) { - throw EncryptionException("Incorrect RSA Block Size", e) - } catch (e: BadPaddingException) { - throw EncryptionException("Incorrect RSA Padding", e) - } - } - - @Throws(EncryptionException::class) - private fun generateAesKey(): SecretKey { - val keyGenerator: KeyGenerator = try { - KeyGenerator.getInstance("AES") - } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("Unable to get AES algorithm", e) - } - keyGenerator.init(KEY_SIZE) - return keyGenerator.generateKey() - } - - /** - * Generate a random Initialization Vector (IV). - * - * @return the IV bytes - */ - private fun generateIV(secureRandom: SecureRandom): ByteArray { - // generate random IV AES is always 16bytes, but in CCM mode this represents the NONCE - val iv = ByteArray(IV_SIZE) - secureRandom.nextBytes(iv) - return iv - } - - companion object { - private const val PREFIX = "adyenan" - private const val VERSION = "0_1_1" - private const val SEPARATOR = "$" - - private const val KEY_SIZE = 256 - private const val IV_SIZE = 12 - private const val RADIX = 16 - } -} diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/CompositeKey.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/CompositeKey.kt new file mode 100644 index 0000000000..35010710d4 --- /dev/null +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/CompositeKey.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by oscars on 27/9/2023. + */ + +package com.adyen.checkout.cse.internal + +import com.adyen.checkout.cse.EncryptionException +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +internal class CompositeKey( + inputKey: SecretKey +) { + + val macKey: SecretKey + val encKey: SecretKey + val truncatedMacLength: Int + + init { + val keyBytes = inputKey.encoded + @Suppress("MagicNumber") + when (keyBytes.size) { + 32 -> { + macKey = SecretKeySpec(keyBytes, 0, 16, "HMACSHA256") + encKey = SecretKeySpec(keyBytes, 16, 16, "AES") + truncatedMacLength = 16 + } + + 48 -> { + macKey = SecretKeySpec(keyBytes, 0, 24, "HMACSHA384") + encKey = SecretKeySpec(keyBytes, 24, 24, "AES") + truncatedMacLength = 24 + } + + 64 -> { + macKey = SecretKeySpec(keyBytes, 0, 32, "HMACSHA512") + encKey = SecretKeySpec(keyBytes, 32, 32, "AES") + truncatedMacLength = 32 + } + + else -> { + throw EncryptionException("Unsupported key length, must be 256, 384 or 512 bits", null) + } + } + } +} diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/DateGenerator.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/DateGenerator.kt index 19f68f1181..c29281ccd2 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/DateGenerator.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/DateGenerator.kt @@ -8,10 +8,8 @@ package com.adyen.checkout.cse.internal -import androidx.annotation.RestrictTo import java.util.Date -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class DateGenerator { +internal class DateGenerator { fun getCurrentDate(): Date = Date() } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt index 190eabe95f..cdef60ecca 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt @@ -7,13 +7,11 @@ */ package com.adyen.checkout.cse.internal -import androidx.annotation.RestrictTo import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.EncryptionException import com.adyen.checkout.cse.UnencryptedCard -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class DefaultCardEncrypter( +internal class DefaultCardEncrypter( private val genericEncrypter: BaseGenericEncrypter ) : BaseCardEncrypter { @@ -45,10 +43,12 @@ class DefaultCardEncrypter( publicKey = publicKey ) } + unencryptedCard.expiryMonth == null && unencryptedCard.expiryYear == null -> { encryptedExpiryMonth = null encryptedExpiryYear = null } + else -> { throw EncryptionException("Both expiryMonth and expiryYear need to be set for encryption.", null) } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt index ed11457b8b..348d15bb11 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt @@ -8,12 +8,9 @@ package com.adyen.checkout.cse.internal -import androidx.annotation.RestrictTo - -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class DefaultGenericEncrypter( - private val clientSideEncrypter: ClientSideEncrypter, +internal class DefaultGenericEncrypter( private val dateGenerator: DateGenerator, + private val jweEncryptor: JSONWebEncryptor, ) : BaseGenericEncrypter { override fun encryptField(fieldKeyToEncrypt: String, fieldValueToEncrypt: Any?, publicKey: String): String { @@ -25,6 +22,6 @@ class DefaultGenericEncrypter( override fun encryptFields(publicKey: String, vararg fieldsToEncrypt: Pair): String { val plainText = EncryptionPlainTextGenerator.generate(dateGenerator.getCurrentDate(), mapOf(*fieldsToEncrypt)) - return clientSideEncrypter.encrypt(publicKey, plainText) + return jweEncryptor.encrypt(publicKey, plainText) } } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt new file mode 100644 index 0000000000..7c0118a04b --- /dev/null +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by oscars on 27/9/2023. + */ + +package com.adyen.checkout.cse.internal + +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object GenericEncryptorFactory { + + fun provide(): BaseGenericEncrypter { + val dateGenerator = DateGenerator() + val jweEncryptor = JSONWebEncryptor() + return DefaultGenericEncrypter(dateGenerator, jweEncryptor) + } +} diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt new file mode 100644 index 0000000000..92d849faad --- /dev/null +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by oscars on 27/9/2023. + */ + +package com.adyen.checkout.cse.internal + +import android.util.Base64 +import com.adyen.checkout.cse.EncryptionException +import org.json.JSONObject +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.AlgorithmParameters +import java.security.InvalidKeyException +import java.security.KeyFactory +import java.security.NoSuchAlgorithmException +import java.security.PublicKey +import java.security.SecureRandom +import java.security.spec.InvalidKeySpecException +import java.security.spec.MGF1ParameterSpec +import java.security.spec.RSAPublicKeySpec +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.Mac +import javax.crypto.NoSuchPaddingException +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.crypto.spec.SecretKeySpec + +@Suppress("TooManyFunctions") +internal class JSONWebEncryptor { + + private val keyFactory: KeyFactory = try { + KeyFactory.getInstance(RSA_ALGORITHM) + } catch (e: NoSuchAlgorithmException) { + throw EncryptionException("RSA KeyFactory not found.", e) + } + + fun encrypt(publicKey: String, payload: String): String { + val pubKey = generatePublicKey(publicKey) + val cek = generateCEK() + val encryptedKey = Base64.encodeToString(encryptCEK(pubKey, cek), Base64.URL_SAFE or Base64.NO_WRAP) + val jweObject = encrypt(payload, cek, encryptedKey) + return serialize(jweObject) + } + + private fun generatePublicKey(publicKey: String): PublicKey { + val keyComponents = publicKey.split("|") + + val pubKeySpec = RSAPublicKeySpec( + BigInteger(keyComponents[1], RADIX), + BigInteger(keyComponents[0], RADIX) + ) + + return try { + keyFactory.generatePublic(pubKeySpec) + } catch (e: InvalidKeySpecException) { + throw EncryptionException("Problem reading public key", e) + } + } + + private fun generateCEK(): SecretKey { + val secRan = SecureRandom() + val bytes = ByteArray(CEK_BYTES) + secRan.nextBytes(bytes) + return SecretKeySpec(bytes, AES_ALGORITHM) + } + + private fun encryptCEK(pub: PublicKey, cek: SecretKey): ByteArray { + val cipher = getRSAOAEPCipher(pub) + + return try { + cipher.doFinal(cek.encoded) + } catch (e: IllegalBlockSizeException) { + throw EncryptionException("The RSA key is invalid, try another one", e) + } + } + + private fun getRSAOAEPCipher(pub: PublicKey): Cipher { + val algp = AlgorithmParameters.getInstance(OAEP_ALGORITHM) + val mgfParamSpec = MGF1ParameterSpec.SHA256 + val paramSpec = OAEPParameterSpec( + mgfParamSpec.digestAlgorithm, + MGF_NAME, + mgfParamSpec, + PSource.PSpecified.DEFAULT, + ) + algp.init(paramSpec) + + val cipher = try { + Cipher.getInstance(RSA_OAEP_CIPHER) + } catch (e: NoSuchAlgorithmException) { + throw EncryptionException("Problem instantiating $RSA_OAEP_CIPHER Algorithm", e) + } catch (e: NoSuchPaddingException) { + throw EncryptionException("Problem instantiating $RSA_OAEP_CIPHER Padding", e) + } + cipher.init(Cipher.ENCRYPT_MODE, pub, algp) + return cipher + } + + private fun encrypt(payload: String, cek: SecretKey, encryptedKey: String): JWEObject { + val aad = getAAD() + val iv = generateIV() + val compositeKey = CompositeKey(cek) + val aesCipher = getAESCBCCipher(compositeKey.encKey, iv) + val cipherText = aesCipher.doFinal(payload.toByteArray()) + val al = ByteBuffer.allocate(BITES_IN_BYTE).putLong(aad.size * BITES_IN_BYTE.toLong()).array() + + val hmacInputLength = aad.size + iv.size + cipherText.size + al.size + val hmacInput = ByteBuffer.allocate(hmacInputLength) + .put(aad) + .put(iv) + .put(cipherText) + .put(al) + .array() + val hmac = computeHMAC(compositeKey.macKey, hmacInput) + val authTag = hmac.copyOf(compositeKey.truncatedMacLength) + + return JWEObject( + header = HEADER.toString(), + encryptedKey = encryptedKey, + iv = Base64.encodeToString(iv, Base64.URL_SAFE or Base64.NO_WRAP), + cipherText = Base64.encodeToString(cipherText, Base64.URL_SAFE or Base64.NO_WRAP), + authTag = Base64.encodeToString(authTag, Base64.URL_SAFE or Base64.NO_WRAP), + ) + } + + private fun getAAD(): ByteArray { + val bytes = HEADER.toString().toByteArray() + val base64Encoded = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP) + return base64Encoded.toByteArray(Charsets.US_ASCII) + } + + private fun generateIV(): ByteArray { + val iv = ByteArray(IV_BYTES) + SecureRandom().nextBytes(iv) + return iv + } + + private fun getAESCBCCipher(secretKey: SecretKey, iv: ByteArray): Cipher { + val keySpec = SecretKeySpec(secretKey.encoded, AES_ALGORITHM) + val ivSpec = IvParameterSpec(iv) + + val cipher = try { + Cipher.getInstance(AES_CBC_CIPHER) + } catch (e: NoSuchAlgorithmException) { + throw EncryptionException("Problem instantiating $AES_CBC_CIPHER Algorithm", e) + } catch (e: NoSuchPaddingException) { + throw EncryptionException("Problem instantiating $AES_CBC_CIPHER Padding", e) + } + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + return cipher + } + + private fun computeHMAC(macKey: SecretKey, input: ByteArray): ByteArray { + val mac = getMacInstance(macKey) + mac.update(input) + return mac.doFinal() + } + + private fun getMacInstance(macKey: SecretKey): Mac { + return try { + Mac.getInstance(macKey.algorithm) + } catch (e: NoSuchAlgorithmException) { + throw EncryptionException("Problem instantiating Mac", e) + } catch (e: InvalidKeyException) { + throw EncryptionException("Problem instantiating Mac", e) + }.apply { init(macKey) } + } + + private fun serialize(jweObject: JWEObject): String { + val encodedHeader = + Base64.encodeToString(jweObject.header.encodeToByteArray(), Base64.URL_SAFE or Base64.NO_WRAP) + return StringBuilder(encodedHeader) + .append(".") + .append(jweObject.encryptedKey) + .append(".") + .append(jweObject.iv) + .append(".") + .append(jweObject.cipherText) + .append(".") + .append(jweObject.authTag) + .toString() + } + + companion object { + private const val RSA_OAEP_CIPHER = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + private const val AES_CBC_CIPHER = "AES/CBC/PKCS5Padding" + + private const val RSA_ALGORITHM = "RSA" + private const val AES_ALGORITHM = "AES" + private const val OAEP_ALGORITHM = "OAEP" + + private const val MGF_NAME = "MGF1" + + private const val BITES_IN_BYTE = 8 + private const val RADIX = 16 + private const val CEK_BYTES = 64 + private const val IV_BYTES = 16 + + val HEADER = JSONObject().apply { + put("alg", "RSA-OAEP-256") + put("enc", "A256CBC-HS512") + put("version", "1") + } + } +} diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt new file mode 100644 index 0000000000..ca28e215d7 --- /dev/null +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2023 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by oscars on 27/9/2023. + */ + +package com.adyen.checkout.cse.internal + +internal data class JWEObject( + val header: String, + val encryptedKey: String, + val iv: String, + val cipherText: String, + val authTag: String, +) diff --git a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt index 5750f9d3a4..06d60bf19d 100644 --- a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt +++ b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt @@ -24,18 +24,18 @@ import java.util.Calendar import java.util.TimeZone @ExtendWith(MockitoExtension::class) -class DefaultCardEncrypterTest( - @Mock private val clientSideEncrypter: ClientSideEncrypter, +internal class DefaultCardEncrypterTest( @Mock private val dateGenerator: DateGenerator, + @Mock private val jsonWebEncryptor: JSONWebEncryptor, ) { private val cardEncrypter = DefaultCardEncrypter( - DefaultGenericEncrypter(clientSideEncrypter, dateGenerator), + DefaultGenericEncrypter(dateGenerator, jsonWebEncryptor), ) @BeforeEach fun setup() { whenever(dateGenerator.getCurrentDate()) doReturn DATE - whenever(clientSideEncrypter.encrypt(any(), any())).thenAnswer { + whenever(jsonWebEncryptor.encrypt(any(), any())).thenAnswer { "${it.arguments[0]}-${it.arguments[1]}" } } diff --git a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt index f77187ee9d..ecf52ef800 100644 --- a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt +++ b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt @@ -23,15 +23,15 @@ import java.util.TimeZone @ExtendWith(MockitoExtension::class) internal class DefaultGenericEncrypterTest( - @Mock private val clientSideEncrypter: ClientSideEncrypter, @Mock private val dateGenerator: DateGenerator, + @Mock private val jsonWebEncryptor: JSONWebEncryptor, ) { - private val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) + private val genericEncrypter = DefaultGenericEncrypter(dateGenerator, jsonWebEncryptor) @BeforeEach fun setup() { whenever(dateGenerator.getCurrentDate()) doReturn DATE - whenever(clientSideEncrypter.encrypt(any(), any())).thenAnswer { + whenever(jsonWebEncryptor.encrypt(any(), any())).thenAnswer { "${it.arguments[0]}-${it.arguments[1]}" } } diff --git a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt index 346dcc54b4..50f83e5d42 100644 --- a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt +++ b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt @@ -32,10 +32,7 @@ import com.adyen.checkout.components.core.internal.util.get import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.data.api.HttpClientFactory -import com.adyen.checkout.cse.internal.ClientSideEncrypter -import com.adyen.checkout.cse.internal.DateGenerator -import com.adyen.checkout.cse.internal.DefaultCardEncrypter -import com.adyen.checkout.cse.internal.DefaultGenericEncrypter +import com.adyen.checkout.cse.internal.CardEncryptorFactory import com.adyen.checkout.giftcard.GiftCardComponent import com.adyen.checkout.giftcard.GiftCardComponentCallback import com.adyen.checkout.giftcard.GiftCardComponentState @@ -90,10 +87,7 @@ constructor( ): GiftCardComponent { assertSupported(paymentMethod) - val clientSideEncrypter = ClientSideEncrypter() - val dateGenerator = DateGenerator() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val cardEncrypter = CardEncryptorFactory.provide() val giftCardFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> val componentParams = componentParamsMapper.mapToParams(configuration, null) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) @@ -118,7 +112,7 @@ constructor( analyticsRepository = analyticsRepository, publicKeyRepository = DefaultPublicKeyRepository(publicKeyService), componentParams = componentParams, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncrypter, submitHandler = SubmitHandler(savedStateHandle), ) @@ -158,10 +152,7 @@ constructor( ): GiftCardComponent { assertSupported(paymentMethod) - val clientSideEncrypter = ClientSideEncrypter() - val dateGenerator = DateGenerator() - val genericEncrypter = DefaultGenericEncrypter(clientSideEncrypter, dateGenerator) - val cardEncrypter = DefaultCardEncrypter(genericEncrypter) + val cardEncryptor = CardEncryptorFactory.provide() val giftCardFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> val componentParams = componentParamsMapper.mapToParams( configuration = configuration, @@ -190,7 +181,7 @@ constructor( analyticsRepository = analyticsRepository, publicKeyRepository = DefaultPublicKeyRepository(publicKeyService), componentParams = componentParams, - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncryptor, submitHandler = SubmitHandler(savedStateHandle), ) diff --git a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt index 73b6f200d5..afb277b378 100644 --- a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt +++ b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt @@ -63,7 +63,7 @@ internal class DefaultGiftCardDelegate( private val analyticsRepository: AnalyticsRepository, private val publicKeyRepository: PublicKeyRepository, override val componentParams: GiftCardComponentParams, - private val cardEncrypter: BaseCardEncrypter, + private val cardEncryptor: BaseCardEncrypter, private val submitHandler: SubmitHandler, ) : GiftCardDelegate { @@ -249,7 +249,7 @@ internal class DefaultGiftCardDelegate( build() } - cardEncrypter.encryptFields(unencryptedCard, publicKey) + cardEncryptor.encryptFields(unencryptedCard, publicKey) } catch (e: EncryptionException) { exceptionChannel.trySend(e) null diff --git a/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt b/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt index 466902746a..b3117d0905 100644 --- a/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt +++ b/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt @@ -60,13 +60,13 @@ internal class DefaultGiftCardDelegateTest( @Mock private val submitHandler: SubmitHandler, ) { - private lateinit var cardEncrypter: TestCardEncrypter + private lateinit var cardEncryptor: TestCardEncrypter private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var delegate: DefaultGiftCardDelegate @BeforeEach fun before() { - cardEncrypter = TestCardEncrypter() + cardEncryptor = TestCardEncrypter() publicKeyRepository = TestPublicKeyRepository() delegate = createGiftCardDelegate() } @@ -121,7 +121,7 @@ internal class DefaultGiftCardDelegateTest( @Test fun `encryption fails, then component state should be invalid`() = runTest { - cardEncrypter.shouldThrowException = true + cardEncryptor.shouldThrowException = true delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -403,7 +403,7 @@ internal class DefaultGiftCardDelegateTest( order = order, publicKeyRepository = publicKeyRepository, componentParams = GiftCardComponentParamsMapper(null, null).mapToParams(configuration, null), - cardEncrypter = cardEncrypter, + cardEncryptor = cardEncryptor, analyticsRepository = analyticsRepository, submitHandler = submitHandler, ) From 48cfa0c53dd3d9cad041c0a83b8c8fce662449d2 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 27 Sep 2023 15:27:18 +0200 Subject: [PATCH 29/66] Rename every occurrence of encrypter with encryptor COAND-655 --- .../ACHDirectDebitComponentProvider.kt | 4 +-- .../ui/DefaultACHDirectDebitDelegate.kt | 4 +-- .../ui/DefaultACHDirectDebitDelegateTest.kt | 10 +++---- .../api/DefaultDetectCardTypeRepository.kt | 4 +-- .../card/internal/ui/DefaultCardDelegate.kt | 8 +++--- .../card/internal/ui/StoredCardDelegate.kt | 4 +-- .../internal/ui/DefaultCardDelegateTest.kt | 26 +++++++++---------- .../internal/ui/StoredCardDelegateTest.kt | 14 +++++----- .../{CardEncrypter.kt => CardEncryptor.kt} | 2 +- .../adyen/checkout/cse/GenericEncrypter.kt | 2 +- .../com/adyen/checkout/cse/UnencryptedCard.kt | 2 +- ...eCardEncrypter.kt => BaseCardEncryptor.kt} | 2 +- ...icEncrypter.kt => BaseGenericEncryptor.kt} | 2 +- .../cse/internal/CardEncryptorFactory.kt | 6 ++--- ...rdEncrypter.kt => DefaultCardEncryptor.kt} | 18 ++++++------- ...ncrypter.kt => DefaultGenericEncryptor.kt} | 4 +-- .../cse/internal/GenericEncryptorFactory.kt | 4 +-- ...tCardEncrypter.kt => TestCardEncryptor.kt} | 6 ++--- ...icEncrypter.kt => TestGenericEncryptor.kt} | 6 ++--- ...terTest.kt => DefaultCardEncryptorTest.kt} | 12 ++++----- .../internal/DefaultGenericEncrypterTest.kt | 6 ++--- .../provider/GiftCardComponentProvider.kt | 4 +-- .../internal/ui/DefaultGiftCardDelegate.kt | 4 +-- .../ui/DefaultGiftCardDelegateTest.kt | 6 ++--- 24 files changed, 80 insertions(+), 80 deletions(-) rename cse/src/main/java/com/adyen/checkout/cse/{CardEncrypter.kt => CardEncryptor.kt} (99%) rename cse/src/main/java/com/adyen/checkout/cse/internal/{BaseCardEncrypter.kt => BaseCardEncryptor.kt} (95%) rename cse/src/main/java/com/adyen/checkout/cse/internal/{BaseGenericEncrypter.kt => BaseGenericEncryptor.kt} (94%) rename cse/src/main/java/com/adyen/checkout/cse/internal/{DefaultCardEncrypter.kt => DefaultCardEncryptor.kt} (87%) rename cse/src/main/java/com/adyen/checkout/cse/internal/{DefaultGenericEncrypter.kt => DefaultGenericEncryptor.kt} (92%) rename cse/src/main/java/com/adyen/checkout/cse/internal/test/{TestCardEncrypter.kt => TestCardEncryptor.kt} (90%) rename cse/src/main/java/com/adyen/checkout/cse/internal/test/{TestGenericEncrypter.kt => TestGenericEncryptor.kt} (85%) rename cse/src/test/java/com/adyen/checkout/cse/internal/{DefaultCardEncrypterTest.kt => DefaultCardEncryptorTest.kt} (92%) diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt index ff33193113..a828293980 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/provider/ACHDirectDebitComponentProvider.kt @@ -115,7 +115,7 @@ constructor( val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) - val genericEncrypter = GenericEncryptorFactory.provide() + val genericEncryptor = GenericEncryptorFactory.provide() val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( application = application, @@ -135,7 +135,7 @@ constructor( publicKeyRepository = publicKeyRepository, addressRepository = addressRepository, submitHandler = SubmitHandler(savedStateHandle), - genericEncryptor = genericEncrypter, + genericEncryptor = genericEncryptor, componentParams = componentParams, order = order ) diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt index 6ce53c504a..f7b0995eb9 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegate.kt @@ -29,7 +29,7 @@ import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.util.LogUtil import com.adyen.checkout.core.internal.util.Logger import com.adyen.checkout.cse.EncryptionException -import com.adyen.checkout.cse.internal.BaseGenericEncrypter +import com.adyen.checkout.cse.internal.BaseGenericEncryptor import com.adyen.checkout.ui.core.internal.data.api.AddressRepository import com.adyen.checkout.ui.core.internal.ui.AddressFormUIState import com.adyen.checkout.ui.core.internal.ui.ButtonComponentViewType @@ -66,7 +66,7 @@ internal class DefaultACHDirectDebitDelegate( private val publicKeyRepository: PublicKeyRepository, private val addressRepository: AddressRepository, private val submitHandler: SubmitHandler, - private val genericEncryptor: BaseGenericEncrypter, + private val genericEncryptor: BaseGenericEncryptor, override val componentParams: ACHDirectDebitComponentParams, private val order: Order? ) : ACHDirectDebitDelegate, ButtonDelegate, UIStateDelegate { diff --git a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt index 5ae73c698c..172965f178 100644 --- a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt +++ b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/DefaultACHDirectDebitDelegateTest.kt @@ -26,8 +26,8 @@ import com.adyen.checkout.components.core.internal.ui.model.FieldState import com.adyen.checkout.components.core.internal.ui.model.Validation import com.adyen.checkout.components.core.paymentmethod.ACHDirectDebitPaymentMethod import com.adyen.checkout.core.Environment -import com.adyen.checkout.cse.internal.BaseGenericEncrypter -import com.adyen.checkout.cse.internal.test.TestGenericEncrypter +import com.adyen.checkout.cse.internal.BaseGenericEncryptor +import com.adyen.checkout.cse.internal.test.TestGenericEncryptor import com.adyen.checkout.test.TestDispatcherExtension import com.adyen.checkout.test.extensions.test import com.adyen.checkout.ui.core.internal.data.api.AddressRepository @@ -72,14 +72,14 @@ internal class DefaultACHDirectDebitDelegateTest( private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var addressRepository: TestAddressRepository - private lateinit var genericEncryptor: TestGenericEncrypter + private lateinit var genericEncryptor: TestGenericEncryptor private lateinit var delegate: DefaultACHDirectDebitDelegate @BeforeEach fun setUp() { publicKeyRepository = TestPublicKeyRepository() addressRepository = TestAddressRepository() - genericEncryptor = TestGenericEncrypter() + genericEncryptor = TestGenericEncryptor() delegate = createAchDelegate() } @@ -676,7 +676,7 @@ internal class DefaultACHDirectDebitDelegateTest( analyticsRepository: AnalyticsRepository = this.analyticsRepository, publicKeyRepository: PublicKeyRepository = this.publicKeyRepository, addressRepository: AddressRepository = this.addressRepository, - genericEncryptor: BaseGenericEncrypter = this.genericEncryptor, + genericEncryptor: BaseGenericEncryptor = this.genericEncryptor, submitHandler: SubmitHandler = this.submitHandler, configuration: ACHDirectDebitConfiguration = getAchConfigurationBuilder().build(), order: OrderRequest? = TEST_ORDER, diff --git a/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt b/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt index 506136478e..9c9426a36f 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/data/api/DefaultDetectCardTypeRepository.kt @@ -21,7 +21,7 @@ import com.adyen.checkout.core.internal.util.LogUtil import com.adyen.checkout.core.internal.util.Logger import com.adyen.checkout.core.internal.util.Sha256 import com.adyen.checkout.core.internal.util.runSuspendCatching -import com.adyen.checkout.cse.internal.BaseCardEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -31,7 +31,7 @@ import java.util.UUID @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class DefaultDetectCardTypeRepository( - private val cardEncryptor: BaseCardEncrypter, + private val cardEncryptor: BaseCardEncryptor, private val binLookupService: BinLookupService, ) : DetectCardTypeRepository { diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt index c354f7d6a1..abe103653b 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegate.kt @@ -55,8 +55,8 @@ import com.adyen.checkout.core.internal.util.runCompileOnly import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.EncryptionException import com.adyen.checkout.cse.UnencryptedCard -import com.adyen.checkout.cse.internal.BaseCardEncrypter -import com.adyen.checkout.cse.internal.BaseGenericEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor +import com.adyen.checkout.cse.internal.BaseGenericEncryptor import com.adyen.checkout.ui.core.internal.data.api.AddressRepository import com.adyen.checkout.ui.core.internal.ui.AddressFormUIState import com.adyen.checkout.ui.core.internal.ui.ButtonComponentViewType @@ -97,8 +97,8 @@ class DefaultCardDelegate( private val addressRepository: AddressRepository, private val detectCardTypeRepository: DetectCardTypeRepository, private val cardValidationMapper: CardValidationMapper, - private val cardEncryptor: BaseCardEncrypter, - private val genericEncryptor: BaseGenericEncrypter, + private val cardEncryptor: BaseCardEncryptor, + private val genericEncryptor: BaseGenericEncryptor, private val submitHandler: SubmitHandler, ) : CardDelegate { diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt index 694180dc56..200688e204 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/StoredCardDelegate.kt @@ -43,7 +43,7 @@ import com.adyen.checkout.core.internal.util.runCompileOnly import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.EncryptionException import com.adyen.checkout.cse.UnencryptedCard -import com.adyen.checkout.cse.internal.BaseCardEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor import com.adyen.checkout.ui.core.internal.ui.AddressFormUIState import com.adyen.checkout.ui.core.internal.ui.ButtonComponentViewType import com.adyen.checkout.ui.core.internal.ui.ComponentViewType @@ -70,7 +70,7 @@ internal class StoredCardDelegate( private val order: OrderRequest?, override val componentParams: CardComponentParams, private val analyticsRepository: AnalyticsRepository, - private val cardEncryptor: BaseCardEncrypter, + private val cardEncryptor: BaseCardEncryptor, private val publicKeyRepository: PublicKeyRepository, private val submitHandler: SubmitHandler, ) : CardDelegate { diff --git a/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt b/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt index 1a9794379a..94fb94fc04 100644 --- a/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt +++ b/card/src/test/java/com/adyen/checkout/card/internal/ui/DefaultCardDelegateTest.kt @@ -50,10 +50,10 @@ import com.adyen.checkout.components.core.internal.test.TestPublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.FieldState import com.adyen.checkout.components.core.internal.ui.model.Validation import com.adyen.checkout.core.Environment -import com.adyen.checkout.cse.internal.BaseCardEncrypter -import com.adyen.checkout.cse.internal.BaseGenericEncrypter -import com.adyen.checkout.cse.internal.test.TestCardEncrypter -import com.adyen.checkout.cse.internal.test.TestGenericEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor +import com.adyen.checkout.cse.internal.BaseGenericEncryptor +import com.adyen.checkout.cse.internal.test.TestCardEncryptor +import com.adyen.checkout.cse.internal.test.TestGenericEncryptor import com.adyen.checkout.test.TestDispatcherExtension import com.adyen.checkout.ui.core.internal.data.api.AddressRepository import com.adyen.checkout.ui.core.internal.test.TestAddressRepository @@ -95,8 +95,8 @@ internal class DefaultCardDelegateTest( @Mock private val submitHandler: SubmitHandler ) { - private lateinit var cardEncrypter: TestCardEncrypter - private lateinit var genericEncrypter: TestGenericEncrypter + private lateinit var cardEncryptor: TestCardEncryptor + private lateinit var genericEncryptor: TestGenericEncryptor private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var addressRepository: TestAddressRepository private lateinit var detectCardTypeRepository: TestDetectCardTypeRepository @@ -104,8 +104,8 @@ internal class DefaultCardDelegateTest( @BeforeEach fun before() { - cardEncrypter = TestCardEncrypter() - genericEncrypter = TestGenericEncrypter() + cardEncryptor = TestCardEncryptor() + genericEncryptor = TestGenericEncryptor() publicKeyRepository = TestPublicKeyRepository() addressRepository = TestAddressRepository() detectCardTypeRepository = TestDetectCardTypeRepository() @@ -678,7 +678,7 @@ internal class DefaultCardDelegateTest( @Test fun `encryption fails, then component state should be invalid`() = runTest { - cardEncrypter.shouldThrowException = true + cardEncryptor.shouldThrowException = true delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -1162,8 +1162,8 @@ internal class DefaultCardDelegateTest( addressRepository: AddressRepository = this.addressRepository, detectCardTypeRepository: DetectCardTypeRepository = this.detectCardTypeRepository, cardValidationMapper: CardValidationMapper = CardValidationMapper(), - cardEncrypter: BaseCardEncrypter = this.cardEncrypter, - genericEncrypter: BaseGenericEncrypter = this.genericEncrypter, + cardEncryptor: BaseCardEncryptor = this.cardEncryptor, + genericEncryptor: BaseGenericEncryptor = this.genericEncryptor, configuration: CardConfiguration = getDefaultCardConfigurationBuilder().build(), paymentMethod: PaymentMethod = PaymentMethod(type = PaymentMethodTypes.SCHEME), analyticsRepository: AnalyticsRepository = this.analyticsRepository, @@ -1182,11 +1182,11 @@ internal class DefaultCardDelegateTest( order = order, publicKeyRepository = publicKeyRepository, componentParams = componentParams, - cardEncryptor = cardEncrypter, + cardEncryptor = cardEncryptor, addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, - genericEncryptor = genericEncrypter, + genericEncryptor = genericEncryptor, analyticsRepository = analyticsRepository, submitHandler = submitHandler, ) diff --git a/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt b/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt index 55625f8ced..e52031d190 100644 --- a/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt +++ b/card/src/test/java/com/adyen/checkout/card/internal/ui/StoredCardDelegateTest.kt @@ -40,8 +40,8 @@ import com.adyen.checkout.components.core.internal.ui.model.FieldState import com.adyen.checkout.components.core.internal.ui.model.Validation import com.adyen.checkout.components.core.paymentmethod.CardPaymentMethod import com.adyen.checkout.core.Environment -import com.adyen.checkout.cse.internal.BaseCardEncrypter -import com.adyen.checkout.cse.internal.test.TestCardEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor +import com.adyen.checkout.cse.internal.test.TestCardEncryptor import com.adyen.checkout.test.TestDispatcherExtension import com.adyen.checkout.ui.core.internal.ui.AddressFormUIState import com.adyen.checkout.ui.core.internal.ui.SubmitHandler @@ -78,13 +78,13 @@ internal class StoredCardDelegateTest( @Mock private val submitHandler: SubmitHandler ) { - private lateinit var cardEncrypter: TestCardEncrypter + private lateinit var cardEncryptor: TestCardEncryptor private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var delegate: StoredCardDelegate @BeforeEach fun before() { - cardEncrypter = TestCardEncrypter() + cardEncryptor = TestCardEncryptor() publicKeyRepository = TestPublicKeyRepository() delegate = createCardDelegate() } @@ -255,7 +255,7 @@ internal class StoredCardDelegateTest( @Test fun `encryption fails, then component state should be invalid`() = runTest { - cardEncrypter.shouldThrowException = true + cardEncryptor.shouldThrowException = true delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -446,7 +446,7 @@ internal class StoredCardDelegateTest( @Suppress("LongParameterList") private fun createCardDelegate( publicKeyRepository: PublicKeyRepository = this.publicKeyRepository, - cardEncrypter: BaseCardEncrypter = this.cardEncrypter, + cardEncryptor: BaseCardEncryptor = this.cardEncryptor, configuration: CardConfiguration = getDefaultCardConfigurationBuilder().build(), storedPaymentMethod: StoredPaymentMethod = getStoredPaymentMethod(), analyticsRepository: AnalyticsRepository = this.analyticsRepository, @@ -464,7 +464,7 @@ internal class StoredCardDelegateTest( storedPaymentMethod = storedPaymentMethod, publicKeyRepository = publicKeyRepository, componentParams = componentParams, - cardEncryptor = cardEncrypter, + cardEncryptor = cardEncryptor, analyticsRepository = analyticsRepository, submitHandler = submitHandler, order = order, diff --git a/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/CardEncryptor.kt similarity index 99% rename from cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/CardEncryptor.kt index b956c25626..095624b2ad 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/CardEncryptor.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.cse.internal.CardEncryptorFactory * Allows the encryption of card data to be sent to Adyen's APIs. * Use this class with the custom card integration. */ -object CardEncrypter { +object CardEncryptor { private val encryptor = CardEncryptorFactory.provide() diff --git a/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt index 6c6b596111..5f9ad33cb6 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.cse.internal.GenericEncryptorFactory * Allows the encryption of any type of data to be sent to Adyen's APIs. * Use this class with custom component integrations. */ -object GenericEncrypter { +object GenericEncryptor { private val encryptor = GenericEncryptorFactory.provide() diff --git a/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt b/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt index 29607031ea..3da7384883 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt @@ -9,7 +9,7 @@ package com.adyen.checkout.cse /** * Class containing raw card data that needs to be encrypted. - * Use [Builder] to instantiate and [CardEncrypter] to encrypt. + * Use [Builder] to instantiate and [CardEncryptor] to encrypt. */ class UnencryptedCard internal constructor( val number: String?, diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/BaseCardEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/BaseCardEncryptor.kt similarity index 95% rename from cse/src/main/java/com/adyen/checkout/cse/internal/BaseCardEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/internal/BaseCardEncryptor.kt index f42ecf833e..aa99b76cc0 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/BaseCardEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/BaseCardEncryptor.kt @@ -13,7 +13,7 @@ import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.UnencryptedCard @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -interface BaseCardEncrypter { +interface BaseCardEncryptor { fun encryptFields( unencryptedCard: UnencryptedCard, diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/BaseGenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/BaseGenericEncryptor.kt similarity index 94% rename from cse/src/main/java/com/adyen/checkout/cse/internal/BaseGenericEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/internal/BaseGenericEncryptor.kt index 700001934a..548c801532 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/BaseGenericEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/BaseGenericEncryptor.kt @@ -11,7 +11,7 @@ package com.adyen.checkout.cse.internal import androidx.annotation.RestrictTo @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -interface BaseGenericEncrypter { +interface BaseGenericEncryptor { fun encryptField( fieldKeyToEncrypt: String, diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt index a427c379e7..ed5964eb01 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/CardEncryptorFactory.kt @@ -13,10 +13,10 @@ import androidx.annotation.RestrictTo @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) object CardEncryptorFactory { - fun provide(): BaseCardEncrypter { + fun provide(): BaseCardEncryptor { val dateGenerator = DateGenerator() val jsonWebEncryptor = JSONWebEncryptor() - val genericEncrypter = DefaultGenericEncrypter(dateGenerator, jsonWebEncryptor) - return DefaultCardEncrypter(genericEncrypter) + val genericEncryptor = DefaultGenericEncryptor(dateGenerator, jsonWebEncryptor) + return DefaultCardEncryptor(genericEncryptor) } } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncryptor.kt similarity index 87% rename from cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncryptor.kt index cdef60ecca..b37e568fd0 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultCardEncryptor.kt @@ -11,9 +11,9 @@ import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.EncryptionException import com.adyen.checkout.cse.UnencryptedCard -internal class DefaultCardEncrypter( - private val genericEncrypter: BaseGenericEncrypter -) : BaseCardEncrypter { +internal class DefaultCardEncryptor( + private val genericEncryptor: BaseGenericEncryptor +) : BaseCardEncryptor { override fun encryptFields( unencryptedCard: UnencryptedCard, @@ -21,7 +21,7 @@ internal class DefaultCardEncrypter( ): EncryptedCard { return try { val encryptedNumber = unencryptedCard.number?.let { number -> - genericEncrypter.encryptField( + genericEncryptor.encryptField( fieldKeyToEncrypt = CARD_NUMBER_KEY, fieldValueToEncrypt = number, publicKey = publicKey @@ -32,12 +32,12 @@ internal class DefaultCardEncrypter( val encryptedExpiryYear: String? when { unencryptedCard.expiryMonth != null && unencryptedCard.expiryYear != null -> { - encryptedExpiryMonth = genericEncrypter.encryptField( + encryptedExpiryMonth = genericEncryptor.encryptField( fieldKeyToEncrypt = EXPIRY_MONTH_KEY, fieldValueToEncrypt = unencryptedCard.expiryMonth, publicKey = publicKey ) - encryptedExpiryYear = genericEncrypter.encryptField( + encryptedExpiryYear = genericEncryptor.encryptField( fieldKeyToEncrypt = EXPIRY_YEAR_KEY, fieldValueToEncrypt = unencryptedCard.expiryYear, publicKey = publicKey @@ -55,7 +55,7 @@ internal class DefaultCardEncrypter( } val encryptedSecurityCode = unencryptedCard.cvc?.let { cvc -> - genericEncrypter.encryptField(CVC_KEY, cvc, publicKey) + genericEncryptor.encryptField(CVC_KEY, cvc, publicKey) } EncryptedCard( encryptedCardNumber = encryptedNumber, @@ -72,7 +72,7 @@ internal class DefaultCardEncrypter( unencryptedCard: UnencryptedCard, publicKey: String ): String { - return genericEncrypter.encryptFields( + return genericEncryptor.encryptFields( publicKey, CARD_NUMBER_KEY to unencryptedCard.number, EXPIRY_MONTH_KEY to unencryptedCard.expiryMonth, @@ -83,7 +83,7 @@ internal class DefaultCardEncrypter( } override fun encryptBin(bin: String, publicKey: String): String { - return genericEncrypter.encryptField( + return genericEncryptor.encryptField( BIN_KEY, bin, publicKey diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncryptor.kt similarity index 92% rename from cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncryptor.kt index 348d15bb11..d48269d338 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/DefaultGenericEncryptor.kt @@ -8,10 +8,10 @@ package com.adyen.checkout.cse.internal -internal class DefaultGenericEncrypter( +internal class DefaultGenericEncryptor( private val dateGenerator: DateGenerator, private val jweEncryptor: JSONWebEncryptor, -) : BaseGenericEncrypter { +) : BaseGenericEncryptor { override fun encryptField(fieldKeyToEncrypt: String, fieldValueToEncrypt: Any?, publicKey: String): String { return encryptFields( diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt index 7c0118a04b..f0000caaa7 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/GenericEncryptorFactory.kt @@ -13,9 +13,9 @@ import androidx.annotation.RestrictTo @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) object GenericEncryptorFactory { - fun provide(): BaseGenericEncrypter { + fun provide(): BaseGenericEncryptor { val dateGenerator = DateGenerator() val jweEncryptor = JSONWebEncryptor() - return DefaultGenericEncrypter(dateGenerator, jweEncryptor) + return DefaultGenericEncryptor(dateGenerator, jweEncryptor) } } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestCardEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestCardEncryptor.kt similarity index 90% rename from cse/src/main/java/com/adyen/checkout/cse/internal/test/TestCardEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/internal/test/TestCardEncryptor.kt index c34ff7acd8..347b56dc22 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestCardEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestCardEncryptor.kt @@ -12,15 +12,15 @@ import androidx.annotation.RestrictTo import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.EncryptionException import com.adyen.checkout.cse.UnencryptedCard -import com.adyen.checkout.cse.internal.BaseCardEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor /** - * Test implementation of [BaseCardEncrypter]. This class should never be used in not test code as it does not do + * Test implementation of [BaseCardEncryptor]. This class should never be used in not test code as it does not do * any encryption! */ // TODO move to test fixtures once it becomes supported on Android @RestrictTo(RestrictTo.Scope.TESTS) -class TestCardEncrypter : BaseCardEncrypter { +class TestCardEncryptor : BaseCardEncryptor { var shouldThrowException = false diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestGenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestGenericEncryptor.kt similarity index 85% rename from cse/src/main/java/com/adyen/checkout/cse/internal/test/TestGenericEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/internal/test/TestGenericEncryptor.kt index c045ea4a48..6017fff73a 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestGenericEncrypter.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/test/TestGenericEncryptor.kt @@ -10,15 +10,15 @@ package com.adyen.checkout.cse.internal.test import androidx.annotation.RestrictTo import com.adyen.checkout.cse.EncryptionException -import com.adyen.checkout.cse.internal.BaseGenericEncrypter +import com.adyen.checkout.cse.internal.BaseGenericEncryptor /** - * Test implementation of [BaseGenericEncrypter]. This class should never be used in not test code as it does not do + * Test implementation of [BaseGenericEncryptor]. This class should never be used in not test code as it does not do * any encryption! */ // TODO move to test fixtures once it becomes supported on Android @RestrictTo(RestrictTo.Scope.TESTS) -class TestGenericEncrypter : BaseGenericEncrypter { +class TestGenericEncryptor : BaseGenericEncryptor { var shouldThrowException = false diff --git a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncryptorTest.kt similarity index 92% rename from cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt rename to cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncryptorTest.kt index 06d60bf19d..86095ade5c 100644 --- a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncrypterTest.kt +++ b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultCardEncryptorTest.kt @@ -24,12 +24,12 @@ import java.util.Calendar import java.util.TimeZone @ExtendWith(MockitoExtension::class) -internal class DefaultCardEncrypterTest( +internal class DefaultCardEncryptorTest( @Mock private val dateGenerator: DateGenerator, @Mock private val jsonWebEncryptor: JSONWebEncryptor, ) { - private val cardEncrypter = DefaultCardEncrypter( - DefaultGenericEncrypter(dateGenerator, jsonWebEncryptor), + private val cardEncryptor = DefaultCardEncryptor( + DefaultGenericEncryptor(dateGenerator, jsonWebEncryptor), ) @BeforeEach @@ -54,7 +54,7 @@ internal class DefaultCardEncrypterTest( .setCvc(cvc) .build() - val encryptedCard = cardEncrypter.encryptFields(unencryptedCard, publicKey) + val encryptedCard = cardEncryptor.encryptFields(unencryptedCard, publicKey) val expectedNumberPlainText = JSONObject().apply { put("number", number) @@ -102,7 +102,7 @@ internal class DefaultCardEncrypterTest( .setHolderName(holderName) .build() - val encryptedCard = cardEncrypter.encrypt(unencryptedCard, publicKey) + val encryptedCard = cardEncryptor.encrypt(unencryptedCard, publicKey) val expectedPlainText = JSONObject().apply { put("number", number) @@ -121,7 +121,7 @@ internal class DefaultCardEncrypterTest( val bin = "123412341234" val publicKey = "PUBLIC_KEY" - val encryptedCard = cardEncrypter.encryptBin(bin, publicKey) + val encryptedCard = cardEncryptor.encryptBin(bin, publicKey) val expectedPlainText = JSONObject().apply { put("binValue", bin) diff --git a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt index ecf52ef800..f73c2e3048 100644 --- a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt +++ b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt @@ -22,11 +22,11 @@ import java.util.Calendar import java.util.TimeZone @ExtendWith(MockitoExtension::class) -internal class DefaultGenericEncrypterTest( +internal class DefaultGenericEncryptorTest( @Mock private val dateGenerator: DateGenerator, @Mock private val jsonWebEncryptor: JSONWebEncryptor, ) { - private val genericEncrypter = DefaultGenericEncrypter(dateGenerator, jsonWebEncryptor) + private val genericEncryptor = DefaultGenericEncryptor(dateGenerator, jsonWebEncryptor) @BeforeEach fun setup() { @@ -42,7 +42,7 @@ internal class DefaultGenericEncrypterTest( val value = "VALUE" val publicKey = "PUBLIC_KEY" - val encrypted = genericEncrypter.encryptField( + val encrypted = genericEncryptor.encryptField( fieldKeyToEncrypt = key, fieldValueToEncrypt = value, publicKey = publicKey diff --git a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt index 50f83e5d42..3346453c19 100644 --- a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt +++ b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/provider/GiftCardComponentProvider.kt @@ -87,7 +87,7 @@ constructor( ): GiftCardComponent { assertSupported(paymentMethod) - val cardEncrypter = CardEncryptorFactory.provide() + val cardEncryptor = CardEncryptorFactory.provide() val giftCardFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> val componentParams = componentParamsMapper.mapToParams(configuration, null) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) @@ -112,7 +112,7 @@ constructor( analyticsRepository = analyticsRepository, publicKeyRepository = DefaultPublicKeyRepository(publicKeyService), componentParams = componentParams, - cardEncryptor = cardEncrypter, + cardEncryptor = cardEncryptor, submitHandler = SubmitHandler(savedStateHandle), ) diff --git a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt index afb277b378..5eff1d99b8 100644 --- a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt +++ b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegate.kt @@ -32,7 +32,7 @@ import com.adyen.checkout.core.internal.util.Logger import com.adyen.checkout.cse.EncryptedCard import com.adyen.checkout.cse.EncryptionException import com.adyen.checkout.cse.UnencryptedCard -import com.adyen.checkout.cse.internal.BaseCardEncrypter +import com.adyen.checkout.cse.internal.BaseCardEncryptor import com.adyen.checkout.giftcard.GiftCardAction import com.adyen.checkout.giftcard.GiftCardComponentState import com.adyen.checkout.giftcard.GiftCardException @@ -63,7 +63,7 @@ internal class DefaultGiftCardDelegate( private val analyticsRepository: AnalyticsRepository, private val publicKeyRepository: PublicKeyRepository, override val componentParams: GiftCardComponentParams, - private val cardEncryptor: BaseCardEncrypter, + private val cardEncryptor: BaseCardEncryptor, private val submitHandler: SubmitHandler, ) : GiftCardDelegate { diff --git a/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt b/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt index b3117d0905..0b080db0a1 100644 --- a/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt +++ b/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/DefaultGiftCardDelegateTest.kt @@ -17,7 +17,7 @@ import com.adyen.checkout.components.core.internal.PaymentObserverRepository import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository import com.adyen.checkout.components.core.internal.test.TestPublicKeyRepository import com.adyen.checkout.core.Environment -import com.adyen.checkout.cse.internal.test.TestCardEncrypter +import com.adyen.checkout.cse.internal.test.TestCardEncryptor import com.adyen.checkout.giftcard.GiftCardAction import com.adyen.checkout.giftcard.GiftCardComponentState import com.adyen.checkout.giftcard.GiftCardConfiguration @@ -60,13 +60,13 @@ internal class DefaultGiftCardDelegateTest( @Mock private val submitHandler: SubmitHandler, ) { - private lateinit var cardEncryptor: TestCardEncrypter + private lateinit var cardEncryptor: TestCardEncryptor private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var delegate: DefaultGiftCardDelegate @BeforeEach fun before() { - cardEncryptor = TestCardEncrypter() + cardEncryptor = TestCardEncryptor() publicKeyRepository = TestPublicKeyRepository() delegate = createGiftCardDelegate() } From 01746d92288ebcbac19ccdf6afe956a4d2494c0e Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 27 Sep 2023 15:45:42 +0200 Subject: [PATCH 30/66] Force base64 encoded strings COAND-655 --- ...enericEncrypter.kt => GenericEncryptor.kt} | 0 .../checkout/cse/internal/JSONWebEncryptor.kt | 29 +++++++++---------- .../adyen/checkout/cse/internal/JWEObject.kt | 18 ++++++++---- ...Test.kt => DefaultGenericEncryptorTest.kt} | 0 4 files changed, 26 insertions(+), 21 deletions(-) rename cse/src/main/java/com/adyen/checkout/cse/{GenericEncrypter.kt => GenericEncryptor.kt} (100%) rename cse/src/test/java/com/adyen/checkout/cse/internal/{DefaultGenericEncrypterTest.kt => DefaultGenericEncryptorTest.kt} (100%) diff --git a/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt b/cse/src/main/java/com/adyen/checkout/cse/GenericEncryptor.kt similarity index 100% rename from cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt rename to cse/src/main/java/com/adyen/checkout/cse/GenericEncryptor.kt diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt index 92d849faad..b7eaed9799 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt @@ -8,7 +8,6 @@ package com.adyen.checkout.cse.internal -import android.util.Base64 import com.adyen.checkout.cse.EncryptionException import org.json.JSONObject import java.math.BigInteger @@ -44,7 +43,7 @@ internal class JSONWebEncryptor { fun encrypt(publicKey: String, payload: String): String { val pubKey = generatePublicKey(publicKey) val cek = generateCEK() - val encryptedKey = Base64.encodeToString(encryptCEK(pubKey, cek), Base64.URL_SAFE or Base64.NO_WRAP) + val encryptedKey = Base64String(encryptCEK(pubKey, cek)) val jweObject = encrypt(payload, cek, encryptedKey) return serialize(jweObject) } @@ -103,8 +102,9 @@ internal class JSONWebEncryptor { return cipher } - private fun encrypt(payload: String, cek: SecretKey, encryptedKey: String): JWEObject { - val aad = getAAD() + private fun encrypt(payload: String, cek: SecretKey, encryptedKey: Base64String): JWEObject { + val base64Header = Base64String(HEADER.toString().encodeToByteArray()) + val aad = getAAD(base64Header) val iv = generateIV() val compositeKey = CompositeKey(cek) val aesCipher = getAESCBCCipher(compositeKey.encKey, iv) @@ -122,18 +122,16 @@ internal class JSONWebEncryptor { val authTag = hmac.copyOf(compositeKey.truncatedMacLength) return JWEObject( - header = HEADER.toString(), + header = base64Header, encryptedKey = encryptedKey, - iv = Base64.encodeToString(iv, Base64.URL_SAFE or Base64.NO_WRAP), - cipherText = Base64.encodeToString(cipherText, Base64.URL_SAFE or Base64.NO_WRAP), - authTag = Base64.encodeToString(authTag, Base64.URL_SAFE or Base64.NO_WRAP), + iv = Base64String(iv), + cipherText = Base64String(cipherText), + authTag = Base64String(authTag), ) } - private fun getAAD(): ByteArray { - val bytes = HEADER.toString().toByteArray() - val base64Encoded = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP) - return base64Encoded.toByteArray(Charsets.US_ASCII) + private fun getAAD(encodedHeader: Base64String): ByteArray { + return encodedHeader.value.toByteArray(Charsets.US_ASCII) } private fun generateIV(): ByteArray { @@ -174,9 +172,8 @@ internal class JSONWebEncryptor { } private fun serialize(jweObject: JWEObject): String { - val encodedHeader = - Base64.encodeToString(jweObject.header.encodeToByteArray(), Base64.URL_SAFE or Base64.NO_WRAP) - return StringBuilder(encodedHeader) + return StringBuilder() + .append(jweObject.header) .append(".") .append(jweObject.encryptedKey) .append(".") @@ -203,7 +200,7 @@ internal class JSONWebEncryptor { private const val CEK_BYTES = 64 private const val IV_BYTES = 16 - val HEADER = JSONObject().apply { + private val HEADER = JSONObject().apply { put("alg", "RSA-OAEP-256") put("enc", "A256CBC-HS512") put("version", "1") diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt index ca28e215d7..2d03d3c99b 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt @@ -8,10 +8,18 @@ package com.adyen.checkout.cse.internal +import android.util.Base64 + internal data class JWEObject( - val header: String, - val encryptedKey: String, - val iv: String, - val cipherText: String, - val authTag: String, + val header: Base64String, + val encryptedKey: Base64String, + val iv: Base64String, + val cipherText: Base64String, + val authTag: Base64String, ) + +internal class Base64String(bytes: ByteArray) { + val value: String = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP) + + override fun toString(): String = value +} diff --git a/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt b/cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncryptorTest.kt similarity index 100% rename from cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncrypterTest.kt rename to cse/src/test/java/com/adyen/checkout/cse/internal/DefaultGenericEncryptorTest.kt From b7c78d7580916d92fe3ba5b4ebec06f752bef09c Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 9 Jan 2024 18:08:28 +0100 Subject: [PATCH 31/66] Rename public facing classes back to ...Encrypter COAND-655 --- .../adyen/checkout/cse/{CardEncryptor.kt => CardEncrypter.kt} | 2 +- .../checkout/cse/{GenericEncryptor.kt => GenericEncrypter.kt} | 2 +- cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename cse/src/main/java/com/adyen/checkout/cse/{CardEncryptor.kt => CardEncrypter.kt} (99%) rename cse/src/main/java/com/adyen/checkout/cse/{GenericEncryptor.kt => GenericEncrypter.kt} (98%) diff --git a/cse/src/main/java/com/adyen/checkout/cse/CardEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt similarity index 99% rename from cse/src/main/java/com/adyen/checkout/cse/CardEncryptor.kt rename to cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt index 095624b2ad..b956c25626 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/CardEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/CardEncrypter.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.cse.internal.CardEncryptorFactory * Allows the encryption of card data to be sent to Adyen's APIs. * Use this class with the custom card integration. */ -object CardEncryptor { +object CardEncrypter { private val encryptor = CardEncryptorFactory.provide() diff --git a/cse/src/main/java/com/adyen/checkout/cse/GenericEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt similarity index 98% rename from cse/src/main/java/com/adyen/checkout/cse/GenericEncryptor.kt rename to cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt index 5f9ad33cb6..6c6b596111 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/GenericEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/GenericEncrypter.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.cse.internal.GenericEncryptorFactory * Allows the encryption of any type of data to be sent to Adyen's APIs. * Use this class with custom component integrations. */ -object GenericEncryptor { +object GenericEncrypter { private val encryptor = GenericEncryptorFactory.provide() diff --git a/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt b/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt index 3da7384883..29607031ea 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/UnencryptedCard.kt @@ -9,7 +9,7 @@ package com.adyen.checkout.cse /** * Class containing raw card data that needs to be encrypted. - * Use [Builder] to instantiate and [CardEncryptor] to encrypt. + * Use [Builder] to instantiate and [CardEncrypter] to encrypt. */ class UnencryptedCard internal constructor( val number: String?, From 6c6198672f53399d8b900293ff10b5e4416acda3 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 9 Jan 2024 18:08:53 +0100 Subject: [PATCH 32/66] Fix merge conflicts after rebasing COAND-655 --- .../provider/BcmcComponentProvider.kt | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt b/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt index 3c55d67055..5729ca54e9 100644 --- a/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt +++ b/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/provider/BcmcComponentProvider.kt @@ -44,6 +44,7 @@ import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.data.api.HttpClientFactory import com.adyen.checkout.cse.internal.CardEncryptorFactory +import com.adyen.checkout.cse.internal.GenericEncryptorFactory import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionComponentCallback import com.adyen.checkout.sessions.core.internal.SessionComponentEventHandler @@ -68,13 +69,13 @@ constructor( BcmcComponent, BcmcConfiguration, BcmcComponentState, - ComponentCallback + ComponentCallback, >, SessionPaymentComponentProvider< BcmcComponent, BcmcConfiguration, BcmcComponentState, - SessionComponentCallback + SessionComponentCallback, > { private val componentParamsMapper = BcmcComponentParamsMapper(overrideComponentParams, overrideSessionParams) @@ -99,10 +100,11 @@ constructor( val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val cardValidationMapper = CardValidationMapper() val cardEncryptor = CardEncryptorFactory.provide() + val genericEncryptor = GenericEncryptorFactory.provide() val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) val binLookupService = BinLookupService(httpClient) - val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncrypter, binLookupService) + val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncryptor, binLookupService) val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( @@ -111,7 +113,7 @@ constructor( paymentMethod = paymentMethod, ), analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment) + HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), ), analyticsMapper = AnalyticsMapper(), ) @@ -127,8 +129,8 @@ constructor( detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, cardEncryptor = cardEncryptor, - genericEncrypter = genericEncrypter, - submitHandler = SubmitHandler(savedStateHandle) + genericEncryptor = genericEncryptor, + submitHandler = SubmitHandler(savedStateHandle), ) val genericActionDelegate = GenericActionComponentProvider(componentParams).getDelegate( @@ -146,7 +148,7 @@ constructor( } return ViewModelProvider( viewModelStoreOwner, - bcmcFactory + bcmcFactory, )[key, BcmcComponent::class.java].also { component -> component.observe(lifecycleOwner) { component.componentEventHandler.onPaymentComponentEvent(it, componentCallback) @@ -171,17 +173,18 @@ constructor( val componentParams = componentParamsMapper.mapToParams( bcmcConfiguration = configuration, sessionParams = SessionParamsFactory.create(checkoutSession), - paymentMethod = paymentMethod + paymentMethod = paymentMethod, ) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) val publicKeyService = PublicKeyService(httpClient) val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val cardValidationMapper = CardValidationMapper() val cardEncryptor = CardEncryptorFactory.provide() + val genericEncryptor = GenericEncryptorFactory.provide() val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) val binLookupService = BinLookupService(httpClient) - val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncrypter, binLookupService) + val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncryptor, binLookupService) val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( analyticsRepositoryData = AnalyticsRepositoryData( @@ -191,7 +194,7 @@ constructor( sessionId = checkoutSession.sessionSetupResponse.id, ), analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment) + HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), ), analyticsMapper = AnalyticsMapper(), ) @@ -207,8 +210,8 @@ constructor( detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, cardEncryptor = cardEncryptor, - genericEncrypter = genericEncrypter, - submitHandler = SubmitHandler(savedStateHandle) + genericEncryptor = genericEncryptor, + submitHandler = SubmitHandler(savedStateHandle), ) val genericActionDelegate = GenericActionComponentProvider(componentParams).getDelegate( @@ -228,7 +231,7 @@ constructor( clientKey = componentParams.clientKey, ), sessionModel = sessionSavedStateHandleContainer.getSessionModel(), - isFlowTakenOver = sessionSavedStateHandleContainer.isFlowTakenOver ?: false + isFlowTakenOver = sessionSavedStateHandleContainer.isFlowTakenOver ?: false, ) val sessionComponentEventHandler = SessionComponentEventHandler( @@ -246,7 +249,7 @@ constructor( return ViewModelProvider( viewModelStoreOwner, - bcmcFactory + bcmcFactory, )[key, BcmcComponent::class.java].also { component -> component.observe(lifecycleOwner) { component.componentEventHandler.onPaymentComponentEvent(it, componentCallback) From 8bc9059805f8bc7226c505032c0f6d54df8dd0dd Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 9 Jan 2024 18:09:27 +0100 Subject: [PATCH 33/66] Validate public key before encryption COAND-655 --- .../com/adyen/checkout/cse/internal/JSONWebEncryptor.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt index b7eaed9799..898f848300 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt @@ -41,6 +41,10 @@ internal class JSONWebEncryptor { } fun encrypt(publicKey: String, payload: String): String { + if (!ValidationUtils.isPublicKeyValid(publicKey)) { + throw EncryptionException("Invalid public key", null) + } + val pubKey = generatePublicKey(publicKey) val cek = generateCEK() val encryptedKey = Base64String(encryptCEK(pubKey, cek)) @@ -53,7 +57,7 @@ internal class JSONWebEncryptor { val pubKeySpec = RSAPublicKeySpec( BigInteger(keyComponents[1], RADIX), - BigInteger(keyComponents[0], RADIX) + BigInteger(keyComponents[0], RADIX), ) return try { From 052cf74c6d9c35495602c8e255292acfa85d876c Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 10 Jan 2024 13:24:41 +0100 Subject: [PATCH 34/66] Replace unclear abbreviations COAND-655 --- .../checkout/cse/internal/JSONWebEncryptor.kt | 67 ++++++++++--------- .../adyen/checkout/cse/internal/JWEObject.kt | 2 +- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt index 898f848300..935e04427f 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt @@ -46,46 +46,46 @@ internal class JSONWebEncryptor { } val pubKey = generatePublicKey(publicKey) - val cek = generateCEK() - val encryptedKey = Base64String(encryptCEK(pubKey, cek)) - val jweObject = encrypt(payload, cek, encryptedKey) + val contentKey = generateContentEncryptionKey() + val encryptedKey = Base64String(encryptContentEncryptionKey(pubKey, contentKey)) + val jweObject = encrypt(payload, contentKey, encryptedKey) return serialize(jweObject) } private fun generatePublicKey(publicKey: String): PublicKey { val keyComponents = publicKey.split("|") - val pubKeySpec = RSAPublicKeySpec( + val publicKeySpec = RSAPublicKeySpec( BigInteger(keyComponents[1], RADIX), BigInteger(keyComponents[0], RADIX), ) return try { - keyFactory.generatePublic(pubKeySpec) + keyFactory.generatePublic(publicKeySpec) } catch (e: InvalidKeySpecException) { throw EncryptionException("Problem reading public key", e) } } - private fun generateCEK(): SecretKey { - val secRan = SecureRandom() - val bytes = ByteArray(CEK_BYTES) - secRan.nextBytes(bytes) + private fun generateContentEncryptionKey(): SecretKey { + val secureRandom = SecureRandom() + val bytes = ByteArray(CONTENT_ENCRYPTION_KEY_BYTES) + secureRandom.nextBytes(bytes) return SecretKeySpec(bytes, AES_ALGORITHM) } - private fun encryptCEK(pub: PublicKey, cek: SecretKey): ByteArray { - val cipher = getRSAOAEPCipher(pub) + private fun encryptContentEncryptionKey(publicKey: PublicKey, contentKey: SecretKey): ByteArray { + val cipher = getRSAOAEPCipher(publicKey) return try { - cipher.doFinal(cek.encoded) + cipher.doFinal(contentKey.encoded) } catch (e: IllegalBlockSizeException) { throw EncryptionException("The RSA key is invalid, try another one", e) } } - private fun getRSAOAEPCipher(pub: PublicKey): Cipher { - val algp = AlgorithmParameters.getInstance(OAEP_ALGORITHM) + private fun getRSAOAEPCipher(publicKey: PublicKey): Cipher { + val algorithmParams = AlgorithmParameters.getInstance(OAEP_ALGORITHM) val mgfParamSpec = MGF1ParameterSpec.SHA256 val paramSpec = OAEPParameterSpec( mgfParamSpec.digestAlgorithm, @@ -93,7 +93,7 @@ internal class JSONWebEncryptor { mgfParamSpec, PSource.PSpecified.DEFAULT, ) - algp.init(paramSpec) + algorithmParams.init(paramSpec) val cipher = try { Cipher.getInstance(RSA_OAEP_CIPHER) @@ -102,25 +102,26 @@ internal class JSONWebEncryptor { } catch (e: NoSuchPaddingException) { throw EncryptionException("Problem instantiating $RSA_OAEP_CIPHER Padding", e) } - cipher.init(Cipher.ENCRYPT_MODE, pub, algp) + cipher.init(Cipher.ENCRYPT_MODE, publicKey, algorithmParams) return cipher } - private fun encrypt(payload: String, cek: SecretKey, encryptedKey: Base64String): JWEObject { + private fun encrypt(payload: String, contentKey: SecretKey, encryptedKey: Base64String): JWEObject { val base64Header = Base64String(HEADER.toString().encodeToByteArray()) - val aad = getAAD(base64Header) - val iv = generateIV() - val compositeKey = CompositeKey(cek) - val aesCipher = getAESCBCCipher(compositeKey.encKey, iv) + val additionalData = getAdditionalAuthenticationData(base64Header) + val vector = generateInitializationVector() + val compositeKey = CompositeKey(contentKey) + val aesCipher = getAESCBCCipher(compositeKey.encKey, vector) val cipherText = aesCipher.doFinal(payload.toByteArray()) - val al = ByteBuffer.allocate(BITES_IN_BYTE).putLong(aad.size * BITES_IN_BYTE.toLong()).array() + val additionalDataBits = + ByteBuffer.allocate(BITES_IN_BYTE).putLong(additionalData.size * BITES_IN_BYTE.toLong()).array() - val hmacInputLength = aad.size + iv.size + cipherText.size + al.size + val hmacInputLength = additionalData.size + vector.size + cipherText.size + additionalDataBits.size val hmacInput = ByteBuffer.allocate(hmacInputLength) - .put(aad) - .put(iv) + .put(additionalData) + .put(vector) .put(cipherText) - .put(al) + .put(additionalDataBits) .array() val hmac = computeHMAC(compositeKey.macKey, hmacInput) val authTag = hmac.copyOf(compositeKey.truncatedMacLength) @@ -128,18 +129,18 @@ internal class JSONWebEncryptor { return JWEObject( header = base64Header, encryptedKey = encryptedKey, - iv = Base64String(iv), + initializationVector = Base64String(vector), cipherText = Base64String(cipherText), authTag = Base64String(authTag), ) } - private fun getAAD(encodedHeader: Base64String): ByteArray { + private fun getAdditionalAuthenticationData(encodedHeader: Base64String): ByteArray { return encodedHeader.value.toByteArray(Charsets.US_ASCII) } - private fun generateIV(): ByteArray { - val iv = ByteArray(IV_BYTES) + private fun generateInitializationVector(): ByteArray { + val iv = ByteArray(INITIALIZATION_VECTOR_BYTES) SecureRandom().nextBytes(iv) return iv } @@ -181,7 +182,7 @@ internal class JSONWebEncryptor { .append(".") .append(jweObject.encryptedKey) .append(".") - .append(jweObject.iv) + .append(jweObject.initializationVector) .append(".") .append(jweObject.cipherText) .append(".") @@ -201,8 +202,8 @@ internal class JSONWebEncryptor { private const val BITES_IN_BYTE = 8 private const val RADIX = 16 - private const val CEK_BYTES = 64 - private const val IV_BYTES = 16 + private const val CONTENT_ENCRYPTION_KEY_BYTES = 64 + private const val INITIALIZATION_VECTOR_BYTES = 16 private val HEADER = JSONObject().apply { put("alg", "RSA-OAEP-256") diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt index 2d03d3c99b..4b2a8049a8 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt @@ -13,7 +13,7 @@ import android.util.Base64 internal data class JWEObject( val header: Base64String, val encryptedKey: Base64String, - val iv: Base64String, + val initializationVector: Base64String, val cipherText: Base64String, val authTag: Base64String, ) From bddb68312524de2a098caaf40710cc4e0ece1d6d Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 10 Jan 2024 14:46:15 +0100 Subject: [PATCH 35/66] Add unit tests to validate encryption COAND-655 --- cse/build.gradle | 1 + .../checkout/cse/internal/JSONWebEncryptor.kt | 4 +- .../adyen/checkout/cse/internal/JWEObject.kt | 6 +- .../cse/internal/JSONWebEncryptorTest.kt | 98 +++++++++++++++++++ dependencies.gradle | 2 + gradle/verification-metadata.xml | 21 ++++ 6 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 cse/src/test/java/com/adyen/checkout/cse/internal/JSONWebEncryptorTest.kt diff --git a/cse/build.gradle b/cse/build.gradle index 05aeb0ca68..b901eb3604 100644 --- a/cse/build.gradle +++ b/cse/build.gradle @@ -34,4 +34,5 @@ dependencies { testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.mockito + testImplementation testLibraries.jose4j } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt index 935e04427f..c49b28065a 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt @@ -37,7 +37,7 @@ internal class JSONWebEncryptor { private val keyFactory: KeyFactory = try { KeyFactory.getInstance(RSA_ALGORITHM) } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("RSA KeyFactory not found.", e) + throw EncryptionException("RSA KeyFactory not found", e) } fun encrypt(publicKey: String, payload: String): String { @@ -80,7 +80,7 @@ internal class JSONWebEncryptor { return try { cipher.doFinal(contentKey.encoded) } catch (e: IllegalBlockSizeException) { - throw EncryptionException("The RSA key is invalid, try another one", e) + throw EncryptionException("The RSA key is invalid", e) } } diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt index 4b2a8049a8..1cd845e652 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JWEObject.kt @@ -8,7 +8,8 @@ package com.adyen.checkout.cse.internal -import android.util.Base64 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi internal data class JWEObject( val header: Base64String, @@ -18,8 +19,9 @@ internal data class JWEObject( val authTag: Base64String, ) +@OptIn(ExperimentalEncodingApi::class) internal class Base64String(bytes: ByteArray) { - val value: String = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP) + val value: String = Base64.UrlSafe.encode(bytes) override fun toString(): String = value } diff --git a/cse/src/test/java/com/adyen/checkout/cse/internal/JSONWebEncryptorTest.kt b/cse/src/test/java/com/adyen/checkout/cse/internal/JSONWebEncryptorTest.kt new file mode 100644 index 0000000000..5e9b1f37e0 --- /dev/null +++ b/cse/src/test/java/com/adyen/checkout/cse/internal/JSONWebEncryptorTest.kt @@ -0,0 +1,98 @@ +package com.adyen.checkout.cse.internal + +import com.adyen.checkout.cse.EncryptionException +import org.jose4j.jwa.AlgorithmConstraints +import org.jose4j.jwe.JsonWebEncryption +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.spec.RSAPublicKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +internal class JSONWebEncryptorTest { + + private val encryptor = JSONWebEncryptor() + + @Test + fun `when public key is incorrect, then an exception will be thrown`() { + val publicKey = "incorrect format" + val input = "random input" + + val exception = assertThrows { + encryptor.encrypt(publicKey, input) + } + assertEquals("Invalid public key", exception.message) + } + + @Test + fun `when encryption succeeds, then encryption string consists of 5 parts`() { + val keyPair = generateRSAKeyPair() + val publicKey = generatePublicKeyString(keyPair) + val input = "random input" + + val encrypted = encryptor.encrypt(publicKey, input) + val parts = encrypted.split(".") + + assertEquals(5, parts.size) + } + + @Test + fun `when encryption succeeds, then data can be decrypted to original form`() { + val keyPair = generateRSAKeyPair() + val publicKey = generatePublicKeyString(keyPair) + val input = "Some string that will be encrypted and some special characters !@#$%^&*()_+/\\?.,<>;: just in case" + + val encrypted = encryptor.encrypt(publicKey, input) + val decrypted = decryptData(keyPair.private, encrypted) + + assertEquals(input, decrypted) + } + + private fun generateRSAKeyPair(): KeyPair { + return KeyPairGenerator.getInstance("RSA") + .apply { initialize(2048) } + .generateKeyPair() + } + + // This method simulates how to backend generates the public key string + private fun generatePublicKeyString(keyPair: KeyPair): String { + val factory = KeyFactory.getInstance("RSA") + val keySpec = factory.getKeySpec(keyPair.public, RSAPublicKeySpec::class.java) + return "${keySpec.publicExponent.toString(16).uppercase()}|${keySpec.modulus.toString(16).uppercase()}" + } + + // This method simulates how to backend decrypts data with the jose4j library + private fun decryptData(privateKey: PrivateKey, encrypted: String): String { + val header = getJweHeader(encrypted) + val alg = header.getString("alg") + val enc = header.getString("enc") + + val jwe = JsonWebEncryption().apply { + setAlgorithmConstraints(AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, alg)) + setContentEncryptionAlgorithmConstraints( + AlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, enc), + ) + key = privateKey + compactSerialization = encrypted + } + + return jwe.plaintextString + } + + private fun getJweHeader(encrypted: String): JSONObject { + val encodedHeader = encrypted.split(".").first() + val decoded = encodedHeader.base64Decode() + return JSONObject(decoded) + } + + @OptIn(ExperimentalEncodingApi::class) + private fun String.base64Decode(): String { + return Base64.UrlSafe.decode(this).decodeToString() + } +} diff --git a/dependencies.gradle b/dependencies.gradle index 65ce3e82e2..e806c09faf 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -69,6 +69,7 @@ ext { arch_core_testing_version = "2.2.0" espresso_version = "3.5.0" json_version = '20231013' + jose4j_version = '0.9.4' junit_jupiter_version = "5.9.1" mockito_kotlin_version = "4.1.0" mockito_version = "4.9.0" @@ -145,6 +146,7 @@ ext { "androidx.test.espresso:espresso-core:$espresso_version", "androidx.test.espresso:espresso-intents:$espresso_version" ], + jose4j : "org.bitbucket.b_c:jose4j:$jose4j_version", json : "org.json:json:$json_version", junit5 : [ "org.junit.jupiter:junit-jupiter-api:$junit_jupiter_version", diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 5cb211387f..8b77956db0 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -8846,6 +8846,14 @@ + + + + + + + + @@ -12100,6 +12108,14 @@ + + + + + + + + @@ -12126,6 +12142,11 @@ + + + + + From 9389201544a4687017608cf8220c2418dfa875ee Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Mon, 15 Jan 2024 15:26:38 +0100 Subject: [PATCH 36/66] Replace A256CBC-HS512 with A256GCM COAND-655 --- .../checkout/cse/internal/JSONWebEncryptor.kt | 69 ++++++------------- 1 file changed, 21 insertions(+), 48 deletions(-) diff --git a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt index c49b28065a..ea0407caef 100644 --- a/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt +++ b/cse/src/main/java/com/adyen/checkout/cse/internal/JSONWebEncryptor.kt @@ -11,9 +11,7 @@ package com.adyen.checkout.cse.internal import com.adyen.checkout.cse.EncryptionException import org.json.JSONObject import java.math.BigInteger -import java.nio.ByteBuffer import java.security.AlgorithmParameters -import java.security.InvalidKeyException import java.security.KeyFactory import java.security.NoSuchAlgorithmException import java.security.PublicKey @@ -23,10 +21,9 @@ import java.security.spec.MGF1ParameterSpec import java.security.spec.RSAPublicKeySpec import javax.crypto.Cipher import javax.crypto.IllegalBlockSizeException -import javax.crypto.Mac import javax.crypto.NoSuchPaddingException import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.OAEPParameterSpec import javax.crypto.spec.PSource import javax.crypto.spec.SecretKeySpec @@ -75,7 +72,7 @@ internal class JSONWebEncryptor { } private fun encryptContentEncryptionKey(publicKey: PublicKey, contentKey: SecretKey): ByteArray { - val cipher = getRSAOAEPCipher(publicKey) + val cipher = getRSACipher(publicKey) return try { cipher.doFinal(contentKey.encoded) @@ -84,7 +81,7 @@ internal class JSONWebEncryptor { } } - private fun getRSAOAEPCipher(publicKey: PublicKey): Cipher { + private fun getRSACipher(publicKey: PublicKey): Cipher { val algorithmParams = AlgorithmParameters.getInstance(OAEP_ALGORITHM) val mgfParamSpec = MGF1ParameterSpec.SHA256 val paramSpec = OAEPParameterSpec( @@ -110,28 +107,19 @@ internal class JSONWebEncryptor { val base64Header = Base64String(HEADER.toString().encodeToByteArray()) val additionalData = getAdditionalAuthenticationData(base64Header) val vector = generateInitializationVector() - val compositeKey = CompositeKey(contentKey) - val aesCipher = getAESCBCCipher(compositeKey.encKey, vector) - val cipherText = aesCipher.doFinal(payload.toByteArray()) - val additionalDataBits = - ByteBuffer.allocate(BITES_IN_BYTE).putLong(additionalData.size * BITES_IN_BYTE.toLong()).array() - - val hmacInputLength = additionalData.size + vector.size + cipherText.size + additionalDataBits.size - val hmacInput = ByteBuffer.allocate(hmacInputLength) - .put(additionalData) - .put(vector) - .put(cipherText) - .put(additionalDataBits) - .array() - val hmac = computeHMAC(compositeKey.macKey, hmacInput) - val authTag = hmac.copyOf(compositeKey.truncatedMacLength) + + val aesCipher = getAESCipher(contentKey, vector) + aesCipher.updateAAD(additionalData) + + val cipherOutput = aesCipher.doFinal(payload.toByteArray()) + val tagIndex = cipherOutput.size - AUTH_TAG_LENGTH return JWEObject( header = base64Header, encryptedKey = encryptedKey, initializationVector = Base64String(vector), - cipherText = Base64String(cipherText), - authTag = Base64String(authTag), + cipherText = Base64String(cipherOutput.copyOfRange(0, tagIndex)), + authTag = Base64String(cipherOutput.copyOfRange(tagIndex, cipherOutput.size)), ) } @@ -145,37 +133,21 @@ internal class JSONWebEncryptor { return iv } - private fun getAESCBCCipher(secretKey: SecretKey, iv: ByteArray): Cipher { + private fun getAESCipher(secretKey: SecretKey, iv: ByteArray): Cipher { val keySpec = SecretKeySpec(secretKey.encoded, AES_ALGORITHM) - val ivSpec = IvParameterSpec(iv) + val ivSpec = GCMParameterSpec(AUTH_TAG_LENGTH * BITES_IN_BYTE, iv) val cipher = try { - Cipher.getInstance(AES_CBC_CIPHER) + Cipher.getInstance(AES_GCM_CIPHER) } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("Problem instantiating $AES_CBC_CIPHER Algorithm", e) + throw EncryptionException("Problem instantiating $AES_GCM_CIPHER Algorithm", e) } catch (e: NoSuchPaddingException) { - throw EncryptionException("Problem instantiating $AES_CBC_CIPHER Padding", e) + throw EncryptionException("Problem instantiating $AES_GCM_CIPHER Padding", e) } cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) return cipher } - private fun computeHMAC(macKey: SecretKey, input: ByteArray): ByteArray { - val mac = getMacInstance(macKey) - mac.update(input) - return mac.doFinal() - } - - private fun getMacInstance(macKey: SecretKey): Mac { - return try { - Mac.getInstance(macKey.algorithm) - } catch (e: NoSuchAlgorithmException) { - throw EncryptionException("Problem instantiating Mac", e) - } catch (e: InvalidKeyException) { - throw EncryptionException("Problem instantiating Mac", e) - }.apply { init(macKey) } - } - private fun serialize(jweObject: JWEObject): String { return StringBuilder() .append(jweObject.header) @@ -192,7 +164,7 @@ internal class JSONWebEncryptor { companion object { private const val RSA_OAEP_CIPHER = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" - private const val AES_CBC_CIPHER = "AES/CBC/PKCS5Padding" + private const val AES_GCM_CIPHER = "AES/GCM/NoPadding" private const val RSA_ALGORITHM = "RSA" private const val AES_ALGORITHM = "AES" @@ -202,12 +174,13 @@ internal class JSONWebEncryptor { private const val BITES_IN_BYTE = 8 private const val RADIX = 16 - private const val CONTENT_ENCRYPTION_KEY_BYTES = 64 - private const val INITIALIZATION_VECTOR_BYTES = 16 + private const val CONTENT_ENCRYPTION_KEY_BYTES = 32 + private const val INITIALIZATION_VECTOR_BYTES = 12 + private const val AUTH_TAG_LENGTH = 16 private val HEADER = JSONObject().apply { put("alg", "RSA-OAEP-256") - put("enc", "A256CBC-HS512") + put("enc", "A256GCM") put("version", "1") } } From 06aca66685132c1e357fcad642fdcd84c9d0dde7 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Fri, 12 Jan 2024 12:10:43 +0100 Subject: [PATCH 37/66] Add sonar dependency COAND-829 --- build.gradle | 5 +++-- dependencies.gradle | 1 + gradle/verification-metadata.xml | 31 +++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 193fa35313..ba5363282c 100644 --- a/build.gradle +++ b/build.gradle @@ -7,8 +7,9 @@ plugins { id 'com.android.library' version "$android_gradle_plugin_version" apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false id 'com.google.dagger.hilt.android' version "$hilt_version" apply false - id "io.gitlab.arturbosch.detekt" version "$detekt_gradle_plugin_version" - id "org.jetbrains.dokka" version "$dokka_version" + id 'io.gitlab.arturbosch.detekt' version "$detekt_gradle_plugin_version" + id 'org.jetbrains.dokka' version "$dokka_version" + id 'org.sonarqube' version "$sonarqube_version" apply false } apply from: "config/gradle/dokkaRoot.gradle" diff --git a/dependencies.gradle b/dependencies.gradle index e806c09faf..368b82eff8 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -29,6 +29,7 @@ ext { // Code quality detekt_version = "1.23.4" ktlint_version = '1.0.1' + sonarqube_version = '4.4.1.3373' // Android Dependencies annotation_version = "1.7.0" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8b77956db0..ca178ba74c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -12165,6 +12165,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7461a0c501799ca5d28ee5bc4bce3e4ff74a34ae Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Fri, 12 Jan 2024 12:12:02 +0100 Subject: [PATCH 38/66] Add Gradle configuration for Sonar COAND-829 --- build.gradle | 1 + .../resources/PaymentMethodsResponse.json | 4 ++-- config/gradle/sonarcloud.gradle | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 config/gradle/sonarcloud.gradle diff --git a/build.gradle b/build.gradle index ba5363282c..0275b8276d 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,7 @@ plugins { } apply from: "config/gradle/dokkaRoot.gradle" +apply from: "config/gradle/sonarcloud.gradle" ext { checkoutRedirectScheme = "adyencheckout" diff --git a/components-core/src/test/resources/PaymentMethodsResponse.json b/components-core/src/test/resources/PaymentMethodsResponse.json index a5d255b8dd..8dc101bf69 100644 --- a/components-core/src/test/resources/PaymentMethodsResponse.json +++ b/components-core/src/test/resources/PaymentMethodsResponse.json @@ -161,7 +161,7 @@ "items": [ { "id": "92", - "name": "Bank Sp�?dzielczy w Brodnicy" + "name": "Bank Spółdzielczy w Brodnicy" }, { "id": "11", @@ -739,4 +739,4 @@ "type": "wechatpayWeb" } ] -} \ No newline at end of file +} diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle new file mode 100644 index 0000000000..44f0e2879d --- /dev/null +++ b/config/gradle/sonarcloud.gradle @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by oscars on 12/1/2024. + */ + +apply plugin: "org.sonarqube" + +sonar { + properties { + property "sonar.projectKey", "Adyen_adyen-android" + property "sonar.organization", "adyen" + property "sonar.host.url", "https://sonarcloud.io" + } +} + +project(":example-app") { + sonar { + skipProject = true + } +} From a96b54858c15bb444d9018ac6aae9123659db6db Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Fri, 12 Jan 2024 12:12:25 +0100 Subject: [PATCH 39/66] Configure Sonar workflow COAND-829 --- .github/workflows/check_pr.yml | 4 +++ .github/workflows/sonar_cloud.yml | 34 +++++++++++++++++++ build.gradle | 2 +- .../resources/PaymentMethodsResponse.json | 2 +- config/gradle/sonarcloud.gradle | 12 ++++--- 5 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/sonar_cloud.yml diff --git a/.github/workflows/check_pr.yml b/.github/workflows/check_pr.yml index 298cc0bedd..8e73cb4e6c 100644 --- a/.github/workflows/check_pr.yml +++ b/.github/workflows/check_pr.yml @@ -22,3 +22,7 @@ jobs: name: Test uses: ./.github/workflows/run_tests.yml needs: assemble + sonar_cloud: + name: SonarCloud + uses: ./.github/workflows/sonar_cloud.yml + secrets: inherit diff --git a/.github/workflows/sonar_cloud.yml b/.github/workflows/sonar_cloud.yml new file mode 100644 index 0000000000..9cd853da9c --- /dev/null +++ b/.github/workflows/sonar_cloud.yml @@ -0,0 +1,34 @@ +name: SonarCloud + +on: + workflow_call + +jobs: + sonar_cloud: + name: Run SonarCloud + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '0' + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + cache: 'gradle' + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew build sonar diff --git a/build.gradle b/build.gradle index 0275b8276d..d6a5259dfd 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { id 'com.google.dagger.hilt.android' version "$hilt_version" apply false id 'io.gitlab.arturbosch.detekt' version "$detekt_gradle_plugin_version" id 'org.jetbrains.dokka' version "$dokka_version" - id 'org.sonarqube' version "$sonarqube_version" apply false + id 'org.sonarqube' version "$sonarqube_version" } apply from: "config/gradle/dokkaRoot.gradle" diff --git a/components-core/src/test/resources/PaymentMethodsResponse.json b/components-core/src/test/resources/PaymentMethodsResponse.json index 8dc101bf69..22ba827d63 100644 --- a/components-core/src/test/resources/PaymentMethodsResponse.json +++ b/components-core/src/test/resources/PaymentMethodsResponse.json @@ -169,7 +169,7 @@ }, { "id": "74", - "name": "Banki Sp�?dzielcze" + "name": "Banki Spółdzielcze" }, { "id": "90", diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index 44f0e2879d..3162620a00 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -6,13 +6,11 @@ * Created by oscars on 12/1/2024. */ -apply plugin: "org.sonarqube" - sonar { properties { - property "sonar.projectKey", "Adyen_adyen-android" - property "sonar.organization", "adyen" - property "sonar.host.url", "https://sonarcloud.io" + property 'sonar.projectKey', 'Adyen_adyen-android' + property 'sonar.organization', 'adyen' + property 'sonar.host.url', 'https://sonarcloud.io' } } @@ -21,3 +19,7 @@ project(":example-app") { skipProject = true } } + +subprojects { + apply plugin: 'org.sonarqube' +} From c44b917a22253d4fc9649bb22279c042c161b4c7 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 16 Jan 2024 16:34:44 +0100 Subject: [PATCH 40/66] Add workflow to run Sonar on develop COAND-829 --- .github/workflows/check_develop.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/check_develop.yml diff --git a/.github/workflows/check_develop.yml b/.github/workflows/check_develop.yml new file mode 100644 index 0000000000..d312ffd653 --- /dev/null +++ b/.github/workflows/check_develop.yml @@ -0,0 +1,16 @@ +name: Check Develop + +on: + push: + branches: + - 'develop' + +concurrency: + group: 'develop' + cancel-in-progress: true + +jobs: + sonar_cloud: + name: SonarCloud + uses: ./.github/workflows/sonar_cloud.yml + secrets: inherit From 5d552108d710adf44d681f1a25b56bb576ef1efe Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 16 Jan 2024 17:03:20 +0100 Subject: [PATCH 41/66] Enable lint and detekt reporting for sonar COAND-829 --- config/gradle/detekt.gradle | 6 +++--- config/gradle/sonarcloud.gradle | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/config/gradle/detekt.gradle b/config/gradle/detekt.gradle index 8b220185c7..490a56816b 100644 --- a/config/gradle/detekt.gradle +++ b/config/gradle/detekt.gradle @@ -26,11 +26,11 @@ detekt { tasks.named("detekt").configure { reports { - xml.required.set(false) - html { + xml { required.set(true) - outputLocation.set(file("$project.buildDir/reports/detekt/detekt-results.html")) + outputLocation.set(file("${layout.buildDirectory.get().asFile}/reports/detekt/detekt-results.xml")) } + html.required.set(false) } } diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index 3162620a00..8211a56a9d 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -22,4 +22,11 @@ project(":example-app") { subprojects { apply plugin: 'org.sonarqube' + + sonar { + properties { + property 'sonar.androidLint.reportPaths', "${layout.buildDirectory.get().asFile}/reports/lint-results-debug.xml" + property 'sonar.kotlin.detekt.reportPaths', "${layout.buildDirectory.get().asFile}/reports/detekt/detekt-results.xml" + } + } } From d867d055f4ecb9e8e065349a1ce471a0921c26e8 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 17 Jan 2024 11:39:38 +0100 Subject: [PATCH 42/66] Enable code coverage with JaCoCo COAND-829 --- build.gradle | 1 + config/gradle/codeQuality.gradle | 3 +- config/gradle/jacoco.gradle | 116 +++++++++++++++++++++++++++++++ gradle/verification-metadata.xml | 82 ++++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 config/gradle/jacoco.gradle diff --git a/build.gradle b/build.gradle index d6a5259dfd..6816fbf9bd 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,7 @@ plugins { id 'io.gitlab.arturbosch.detekt' version "$detekt_gradle_plugin_version" id 'org.jetbrains.dokka' version "$dokka_version" id 'org.sonarqube' version "$sonarqube_version" + id 'jacoco' } apply from: "config/gradle/dokkaRoot.gradle" diff --git a/config/gradle/codeQuality.gradle b/config/gradle/codeQuality.gradle index b952671641..23623ed21a 100644 --- a/config/gradle/codeQuality.gradle +++ b/config/gradle/codeQuality.gradle @@ -6,7 +6,8 @@ * Created by ran on 6/2/2019. */ +apply from: "${rootDir}/config/gradle/jacoco.gradle" apply from: "${rootDir}/config/gradle/ktlint.gradle" apply from: "${rootDir}/config/gradle/detekt.gradle" -check.dependsOn "ktlint", "detekt" \ No newline at end of file +check.dependsOn "ktlint", "detekt" diff --git a/config/gradle/jacoco.gradle b/config/gradle/jacoco.gradle new file mode 100644 index 0000000000..82496164cf --- /dev/null +++ b/config/gradle/jacoco.gradle @@ -0,0 +1,116 @@ +apply plugin: 'jacoco' + +if (project.hasProperty('android')) { + android.buildTypes { + debug { + testCoverageEnabled = true + } + } +} + +project.afterEvaluate { + jacoco { + toolVersion = "0.8.9" + } + + tasks.withType(Test).configureEach { + jacoco { + excludes += coverageExclusions + includeNoLocationClasses = true + } + } + + if (project.hasProperty('android')) { + tasks.register('jacocoTestReport', JacocoReport) { + group 'Reporting' + description 'Generate JaCoCo report for debug unit tests' + dependsOn 'testDebugUnitTest' + + reports { + // TODO: Set directory + xml.required = true + html.required = false + csv.required = false + } + + additionalSourceDirs(android.sourceSets.main.java.sourceFiles) + additionalSourceDirs(android.sourceSets.debug.java.sourceFiles) + additionalClassDirs(fileTree(dir: "${layout.buildDirectory.get().asFile}/intermediates/javac/debug", excludes: coverageExclusions)) + additionalClassDirs(fileTree(dir: "${layout.buildDirectory.get().asFile}/tmp/kotlin-classes/debug", excludes: coverageExclusions)) + executionData( + fileTree(dir: "${layout.buildDirectory.get().asFile}", includes: [ + "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", + "jacoco/test.exec", + "outputs/code-coverage/connected/*coverage.ec" + ]), + fileTree(dir: "$projectDir", includes: ['jacoco.exec']) + ) + } + } +} + +ext.coverageExclusions = [ + // Android + '**/BuildConfig.*', + '**/Manifest*.*', + '**/R$*.class', + '**/R.class', + + // Data binding + '**/BR$*.class', + '**/BR.class', + '**/DataBinderMapperImpl.*', + '**/DataBinderMapperImpl*.*', + '**/databinding/**', + '**/*BindingAdapter.*', + '**/*BindingAdapters.*', + + // Activities, Fragments, etc. (not tested with unit tests) + '**/*Activity$*.*', + '**/*Activity.*', + '**/*Adapter.*', + '**/*Behavior.*', + '**/*Dialog.*', + '**/*Drawable.*', + '**/*Fragment$*.*', + '**/*Fragment.*', + '**/*View.*', + + // Activity result contract + '**/*ActivityResults.*', + '**/*ResultContract.*', + + // Dagger + Hilt + '**/*_ComponentTreeDeps.*', + '**/*_Factory.*', + '**/*_GeneratedInjector.*', + '**/*_HiltComponents.*', + '**/*_HiltComponents_*.*', + '**/*_HiltModules.*', + '**/*_HiltModules_*.*', + '**/*_Member*Injector.*', + '**/*_ProvideFactory.*', + '**/*_Provide*Factory.*', + '**/dagger/**', + '**/Dagger*.*', + '**/hilt_aggregated_deps/**', + '**/Hilt_*.*', + + // Custom views not following *View naming + '**/AddressFormInput.*', + '**/AdyenSwipeToRevealLayout.*', + '**/AdyenTextInputEditText.*', + '**/CardNumberInput.*', + '**/ExpiryDateInput.*', + '**/GiftCardNumberInput.*', + '**/IbanInput.*', + '**/SecurityCodeInput.*', + '**/SocialSecurityNumberInput.*', + + // Test classes + '**/*Test.*', + '**/Test*.*', + + // Fix issue with JaCoCo on JDK + 'jdk.internal.*' +] diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ca178ba74c..ad73ab43e2 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -9083,6 +9083,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11708,6 +11785,11 @@ + + + + + From a0e3dce05cbabb6addabe21e2da182e0fbb6d455 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 17 Jan 2024 11:47:11 +0100 Subject: [PATCH 43/66] Run code coverage on Sonar COAND-829 --- .github/workflows/sonar_cloud.yml | 2 +- config/gradle/sonarcloud.gradle | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonar_cloud.yml b/.github/workflows/sonar_cloud.yml index 9cd853da9c..309cf1aa04 100644 --- a/.github/workflows/sonar_cloud.yml +++ b/.github/workflows/sonar_cloud.yml @@ -31,4 +31,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew build sonar + run: ./gradlew build jacocoTestReport sonar diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index 8211a56a9d..79f2d8c652 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -11,6 +11,7 @@ sonar { property 'sonar.projectKey', 'Adyen_adyen-android' property 'sonar.organization', 'adyen' property 'sonar.host.url', 'https://sonarcloud.io' + property 'sonar.gradle.skipCompile', 'true' } } From 93a0c94fc0b40ef01777bcc5c9c769631f3e54db Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 17 Jan 2024 12:34:51 +0100 Subject: [PATCH 44/66] Optimize Sonar workflow command COAND-829 --- .github/workflows/sonar_cloud.yml | 2 +- config/gradle/jacoco.gradle | 1 - config/gradle/sonarcloud.gradle | 2 ++ 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonar_cloud.yml b/.github/workflows/sonar_cloud.yml index 309cf1aa04..550fc1a781 100644 --- a/.github/workflows/sonar_cloud.yml +++ b/.github/workflows/sonar_cloud.yml @@ -31,4 +31,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew build jacocoTestReport sonar + run: ./gradlew detekt assDeb teDebUnTe lintDeb jacocoTestReport sonar diff --git a/config/gradle/jacoco.gradle b/config/gradle/jacoco.gradle index 82496164cf..adcd1aeb17 100644 --- a/config/gradle/jacoco.gradle +++ b/config/gradle/jacoco.gradle @@ -27,7 +27,6 @@ project.afterEvaluate { dependsOn 'testDebugUnitTest' reports { - // TODO: Set directory xml.required = true html.required = false csv.required = false diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index 79f2d8c652..81b2864cc2 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -28,6 +28,8 @@ subprojects { properties { property 'sonar.androidLint.reportPaths', "${layout.buildDirectory.get().asFile}/reports/lint-results-debug.xml" property 'sonar.kotlin.detekt.reportPaths', "${layout.buildDirectory.get().asFile}/reports/detekt/detekt-results.xml" + property 'sonar.jacoco.reportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" + property 'sonar.groovy.jacoco.reportPath', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" } } } From 0db906de3939ff10ab30e0f018f5fda7477f51ba Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Wed, 17 Jan 2024 13:46:27 +0100 Subject: [PATCH 45/66] Set correct jacoco report path for sonar COAND-829 --- config/gradle/sonarcloud.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index 81b2864cc2..ec1f1c01f3 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -30,6 +30,7 @@ subprojects { property 'sonar.kotlin.detekt.reportPaths', "${layout.buildDirectory.get().asFile}/reports/detekt/detekt-results.xml" property 'sonar.jacoco.reportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" property 'sonar.groovy.jacoco.reportPath', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" + property 'sonar.coverage.jacoco.xmlReportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" } } } From 9643853c88b8504978ec1f68d0e9b4ded0c49a8d Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Mon, 22 Jan 2024 13:11:57 +0100 Subject: [PATCH 46/66] Exclude project specific files from test coverage These files can't be unit tested. --- config/gradle/jacoco.gradle | 69 +++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/config/gradle/jacoco.gradle b/config/gradle/jacoco.gradle index adcd1aeb17..4f8ac9a629 100644 --- a/config/gradle/jacoco.gradle +++ b/config/gradle/jacoco.gradle @@ -56,24 +56,33 @@ ext.coverageExclusions = [ '**/R.class', // Data binding + '**/*BindingAdapter.*', + '**/*BindingAdapters.*', + '**/*ViewBinding.*', '**/BR$*.class', '**/BR.class', - '**/DataBinderMapperImpl.*', '**/DataBinderMapperImpl*.*', + '**/DataBinderMapperImpl.*', '**/databinding/**', - '**/*BindingAdapter.*', - '**/*BindingAdapters.*', // Activities, Fragments, etc. (not tested with unit tests) '**/*Activity$*.*', '**/*Activity.*', + '**/*Adapter$*.*', '**/*Adapter.*', '**/*Behavior.*', '**/*Dialog.*', '**/*Drawable.*', '**/*Fragment$*.*', '**/*Fragment.*', + '**/*View$*.*', '**/*View.*', + '**/*ViewHolder$*.*', + '**/*ViewHolder.*', + + // Parcelize + '**/*$Creator', + '**/*$Creator*.*', // Activity result contract '**/*ActivityResults.*', @@ -85,31 +94,63 @@ ext.coverageExclusions = [ '**/*_GeneratedInjector.*', '**/*_HiltComponents.*', '**/*_HiltComponents_*.*', + '**/*_HiltModules*.*', '**/*_HiltModules.*', '**/*_HiltModules_*.*', '**/*_Member*Injector.*', - '**/*_ProvideFactory.*', + '**/*_Provide*Factory*.*', '**/*_Provide*Factory.*', - '**/dagger/**', + '**/*_ProvideFactory.*', '**/Dagger*.*', - '**/hilt_aggregated_deps/**', '**/Hilt_*.*', + '**/dagger/**', + '**/hilt_aggregated_deps/**', + + // Test classes + '**/*Test.*', + '**/Test*.*', + + // Fix issue with JaCoCo on JDK + 'jdk.internal.*', // Custom views not following *View naming - '**/AddressFormInput.*', - '**/AdyenSwipeToRevealLayout.*', - '**/AdyenTextInputEditText.*', + '**/AddressFormInput*.*', + '**/AdyenSwipeToRevealLayout*.*', + '**/AdyenTextInputEditText*.*', '**/CardNumberInput.*', + '**/DefaultPayButton.*', '**/ExpiryDateInput.*', '**/GiftCardNumberInput.*', '**/IbanInput.*', + '**/PayButton.*', '**/SecurityCodeInput.*', '**/SocialSecurityNumberInput.*', - // Test classes - '**/*Test.*', - '**/Test*.*', + // Project specific files that can't be unit tested + '**/*ComponentProvider$*.*', + '**/*ComponentProvider.*', + '**/*DropInService$*.*', + '**/*DropInService.*', + '**/*Factory.*', + '**/*ViewProvider.*', + '**/AdyenLogger.*', + '**/BuildUtils.*', + '**/ContextExtensions*.*', + '**/DropInExt*.*', + '**/FileDownloader*.*', + '**/FragmentExtensions*.*', + '**/ImageLoadingExtensions*.*', + '**/ImageLoadingExtensions.*', + '**/ImageSaver.*', + '**/InstallmentFilter.*', + '**/LazyArguments*.*', + '**/LifecycleExtensions*.*', + '**/LogUtil.*', + '**/Logger.*', + '**/PdfOpener.*', + '**/ViewExtensions*.*', + '**/ViewModelExt*.*', - // Fix issue with JaCoCo on JDK - 'jdk.internal.*' + // Example app is not applicable + 'com/adyen/checkout/example/**' ] From 7970f9ab297190cafb88b1250987ef12f6f2fe99 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 23 Jan 2024 14:13:44 +0100 Subject: [PATCH 47/66] Make jacoco task build type specific COAND-829 --- .github/workflows/sonar_cloud.yml | 2 +- config/gradle/jacoco.gradle | 54 ++++++++++++++++++------------- config/gradle/sonarcloud.gradle | 6 ++-- dependencies.gradle | 1 + 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/.github/workflows/sonar_cloud.yml b/.github/workflows/sonar_cloud.yml index 550fc1a781..e6f9d7acd6 100644 --- a/.github/workflows/sonar_cloud.yml +++ b/.github/workflows/sonar_cloud.yml @@ -31,4 +31,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew detekt assDeb teDebUnTe lintDeb jacocoTestReport sonar + run: ./gradlew detekt assDeb teDebUnTe jacocoDebugTestReport lintDeb sonar diff --git a/config/gradle/jacoco.gradle b/config/gradle/jacoco.gradle index 4f8ac9a629..192a15b1c5 100644 --- a/config/gradle/jacoco.gradle +++ b/config/gradle/jacoco.gradle @@ -10,7 +10,7 @@ if (project.hasProperty('android')) { project.afterEvaluate { jacoco { - toolVersion = "0.8.9" + toolVersion = "$jacoco_version" } tasks.withType(Test).configureEach { @@ -21,29 +21,37 @@ project.afterEvaluate { } if (project.hasProperty('android')) { - tasks.register('jacocoTestReport', JacocoReport) { - group 'Reporting' - description 'Generate JaCoCo report for debug unit tests' - dependsOn 'testDebugUnitTest' - - reports { - xml.required = true - html.required = false - csv.required = false - } + android.buildTypes.every { buildType -> + def buildTypeName = buildType.name.capitalize() + + tasks.register("jacoco${buildTypeName}TestReport", JacocoReport) { + group 'Reporting' + description "Generate JaCoCo report for ${buildTypeName} tests" + dependsOn "test${buildTypeName}UnitTest" + + reports { + xml.required = true + html.required = false + csv.required = false + } + + additionalSourceDirs(files([ + android.sourceSets.main.java.srcDirs, + android.sourceSets.debug.java.srcDirs + ])) + additionalClassDirs(files([ + fileTree(dir: "${layout.buildDirectory.get().asFile}/intermediates/javac/${buildType.name}", excludes: coverageExclusions), + fileTree(dir: "${layout.buildDirectory.get().asFile}/tmp/kotlin-classes/${buildType.name}", excludes: coverageExclusions) + ])) + executionData( + fileTree(dir: "${layout.buildDirectory.get().asFile}", includes: [ + "outputs/unit_test_code_coverage/${buildType.name}UnitTest/test${buildTypeName}UnitTest.exec", + "jacoco/test${buildTypeName}UnitTest.exec", + "outputs/code-coverage/connected/*coverage.ec" + ]) + ) - additionalSourceDirs(android.sourceSets.main.java.sourceFiles) - additionalSourceDirs(android.sourceSets.debug.java.sourceFiles) - additionalClassDirs(fileTree(dir: "${layout.buildDirectory.get().asFile}/intermediates/javac/debug", excludes: coverageExclusions)) - additionalClassDirs(fileTree(dir: "${layout.buildDirectory.get().asFile}/tmp/kotlin-classes/debug", excludes: coverageExclusions)) - executionData( - fileTree(dir: "${layout.buildDirectory.get().asFile}", includes: [ - "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", - "jacoco/test.exec", - "outputs/code-coverage/connected/*coverage.ec" - ]), - fileTree(dir: "$projectDir", includes: ['jacoco.exec']) - ) + } } } } diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index ec1f1c01f3..3dd64b0a37 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -28,9 +28,9 @@ subprojects { properties { property 'sonar.androidLint.reportPaths', "${layout.buildDirectory.get().asFile}/reports/lint-results-debug.xml" property 'sonar.kotlin.detekt.reportPaths', "${layout.buildDirectory.get().asFile}/reports/detekt/detekt-results.xml" - property 'sonar.jacoco.reportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" - property 'sonar.groovy.jacoco.reportPath', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" - property 'sonar.coverage.jacoco.xmlReportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" + property 'sonar.jacoco.reportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml" + property 'sonar.groovy.jacoco.reportPath', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml" + property 'sonar.coverage.jacoco.xmlReportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml" } } } diff --git a/dependencies.gradle b/dependencies.gradle index 368b82eff8..a2bff6fc46 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -28,6 +28,7 @@ ext { // Code quality detekt_version = "1.23.4" + jacoco_version = '0.8.9' ktlint_version = '1.0.1' sonarqube_version = '4.4.1.3373' From cd2f106bf71bf4b7e5f730adcae9e96c5b739f9a Mon Sep 17 00:00:00 2001 From: jreij Date: Tue, 9 May 2023 15:54:55 +0200 Subject: [PATCH 48/66] Add workflow to validate release notes being updated with labeled PRs COAND-762 --- .github/workflows/check_release_notes.yml | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/check_release_notes.yml diff --git a/.github/workflows/check_release_notes.yml b/.github/workflows/check_release_notes.yml new file mode 100644 index 0000000000..baf9ddd762 --- /dev/null +++ b/.github/workflows/check_release_notes.yml @@ -0,0 +1,35 @@ +name: Check Release Notes + +# Every PR with a label should include an update to the release notes +on: + pull_request: + branches-ignore: + - 'main' + types: [ opened, synchronize, reopened, labeled, unlabeled ] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + release-notes-check: + # https://github.com/actions/virtual-environments/ + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Check release notes + if: | + contains(github.event.pull_request.labels.*.name, 'Breaking change') || + contains(github.event.pull_request.labels.*.name, 'Feature') || + contains(github.event.pull_request.labels.*.name, 'Fix') + run: | + git fetch origin develop --depth 1 + if [ -n "$(git diff origin/develop RELEASE_NOTES.md)" ] + then + echo "RELEASE_NOTES.md was updated" + exit 0 + else + echo "::error::Add release notes for your PR by updating RELEASE_NOTES.md" + exit 1 + fi From 7ad9cb541bb7e387e2cd9023d1c79e8e1a5b791a Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Mon, 22 Jan 2024 11:58:26 +0100 Subject: [PATCH 49/66] Display generic error message when SessionDropInService encounters an error COAND-839 --- .../checkout/dropin/SessionDropInService.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt index 414f9b7277..b5c1e809e0 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt @@ -88,15 +88,15 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter is SessionCallResult.Payments.Action -> DropInServiceResult.Action(result.action) is SessionCallResult.Payments.Error -> DropInServiceResult.Error( - errorDialog = ErrorDialog(message = result.throwable.message), - dismissDropIn = true + errorDialog = ErrorDialog(), + dismissDropIn = true, ) is SessionCallResult.Payments.Finished -> SessionDropInServiceResult.Finished(result.result) is SessionCallResult.Payments.NotFullyPaidOrder -> updatePaymentMethods(result.result.order) is SessionCallResult.Payments.RefusedPartialPayment -> DropInServiceResult.Error( - errorDialog = ErrorDialog(message = "Payment is refused while making a partial payment.") + errorDialog = ErrorDialog(), ) is SessionCallResult.Payments.TakenOver -> { @@ -114,15 +114,15 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter val result = sessionInteractor.onDetailsCallRequested( actionComponentData, ::onAdditionalDetails, - ::onAdditionalDetails.name + ::onAdditionalDetails.name, ) val dropInServiceResult = when (result) { is SessionCallResult.Details.Action -> DropInServiceResult.Action(result.action) is SessionCallResult.Details.Error -> DropInServiceResult.Error( - errorDialog = ErrorDialog(message = result.throwable.message), - dismissDropIn = true + errorDialog = ErrorDialog(), + dismissDropIn = true, ) is SessionCallResult.Details.Finished -> SessionDropInServiceResult.Finished(result.result) @@ -146,7 +146,7 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter val dropInServiceResult = when (result) { is SessionCallResult.Balance.Error -> - BalanceDropInServiceResult.Error(errorDialog = ErrorDialog(message = result.throwable.message)) + BalanceDropInServiceResult.Error(errorDialog = ErrorDialog()) is SessionCallResult.Balance.Successful -> BalanceDropInServiceResult.Balance(result.balanceResult) SessionCallResult.Balance.TakenOver -> { @@ -163,14 +163,14 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter launch { val result = sessionInteractor.createOrder( ::onOrderRequest, - ::onOrderRequest.name + ::onOrderRequest.name, ) val dropInServiceResult = when (result) { is SessionCallResult.CreateOrder.Error -> OrderDropInServiceResult.Error( - errorDialog = ErrorDialog(message = result.throwable.message), - dismissDropIn = true + errorDialog = ErrorDialog(), + dismissDropIn = true, ) is SessionCallResult.CreateOrder.Successful -> OrderDropInServiceResult.OrderCreated(result.order) @@ -190,12 +190,12 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter val result = sessionInteractor.cancelOrder( order, { onOrderCancel(it, shouldUpdatePaymentMethods) }, - ::onOrderCancel.name + ::onOrderCancel.name, ) val dropInServiceResult = when (result) { is SessionCallResult.CancelOrder.Error -> - DropInServiceResult.Error(errorDialog = ErrorDialog(message = result.throwable.message)) + DropInServiceResult.Error(errorDialog = ErrorDialog()) SessionCallResult.CancelOrder.Successful -> { if (!shouldUpdatePaymentMethods) return@launch @@ -216,13 +216,13 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter return when (val result = sessionInteractor.updatePaymentMethods(order)) { is SessionCallResult.UpdatePaymentMethods.Successful -> DropInServiceResult.Update( result.paymentMethods, - result.order + result.order, ) is SessionCallResult.UpdatePaymentMethods.Error -> DropInServiceResult.Error( - errorDialog = ErrorDialog(message = result.throwable.message), - dismissDropIn = true + errorDialog = ErrorDialog(), + dismissDropIn = true, ) } } From 8273248deb36bf212ebfd6039b9f3e7d4fbafc70 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Mon, 22 Jan 2024 12:03:32 +0100 Subject: [PATCH 50/66] Specify reason for when SessionDropInService encounters an error COAND-839 --- .../adyen/checkout/dropin/SessionDropInService.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt index b5c1e809e0..d6ea5b381f 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/SessionDropInService.kt @@ -89,6 +89,7 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter is SessionCallResult.Payments.Error -> DropInServiceResult.Error( errorDialog = ErrorDialog(), + reason = result.throwable.message, dismissDropIn = true, ) @@ -97,6 +98,7 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter is SessionCallResult.Payments.RefusedPartialPayment -> DropInServiceResult.Error( errorDialog = ErrorDialog(), + reason = "Payment was refused while making a partial payment", ) is SessionCallResult.Payments.TakenOver -> { @@ -122,6 +124,7 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter is SessionCallResult.Details.Error -> DropInServiceResult.Error( errorDialog = ErrorDialog(), + reason = result.throwable.message, dismissDropIn = true, ) @@ -146,7 +149,10 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter val dropInServiceResult = when (result) { is SessionCallResult.Balance.Error -> - BalanceDropInServiceResult.Error(errorDialog = ErrorDialog()) + BalanceDropInServiceResult.Error( + errorDialog = ErrorDialog(), + reason = result.throwable.message, + ) is SessionCallResult.Balance.Successful -> BalanceDropInServiceResult.Balance(result.balanceResult) SessionCallResult.Balance.TakenOver -> { @@ -170,6 +176,7 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter is SessionCallResult.CreateOrder.Error -> OrderDropInServiceResult.Error( errorDialog = ErrorDialog(), + reason = result.throwable.message, dismissDropIn = true, ) @@ -195,7 +202,10 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter val dropInServiceResult = when (result) { is SessionCallResult.CancelOrder.Error -> - DropInServiceResult.Error(errorDialog = ErrorDialog()) + DropInServiceResult.Error( + errorDialog = ErrorDialog(), + reason = result.throwable.message, + ) SessionCallResult.CancelOrder.Successful -> { if (!shouldUpdatePaymentMethods) return@launch @@ -222,6 +232,7 @@ open class SessionDropInService : BaseDropInService(), SessionDropInServiceInter is SessionCallResult.UpdatePaymentMethods.Error -> DropInServiceResult.Error( errorDialog = ErrorDialog(), + reason = result.throwable.message, dismissDropIn = true, ) } From 9d21063a979d5b681703ac7f9b33f1e1d7c7eaaa Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Mon, 22 Jan 2024 14:46:16 +0100 Subject: [PATCH 51/66] Add release notes entry COAND-839 --- RELEASE_NOTES.md | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 102bce94d9..c52324a588 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -9,27 +9,10 @@ [//]: # ( - Configurations public constructor are deprecated, please use each Configuration's builder to make a Configuration object) ## New -- The [BcmcComponent](https://adyen.github.io/adyen-android/bcmc/com.adyen.checkout.bcmc/-bcmc-component/index.html) now supports co-badged Bancontact cards and card brand detection. - - The [BcmcComponentState](https://adyen.github.io/adyen-android/bcmc/com.adyen.checkout.bcmc/-bcmc-component-state/index.html) now contains 3 extra fields: `cardBrand`, `binValue` and `lastFourDigits`. -- You can now override payment method names in Drop-in by using [DropInConfiguration.Builder.overridePaymentMethodName(type, name)](https://adyen.github.io/adyen-android/drop-in/com.adyen.checkout.dropin/-drop-in-configuration/-builder/override-payment-method-name.html). -- For stored cards, Drop-in now shows the card name (for example **Visa** or **Mastercard**) instead of **Credit Card**. -- Now it is possible to show installment amounts for card payments using [InstallmentConfiguration.showInstallmentAmount](https://adyen.github.io/adyen-android/card/com.adyen.checkout.card/-installment-configuration/show-installment-amount.html) in [CardConfiguration.Builder.setInstallmentConfigurations()](https://adyen.github.io/adyen-android/card/com.adyen.checkout.card/-card-configuration/-builder/set-installment-configurations.html). -- For gift cards, you can now hide the PIN text field by setting [GiftCardConfiguration.Builder.setPinRequired()](https://adyen.github.io/adyen-android/giftcard/com.adyen.checkout.giftcard/-gift-card-configuration/-builder/set-pin-required.html) to **false**. -- For Google Pay: - - When initializing the [Google Pay button](https://docs.adyen.com/payment-methods/google-pay/android-component/#2-show-the-google-pay-button), you can now use [GooglePayComponent.getGooglePayButtonParameters()](https://adyen.github.io/adyen-android/googlepay/com.adyen.checkout.googlepay/-google-pay-component/get-google-pay-button-parameters.html) to get the `allowedPaymentMethods` attribute. - - You can now use [AllowedAuthMethods](https://adyen.github.io/adyen-android/googlepay/com.adyen.checkout.googlepay/-allowed-auth-methods/index.html) and [AllowedCardNetworks](https://adyen.github.io/adyen-android/googlepay/com.adyen.checkout.googlepay/-allowed-card-networks/index.html) to easily access to the possible values for [GooglePayConfiguration.Builder.setAllowedAuthMethods()](https://adyen.github.io/adyen-android/googlepay/com.adyen.checkout.googlepay/-google-pay-configuration/-builder/set-allowed-auth-methods.html) and [GooglePayConfiguration.Builder.setAllowedCardNetworks()](https://adyen.github.io/adyen-android/googlepay/com.adyen.checkout.googlepay/-google-pay-configuration/-builder/set-allowed-card-networks.html). + ## Fixed -- Fixed a bug where components would not be displayed in Jetpack Compose lazy lists. +- For drop-in with sessions, error dialogs will no longer display user unfriendly messages. ## Changed -- Dependency versions: - | Name | Version | - |--------------------------------------------------------------------------------------------------------|-------------------------------| - | [AndroidX Compose Activity](https://developer.android.com/jetpack/androidx/releases/activity#1.8.0) | **1.8.0** | - | [Material Design](https://m2.material.io/) | **1.10.0** | - | [Gradle](https://docs.gradle.org/8.4/release-notes.html) | **8.4** | - | [Android Gradle plugin](https://developer.android.com/build/releases/gradle-plugin) | **8.1.2** | - | [AndroidX Compose BoM](https://developer.android.com/jetpack/compose/bom/bom-mapping) | **2023.10.01** | - | [AndroidX Recyclerview](https://developer.android.com/jetpack/androidx/releases/recyclerview#1.3.2) | **1.3.2** | - | [AndroidX Fragment](https://developer.android.com/jetpack/androidx/releases/fragment#1.6.2) | **1.6.2** | + From 51327f862034a2a1258c24affd4e33b127ec1883 Mon Sep 17 00:00:00 2001 From: Oscar Spruit Date: Tue, 23 Jan 2024 15:44:01 +0100 Subject: [PATCH 52/66] Re-use jacoco exclusions for sonar COAND-829 --- config/gradle/sonarcloud.gradle | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/gradle/sonarcloud.gradle b/config/gradle/sonarcloud.gradle index 3dd64b0a37..1d8737a96e 100644 --- a/config/gradle/sonarcloud.gradle +++ b/config/gradle/sonarcloud.gradle @@ -28,9 +28,10 @@ subprojects { properties { property 'sonar.androidLint.reportPaths', "${layout.buildDirectory.get().asFile}/reports/lint-results-debug.xml" property 'sonar.kotlin.detekt.reportPaths', "${layout.buildDirectory.get().asFile}/reports/detekt/detekt-results.xml" - property 'sonar.jacoco.reportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml" - property 'sonar.groovy.jacoco.reportPath', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml" property 'sonar.coverage.jacoco.xmlReportPaths', "${layout.buildDirectory.get().asFile}/reports/jacoco/jacocoDebugTestReport/jacocoDebugTestReport.xml" + + def sonarExclusions = coverageExclusions.join(', ') + property 'sonar.coverage.exclusions', sonarExclusions } } } From 1cc042dec3594745ab5a7b12293a79581ca70c2d Mon Sep 17 00:00:00 2001 From: josephj Date: Wed, 24 Jan 2024 18:12:54 +0100 Subject: [PATCH 53/66] Declare missing parent styles in xml COAND-841 --- bacs/src/main/res/values/styles.xml | 2 ++ paybybank/src/main/res/values/styles.xml | 2 ++ ui-core/src/main/res/values/styles.xml | 6 ++++++ upi/src/main/res/values/styles.xml | 2 ++ voucher/src/main/res/values/styles.xml | 2 ++ 5 files changed, 14 insertions(+) diff --git a/bacs/src/main/res/values/styles.xml b/bacs/src/main/res/values/styles.xml index 1fa82c035a..922ed67ef5 100644 --- a/bacs/src/main/res/values/styles.xml +++ b/bacs/src/main/res/values/styles.xml @@ -31,6 +31,8 @@ textEmailAddress + + + + diff --git a/voucher/src/main/res/values/styles.xml b/voucher/src/main/res/values/styles.xml index ef91dfd0a2..32b7fd539f 100644 --- a/voucher/src/main/res/values/styles.xml +++ b/voucher/src/main/res/values/styles.xml @@ -10,6 +10,8 @@ +``` + +### Option 3: Keep default Adyen theme + +You can keep the default Adyen theme by simply adding one line. Optionally, you can change some attributes. + +```XML + +``` + +To figure out all the colors and styling you can override have a look at [the Material guides](https://m2.material.io/design/color/the-color-system.html) or check [our Adyen base style](https://github.com/Adyen/adyen-android/blob/main/ui-core/src/main/res/values/styles.xml). + +## Customizing the style for specific view types + +In case you want to change the styling for a specific view type, you can do this by copying the base styling into your `styles.xml` and change the values as you like. For example, if you want to change the border color of every `TextInputLayout` to red: + +```XML + +``` + +It can be difficult to find which style is applied to which view. To figure this out we recommend to take a look at [our styles.xml](https://github.com/Adyen/adyen-android/blob/main/ui-core/src/main/res/values/styles.xml) or use the Layout Inspector. + +## Customizing a specific view + +Every view has its own style that builds on top of the view type style. This allows you to customize a specific view. Take for example the card number input field, copy the base style in your `styles.xml` + +```XML + +``` + +## Adding dark mode support +Out of the box the SDK doesn’t support dark mode, but you can easily add this with some additional setup. Copy the base `AdyenCheckout` style in your `styles.xml` and change the parent to a Material DayNight theme. Now you can use the `values-night` resource folder to define colors and styles to be used in dark mode. + +```XML + +``` + +To learn more about Material dark theming you can read [this guide](https://m2.material.io/develop/android/theming/dark) or take a look at [our example app](https://github.com/Adyen/adyen-android/blob/main/example-app/src/main/res/values/styles.xml). + +## Overriding string resources + +It is possible to change text in the SDK by overriding string resources. To override a string resource you have to copy the string resource in your own strings.xml. The easiest way to find the string resource is to search for the string on Github. In case your app supports multiple languages you can do the exact same thing, but make sure to use the right `values` directory (for example `values-nl-rNL`). + +```XML + + + More Payment Methods + +``` + +Payment method names in the payment method list can be overridden with a configuration object: + +```kotlin +DropInConfiguration.Builder(shopperLocale, environment, clientKey) + .overridePaymentMethodName(PaymentMethodTypes.SCHEME, "Credit cards") + .overridePaymentMethodName(PaymentMethodTypes.GIFTCARD, "Specific gift card") + .build() +``` + +If you cannot find a certain string in the code base, then check whether it is coming from the Checkout API. Make sure you localize these strings by sending the correct [shopperLocale](https://docs.adyen.com/api-explorer/Checkout/latest/post/sessions#request-shopperLocale). From cb7651fa2e36177d361e4e6382a875c797d60203 Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Mon, 29 Jan 2024 09:41:05 +0100 Subject: [PATCH 56/66] Add missing comment for `showInstallmentAmount` param of `InstallmentConfiguration` COAND-802 --- .../java/com/adyen/checkout/card/InstallmentConfiguration.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/card/src/main/java/com/adyen/checkout/card/InstallmentConfiguration.kt b/card/src/main/java/com/adyen/checkout/card/InstallmentConfiguration.kt index 772177514d..55c326481c 100644 --- a/card/src/main/java/com/adyen/checkout/card/InstallmentConfiguration.kt +++ b/card/src/main/java/com/adyen/checkout/card/InstallmentConfiguration.kt @@ -24,6 +24,7 @@ import kotlinx.parcelize.Parcelize * * @param defaultOptions Installment Options to be used for all card types. * @param cardBasedOptions Installment Options to be used for specific card types. + * @param showInstallmentAmount A flag to show the installment amount. */ @Parcelize data class InstallmentConfiguration( From 40d84837fff3e734b9df9a78e6c16c0fd1da39e8 Mon Sep 17 00:00:00 2001 From: josephj Date: Mon, 22 Jan 2024 18:27:32 +0100 Subject: [PATCH 57/66] Add detailed error logging to internal API calls --- .../checkout/core/exception/HttpException.kt | 4 +-- .../core/internal/data/api/HttpClientExt.kt | 25 ++++++++++++++++--- .../core/internal/data/api/OkHttpClient.kt | 17 +++++-------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/exception/HttpException.kt b/checkout-core/src/main/java/com/adyen/checkout/core/exception/HttpException.kt index 26058a7b8a..a799a44938 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/exception/HttpException.kt +++ b/checkout-core/src/main/java/com/adyen/checkout/core/exception/HttpException.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.core.internal.data.model.ErrorResponseBody * Indicates that an internal API call has failed. */ class HttpException( - code: Int, + val code: Int, message: String, val errorBody: ErrorResponseBody?, -) : CheckoutException("$code $message") +) : CheckoutException(message) diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/HttpClientExt.kt b/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/HttpClientExt.kt index 884848141c..6652cbbf6c 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/HttpClientExt.kt +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/HttpClientExt.kt @@ -9,6 +9,8 @@ package com.adyen.checkout.core.internal.data.api import androidx.annotation.RestrictTo +import com.adyen.checkout.core.exception.HttpException +import com.adyen.checkout.core.internal.data.model.ErrorResponseBody import com.adyen.checkout.core.internal.data.model.ModelObject import com.adyen.checkout.core.internal.data.model.ModelUtils import com.adyen.checkout.core.internal.data.model.toStringPretty @@ -27,7 +29,7 @@ suspend fun HttpClient.get( ): T { Logger.d(TAG, "GET - $path") - val result = this.get(path, queryParameters) + val result = runAndLogHttpException { get(path, queryParameters) } val resultJson = JSONObject(String(result, Charsets.UTF_8)) Logger.v(TAG, "response - ${resultJson.toStringPretty()}") @@ -43,7 +45,7 @@ suspend fun HttpClient.getList( ): List { Logger.d(TAG, "GET - $path") - val result = this.get(path, queryParameters) + val result = runAndLogHttpException { get(path, queryParameters) } val resultJson = JSONArray(String(result, Charsets.UTF_8)) Logger.v(TAG, "response - ${resultJson.toStringPretty()}") @@ -65,10 +67,27 @@ suspend fun HttpClient.post( Logger.v(TAG, "request - ${requestJson.toStringPretty()}") - val result = this.post(path, requestJson.toString(), queryParameters) + val result = runAndLogHttpException { post(path, requestJson.toString(), queryParameters) } val resultJson = JSONObject(String(result, Charsets.UTF_8)) Logger.v(TAG, "response - ${resultJson.toStringPretty()}") return responseSerializer.deserialize(resultJson) } + +private inline fun T.runAndLogHttpException(block: T.() -> R): R { + return try { + block() + } catch (httpException: HttpException) { + Logger.e(TAG, "API error - ${httpException.getLogMessage()}") + throw httpException + } +} + +private fun HttpException.getLogMessage(): String { + return if (errorBody != null) { + ErrorResponseBody.SERIALIZER.serialize(errorBody).toStringPretty() + } else { + "[$code] $message" + } +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/OkHttpClient.kt b/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/OkHttpClient.kt index 4434c8c3e8..3a9c9e6381 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/OkHttpClient.kt +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/api/OkHttpClient.kt @@ -83,11 +83,7 @@ internal class OkHttpClient( response.body?.close() return bytes } else { - val exception = HttpException( - code = response.code, - message = response.message, - errorBody = response.errorBody() - ) + val exception = response.getHttpException() response.body?.close() throw exception } @@ -101,7 +97,7 @@ internal class OkHttpClient( (defaultHeaders + this).toHeaders() @Suppress("SwallowedException") - private fun Response.errorBody(): ErrorResponseBody? { + private fun Response.getHttpException(): HttpException { val stringBody = try { body?.string() } catch (e: IOException) { @@ -118,11 +114,10 @@ internal class OkHttpClient( null } - return parsedErrorResponseBody ?: ErrorResponseBody( - status = null, - errorCode = null, - message = stringBody, - errorType = null, + return HttpException( + code = parsedErrorResponseBody?.status ?: code, + message = parsedErrorResponseBody?.message ?: stringBody?.takeIf { it.isNotBlank() } ?: message, + errorBody = parsedErrorResponseBody, ) } From e5cb16cc676ee9719ff7ca2169e30722a332d9fd Mon Sep 17 00:00:00 2001 From: josephj Date: Mon, 22 Jan 2024 18:27:56 +0100 Subject: [PATCH 58/66] Add pspReference attribute to ErrorResponseBody --- .../checkout/core/internal/data/model/ErrorResponseBody.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/model/ErrorResponseBody.kt b/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/model/ErrorResponseBody.kt index 54ab2d68e4..ece6673222 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/model/ErrorResponseBody.kt +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/data/model/ErrorResponseBody.kt @@ -21,6 +21,7 @@ data class ErrorResponseBody( val errorCode: String?, val message: String?, val errorType: String?, + val pspReference: String?, ) : ModelObject() { companion object { @@ -29,6 +30,7 @@ data class ErrorResponseBody( private const val ERROR_CODE = "errorCode" private const val MESSAGE = "message" private const val ERROR_TYPE = "errorType" + private const val PSP_REFERENCE = "pspReference" @JvmField val SERIALIZER: Serializer = object : Serializer { @@ -39,6 +41,7 @@ data class ErrorResponseBody( jsonObject.putOpt(ERROR_CODE, modelObject.errorCode) jsonObject.putOpt(MESSAGE, modelObject.message) jsonObject.putOpt(ERROR_TYPE, modelObject.errorType) + jsonObject.putOpt(PSP_REFERENCE, modelObject.pspReference) } catch (e: JSONException) { throw ModelSerializationException(ErrorResponseBody::class.java, e) } @@ -52,6 +55,7 @@ data class ErrorResponseBody( errorCode = jsonObject.optString(ERROR_CODE), message = jsonObject.optString(MESSAGE), errorType = jsonObject.optString(ERROR_TYPE), + pspReference = jsonObject.optString(PSP_REFERENCE), ) } catch (e: JSONException) { throw ModelSerializationException(ErrorResponseBody::class.java, e) From 9c96a83c07a8df1ed56cb6e8893042ce5c199bc2 Mon Sep 17 00:00:00 2001 From: josephj Date: Mon, 22 Jan 2024 18:28:34 +0100 Subject: [PATCH 59/66] Change analytics API error logging level back to error --- .../core/internal/data/api/DefaultAnalyticsRepository.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/DefaultAnalyticsRepository.kt b/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/DefaultAnalyticsRepository.kt index 922cc5718b..6de3590329 100644 --- a/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/DefaultAnalyticsRepository.kt +++ b/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/DefaultAnalyticsRepository.kt @@ -58,8 +58,7 @@ class DefaultAnalyticsRepository( Logger.v(TAG, "Analytics setup call successful") }.onFailure { e -> state = State.Failed - // TODO change back to error when all analytic endpoints are live - Logger.w(TAG, "Failed to send analytics setup call - ${e::class.simpleName}: ${e.message}") + Logger.e(TAG, "Failed to send analytics setup call - ${e::class.simpleName}: ${e.message}") } } From df89641a8c721ccb4147468e08bf8c600fda947c Mon Sep 17 00:00:00 2001 From: josephj Date: Mon, 29 Jan 2024 14:32:07 +0100 Subject: [PATCH 60/66] Fix polling interval values in StatusRepository --- .../components/core/internal/data/api/StatusRepository.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/StatusRepository.kt b/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/StatusRepository.kt index b9fee45821..c32655671c 100644 --- a/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/StatusRepository.kt +++ b/components-core/src/main/java/com/adyen/checkout/components/core/internal/data/api/StatusRepository.kt @@ -105,8 +105,8 @@ class DefaultStatusRepository constructor( companion object { private val TAG = LogUtil.getTag() - private val POLLING_DELAY_FAST = TimeUnit.SECONDS.toMillis(60) - private val POLLING_DELAY_SLOW = TimeUnit.SECONDS.toMillis(60) - private val POLLING_THRESHOLD = TimeUnit.SECONDS.toMillis(120) + private val POLLING_DELAY_FAST = TimeUnit.SECONDS.toMillis(2) + private val POLLING_DELAY_SLOW = TimeUnit.SECONDS.toMillis(10) + private val POLLING_THRESHOLD = TimeUnit.SECONDS.toMillis(60) } } From 5706e2cbc0ea483c03bea5e94df495f3f2cb9c59 Mon Sep 17 00:00:00 2001 From: josephj Date: Mon, 29 Jan 2024 14:41:59 +0100 Subject: [PATCH 61/66] Update release notes for the polling duration --- RELEASE_NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4b30a96527..5403f6be54 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,6 +14,7 @@ ## Fixed - For drop-in with sessions, error dialogs will no longer display user unfriendly messages. - Overriding some of the XML styles without specifying a parent style no longer causes a build error. +- The Await and QR Code action components will no longer be stuck in a loading state for a long time after the payment is finalized. ## Changed From 26be320191fd489303d13154728cbb1a5cefc692 Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Mon, 29 Jan 2024 18:09:17 +0100 Subject: [PATCH 62/66] Bump version to 5.2.0 --- README.md | 10 +++++----- .../core/internal/data/api/AnalyticsMapperTest.kt | 2 +- dependencies.gradle | 2 +- example-app/build.gradle | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8efc3d5a86..647b5d1fb5 100644 --- a/README.md +++ b/README.md @@ -31,23 +31,23 @@ Import the corresponding module in your `build.gradle` file. For Drop-in: ```groovy -implementation "com.adyen.checkout:drop-in-compose:5.1.0" +implementation "com.adyen.checkout:drop-in-compose:5.2.0" ``` For the Credit Card component: ```groovy -implementation "com.adyen.checkout:card:5.1.0" -implementation "com.adyen.checkout:components-compose:5.1.0" +implementation "com.adyen.checkout:card:5.2.0" +implementation "com.adyen.checkout:components-compose:5.2.0" ``` ### Without Jetpack Compose For Drop-in: ```groovy -implementation "com.adyen.checkout:drop-in:5.1.0" +implementation "com.adyen.checkout:drop-in:5.2.0" ``` For the Credit Card component: ```groovy -implementation "com.adyen.checkout:card:5.1.0" +implementation "com.adyen.checkout:card:5.2.0" ``` The library is available on [Maven Central][mavenRepo]. diff --git a/components-core/src/test/java/com/adyen/checkout/components/core/internal/data/api/AnalyticsMapperTest.kt b/components-core/src/test/java/com/adyen/checkout/components/core/internal/data/api/AnalyticsMapperTest.kt index a860fd0db7..7cece7b550 100644 --- a/components-core/src/test/java/com/adyen/checkout/components/core/internal/data/api/AnalyticsMapperTest.kt +++ b/components-core/src/test/java/com/adyen/checkout/components/core/internal/data/api/AnalyticsMapperTest.kt @@ -119,7 +119,7 @@ internal class AnalyticsMapperTest { ) val expected = AnalyticsSetupRequest( - version = "5.1.0", + version = "5.2.0", channel = "android", platform = "android", locale = "en_US", diff --git a/dependencies.gradle b/dependencies.gradle index a2bff6fc46..8e986e6324 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -16,7 +16,7 @@ ext { // just for example app, don't need to increment version_code = 1 // The version_name format is "major.minor.patch(-(alpha|beta|rc)[0-9]{2}){0,1}" (e.g. 3.0.0, 3.1.1-alpha04 or 3.1.4-rc01 etc). - version_name = "5.1.0" + version_name = "5.2.0" // Build Script android_gradle_plugin_version = '8.2.0' diff --git a/example-app/build.gradle b/example-app/build.gradle index 5cddf7b64e..6a523c3a96 100644 --- a/example-app/build.gradle +++ b/example-app/build.gradle @@ -68,7 +68,7 @@ dependencies { // Checkout implementation project(':drop-in') implementation project(':components-compose') -// implementation "com.adyen.checkout:drop-in:5.1.0" +// implementation "com.adyen.checkout:drop-in:5.2.0" // Dependencies implementation libraries.kotlinCoroutines From 7568d045373b1821efa2b47705dbf7003a3924d5 Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Mon, 29 Jan 2024 18:10:26 +0100 Subject: [PATCH 63/66] Update release notes for 5.2.0 --- RELEASE_NOTES.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5403f6be54..7c4915cda7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,5 @@ [//]: # (This file will be used for the release notes on GitHub when publishing.) -[//]: # (Types of changes: `Breaking changes` `New` `Added` `Changed` `Deprecated` `Removed` `Fixed`) +[//]: # (Types of changes: `Breaking changes` `New` `Added` `Improved` `Changed` `Deprecated` `Removed` `Fixed`) [//]: # (Example:) [//]: # (## Added) [//]: # ( - New payment method) @@ -8,8 +8,12 @@ [//]: # (## Deprecated) [//]: # ( - Configurations public constructor are deprecated, please use each Configuration's builder to make a Configuration object) -## New +## Added +- We added a guide on UI customization of the Android SDK: https://github.com/Adyen/adyen-android/blob/develop/docs/UI_CUSTOMIZATION.md. +- We added Google Pay to the example app, including the availability check and the Google Pay dynamic button. +## Improved +- For encryption now JSON Web Encryption (JWE) with RSA OAEP 256 and AES GCM 256 is used. You don't need to make any changes to your integration. ## Fixed - For drop-in with sessions, error dialogs will no longer display user unfriendly messages. @@ -17,4 +21,11 @@ - The Await and QR Code action components will no longer be stuck in a loading state for a long time after the payment is finalized. ## Changed - +- Dependency versions: + | Name | Version | + |--------------------------------------------------------------------------------------------------------|-------------------------------| + | [Kotlin](https://kotlinlang.org/docs/releases.html#release-details) | **1.9.21** | + | [Android Gradle plugin](https://developer.android.com/build/releases/gradle-plugin) | **8.2.0** | + | [AndroidX Compose compiler](https://developer.android.com/jetpack/androidx/releases/compose-compiler) | **1.5.7** | + | [AndroidX Compose Activity](https://developer.android.com/jetpack/androidx/releases/activity#1.8.1) | **1.8.1** | + | [AndroidX Browser](https://developer.android.com/jetpack/androidx/releases/browser#1.7.0) | **1.7.0** | From 67ccfadbabd4148323b33a5fa72be6655e3e6204 Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Mon, 29 Jan 2024 18:10:26 +0100 Subject: [PATCH 64/66] Update release notes for 5.2.0 --- RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7c4915cda7..1ef33f3372 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -9,7 +9,7 @@ [//]: # ( - Configurations public constructor are deprecated, please use each Configuration's builder to make a Configuration object) ## Added -- We added a guide on UI customization of the Android SDK: https://github.com/Adyen/adyen-android/blob/develop/docs/UI_CUSTOMIZATION.md. +- We added [a guide](https://github.com/Adyen/adyen-android/blob/develop/docs/UI_CUSTOMIZATION.md) on UI customization of the Android SDK. - We added Google Pay to the example app, including the availability check and the Google Pay dynamic button. ## Improved From b4850bf16483741995c083af7851be3ae437ad4a Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Mon, 29 Jan 2024 18:10:26 +0100 Subject: [PATCH 65/66] Update release notes for 5.2.0 --- RELEASE_NOTES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1ef33f3372..67366d1e62 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,6 @@ ## Added - We added [a guide](https://github.com/Adyen/adyen-android/blob/develop/docs/UI_CUSTOMIZATION.md) on UI customization of the Android SDK. -- We added Google Pay to the example app, including the availability check and the Google Pay dynamic button. ## Improved - For encryption now JSON Web Encryption (JWE) with RSA OAEP 256 and AES GCM 256 is used. You don't need to make any changes to your integration. From 09e80eeb6d8885f7b2dd8fb220a578754be11624 Mon Sep 17 00:00:00 2001 From: Ararat Mnatsakanyan Date: Tue, 30 Jan 2024 13:05:04 +0100 Subject: [PATCH 66/66] Improve release notes --- RELEASE_NOTES.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 67366d1e62..a5300f72d8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -8,16 +8,16 @@ [//]: # (## Deprecated) [//]: # ( - Configurations public constructor are deprecated, please use each Configuration's builder to make a Configuration object) -## Added -- We added [a guide](https://github.com/Adyen/adyen-android/blob/develop/docs/UI_CUSTOMIZATION.md) on UI customization of the Android SDK. +## New +- We added a [UI customization guide](docs/UI_CUSTOMIZATION.md), which explains how to customize the styles and string resources. ## Improved -- For encryption now JSON Web Encryption (JWE) with RSA OAEP 256 and AES GCM 256 is used. You don't need to make any changes to your integration. +- The integration now uses JSON Web Encryption (JWE) with RSA OAEP 256 and AES GCM 256 for encryption. You do not need to make any changes to your integration. ## Fixed -- For drop-in with sessions, error dialogs will no longer display user unfriendly messages. +- For Drop-in, error dialogs no longer display user unfriendly messages when using the Sessions flow. - Overriding some of the XML styles without specifying a parent style no longer causes a build error. -- The Await and QR Code action components will no longer be stuck in a loading state for a long time after the payment is finalized. +- The Await and QR Code action components no longer get stuck in a loading state after the payment is completed. ## Changed - Dependency versions: