diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c66d60759d..efef911f56 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,9 @@ [//]: # (If this is a bug fix: include a reproduction path) ## Checklist +- [ ] PR is labelled - [ ] Code is unit tested - [ ] Changes are tested manually -- [ ] Link to related issues -- [ ] Add relevant labels to PR +- [ ] Related issues are linked COAND-XXX diff --git a/.github/workflows/assemble.yml b/.github/workflows/assemble.yml index 2795869d2c..1fa221cb69 100644 --- a/.github/workflows/assemble.yml +++ b/.github/workflows/assemble.yml @@ -16,16 +16,14 @@ jobs: with: distribution: 'zulu' java-version: 17 - cache: 'gradle' + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Run assembleDebug - run: ./gradlew assDeb --no-daemon - - - name: Cache build output - uses: actions/cache/save@v3 - with: - path: /home/runner/work/adyen-android/adyen-android - key: cache-${{ github.run_id }}-${{ github.run_attempt }} + run: ./gradlew assDeb -Pstrip-resources=true diff --git a/.github/workflows/check_develop.yml b/.github/workflows/check_develop.yml index d312ffd653..63248a3ab1 100644 --- a/.github/workflows/check_develop.yml +++ b/.github/workflows/check_develop.yml @@ -10,7 +10,11 @@ concurrency: cancel-in-progress: true jobs: + assemble: + name: Assemble + uses: ./.github/workflows/assemble.yml sonar_cloud: name: SonarCloud uses: ./.github/workflows/sonar_cloud.yml + needs: assemble secrets: inherit diff --git a/.github/workflows/check_labels.yml b/.github/workflows/check_labels.yml new file mode 100644 index 0000000000..aa5742fe8d --- /dev/null +++ b/.github/workflows/check_labels.yml @@ -0,0 +1,48 @@ +name: Check Labels + +# Every PR should have a label and some labels should include an update to the release notes +on: + pull_request: + branches-ignore: + - 'main' + types: [ synchronize, labeled, unlabeled ] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + labels-check: + # https://github.com/actions/virtual-environments/ + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Check PR labels + run: | + all_pr_labels_json=$(cat < { @@ -66,12 +67,14 @@ constructor( Adyen3DS2Component( delegate = adyen3DS2Delegate, - actionComponentEventHandler = DefaultActionComponentEventHandler(callback), + actionComponentEventHandler = DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, threeDS2Factory)[key, Adyen3DS2Component::class.java] .also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } @@ -103,8 +106,8 @@ constructor( redirectHandler = redirectHandler, threeDS2Service = ThreeDS2Service.INSTANCE, coroutineDispatcher = Dispatchers.Default, - base64Encoder = AndroidBase64Encoder(), application = application, + analyticsManager = analyticsManager, ) } diff --git a/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2Delegate.kt b/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2Delegate.kt index cc5295a438..bc1f82afa7 100644 --- a/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2Delegate.kt +++ b/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2Delegate.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.adyen3ds2.Authentication3DS2Exception import com.adyen.checkout.adyen3ds2.Cancelled3DS2Exception +import com.adyen.checkout.adyen3ds2.internal.analytics.ThreeDS2Events import com.adyen.checkout.adyen3ds2.internal.data.api.SubmitFingerprintRepository import com.adyen.checkout.adyen3ds2.internal.data.model.Adyen3DS2Serializer import com.adyen.checkout.adyen3ds2.internal.data.model.ChallengeToken @@ -34,7 +35,8 @@ import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository import com.adyen.checkout.components.core.internal.SavedStateHandleContainer import com.adyen.checkout.components.core.internal.SavedStateHandleProperty -import com.adyen.checkout.components.core.internal.util.Base64Encoder +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.util.bufferedChannel import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.exception.CheckoutException @@ -46,8 +48,10 @@ import com.adyen.checkout.ui.core.internal.ui.ComponentViewType import com.adyen.threeds2.AuthenticationRequestParameters import com.adyen.threeds2.ChallengeResult import com.adyen.threeds2.ChallengeStatusHandler +import com.adyen.threeds2.InitializeResult import com.adyen.threeds2.ThreeDS2Service import com.adyen.threeds2.Transaction +import com.adyen.threeds2.TransactionResult import com.adyen.threeds2.exception.InvalidInputException import com.adyen.threeds2.exception.SDKNotInitializedException import com.adyen.threeds2.exception.SDKRuntimeException @@ -64,6 +68,8 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.json.JSONException import org.json.JSONObject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi @Suppress("TooManyFunctions", "LongParameterList") internal class DefaultAdyen3DS2Delegate( @@ -76,8 +82,8 @@ internal class DefaultAdyen3DS2Delegate( private val redirectHandler: RedirectHandler, private val threeDS2Service: ThreeDS2Service, private val coroutineDispatcher: CoroutineDispatcher, - private val base64Encoder: Base64Encoder, private val application: Application, + private val analyticsManager: AnalyticsManager?, ) : Adyen3DS2Delegate, ChallengeStatusHandler, SavedStateHandleContainer { private val detailsChannel: Channel = bufferedChannel() @@ -93,10 +99,11 @@ internal class DefaultAdyen3DS2Delegate( private var currentTransaction: Transaction? = null - private var authorizationToken: String? by SavedStateHandleProperty(AUTHORIZATION_TOKEN_KEY) + private var action: BaseThreeds2Action? by SavedStateHandleProperty(ACTION_KEY) override fun initialize(coroutineScope: CoroutineScope) { _coroutineScope = coroutineScope + SharedChallengeStatusHandler.onCompletionListener = this } override fun observe( @@ -120,10 +127,12 @@ internal class DefaultAdyen3DS2Delegate( override fun handleAction(action: Action, activity: Activity) { if (action !is BaseThreeds2Action) { - exceptionChannel.trySend(ComponentException("Unsupported action")) + emitError(ComponentException("Unsupported action")) return } + this.action = action + val paymentData = action.paymentData paymentDataRepository.paymentData = paymentData when (action) { @@ -138,9 +147,12 @@ internal class DefaultAdyen3DS2Delegate( activity: Activity, ) { if (action.token.isNullOrEmpty()) { - exceptionChannel.trySend(ComponentException("Fingerprint token not found.")) + emitError(ComponentException("Fingerprint token not found.")) return } + + trackFingerprintActionEvent(action) + identifyShopper( activity = activity, encodedFingerprintToken = action.token.orEmpty(), @@ -153,9 +165,12 @@ internal class DefaultAdyen3DS2Delegate( activity: Activity, ) { if (action.token.isNullOrEmpty()) { - exceptionChannel.trySend(ComponentException("Challenge token not found.")) + emitError(ComponentException("Challenge token not found.")) return } + + trackChallengeActionEvent(action) + challengeShopper(activity, action.token.orEmpty()) } @@ -164,32 +179,39 @@ internal class DefaultAdyen3DS2Delegate( activity: Activity, ) { if (action.token.isNullOrEmpty()) { - exceptionChannel.trySend(ComponentException("3DS2 token not found.")) + emitError(ComponentException("3DS2 token not found.")) return } if (action.subtype == null) { - exceptionChannel.trySend(ComponentException("3DS2 Action subtype not found.")) + emitError(ComponentException("3DS2 Action subtype not found.")) return } val subtype = Threeds2Action.SubType.parse(action.subtype.orEmpty()) - // We need to keep authorizationToken in memory to access it later when the 3DS2 challenge is done - authorizationToken = action.authorisationToken - handleActionSubtype(activity, subtype, action.token.orEmpty()) + handleThreeds2ActionSubtype(action, activity, subtype) } - private fun handleActionSubtype( + private fun handleThreeds2ActionSubtype( + action: Threeds2Action, activity: Activity, subtype: Threeds2Action.SubType, - token: String, ) { + val token = action.token.orEmpty() when (subtype) { - Threeds2Action.SubType.FINGERPRINT -> identifyShopper( - activity = activity, - encodedFingerprintToken = token, - submitFingerprintAutomatically = true, - ) + Threeds2Action.SubType.FINGERPRINT -> { + trackFingerprintActionEvent(action) + + identifyShopper( + activity = activity, + encodedFingerprintToken = token, + submitFingerprintAutomatically = true, + ) + } - Threeds2Action.SubType.CHALLENGE -> challengeShopper(activity, token) + Threeds2Action.SubType.CHALLENGE -> { + trackChallengeActionEvent(action) + + challengeShopper(activity, token) + } } } @@ -206,26 +228,31 @@ internal class DefaultAdyen3DS2Delegate( val fingerprintToken = try { decodeFingerprintToken(encodedFingerprintToken) } catch (e: CheckoutException) { - exceptionChannel.trySend(ComponentException("Failed to decode fingerprint token", e)) + emitError(ComponentException("Failed to decode fingerprint token", e)) return } - val configParameters = createAdyenConfigParameters(fingerprintToken) + val configParameters = createAdyenConfigParameters(fingerprintToken) ?: run { + emitError(ComponentException("Failed to create ConfigParameters.")) + return + } val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> adyenLog(AdyenLogLevel.ERROR, throwable) { "Unexpected uncaught 3DS2 Exception" } - exceptionChannel.trySend(CheckoutException("Unexpected 3DS2 exception.", throwable)) + emitError(CheckoutException("Unexpected 3DS2 exception.", throwable)) } coroutineScope.launch(coroutineDispatcher + coroutineExceptionHandler) { // This makes sure the 3DS2 SDK doesn't re-use any state from previous transactions closeTransaction() - try { - adyenLog(AdyenLogLevel.DEBUG) { "initialize 3DS2 SDK" } + adyenLog(AdyenLogLevel.DEBUG) { "initialize 3DS2 SDK" } + val initializeResult = threeDS2Service.initialize(activity, configParameters, null, componentParams.uiCustomization) - } catch (e: SDKRuntimeException) { - exceptionChannel.trySend(ComponentException("Failed to initialize 3DS2 SDK", e)) + + if (initializeResult is InitializeResult.Failure) { + val details = makeDetails(initializeResult.transactionStatus, initializeResult.additionalDetails) + emitDetails(details) return@launch } @@ -233,7 +260,7 @@ internal class DefaultAdyen3DS2Delegate( val authenticationRequestParameters = currentTransaction?.authenticationRequestParameters if (authenticationRequestParameters == null) { - exceptionChannel.trySend(ComponentException("Failed to retrieve 3DS2 authentication parameters")) + emitError(ComponentException("Failed to retrieve 3DS2 authentication parameters")) return@launch } val encodedFingerprint = createEncodedFingerprint(authenticationRequestParameters) @@ -246,9 +273,10 @@ internal class DefaultAdyen3DS2Delegate( } } + @OptIn(ExperimentalEncodingApi::class) @Throws(ComponentException::class, ModelSerializationException::class) private fun decodeFingerprintToken(encoded: String): FingerprintToken { - val decodedFingerprintToken = base64Encoder.decode(encoded) + val decodedFingerprintToken = Base64.decode(encoded).toString(Charsets.UTF_8) val fingerprintJson: JSONObject = try { JSONObject(decodedFingerprintToken) @@ -259,41 +287,61 @@ internal class DefaultAdyen3DS2Delegate( return FingerprintToken.SERIALIZER.deserialize(fingerprintJson) } + @Suppress("DestructuringDeclarationWithTooManyEntries") private fun createAdyenConfigParameters( fingerprintToken: FingerprintToken - ): ConfigParameters = AdyenConfigParameters.Builder( - // directoryServerId - fingerprintToken.directoryServerId, - // directoryServerPublicKey - fingerprintToken.directoryServerPublicKey, - // directoryServerRootCertificates - fingerprintToken.directoryServerRootCertificates, - ) - .deviceParameterBlockList(componentParams.deviceParameterBlockList) - .build() + ): ConfigParameters? { + val (directoryServerId, directoryServerPublicKey, directoryServerRootCertificates, _, _) = fingerprintToken + + if (directoryServerId == null || directoryServerPublicKey == null || directoryServerRootCertificates == null) { + adyenLog(AdyenLogLevel.DEBUG) { + "directoryServerId, directoryServerPublicKey or directoryServerRootCertificates is null." + } + return null + } + + return AdyenConfigParameters.Builder( + directoryServerId, + directoryServerPublicKey, + directoryServerRootCertificates, + ) + .deviceParameterBlockList(componentParams.deviceParameterBlockList) + .build() + } private fun createTransaction(fingerprintToken: FingerprintToken): Transaction? { if (fingerprintToken.threeDSMessageVersion == null) { - exceptionChannel.trySend( - ComponentException( - "Failed to create 3DS2 Transaction. Missing threeDSMessageVersion inside fingerprintToken.", - ), - ) + val error = "Failed to create 3DS2 Transaction. Missing threeDSMessageVersion inside fingerprintToken." + emitError(ComponentException(error)) return null } + val event = ThreeDS2Events.threeDS2Fingerprint( + subType = ThreeDS2Events.SubType.FINGERPRINT_DATA_SENT, + ) + analyticsManager?.trackEvent(event) + return try { adyenLog(AdyenLogLevel.DEBUG) { "create transaction" } - threeDS2Service.createTransaction(null, fingerprintToken.threeDSMessageVersion) + when (val result = threeDS2Service.createTransaction(null, fingerprintToken.threeDSMessageVersion)) { + is TransactionResult.Failure -> { + val details = makeDetails(result.transactionStatus, result.additionalDetails) + emitDetails(details) + null + } + + is TransactionResult.Success -> result.transaction + } } catch (e: SDKNotInitializedException) { - exceptionChannel.trySend(ComponentException("Failed to create 3DS2 Transaction", e)) + emitError(ComponentException("Failed to create 3DS2 Transaction", e)) null } catch (e: SDKRuntimeException) { - exceptionChannel.trySend(ComponentException("Failed to create 3DS2 Transaction", e)) + emitError(ComponentException("Failed to create 3DS2 Transaction", e)) null } } + @OptIn(ExperimentalEncodingApi::class) @Throws(ComponentException::class) private fun createEncodedFingerprint(authenticationRequestParameters: AuthenticationRequestParameters): String { return try { @@ -308,7 +356,7 @@ internal class DefaultAdyen3DS2Delegate( } } - base64Encoder.encode(fingerprintJson.toString()) + Base64.encode(fingerprintJson.toString().toByteArray()) } catch (e: JSONException) { throw ComponentException("Failed to create encoded fingerprint", e) } @@ -325,7 +373,7 @@ internal class DefaultAdyen3DS2Delegate( ) .fold( onSuccess = { result -> onSubmitFingerprintResult(result, activity) }, - onFailure = { e -> exceptionChannel.trySend(ComponentException("Unable to submit fingerprint", e)) }, + onFailure = { e -> emitError(ComponentException("Unable to submit fingerprint", e)) }, ) } @@ -337,25 +385,28 @@ internal class DefaultAdyen3DS2Delegate( when (result) { is SubmitFingerprintResult.Completed -> { + trackFingerprintCompletedEvent(ThreeDS2Events.Result.COMPLETED) emitDetails(result.details) } is SubmitFingerprintResult.Redirect -> { + trackFingerprintCompletedEvent(ThreeDS2Events.Result.REDIRECT) makeRedirect(activity, result.action) } is SubmitFingerprintResult.Threeds2 -> { + trackFingerprintCompletedEvent(ThreeDS2Events.Result.THREEDS2) handleAction(result.action, activity) } } } - private fun emitDetails(details: JSONObject) { - val actionComponentData = ActionComponentData( - details = details, - paymentData = paymentDataRepository.paymentData, + private fun trackFingerprintCompletedEvent(result: ThreeDS2Events.Result) { + val event = ThreeDS2Events.threeDS2Fingerprint( + subType = ThreeDS2Events.SubType.FINGERPRINT_COMPLETED, + result = result, ) - detailsChannel.trySend(actionComponentData) + analyticsManager?.trackEvent(event) } private fun makeRedirect(activity: Activity, action: RedirectAction) { @@ -364,35 +415,51 @@ internal class DefaultAdyen3DS2Delegate( adyenLog(AdyenLogLevel.DEBUG) { "makeRedirect - $url" } redirectHandler.launchUriRedirect(activity, url) } catch (e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } } + @OptIn(ExperimentalEncodingApi::class) @VisibleForTesting internal fun challengeShopper(activity: Activity, encodedChallengeToken: String) { adyenLog(AdyenLogLevel.DEBUG) { "challengeShopper" } if (currentTransaction == null) { - exceptionChannel.trySend( + emitError( Authentication3DS2Exception("Failed to make challenge, missing reference to initial transaction."), ) return } - val decodedChallengeToken = base64Encoder.decode(encodedChallengeToken) + val decodedChallengeToken = Base64.decode(encodedChallengeToken).toString(Charsets.UTF_8) val challengeTokenJson: JSONObject = try { JSONObject(decodedChallengeToken) } catch (e: JSONException) { - exceptionChannel.trySend(ComponentException("JSON parsing of FingerprintToken failed", e)) + emitError(ComponentException("JSON parsing of FingerprintToken failed", e)) return } + val challengeSentEvent = ThreeDS2Events.threeDS2Challenge( + subType = ThreeDS2Events.SubType.CHALLENGE_DATA_SENT, + ) + analyticsManager?.trackEvent(challengeSentEvent) + val challengeToken = ChallengeToken.SERIALIZER.deserialize(challengeTokenJson) val challengeParameters = createChallengeParameters(challengeToken) try { - currentTransaction?.doChallenge(activity, challengeParameters, this, DEFAULT_CHALLENGE_TIME_OUT) + currentTransaction?.doChallenge( + activity, + challengeParameters, + SharedChallengeStatusHandler, + DEFAULT_CHALLENGE_TIME_OUT, + ) + + val challengeDisplayedEvent = ThreeDS2Events.threeDS2Challenge( + subType = ThreeDS2Events.SubType.CHALLENGE_DISPLAYED, + ) + analyticsManager?.trackEvent(challengeDisplayedEvent) } catch (e: InvalidInputException) { - exceptionChannel.trySend(CheckoutException("Error starting challenge", e)) + emitError(CheckoutException("Error starting challenge", e)) } } @@ -415,7 +482,7 @@ internal class DefaultAdyen3DS2Delegate( val parsedResult = redirectHandler.parseRedirectResult(intent.data) emitDetails(parsedResult) } catch (e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } } @@ -425,7 +492,7 @@ internal class DefaultAdyen3DS2Delegate( val details = makeDetails(transactionStatus) emitDetails(details) } catch (e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } finally { closeTransaction() } @@ -433,7 +500,7 @@ internal class DefaultAdyen3DS2Delegate( private fun onCancelled() { adyenLog(AdyenLogLevel.DEBUG) { "challenge cancelled" } - exceptionChannel.trySend(Cancelled3DS2Exception("Challenge canceled.")) + emitError(Cancelled3DS2Exception("Challenge canceled.")) closeTransaction() } @@ -443,7 +510,7 @@ internal class DefaultAdyen3DS2Delegate( val details = makeDetails(result.transactionStatus, result.additionalDetails) emitDetails(details) } catch (e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } finally { closeTransaction() } @@ -455,7 +522,7 @@ internal class DefaultAdyen3DS2Delegate( val details = makeDetails(result.transactionStatus, result.additionalDetails) emitDetails(details) } catch (e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } finally { closeTransaction() } @@ -463,13 +530,49 @@ internal class DefaultAdyen3DS2Delegate( override fun onCompletion(result: ChallengeResult) { when (result) { - is ChallengeResult.Cancelled -> onCancelled() - is ChallengeResult.Completed -> onCompleted(result.transactionStatus) - is ChallengeResult.Error -> onError(result) - is ChallengeResult.Timeout -> onTimeout(result) + is ChallengeResult.Cancelled -> { + trackChallengeCompletedEvent(ThreeDS2Events.Result.CANCELLED) + onCancelled() + } + + is ChallengeResult.Completed -> { + trackChallengeCompletedEvent(ThreeDS2Events.Result.COMPLETED) + onCompleted(result.transactionStatus) + } + + is ChallengeResult.Error -> { + trackChallengeCompletedEvent(ThreeDS2Events.Result.ERROR) + onError(result) + } + + is ChallengeResult.Timeout -> { + trackChallengeCompletedEvent(ThreeDS2Events.Result.TIMEOUT) + onTimeout(result) + } } } + private fun trackChallengeCompletedEvent(result: ThreeDS2Events.Result) { + val event = ThreeDS2Events.threeDS2Challenge( + subType = ThreeDS2Events.SubType.CHALLENGE_COMPLETED, + result = result, + ) + analyticsManager?.trackEvent(event) + } + + private fun trackFingerprintActionEvent(action: Action) = trackActionEvent(action, ANALYTICS_MESSAGE_FINGERPRINT) + + private fun trackChallengeActionEvent(action: Action) = trackActionEvent(action, ANALYTICS_MESSAGE_CHALLENGE) + + private fun trackActionEvent(action: Action, message: String) { + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + message = message, + ) + analyticsManager?.trackEvent(event) + } + private fun closeTransaction() { currentTransaction?.close() currentTransaction = null @@ -479,29 +582,37 @@ internal class DefaultAdyen3DS2Delegate( private fun cleanUp3DS2() { @Suppress("SwallowedException") try { - ThreeDS2Service.INSTANCE.cleanup(application) + threeDS2Service.cleanup(application) } catch (e: SDKNotInitializedException) { // Safe to ignore } } override fun onError(e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } override fun setOnRedirectListener(listener: () -> Unit) { redirectHandler.setOnRedirectListener(listener) } - override fun onCleared() { - removeObserver() - _coroutineScope = null - redirectHandler.removeOnRedirectListener() + private fun emitError(e: CheckoutException) { + exceptionChannel.trySend(e) + clearState() + } + + private fun emitDetails(details: JSONObject) { + val actionComponentData = ActionComponentData( + details = details, + paymentData = paymentDataRepository.paymentData, + ) + detailsChannel.trySend(actionComponentData) + clearState() } private fun makeDetails(transactionStatus: String, errorDetails: String? = null): JSONObject { // Check whether authorizationToken was set and create the corresponding details object - val token = authorizationToken + val token = (action as? Threeds2Action)?.authorisationToken return if (token == null) { adyen3DS2Serializer.createChallengeDetails( transactionStatus = transactionStatus, @@ -516,9 +627,28 @@ internal class DefaultAdyen3DS2Delegate( } } + private fun clearState() { + action = null + } + + override fun onCleared() { + removeObserver() + SharedChallengeStatusHandler.onCompletionListener = null + _coroutineScope = null + redirectHandler.removeOnRedirectListener() + } + companion object { - private const val AUTHORIZATION_TOKEN_KEY = "authorization_token" + @VisibleForTesting + internal const val ANALYTICS_MESSAGE_FINGERPRINT = "Fingerprint action was handled by the SDK" + + @VisibleForTesting + internal const val ANALYTICS_MESSAGE_CHALLENGE = "Challenge action was handled by the SDK" + private const val DEFAULT_CHALLENGE_TIME_OUT = 10 private const val PROTOCOL_VERSION_2_1_0 = "2.1.0" + + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" } } diff --git a/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/SharedChallengeStatusHandler.kt b/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/SharedChallengeStatusHandler.kt new file mode 100644 index 0000000000..da08169cba --- /dev/null +++ b/3ds2/src/main/java/com/adyen/checkout/adyen3ds2/internal/ui/SharedChallengeStatusHandler.kt @@ -0,0 +1,40 @@ +/* + * 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 21/5/2024. + */ + +package com.adyen.checkout.adyen3ds2.internal.ui + +import androidx.annotation.VisibleForTesting +import com.adyen.threeds2.ChallengeResult +import com.adyen.threeds2.ChallengeStatusHandler + +internal object SharedChallengeStatusHandler : ChallengeStatusHandler { + + var onCompletionListener: ChallengeStatusHandler? = null + set(value) { + field = value + queuedResult?.let { onCompletion(it) } + } + + private var queuedResult: ChallengeResult? = null + + override fun onCompletion(result: ChallengeResult) { + onCompletionListener + ?.onCompletion(result) + ?.also { + queuedResult = null + } ?: run { + queuedResult = result + } + } + + @VisibleForTesting + internal fun reset() { + onCompletionListener = null + queuedResult = null + } +} diff --git a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/Adyen3DS2ComponentTest.kt b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/Adyen3DS2ComponentTest.kt index daa9052fee..aebc8b5ca7 100644 --- a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/Adyen3DS2ComponentTest.kt +++ b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/Adyen3DS2ComponentTest.kt @@ -12,7 +12,6 @@ import android.app.Activity import android.content.Intent import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.viewModelScope -import app.cash.turbine.test import com.adyen.checkout.adyen3ds2.internal.ui.Adyen3DS2ComponentViewType import com.adyen.checkout.adyen3ds2.internal.ui.Adyen3DS2Delegate import com.adyen.checkout.components.core.action.Threeds2Action @@ -21,6 +20,7 @@ import com.adyen.checkout.components.core.internal.ActionComponentEventHandler import com.adyen.checkout.test.LoggingExtension import com.adyen.checkout.test.TestDispatcherExtension import com.adyen.checkout.test.extensions.invokeOnCleared +import com.adyen.checkout.test.extensions.test import com.adyen.checkout.ui.core.internal.test.TestComponentViewType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -80,10 +80,9 @@ internal class Adyen3DS2ComponentTest( @Test fun `when component is initialized then view flow should match delegate view flow`() = runTest { - component.viewFlow.test { - assertEquals(Adyen3DS2ComponentViewType, awaitItem()) - expectNoEvents() - } + val viewFlow = component.viewFlow.test(testScheduler) + + assertEquals(Adyen3DS2ComponentViewType, viewFlow.latestValue) } @Test @@ -91,15 +90,12 @@ internal class Adyen3DS2ComponentTest( val delegateViewFlow = MutableStateFlow(TestComponentViewType.VIEW_TYPE_1) whenever(adyen3DS2Delegate.viewFlow) doReturn delegateViewFlow component = Adyen3DS2Component(adyen3DS2Delegate, actionComponentEventHandler) + val viewFlow = component.viewFlow.test(testScheduler) - component.viewFlow.test { - assertEquals(TestComponentViewType.VIEW_TYPE_1, awaitItem()) - - delegateViewFlow.emit(TestComponentViewType.VIEW_TYPE_2) - assertEquals(TestComponentViewType.VIEW_TYPE_2, awaitItem()) + assertEquals(TestComponentViewType.VIEW_TYPE_1, viewFlow.values[0]) - expectNoEvents() - } + delegateViewFlow.emit(TestComponentViewType.VIEW_TYPE_2) + assertEquals(TestComponentViewType.VIEW_TYPE_2, viewFlow.values[1]) } @Test diff --git a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2DelegateTest.kt b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2DelegateTest.kt index 5b57a41917..f6d3a906d8 100644 --- a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2DelegateTest.kt +++ b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/DefaultAdyen3DS2DelegateTest.kt @@ -6,15 +6,18 @@ * Created by oscars on 24/8/2022. */ +@file:OptIn(ExperimentalEncodingApi::class) + package com.adyen.checkout.adyen3ds2.internal.ui import android.app.Activity import android.app.Application +import android.content.Context import android.content.Intent import androidx.lifecycle.SavedStateHandle -import app.cash.turbine.test import com.adyen.checkout.adyen3ds2.Authentication3DS2Exception import com.adyen.checkout.adyen3ds2.Cancelled3DS2Exception +import com.adyen.checkout.adyen3ds2.internal.analytics.ThreeDS2Events import com.adyen.checkout.adyen3ds2.internal.data.api.SubmitFingerprintRepository import com.adyen.checkout.adyen3ds2.internal.data.model.Adyen3DS2Serializer import com.adyen.checkout.adyen3ds2.internal.data.model.SubmitFingerprintResult @@ -27,22 +30,30 @@ import com.adyen.checkout.components.core.action.Threeds2ChallengeAction import com.adyen.checkout.components.core.action.Threeds2FingerprintAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper -import com.adyen.checkout.components.core.internal.util.JavaBase64Encoder import com.adyen.checkout.core.Environment import com.adyen.checkout.core.exception.ComponentException +import com.adyen.checkout.test.LoggingExtension import com.adyen.checkout.test.TestDispatcherExtension +import com.adyen.checkout.test.extensions.test import com.adyen.checkout.ui.core.internal.test.TestRedirectHandler import com.adyen.threeds2.AuthenticationRequestParameters import com.adyen.threeds2.ChallengeResult import com.adyen.threeds2.ChallengeStatusHandler import com.adyen.threeds2.ChallengeStatusReceiver +import com.adyen.threeds2.InitializeResult import com.adyen.threeds2.ProgressDialog import com.adyen.threeds2.ThreeDS2Service import com.adyen.threeds2.Transaction +import com.adyen.threeds2.TransactionResult +import com.adyen.threeds2.Warning +import com.adyen.threeds2.customization.UiCustomization import com.adyen.threeds2.exception.InvalidInputException import com.adyen.threeds2.exception.SDKRuntimeException import com.adyen.threeds2.parameters.ChallengeParameters +import com.adyen.threeds2.parameters.ConfigParameters import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -50,44 +61,59 @@ import kotlinx.coroutines.test.runTest import org.json.JSONException import org.json.JSONObject import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.io.IOException import java.util.Locale +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(MockitoExtension::class, TestDispatcherExtension::class) +@ExtendWith(LoggingExtension::class, MockitoExtension::class, TestDispatcherExtension::class) internal class DefaultAdyen3DS2DelegateTest( @Mock private val submitFingerprintRepository: SubmitFingerprintRepository, - @Mock private val adyen3DS2Serializer: Adyen3DS2Serializer, - @Mock private val threeDS2Service: ThreeDS2Service, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var redirectHandler: TestRedirectHandler private lateinit var delegate: DefaultAdyen3DS2Delegate private lateinit var paymentDataRepository: PaymentDataRepository - private val base64Encoder = JavaBase64Encoder() + private val threeDS2Service: TestThreeDS2Service = TestThreeDS2Service() @BeforeEach fun setup() { + analyticsManager = TestAnalyticsManager() redirectHandler = TestRedirectHandler() paymentDataRepository = PaymentDataRepository(SavedStateHandle()) + delegate = createDelegate() + } + + private fun createDelegate( + adyen3DS2Serializer: Adyen3DS2Serializer = Adyen3DS2Serializer(), + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): DefaultAdyen3DS2Delegate { val configuration = CheckoutConfiguration(Environment.TEST, TEST_CLIENT_KEY) - delegate = DefaultAdyen3DS2Delegate( + return DefaultAdyen3DS2Delegate( observerRepository = ActionObserverRepository(), - savedStateHandle = SavedStateHandle(), + savedStateHandle = savedStateHandle, componentParams = Adyen3DS2ComponentParamsMapper(CommonComponentParamsMapper()) .mapToParams(configuration, Locale.US, null, null) // Set it to null to avoid a crash in 3DS2 library (they use Android APIs) @@ -98,8 +124,8 @@ internal class DefaultAdyen3DS2DelegateTest( redirectHandler = redirectHandler, threeDS2Service = threeDS2Service, coroutineDispatcher = UnconfinedTestDispatcher(), - base64Encoder = base64Encoder, application = Application(), + analyticsManager = analyticsManager, ) } @@ -110,45 +136,41 @@ internal class DefaultAdyen3DS2DelegateTest( @Test fun `Threeds2FingerprintAction and token is null, then an exception is thrown`() = runTest { delegate.initialize(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction(Threeds2FingerprintAction(token = null), Activity()) + delegate.handleAction(Threeds2FingerprintAction(token = null), Activity()) - assertTrue(awaitItem() is ComponentException) - } + assertTrue(exceptionFlow.latestValue is ComponentException) } @Test fun `Threeds2ChallengeAction and token is null, then an exception is thrown`() = runTest { delegate.initialize(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction(Threeds2ChallengeAction(token = null), Activity()) + delegate.handleAction(Threeds2ChallengeAction(token = null), Activity()) - assertTrue(awaitItem() is ComponentException) - } + assertTrue(exceptionFlow.latestValue is ComponentException) } @Test fun `Threeds2Action and token is null, then an exception is thrown`() = runTest { delegate.initialize(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction(Threeds2Action(token = null), Activity()) + delegate.handleAction(Threeds2Action(token = null), Activity()) - assertTrue(awaitItem() is ComponentException) - } + assertTrue(exceptionFlow.latestValue is ComponentException) } @Test fun `Threeds2Action and sub type is null, then an exception is thrown`() = runTest { delegate.initialize(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction(Threeds2Action(token = "sometoken", subtype = null), Activity()) + delegate.handleAction(Threeds2Action(token = "sometoken", subtype = null), Activity()) - assertTrue(awaitItem() is ComponentException) - } + assertTrue(exceptionFlow.latestValue is ComponentException) } } @@ -159,115 +181,113 @@ internal class DefaultAdyen3DS2DelegateTest( @Test fun `fingerprint is malformed, then an exception is thrown`() = runTest { delegate.initialize(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - val encodedJson = base64Encoder.encode("{incorrectJson}") - delegate.identifyShopper(Activity(), encodedJson, false) + val encodedJson = Base64.encode("{incorrectJson}".toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) - assertTrue(awaitItem() is ComponentException) - } + assertTrue(exceptionFlow.latestValue is ComponentException) } @Test fun `3ds2 sdk throws an exception while initializing, then an exception emitted`() = runTest { - val error = SDKRuntimeException("test", "test", null) - whenever(threeDS2Service.initialize(any(), any(), anyOrNull(), anyOrNull())) doAnswer { - throw error - } - delegate.initialize(this) + val error = InvalidInputException("test", null) + threeDS2Service.initializeError = error + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - val encodedJson = base64Encoder.encode( - """ - { - "directoryServerId":"id", - "directoryServerPublicKey":"key" - } - """.trimIndent(), - ) - delegate.identifyShopper(Activity(), encodedJson, false) - - assertEquals(error, awaitItem().cause) - } + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) + + assertEquals(error, exceptionFlow.latestValue.cause) + } + + @Test + fun `3ds2 sdk returns an initialization error, then details are emitted`() = runTest { + val transStatus = "X" + val additionalDetails = "mockAdditionalDetails" + threeDS2Service.initializeResult = InitializeResult.Failure(transStatus, additionalDetails) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) + + // We don't care about the encoded value in this test, we just want to know if details are there + assertNotNull(detailsFlow.latestValue.details) } @Test fun `creating 3ds2 transaction fails, then an exception emitted`() = runTest { val error = SDKRuntimeException("test", "test", null) - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doAnswer { - throw error - } - delegate.initialize(this) + threeDS2Service.createTransactionError = error + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) - delegate.identifyShopper(Activity(), encodedJson, false) + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) - assertEquals(error, awaitItem().cause) - } + assertEquals(error, exceptionFlow.latestValue.cause) + } + + @Test + fun `creating 3ds2 transaction return transaction error, then details are emitted`() = runTest { + threeDS2Service.transactionResult = TransactionResult.Failure("X", "mockDetails") + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) + + // We don't care about the encoded value in this test, we just want to know if details are there + assertNotNull(detailsFlow.latestValue.details) } @Test fun `transaction parameters are null, then an exception emitted`() = runTest { - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doReturn TestTransaction() delegate.initialize(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) - delegate.identifyShopper(Activity(), encodedJson, false) + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) - assertTrue(awaitItem() is ComponentException) - } + assertEquals("Failed to retrieve 3DS2 authentication parameters", exceptionFlow.latestValue.message) } @Test fun `fingerprint is submitted automatically and result is completed, then details are emitted`() = runTest { - val authReqParams = TestAuthenticationRequestParameters( - deviceData = "deviceData", - sdkTransactionID = "sdkTransactionID", - sdkAppID = "sdkAppID", - sdkReferenceNumber = "sdkReferenceNumber", - sdkEphemeralPublicKey = "{}", - messageVersion = "messageVersion", - ) - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doReturn TestTransaction(authReqParams) + threeDS2Service.transactionResult = + TransactionResult.Success(TestTransaction(getAuthenticationRequestParams())) val submitFingerprintResult = SubmitFingerprintResult.Completed(JSONObject()) whenever(submitFingerprintRepository.submitFingerprint(any(), any(), anyOrNull())) doReturn Result.success(submitFingerprintResult) + val detailsFlow = delegate.detailsFlow.test(testScheduler) delegate.initialize(this) - delegate.detailsFlow.test { - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) - delegate.identifyShopper(Activity(), encodedJson, true) + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, true) - val expected = ActionComponentData( - paymentData = null, - details = submitFingerprintResult.details, - ) - assertEquals(expected, awaitItem()) - } + val expected = ActionComponentData( + paymentData = null, + details = submitFingerprintResult.details, + ) + assertEquals(expected, detailsFlow.latestValue) } @Test fun `fingerprint is submitted automatically and result is redirect, then redirect should be handled`() = runTest { - val authReqParams = TestAuthenticationRequestParameters( - deviceData = "deviceData", - sdkTransactionID = "sdkTransactionID", - sdkAppID = "sdkAppID", - sdkReferenceNumber = "sdkReferenceNumber", - sdkEphemeralPublicKey = "{}", - messageVersion = "messageVersion", - ) - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doReturn TestTransaction(authReqParams) + threeDS2Service.transactionResult = + TransactionResult.Success(TestTransaction(getAuthenticationRequestParams())) val submitFingerprintResult = SubmitFingerprintResult.Redirect(RedirectAction()) whenever(submitFingerprintRepository.submitFingerprint(any(), any(), anyOrNull())) doReturn Result.success(submitFingerprintResult) delegate.initialize(this) - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) delegate.identifyShopper(Activity(), encodedJson, true) redirectHandler.assertLaunchRedirectCalled() @@ -275,54 +295,31 @@ internal class DefaultAdyen3DS2DelegateTest( @Test fun `fingerprint is submitted automatically and it fails, then an exception is emitted`() = runTest { - val authReqParams = TestAuthenticationRequestParameters( - deviceData = "deviceData", - sdkTransactionID = "sdkTransactionID", - sdkAppID = "sdkAppID", - sdkReferenceNumber = "sdkReferenceNumber", - sdkEphemeralPublicKey = "{}", - messageVersion = "messageVersion", - ) - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doReturn TestTransaction(authReqParams) + threeDS2Service.transactionResult = + TransactionResult.Success(TestTransaction(getAuthenticationRequestParams())) val error = IOException("test") whenever(submitFingerprintRepository.submitFingerprint(any(), any(), anyOrNull())) doReturn Result.failure(error) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) delegate.initialize(this) - delegate.exceptionFlow.test { - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) - delegate.identifyShopper(Activity(), encodedJson, true) - assertEquals(error, awaitItem().cause) - } + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, true) + assertEquals(error, exceptionFlow.latestValue.cause) } @Test fun `fingerprint is not submitted automatically, then details are emitted`() = runTest { - val authReqParams = TestAuthenticationRequestParameters( - deviceData = "deviceData", - sdkTransactionID = "sdkTransactionID", - sdkAppID = "sdkAppID", - sdkReferenceNumber = "sdkReferenceNumber", - sdkEphemeralPublicKey = "{}", - messageVersion = "messageVersion", - ) - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doReturn TestTransaction(authReqParams) - val fingerprintDetails = JSONObject("{\"finger\":\"print\"}") - whenever(adyen3DS2Serializer.createFingerprintDetails(any())) doReturn fingerprintDetails - + threeDS2Service.transactionResult = + TransactionResult.Success(TestTransaction(getAuthenticationRequestParams())) + val detailsFlow = delegate.detailsFlow.test(testScheduler) delegate.initialize(this) - delegate.detailsFlow.test { - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) - delegate.identifyShopper(Activity(), encodedJson, false) + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) - val expected = ActionComponentData( - paymentData = null, - details = fingerprintDetails, - ) - assertEquals(expected, awaitItem()) - } + assertNotNull(detailsFlow.latestValue.details) } } @@ -332,65 +329,57 @@ internal class DefaultAdyen3DS2DelegateTest( @Test fun `transaction is null, then an exception is emitted`() = runTest { - delegate.exceptionFlow.test { - delegate.challengeShopper(Activity(), "token") + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + delegate.challengeShopper(mock(), "token") - assertTrue(awaitItem() is Authentication3DS2Exception) - } + assertTrue(exceptionFlow.latestValue is Authentication3DS2Exception) } @Test fun `token can't be decoded, then an exception is emitted`() = runTest { - initializeTransaction(this) + initializeChallengeTransaction(this) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.challengeShopper(Activity(), base64Encoder.encode("token")) + delegate.challengeShopper(Activity(), Base64.encode("token".toByteArray())) - assertTrue(awaitItem().cause is JSONException) - } + assertTrue(exceptionFlow.latestValue.cause is JSONException) } @Test fun `everything is good, then challenge should be executed`() = runTest { - val transaction = initializeTransaction(this) + val transaction = initializeChallengeTransaction(this) - delegate.challengeShopper(Activity(), base64Encoder.encode("{}")) + // We need to set the messageVersion to workaround an error in the 3DS2 SDK + delegate.challengeShopper(Activity(), Base64.encode("{\"messageVersion\":\"2.1.0\"}".toByteArray())) transaction.assertDoChallengeCalled() } @Test fun `challenge fails, then an exception is emitted`() = runTest { - initializeTransaction(this).apply { + initializeChallengeTransaction(this).apply { shouldThrowError = true } - delegate.exceptionFlow.test { - delegate.challengeShopper(Activity(), base64Encoder.encode("{}")) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - assertTrue(awaitItem().cause is InvalidInputException) - } + // We need to set the messageVersion to workaround an error in the 3DS2 SDK + delegate.challengeShopper(Activity(), Base64.encode("{\"messageVersion\":\"2.1.0\"}".toByteArray())) + + assertTrue(exceptionFlow.latestValue.cause is InvalidInputException) } + } - private fun initializeTransaction(scope: CoroutineScope): TestTransaction { - val authReqParams = TestAuthenticationRequestParameters( - deviceData = "deviceData", - sdkTransactionID = "sdkTransactionID", - sdkAppID = "sdkAppID", - sdkReferenceNumber = "sdkReferenceNumber", - sdkEphemeralPublicKey = "{}", - messageVersion = "messageVersion", - ) - val transaction = TestTransaction(authReqParams) - whenever(threeDS2Service.createTransaction(anyOrNull(), any())) doReturn transaction + private fun initializeChallengeTransaction(scope: CoroutineScope): TestTransaction { + val transaction = TestTransaction(getAuthenticationRequestParams()) + threeDS2Service.transactionResult = TransactionResult.Success(transaction) - delegate.initialize(scope) + delegate.initialize(scope) - val encodedJson = base64Encoder.encode(TEST_FINGERPRINT_TOKEN) - delegate.identifyShopper(Activity(), encodedJson, false) + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.identifyShopper(Activity(), encodedJson, false) - return transaction - } + return transaction } @Nested @@ -399,27 +388,26 @@ internal class DefaultAdyen3DS2DelegateTest( @Test fun `result is parsed, then details are emitted`() = runTest { - delegate.detailsFlow.test { - delegate.handleIntent(Intent()) - - val expected = ActionComponentData( - paymentData = null, - details = TestRedirectHandler.REDIRECT_RESULT, - ) - assertEquals(expected, awaitItem()) - } + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + delegate.handleIntent(Intent()) + + val expected = ActionComponentData( + paymentData = null, + details = TestRedirectHandler.REDIRECT_RESULT, + ) + assertEquals(expected, detailsFlow.latestValue) } @Test fun `parsing fails, then an exception is emitted`() = runTest { val error = ComponentException("yes") redirectHandler.exception = error + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleIntent(Intent()) + delegate.handleIntent(Intent()) - assertEquals(error, awaitItem()) - } + assertEquals(error, exceptionFlow.latestValue) } } @@ -429,151 +417,273 @@ internal class DefaultAdyen3DS2DelegateTest( @Test fun `completed, then details are emitted`() = runTest { - val details = JSONObject("{}") - whenever( - adyen3DS2Serializer.createChallengeDetails( + val details = + JSONObject("{\"threeds2.challengeResult\":\"eyJ0cmFuc1N0YXR1cyI6InRyYW5zYWN0aW9uU3RhdHVzIn0=\"}") + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + delegate.onCompletion( + result = ChallengeResult.Completed( transactionStatus = "transactionStatus", ), - ) doReturn details - - delegate.detailsFlow.test { - delegate.onCompletion( - result = ChallengeResult.Completed( - transactionStatus = "transactionStatus", - ), - ) - - val expected = ActionComponentData( - paymentData = null, - details = details, - ) - assertEquals(expected, awaitItem()) - } + ) + + val expected = ActionComponentData( + paymentData = null, + details = details, + ) + assertEquals(expected.details.toString(), detailsFlow.latestValue.details.toString()) } @Test fun `completed and creating details fails, then an error is emitted`() = runTest { val error = ComponentException("test") - whenever( - adyen3DS2Serializer.createChallengeDetails( + // We have to mock the serializer in order to throw an exception + val adyen3DS2Serializer: Adyen3DS2Serializer = mock() + whenever(adyen3DS2Serializer.createChallengeDetails(transactionStatus = "transactionStatus")) doAnswer { + throw error + } + delegate = createDelegate(adyen3DS2Serializer) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.onCompletion( + result = ChallengeResult.Completed( transactionStatus = "transactionStatus", ), - ) doAnswer { throw error } - - delegate.exceptionFlow.test { - delegate.onCompletion( - result = ChallengeResult.Completed( - transactionStatus = "transactionStatus", - ), - ) + ) - assertEquals(error, awaitItem()) - } + assertEquals(error, exceptionFlow.latestValue) } @Test fun `cancelled, then an error is emitted`() = runTest { - delegate.exceptionFlow.test { - delegate.onCompletion( - result = ChallengeResult.Cancelled( - transactionStatus = "transactionStatus", - additionalDetails = "additionalDetails", - ), - ) - - assertTrue(awaitItem() is Cancelled3DS2Exception) - } + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.onCompletion( + result = ChallengeResult.Cancelled( + transactionStatus = "transactionStatus", + additionalDetails = "additionalDetails", + ), + ) + + assertTrue(exceptionFlow.latestValue is Cancelled3DS2Exception) } @Test - fun `timedout, then an error is emitted`() = runTest { - val details = JSONObject("{}") - whenever( - adyen3DS2Serializer.createChallengeDetails( + fun `timedout, then details are emitted`() = runTest { + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + delegate.onCompletion( + result = ChallengeResult.Timeout( transactionStatus = "transactionStatus", - errorDetails = "additionalDetails", + additionalDetails = "additionalDetails", ), - ) doReturn details - - delegate.detailsFlow.test { - delegate.onCompletion( - result = ChallengeResult.Timeout( - transactionStatus = "transactionStatus", - additionalDetails = "additionalDetails", - ), - ) - - val expected = ActionComponentData( - paymentData = null, - details = details, - ) - assertEquals(expected, awaitItem()) - } + ) + + assertNotNull(detailsFlow.latestValue.details) } @Test - fun `error, then an error is emitted`() = runTest { - val details = JSONObject("{}") - whenever( - adyen3DS2Serializer.createChallengeDetails( + fun `error, then details are emitted`() = runTest { + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + delegate.onCompletion( + result = ChallengeResult.Error( transactionStatus = "transactionStatus", - errorDetails = "additionalDetails", + additionalDetails = "additionalDetails", ), - ) doReturn details - - delegate.detailsFlow.test { - delegate.onCompletion( - result = ChallengeResult.Error( - transactionStatus = "transactionStatus", - additionalDetails = "additionalDetails", - ), - ) - - val expected = ActionComponentData( - paymentData = null, - details = details, - ) - assertEquals(expected, awaitItem()) - } + ) + + assertNotNull(detailsFlow.latestValue.details) } } - private class TestTransaction( - val authReqParameters: AuthenticationRequestParameters? = null - ) : Transaction { + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called for Threeds2FingerprintAction, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = Threeds2FingerprintAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + token = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()), + ) + + delegate.handleAction(action, Activity()) - var shouldThrowError: Boolean = false + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + message = DefaultAdyen3DS2Delegate.ANALYTICS_MESSAGE_FINGERPRINT, + ) + analyticsManager.assertHasEventEquals(expectedEvent) + } + + @Test + fun `when handleAction is called for Threeds2ChallengeAction, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = Threeds2ChallengeAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + token = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()), + ) + + delegate.handleAction(action, Activity()) - private var timesDoChallengeCalled = 0 + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + message = DefaultAdyen3DS2Delegate.ANALYTICS_MESSAGE_CHALLENGE, + ) + analyticsManager.assertHasEventEquals(expectedEvent) + } - override fun getAuthenticationRequestParameters(): AuthenticationRequestParameters? = authReqParameters + @Test + fun `when handleAction is called for Threeds2Action and subType is fingerprint, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = Threeds2Action( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + subtype = Threeds2Action.SubType.FINGERPRINT.value, + token = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()), + ) - @Suppress("OVERRIDE_DEPRECATION", "deprecation") - override fun doChallenge(p0: Activity?, p1: ChallengeParameters?, p2: ChallengeStatusReceiver?, p3: Int) = Unit + delegate.handleAction(action, Activity()) - override fun doChallenge( - currentActivity: Activity?, - challengeParameters: ChallengeParameters?, - challengeStatusHandler: ChallengeStatusHandler?, - timeOut: Int - ) { - timesDoChallengeCalled++ - if (shouldThrowError) { - throw InvalidInputException("test", null) - } + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + message = DefaultAdyen3DS2Delegate.ANALYTICS_MESSAGE_FINGERPRINT, + ) + analyticsManager.assertHasEventEquals(expectedEvent) } - override fun getProgressView(p0: Activity?): ProgressDialog { - error("This method should not be used") + @Test + fun `when handleAction is called for Threeds2Action and subType is challenge, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = Threeds2Action( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + subtype = Threeds2Action.SubType.CHALLENGE.value, + token = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()), + ) + + delegate.handleAction(action, Activity()) + + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + message = DefaultAdyen3DS2Delegate.ANALYTICS_MESSAGE_CHALLENGE, + ) + analyticsManager.assertHasEventEquals(expectedEvent) } - override fun close() = Unit + @Test + fun `when identifyShopper is called, then event is tracked`() = runTest { + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + delegate.initialize(this) + + delegate.identifyShopper(Activity(), encodedJson, true) - fun assertDoChallengeCalled() { - assert(timesDoChallengeCalled > 0) + val expectedEvent = ThreeDS2Events.threeDS2Fingerprint( + subType = ThreeDS2Events.SubType.FINGERPRINT_DATA_SENT, + ) + analyticsManager.assertLastEventEquals(expectedEvent) } + + @ParameterizedTest + @MethodSource("com.adyen.checkout.adyen3ds2.internal.ui.DefaultAdyen3DS2DelegateTest#fingerprintResult") + fun `when fingerprint result is returned, then event is tracked`( + fingerprintResult: SubmitFingerprintResult, + analyticsResult: ThreeDS2Events.Result + ) = runTest { + val encodedJson = Base64.encode(TEST_FINGERPRINT_TOKEN.toByteArray()) + threeDS2Service.transactionResult = + TransactionResult.Success(TestTransaction(getAuthenticationRequestParams())) + whenever(submitFingerprintRepository.submitFingerprint(any(), any(), anyOrNull())) doReturn + Result.success(fingerprintResult) + delegate.initialize(this) + + delegate.identifyShopper(Activity(), encodedJson, true) + + val expectedEvent = ThreeDS2Events.threeDS2Fingerprint( + subType = ThreeDS2Events.SubType.FINGERPRINT_COMPLETED, + result = analyticsResult, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when challengeShopper is called, then event is tracked`() = runTest { + initializeChallengeTransaction(this) + // We need to set the messageVersion to workaround an error in the 3DS2 SDK + delegate.challengeShopper(Activity(), Base64.encode("{\"messageVersion\":\"2.1.0\"}".toByteArray())) + + val expectedDataSentEvent = ThreeDS2Events.threeDS2Challenge( + subType = ThreeDS2Events.SubType.CHALLENGE_DATA_SENT, + ) + analyticsManager.assertHasEventEquals(expectedDataSentEvent) + + val expectedDisplayedEvent = ThreeDS2Events.threeDS2Challenge( + subType = ThreeDS2Events.SubType.CHALLENGE_DISPLAYED, + ) + analyticsManager.assertLastEventEquals(expectedDisplayedEvent) + } + + @ParameterizedTest + @MethodSource("com.adyen.checkout.adyen3ds2.internal.ui.DefaultAdyen3DS2DelegateTest#challengeResult") + fun `when challenge result is returned, then event is tracked`( + challengeResult: ChallengeResult, + analyticsResult: ThreeDS2Events.Result + ) = runTest { + delegate.onCompletion( + result = challengeResult, + ) + + val expectedEvent = ThreeDS2Events.threeDS2Challenge( + subType = ThreeDS2Events.SubType.CHALLENGE_COMPLETED, + result = analyticsResult, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + } + + @Test + fun `when details are emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultAdyen3DS2Delegate.ACTION_KEY, + Threeds2Action(paymentMethodType = "test", paymentData = "paymentData"), + ) + } + delegate = createDelegate(savedStateHandle = savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.onCompletion(ChallengeResult.Completed("test")) + + assertNull(savedStateHandle[DefaultAdyen3DS2Delegate.ACTION_KEY]) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle = savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.handleAction(Threeds2Action(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + + assertNull(savedStateHandle[DefaultAdyen3DS2Delegate.ACTION_KEY]) } + private fun getAuthenticationRequestParams() = TestAuthenticationRequestParameters( + deviceData = "deviceData", + sdkTransactionID = "sdkTransactionID", + sdkAppID = "sdkAppID", + sdkReferenceNumber = "sdkReferenceNumber", + sdkEphemeralPublicKey = "{}", + messageVersion = "messageVersion", + ) + private class TestAuthenticationRequestParameters( private val deviceData: String? = null, private val sdkTransactionID: String? = null, @@ -598,13 +708,123 @@ internal class DefaultAdyen3DS2DelegateTest( companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_ACTION_TYPE = "TEST_ACTION_TYPE" private val TEST_FINGERPRINT_TOKEN = """ { "directoryServerId":"id", "directoryServerPublicKey":"key", + "directoryServerRootCertificates":"certs", "threeDSMessageVersion":"2.1.0" } """.trimIndent() + + @JvmStatic + fun fingerprintResult() = listOf( + // fingerprintResult, analyticsResult + Arguments.arguments(SubmitFingerprintResult.Completed(JSONObject()), ThreeDS2Events.Result.COMPLETED), + Arguments.arguments(SubmitFingerprintResult.Redirect(RedirectAction()), ThreeDS2Events.Result.REDIRECT), + Arguments.arguments(SubmitFingerprintResult.Threeds2(Threeds2Action()), ThreeDS2Events.Result.THREEDS2), + ) + + @JvmStatic + fun challengeResult() = listOf( + // challengeResult, analyticsResult + Arguments.arguments( + ChallengeResult.Completed("transactionStatus"), + ThreeDS2Events.Result.COMPLETED, + ), + Arguments.arguments( + ChallengeResult.Cancelled("transactionStatus", "additionalDetails"), + ThreeDS2Events.Result.CANCELLED, + ), + Arguments.arguments( + ChallengeResult.Error("transactionStatus", "additionalDetails"), + ThreeDS2Events.Result.ERROR, + ), + Arguments.arguments( + ChallengeResult.Timeout("transactionStatus", "additionalDetails"), + ThreeDS2Events.Result.TIMEOUT, + ), + ) + } +} + +private class TestThreeDS2Service : ThreeDS2Service { + + var initializeResult: InitializeResult = InitializeResult.Success + + var initializeError: Throwable? = null + + var transactionResult: TransactionResult = TransactionResult.Success(TestTransaction()) + + var createTransactionError: Throwable? = null + + private var didCallInitialize = false + + private var didCallCleanup = false + + override fun initialize(p0: Context?, p1: ConfigParameters?, p2: String?, p3: UiCustomization?): InitializeResult { + didCallInitialize = true + initializeError?.let { throw it } + return initializeResult + } + + override fun createTransaction(p0: String?, p1: String): TransactionResult { + createTransactionError?.let { throw it } + return transactionResult + } + + override fun cleanup(p0: Context?) { + didCallCleanup = true + } + + override fun getSDKVersion(): String { + return TEST_SDK_VERSION + } + + override fun getWarnings(): MutableList { + error("Should not be called.") + } + + companion object { + private const val TEST_SDK_VERSION = "1.2.3-test" + } +} + +private class TestTransaction( + val authReqParameters: AuthenticationRequestParameters? = null +) : Transaction { + + var shouldThrowError: Boolean = false + + private var timesDoChallengeCalled = 0 + + override fun getAuthenticationRequestParameters(): AuthenticationRequestParameters? = authReqParameters + + @Suppress("OVERRIDE_DEPRECATION", "deprecation") + override fun doChallenge(p0: Activity?, p1: ChallengeParameters?, p2: ChallengeStatusReceiver?, p3: Int) = Unit + + override fun doChallenge( + currentActivity: Activity?, + challengeParameters: ChallengeParameters?, + challengeStatusHandler: ChallengeStatusHandler?, + timeOut: Int + ) { + timesDoChallengeCalled++ + if (shouldThrowError) { + throw InvalidInputException("test", null) + } + } + + override fun getProgressView(p0: Activity?): ProgressDialog { + error("This method should not be used") + } + + override fun close() = Unit + + fun assertDoChallengeCalled() { + assert(timesDoChallengeCalled > 0) } } diff --git a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/SharedChallengeStatusHandlerTest.kt b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/SharedChallengeStatusHandlerTest.kt new file mode 100644 index 0000000000..73c6d5e0d2 --- /dev/null +++ b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/SharedChallengeStatusHandlerTest.kt @@ -0,0 +1,47 @@ +package com.adyen.checkout.adyen3ds2.internal.ui + +import com.adyen.threeds2.ChallengeResult +import com.adyen.threeds2.ChallengeStatusHandler +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class SharedChallengeStatusHandlerTest { + + @BeforeEach + fun beforeEach() { + SharedChallengeStatusHandler.reset() + } + + @Test + fun `when onCompletion is triggered, then listener is called`() { + val onCompletionListener = TestOnCompletionListener() + SharedChallengeStatusHandler.onCompletionListener = onCompletionListener + + SharedChallengeStatusHandler.onCompletion(ChallengeResult.Completed("test")) + + onCompletionListener.assertOnCompletionCalled() + } + + @Test + fun `when onCompletion is triggered and no listener is set, then onCompletion is queued until a listener is set`() { + val onCompletionListener = TestOnCompletionListener() + SharedChallengeStatusHandler.onCompletion(ChallengeResult.Completed("test")) + + SharedChallengeStatusHandler.onCompletionListener = onCompletionListener + + onCompletionListener.assertOnCompletionCalled() + } + + private class TestOnCompletionListener : ChallengeStatusHandler { + + private var timesOnCompletionCalled = 0 + + override fun onCompletion(result: ChallengeResult) { + timesOnCompletionCalled++ + } + + fun assertOnCompletionCalled() { + assert(timesOnCompletionCalled > 0) + } + } +} diff --git a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/model/Adyen3DS2ComponentParamsMapperTest.kt b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/model/Adyen3DS2ComponentParamsMapperTest.kt index 7b4bd50998..545255bf63 100644 --- a/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/model/Adyen3DS2ComponentParamsMapperTest.kt +++ b/3ds2/src/test/java/com/adyen/checkout/adyen3ds2/internal/ui/model/Adyen3DS2ComponentParamsMapperTest.kt @@ -91,7 +91,7 @@ internal class Adyen3DS2ComponentParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, amount = Amount( currency = "CAD", @@ -223,7 +223,7 @@ internal class Adyen3DS2ComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, amount: Amount? = null, uiCustomization: UiCustomization? = null, diff --git a/README.md b/README.md index 875e809922..90edf1f1a1 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.3.1" +implementation "com.adyen.checkout:drop-in-compose:5.4.0" ``` For the Credit Card component: ```groovy -implementation "com.adyen.checkout:card:5.3.1" -implementation "com.adyen.checkout:components-compose:5.3.1" +implementation "com.adyen.checkout:card:5.4.0" +implementation "com.adyen.checkout:components-compose:5.4.0" ``` ### Without Jetpack Compose For Drop-in: ```groovy -implementation "com.adyen.checkout:drop-in:5.3.1" +implementation "com.adyen.checkout:drop-in:5.4.0" ``` For the Credit Card component: ```groovy -implementation "com.adyen.checkout:card:5.3.1" +implementation "com.adyen.checkout:card:5.4.0" ``` The library is available on [Maven Central][mavenRepo]. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3574118366..fbb0bf6778 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,25 +1,52 @@ [//]: # (This file will be used for the release notes on GitHub when publishing.) [//]: # (Types of changes: `Breaking changes` `New` `Added` `Improved` `Changed` `Deprecated` `Removed` `Fixed`) [//]: # (Example:) -[//]: # (## Added) +[//]: # (## New) [//]: # ( - New payment method) [//]: # (## Changed) [//]: # ( - DropIn service's package changed from `com.adyen.dropin` to `com.adyen.dropin.services`) [//]: # (## Deprecated) [//]: # ( - Configurations public constructor are deprecated, please use each Configuration's builder to make a Configuration object) -## Fixed -- For Drop-in and Components, when `?android:attr/textColor` is not defined in your own theme, the Card Component no longer crashes. -- The `onAdditionalDetails` event is now triggered only once. Previously, the event was triggered multiple times in some edge cases. -- The build output no longer contains warnings about multiple substitutions specified in non-positional format in string resources. -- For the Card Component, we fixed localization issues that occurred when using the Address Lookup functionality. -- Overriding some of the XML styles without specifying a parent style no longer causes a build error. +## New +- For external redirects, you can now [customize the colors of the toolbar and navigation bar](docs/UI_CUSTOMIZATION.md#styling-custom-tabs) displayed in [Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs). +- TWINT is now supported with a native flow, and you no longer need to redirect shoppers through the browser. To use the redirect flow, set the following configuration: +```kotlin +CheckoutConfiguration( + environment = environment, + clientKey = clientKey, + .. +) { + // Optionally pass the payment method type to only configure it for the specific payment method. + instantPayment(PaymentMethodTypes.TWINT) { + setActionHandlingMethod(ActionHandlingMethod.PREFER_WEB) + } +} +``` -## Removed -- You can no longer use functions like `CheckoutConfiguration.getCardConfiguration()` or `CheckoutConfiguration.getDropInConfiguration()` to get configurations from the `CheckoutConfiguration` object. When starting Drop-in or Components, pass the full `CheckoutConfiguration` object. +## Fixed +- Fixed some memory leaks. +- In case of a debug build, Drop-in no longer overrides the log level. +- For cards, when a shopper does not select an address, the address lookup function now displays a validation error. +- Actions no longer crash when your app uses obfuscation. +- When handling a 3D Secure 2 challenge using Checkout API v66 or earlier, Drop-in no longer throws an error. +- If the app process unexpectedly terminates when handling actions, the state is now restored and you can proceed with the payment flow. +- For `/sessions`, fixed an issue where the `setEnableRemovingStoredPaymentMethods` flag in the [Drop-in configuration](https://docs.adyen.com/online-payments/build-your-integration/sessions-flow/?platform=Android&integration=Drop-in#3-optional-add-a-configuration-object) was ignored. ## Changed +- The phone number input field in the payment form now shows ISO codes instead of flags. +- The UI elements that were previously labelled **Country** are now **Country/Region**. - Dependency versions: - | Name | Version | - |--------------------------------------------------------------------------------------------------------|-------------------------------| - | [Android Gradle plugin](https://developer.android.com/build/releases/gradle-plugin) | **8.3.1** | + | Name | Version | + |--------------------------------------------------------------------------------------------------------------|-------------------------------| + | [Adyen 3DS2](https://github.com/Adyen/adyen-3ds2-android/releases/tag/2.2.18) | **2.2.18** | + | [Android Gradle plugin](https://developer.android.com/build/releases/gradle-plugin) | **8.3.2** | + | [AndroidX Browser](https://developer.android.com/jetpack/androidx/releases/browser#1.8.0) | **1.8.0** | + | [AndroidX Compose Activity](https://developer.android.com/jetpack/androidx/releases/activity#1.9.0) | **1.9.0** | + | [AndroidX Compose BoM](https://developer.android.com/develop/ui/compose/bom/bom-mapping) | **2024.04.01** | + | [AndroidX Compose Compiler](https://developer.android.com/jetpack/androidx/releases/compose-compiler#1.5.12) | **1.5.12** | + | [AndroidX Lifecycle](https://developer.android.com/jetpack/androidx/releases/lifecycle#2.7.0) | **2.7.0** | + | [Google Pay](https://developers.google.com/pay/api/android/support/release-notes#feb-24) | **19.3.0** | + | [Google Pay Compose Button](https://github.com/google-pay/compose-pay-button/releases/tag/v1.0.0) | **1.0.0** | + | [Kotlin](https://github.com/JetBrains/kotlin/releases/tag/v1.9.24) | **1.9.24** | + | [Kotlin coroutines](https://github.com/Kotlin/kotlinx.coroutines/releases/tag/1.8.0) | **1.8.0** | diff --git a/ach/build.gradle b/ach/build.gradle index 953cb6ff44..7672767240 100644 --- a/ach/build.gradle +++ b/ach/build.gradle @@ -41,6 +41,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines 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 410cadd5e3..f4ea620536 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 @@ -34,11 +34,9 @@ import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.ComponentEventHandler import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.data.api.DefaultPublicKeyRepository import com.adyen.checkout.components.core.internal.data.api.PublicKeyService import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider @@ -71,7 +69,7 @@ class ACHDirectDebitComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -121,23 +119,19 @@ constructor( ) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val achDelegate = createDefaultDelegate( paymentMethod = paymentMethod, savedStateHandle = savedStateHandle, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, httpClient = httpClient, order = order, ) @@ -207,24 +201,19 @@ constructor( ) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val achDelegate = createDefaultDelegate( paymentMethod = paymentMethod, savedStateHandle = savedStateHandle, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, httpClient = httpClient, order = checkoutSession.order, ) @@ -285,7 +274,7 @@ constructor( paymentMethod: PaymentMethod, savedStateHandle: SavedStateHandle, componentParams: ACHDirectDebitComponentParams, - analyticsRepository: AnalyticsRepository, + analyticsManager: AnalyticsManager, httpClient: HttpClient, order: Order?, ): DefaultACHDirectDebitDelegate { @@ -297,7 +286,7 @@ constructor( return DefaultACHDirectDebitDelegate( observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, publicKeyRepository = publicKeyRepository, addressRepository = addressRepository, submitHandler = SubmitHandler(savedStateHandle), @@ -328,22 +317,17 @@ constructor( componentSessionParams = null, ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - storedPaymentMethod = storedPaymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(storedPaymentMethod.type.orEmpty()), + sessionId = null, ) val achDelegate = createStoredDelegate( paymentMethod = storedPaymentMethod, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, order = order, ) @@ -414,22 +398,16 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - storedPaymentMethod = storedPaymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(storedPaymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val achDelegate = createStoredDelegate( paymentMethod = storedPaymentMethod, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, componentParams = componentParams, order = checkoutSession.order, ) @@ -487,13 +465,13 @@ constructor( private fun createStoredDelegate( paymentMethod: StoredPaymentMethod, componentParams: ACHDirectDebitComponentParams, - analyticsRepository: AnalyticsRepository, + analyticsManager: AnalyticsManager, order: Order? ): StoredACHDirectDebitDelegate { return StoredACHDirectDebitDelegate( observerRepository = PaymentObserverRepository(), storedPaymentMethod = paymentMethod, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, componentParams = componentParams, order = order, ) @@ -506,7 +484,7 @@ constructor( delegate: ACHDirectDebitDelegate, componentEventHandler: ComponentEventHandler, ): ACHDirectDebitComponent { - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( + val genericActionDelegate = GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( checkoutConfiguration = checkoutConfiguration, savedStateHandle = savedStateHandle, application = application, 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 b51d517f2a..5532b95b4e 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 @@ -20,7 +20,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.data.api.PublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel import com.adyen.checkout.components.core.internal.util.bufferedChannel @@ -62,7 +63,7 @@ import kotlinx.coroutines.launch internal class DefaultACHDirectDebitDelegate( private val observerRepository: PaymentObserverRepository, private val paymentMethod: PaymentMethod, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val publicKeyRepository: PublicKeyRepository, private val addressRepository: AddressRepository, private val submitHandler: SubmitHandler, @@ -110,7 +111,7 @@ internal class DefaultACHDirectDebitDelegate( _coroutineScope = coroutineScope submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) fetchPublicKey(coroutineScope) if (componentParams.addressParams is AddressParams.FullAddress) { @@ -120,6 +121,14 @@ internal class DefaultACHDirectDebitDelegate( } } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + } + override fun updateAddressInputData(update: AddressInputModel.() -> Unit) { updateInputData { this.address.update() @@ -250,13 +259,6 @@ internal class DefaultACHDirectDebitDelegate( ) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } - } - private fun updateComponentState(outputData: ACHDirectDebitOutputData) { adyenLog(AdyenLogLevel.VERBOSE) { "updateComponentState" } val componentState = createComponentState(outputData) @@ -290,7 +292,7 @@ internal class DefaultACHDirectDebitDelegate( val achPaymentMethod = ACHDirectDebitPaymentMethod( type = ACHDirectDebitPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), encryptedBankAccountNumber = encryptedBankAccountNumber, encryptedBankLocationId = encryptedBankLocationId, ownerName = outputData.ownerName.value, @@ -354,9 +356,13 @@ internal class DefaultACHDirectDebitDelegate( override fun onCleared() { removeObserver() _coroutineScope = null + analyticsManager.clear(this) } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit( state = state, diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegate.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegate.kt index 41bcbe2ed2..0215d76fde 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegate.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegate.kt @@ -19,7 +19,8 @@ import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel import com.adyen.checkout.components.core.internal.ui.model.FieldState import com.adyen.checkout.components.core.internal.ui.model.Validation @@ -42,13 +43,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class StoredACHDirectDebitDelegate( private val observerRepository: PaymentObserverRepository, private val storedPaymentMethod: StoredPaymentMethod, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, override val componentParams: ACHDirectDebitComponentParams, private val order: OrderRequest?, ) : ACHDirectDebitDelegate { @@ -87,15 +87,29 @@ internal class StoredACHDirectDebitDelegate( override fun initialize(coroutineScope: CoroutineScope) { _coroutineScope = coroutineScope - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) componentStateFlow.onEach { onState(it) }.launchIn(coroutineScope) } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered( + component = storedPaymentMethod.type.orEmpty(), + isStoredPaymentMethod = true + ) + analyticsManager.trackEvent(event) + } + private fun onState(achDirectDebitComponentState: ACHDirectDebitComponentState) { if (achDirectDebitComponentState.isValid) { + val event = GenericEvents.submit(storedPaymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + submitChannel.trySend(achDirectDebitComponentState) } } @@ -104,17 +118,13 @@ internal class StoredACHDirectDebitDelegate( adyenLog(AdyenLogLevel.ERROR) { "updateInputData should not be called in StoredACHDirectDebitDelegate" } } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } - } - + @Suppress("ForbiddenComment") + // TODO: Here we only call this method on initialization. The checkoutAttemptId will only be available if it is + // passed by drop-in. This should be fixed as part of state refactoring. private fun createComponentState(): ACHDirectDebitComponentState { val paymentMethod = ACHDirectDebitPaymentMethod( type = ACHDirectDebitPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), storedPaymentMethodId = storedPaymentMethod.id, ) @@ -146,6 +156,7 @@ internal class StoredACHDirectDebitDelegate( override fun onCleared() { removeObserver() _coroutineScope = null + analyticsManager.clear(this) } override fun getPaymentMethodType(): String { diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapper.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapper.kt index 105454cd6a..3b9bd0c7c8 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapper.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapper.kt @@ -39,6 +39,7 @@ internal class ACHDirectDebitComponentParamsMapper( return mapToParams( commonComponentParamsMapperData.commonComponentParams, commonComponentParamsMapperData.sessionParams, + dropInOverrideParams, achDirectDebitConfiguration, ) } @@ -46,11 +47,13 @@ internal class ACHDirectDebitComponentParamsMapper( private fun mapToParams( commonComponentParams: CommonComponentParams, sessionParams: SessionParams?, + dropInOverrideParams: DropInOverrideParams?, achDirectDebitConfiguration: ACHDirectDebitConfiguration?, ): ACHDirectDebitComponentParams { return ACHDirectDebitComponentParams( commonComponentParams = commonComponentParams, - isSubmitButtonVisible = achDirectDebitConfiguration?.isSubmitButtonVisible ?: true, + isSubmitButtonVisible = dropInOverrideParams?.isSubmitButtonVisible + ?: achDirectDebitConfiguration?.isSubmitButtonVisible ?: true, addressParams = achDirectDebitConfiguration?.addressConfiguration?.mapToAddressParam() ?: AddressParams.FullAddress( supportedCountryCodes = DEFAULT_SUPPORTED_COUNTRY_LIST, diff --git a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/view/ACHDirectDebitView.kt b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/view/ACHDirectDebitView.kt index 0433eff209..70bc6511b5 100644 --- a/ach/src/main/java/com/adyen/checkout/ach/internal/ui/view/ACHDirectDebitView.kt +++ b/ach/src/main/java/com/adyen/checkout/ach/internal/ui/view/ACHDirectDebitView.kt @@ -29,6 +29,7 @@ import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class ACHDirectDebitView @JvmOverloads constructor( @@ -49,7 +50,7 @@ internal class ACHDirectDebitView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } 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 3bcc431788..67e4ccaa3b 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 @@ -21,7 +21,9 @@ import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.PaymentMethod 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.data.api.PublicKeyRepository import com.adyen.checkout.components.core.internal.test.TestPublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel @@ -61,21 +63,19 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, TestDispatcherExtension::class) internal class DefaultACHDirectDebitDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler ) { private lateinit var publicKeyRepository: TestPublicKeyRepository private lateinit var addressRepository: TestAddressRepository private lateinit var genericEncryptor: TestGenericEncryptor + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultACHDirectDebitDelegate @BeforeEach @@ -83,6 +83,7 @@ internal class DefaultACHDirectDebitDelegateTest( publicKeyRepository = TestPublicKeyRepository() addressRepository = TestAddressRepository() genericEncryptor = TestGenericEncryptor() + analyticsManager = TestAnalyticsManager() delegate = createAchDelegate() } @@ -634,9 +635,32 @@ internal class DefaultACHDirectDebitDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -651,6 +675,13 @@ internal class DefaultACHDirectDebitDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, componentState.data.paymentMethod?.checkoutAttemptId) } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } @Suppress("LongParameterList") @@ -682,8 +713,8 @@ internal class DefaultACHDirectDebitDelegateTest( @Suppress("LongParameterList") private fun createAchDelegate( - paymentMethod: PaymentMethod = PaymentMethod(), - analyticsRepository: AnalyticsRepository = this.analyticsRepository, + paymentMethod: PaymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), + analyticsManager: AnalyticsManager = this.analyticsManager, publicKeyRepository: PublicKeyRepository = this.publicKeyRepository, addressRepository: AddressRepository = this.addressRepository, genericEncryptor: BaseGenericEncryptor = this.genericEncryptor, @@ -693,7 +724,7 @@ internal class DefaultACHDirectDebitDelegateTest( ) = DefaultACHDirectDebitDelegate( observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, publicKeyRepository = publicKeyRepository, addressRepository = addressRepository, submitHandler = submitHandler, @@ -736,6 +767,7 @@ internal class DefaultACHDirectDebitDelegateTest( private val DEFAULT_SUPPORTED_COUNTRY_LIST = listOf("US", "PR") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" private val DEVICE_LOCALE = Locale("nl", "NL") + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun shouldStorePaymentMethodSource() = listOf( diff --git a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegateTest.kt b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegateTest.kt index 27eea5d09a..4e08d25023 100644 --- a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegateTest.kt +++ b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/StoredACHDirectDebitDelegateTest.kt @@ -17,7 +17,9 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.StoredPaymentMethod 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment import com.adyen.checkout.test.TestDispatcherExtension @@ -36,21 +38,19 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource -import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, TestDispatcherExtension::class) -internal class StoredACHDirectDebitDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository -) { +internal class StoredACHDirectDebitDelegateTest { + + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: ACHDirectDebitDelegate @BeforeEach fun setUp() { + analyticsManager = TestAnalyticsManager() delegate = createAchDelegate() } @@ -93,26 +93,51 @@ internal class StoredACHDirectDebitDelegateTest( inner class AnalyticsTest { @Test - fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate = createAchDelegate() + analyticsManager.assertIsInitialized() + } - delegate.componentStateFlow.test { - assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) - } + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered( + component = TEST_PAYMENT_METHOD_TYPE, + isStoredPaymentMethod = true, + ) + analyticsManager.assertHasEventEquals(expectedEvent) + } + + @Test + fun `when component is initialized with valid data, then submit event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() } } private fun createAchDelegate( - paymentMethod: StoredPaymentMethod = StoredPaymentMethod(id = STORED_ID), - analyticsRepository: AnalyticsRepository = this.analyticsRepository, + paymentMethod: StoredPaymentMethod = StoredPaymentMethod( + id = STORED_ID, + type = TEST_PAYMENT_METHOD_TYPE, + ), + analyticsManager: AnalyticsManager = this.analyticsManager, configuration: CheckoutConfiguration = createCheckoutConfiguration(), order: OrderRequest? = TEST_ORDER, ) = StoredACHDirectDebitDelegate( observerRepository = PaymentObserverRepository(), storedPaymentMethod = paymentMethod, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, componentParams = ACHDirectDebitComponentParamsMapper(CommonComponentParamsMapper()) .mapToParams(configuration, DEVICE_LOCALE, null, null), order = order, @@ -134,8 +159,8 @@ internal class StoredACHDirectDebitDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val STORED_ID = "Stored_id" - private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" private val DEVICE_LOCALE = Locale("nl", "NL") + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapperTest.kt b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapperTest.kt index ee28dba073..632e32b591 100644 --- a/ach/src/test/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapperTest.kt +++ b/ach/src/test/java/com/adyen/checkout/ach/internal/ui/model/ACHDirectDebitComponentParamsMapperTest.kt @@ -88,7 +88,7 @@ internal class ACHDirectDebitComponentParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, amount = Amount( currency = "EUR", @@ -99,6 +99,27 @@ internal class ACHDirectDebitComponentParamsMapperTest { assertEquals(expected, params) } + @Test + fun `when setSubmitButtonVisible is set to false in ach configuration and drop-in override params are set then card component params should have isSubmitButtonVisible true`() { + val configuration = CheckoutConfiguration( + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + ) { + achDirectDebit { + setSubmitButtonVisible(false) + } + } + val dropInOverrideParams = DropInOverrideParams(Amount("EUR", 123L), null) + val params = achDirectDebitComponentParamsMapper.mapToParams( + checkoutConfiguration = configuration, + deviceLocale = DEVICE_LOCALE, + dropInOverrideParams = dropInOverrideParams, + componentSessionParams = null, + ) + + assertEquals(true, params.isSubmitButtonVisible) + } + @Test fun `when a address is selected as FullAddress, addressParams should return FullAddress`() { val addressConfiguration = @@ -326,7 +347,7 @@ internal class ACHDirectDebitComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, amount: Amount? = null, isSubmitButtonVisible: Boolean = true, diff --git a/action-core/build.gradle b/action-core/build.gradle index b2c6de067c..663577ac5b 100644 --- a/action-core/build.gradle +++ b/action-core/build.gradle @@ -41,13 +41,16 @@ dependencies { api project(':await') api project(':qr-code') api project(':redirect') - compileOnly project(':wechatpay') + compileOnly project(':twint') api project(':voucher') + compileOnly project(':wechatpay') //Tests testImplementation project(':3ds2') testImplementation project(':test-core') + testImplementation project(':twint') testImplementation project(':wechatpay') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.mockito diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/GenericActionConfiguration.kt b/action-core/src/main/java/com/adyen/checkout/action/core/GenericActionConfiguration.kt index a8b6792ba3..e9dad0c222 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/GenericActionConfiguration.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/GenericActionConfiguration.kt @@ -23,6 +23,7 @@ import com.adyen.checkout.components.core.internal.Configuration import com.adyen.checkout.core.Environment import com.adyen.checkout.qrcode.QRCodeConfiguration import com.adyen.checkout.redirect.RedirectConfiguration +import com.adyen.checkout.twint.TwintActionConfiguration import com.adyen.checkout.voucher.VoucherConfiguration import com.adyen.checkout.wechatpay.WeChatPayActionConfiguration import kotlinx.parcelize.Parcelize @@ -136,6 +137,14 @@ class GenericActionConfiguration private constructor( return this } + /** + * Add configuration for Twint action. + */ + override fun addTwintActionConfiguration(configuration: TwintActionConfiguration): Builder { + availableActionConfigs[configuration::class.java] = configuration + return this + } + /** * Add configuration for WeChat Pay action. */ diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingConfigurationBuilder.kt b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingConfigurationBuilder.kt index 2f841fc99b..91da96d609 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingConfigurationBuilder.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingConfigurationBuilder.kt @@ -12,6 +12,7 @@ import com.adyen.checkout.adyen3ds2.Adyen3DS2Configuration import com.adyen.checkout.await.AwaitConfiguration import com.adyen.checkout.qrcode.QRCodeConfiguration import com.adyen.checkout.redirect.RedirectConfiguration +import com.adyen.checkout.twint.TwintActionConfiguration import com.adyen.checkout.voucher.VoucherConfiguration import com.adyen.checkout.wechatpay.WeChatPayActionConfiguration @@ -37,6 +38,11 @@ internal interface ActionHandlingConfigurationBuilder { */ fun addRedirectActionConfiguration(configuration: RedirectConfiguration): BuilderT + /** + * Add configuration for Twint action. + */ + fun addTwintActionConfiguration(configuration: TwintActionConfiguration): BuilderT + /** * Add configuration for Voucher action. */ diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingPaymentMethodConfigurationBuilder.kt b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingPaymentMethodConfigurationBuilder.kt index 411c6f740f..f3256e4464 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingPaymentMethodConfigurationBuilder.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ActionHandlingPaymentMethodConfigurationBuilder.kt @@ -17,6 +17,7 @@ import com.adyen.checkout.components.core.internal.Configuration import com.adyen.checkout.core.Environment import com.adyen.checkout.qrcode.QRCodeConfiguration import com.adyen.checkout.redirect.RedirectConfiguration +import com.adyen.checkout.twint.TwintActionConfiguration import com.adyen.checkout.voucher.VoucherConfiguration import com.adyen.checkout.wechatpay.WeChatPayActionConfiguration import java.util.Locale @@ -124,6 +125,14 @@ constructor( return this as BuilderT } + /** + * Add configuration for Twint action. + */ + final override fun addTwintActionConfiguration(configuration: TwintActionConfiguration): BuilderT { + genericActionConfigurationBuilder.addTwintActionConfiguration(configuration) + return this as BuilderT + } + /** * Add configuration for Voucher action. */ diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/ActionComponentExtensions.kt b/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/ActionComponentExtensions.kt index 70adbf359d..b57cacbad2 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/ActionComponentExtensions.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/ActionComponentExtensions.kt @@ -15,6 +15,7 @@ import com.adyen.checkout.components.core.internal.provider.ActionComponentProvi import com.adyen.checkout.core.internal.util.runCompileOnly import com.adyen.checkout.qrcode.QRCodeComponent import com.adyen.checkout.redirect.RedirectComponent +import com.adyen.checkout.twint.TwintActionComponent import com.adyen.checkout.voucher.VoucherComponent import com.adyen.checkout.wechatpay.WeChatPayActionComponent @@ -23,6 +24,7 @@ private val allActionProviders = listOfNotNull( runCompileOnly { AwaitComponent.PROVIDER }, runCompileOnly { QRCodeComponent.PROVIDER }, runCompileOnly { RedirectComponent.PROVIDER }, + runCompileOnly { TwintActionComponent.PROVIDER }, runCompileOnly { VoucherComponent.PROVIDER }, runCompileOnly { WeChatPayActionComponent.PROVIDER }, ) diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/GenericActionComponentProvider.kt b/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/GenericActionComponentProvider.kt index 9dfc7f7ef2..3a30f829a3 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/GenericActionComponentProvider.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/internal/provider/GenericActionComponentProvider.kt @@ -34,6 +34,7 @@ import com.adyen.checkout.components.core.action.Threeds2FingerprintAction import com.adyen.checkout.components.core.action.VoucherAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -45,6 +46,7 @@ import com.adyen.checkout.core.internal.util.LocaleProvider class GenericActionComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( + private val analyticsManager: AnalyticsManager? = null, private val dropInOverrideParams: DropInOverrideParams? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : ActionComponentProvider { @@ -59,22 +61,28 @@ constructor( key: String? ): GenericActionComponent { val genericActionFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> - val genericActionDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) + val genericActionDelegate = getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) GenericActionComponent( genericActionDelegate = genericActionDelegate, - actionComponentEventHandler = DefaultActionComponentEventHandler(callback), + actionComponentEventHandler = DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, genericActionFactory)[key, GenericActionComponent::class.java] .also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } override fun getDelegate( checkoutConfiguration: CheckoutConfiguration, savedStateHandle: SavedStateHandle, - application: Application + application: Application, ): GenericActionDelegate { val componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( checkoutConfiguration = checkoutConfiguration, @@ -88,7 +96,8 @@ constructor( savedStateHandle = savedStateHandle, checkoutConfiguration = checkoutConfiguration, componentParams = componentParams, - actionDelegateProvider = ActionDelegateProvider(dropInOverrideParams), + actionDelegateProvider = ActionDelegateProvider(analyticsManager, dropInOverrideParams), + application = application, ) } diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProvider.kt b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProvider.kt index 951b17dccf..a6b759dbd7 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProvider.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProvider.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.adyen3ds2.internal.provider.Adyen3DS2ComponentProvider import com.adyen.checkout.await.internal.provider.AwaitComponentProvider import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.AwaitAction import com.adyen.checkout.components.core.action.BaseThreeds2Action @@ -20,16 +21,20 @@ import com.adyen.checkout.components.core.action.QrCodeAction import com.adyen.checkout.components.core.action.RedirectAction import com.adyen.checkout.components.core.action.SdkAction import com.adyen.checkout.components.core.action.VoucherAction +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider import com.adyen.checkout.components.core.internal.ui.ActionDelegate import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.internal.util.LocaleProvider import com.adyen.checkout.qrcode.internal.provider.QRCodeComponentProvider import com.adyen.checkout.redirect.internal.provider.RedirectComponentProvider +import com.adyen.checkout.twint.internal.provider.TwintActionComponentProvider import com.adyen.checkout.voucher.internal.provider.VoucherComponentProvider import com.adyen.checkout.wechatpay.internal.provider.WeChatPayActionComponentProvider internal class ActionDelegateProvider( + private val analyticsManager: AnalyticsManager?, private val dropInOverrideParams: DropInOverrideParams?, private val localeProvider: LocaleProvider = LocaleProvider(), ) { @@ -41,12 +46,12 @@ internal class ActionDelegateProvider( application: Application, ): ActionDelegate { val provider = when (action) { - is AwaitAction -> AwaitComponentProvider(dropInOverrideParams, localeProvider) - is QrCodeAction -> QRCodeComponentProvider(dropInOverrideParams, localeProvider) - is RedirectAction -> RedirectComponentProvider(dropInOverrideParams, localeProvider) - is BaseThreeds2Action -> Adyen3DS2ComponentProvider(dropInOverrideParams, localeProvider) - is VoucherAction -> VoucherComponentProvider(dropInOverrideParams, localeProvider) - is SdkAction<*> -> WeChatPayActionComponentProvider(dropInOverrideParams, localeProvider) + is AwaitAction -> AwaitComponentProvider(analyticsManager, dropInOverrideParams, localeProvider) + is QrCodeAction -> QRCodeComponentProvider(analyticsManager, dropInOverrideParams, localeProvider) + is RedirectAction -> RedirectComponentProvider(analyticsManager, dropInOverrideParams, localeProvider) + is BaseThreeds2Action -> Adyen3DS2ComponentProvider(analyticsManager, dropInOverrideParams, localeProvider) + is VoucherAction -> VoucherComponentProvider(analyticsManager, dropInOverrideParams, localeProvider) + is SdkAction<*> -> getSdkActionComponentProvider(action) else -> throw CheckoutException("Can't find delegate for action: ${action.type}") } @@ -56,4 +61,26 @@ internal class ActionDelegateProvider( application = application, ) } + + private fun getSdkActionComponentProvider( + action: Action, + ): ActionComponentProvider<*, *, *> { + return when (action.paymentMethodType) { + PaymentMethodTypes.TWINT -> TwintActionComponentProvider( + analyticsManager, + dropInOverrideParams, + localeProvider, + ) + + PaymentMethodTypes.WECHAT_PAY_SDK -> WeChatPayActionComponentProvider( + analyticsManager, + dropInOverrideParams, + localeProvider, + ) + + else -> throw CheckoutException( + "Can't find delegate for action: ${action.type} and type: ${action.paymentMethodType}", + ) + } + } } diff --git a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/DefaultGenericActionDelegate.kt b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/DefaultGenericActionDelegate.kt index 5a19fd0bb4..65c5e92a03 100644 --- a/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/DefaultGenericActionDelegate.kt +++ b/action-core/src/main/java/com/adyen/checkout/action/core/internal/ui/DefaultGenericActionDelegate.kt @@ -9,7 +9,9 @@ package com.adyen.checkout.action.core.internal.ui import android.app.Activity +import android.app.Application import android.content.Intent +import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.adyen3ds2.internal.ui.Adyen3DS2Delegate @@ -20,6 +22,8 @@ import com.adyen.checkout.components.core.action.Threeds2ChallengeAction import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PermissionRequestData +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty import com.adyen.checkout.components.core.internal.ui.ActionDelegate import com.adyen.checkout.components.core.internal.ui.DetailsEmittingDelegate import com.adyen.checkout.components.core.internal.ui.IntentHandlingDelegate @@ -47,11 +51,12 @@ import kotlinx.coroutines.flow.receiveAsFlow @Suppress("TooManyFunctions") internal class DefaultGenericActionDelegate( private val observerRepository: ActionObserverRepository, - private val savedStateHandle: SavedStateHandle, + override val savedStateHandle: SavedStateHandle, private val checkoutConfiguration: CheckoutConfiguration, override val componentParams: GenericComponentParams, private val actionDelegateProvider: ActionDelegateProvider, -) : GenericActionDelegate { + private val application: Application, +) : GenericActionDelegate, SavedStateHandleContainer { private var _delegate: ActionDelegate? = null override val delegate: ActionDelegate get() = requireNotNull(_delegate) @@ -73,9 +78,20 @@ internal class DefaultGenericActionDelegate( private var onRedirectListener: (() -> Unit)? = null + private val action: Action? by SavedStateHandleProperty(ACTION_KEY) + override fun initialize(coroutineScope: CoroutineScope) { adyenLog(AdyenLogLevel.DEBUG) { "initialize" } _coroutineScope = coroutineScope + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + val action: Action? = action + if (_delegate == null && action != null) { + createDelegateAndObserve(action) + } } override fun observe( @@ -108,28 +124,36 @@ internal class DefaultGenericActionDelegate( if (isOld3DS2Flow(action)) { adyenLog(AdyenLogLevel.DEBUG) { "Continuing the handling of 3ds2 challenge with old flow." } } else { - val delegate = actionDelegateProvider.getDelegate( - action = action, - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = activity.application, - ) - this._delegate = delegate - adyenLog(AdyenLogLevel.DEBUG) { "Created delegate of type ${delegate::class.simpleName}" } + createDelegateAndObserve(action) + } - if (delegate is RedirectableDelegate) { - onRedirectListener?.let { delegate.setOnRedirectListener(it) } - } + delegate.handleAction(action, activity) + } - delegate.initialize(coroutineScope) + private fun createDelegateAndObserve(action: Action) { + val delegate = actionDelegateProvider.getDelegate( + action = action, + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) + _delegate = delegate + adyenLog(AdyenLogLevel.DEBUG) { "Created delegate of type ${delegate::class.simpleName}" } - observeDetails(delegate) - observeExceptions(delegate) - observePermissionRequests(delegate) - observeViewFlow(delegate) + if (delegate is RedirectableDelegate) { + onRedirectListener?.let { delegate.setOnRedirectListener(it) } } - delegate.handleAction(action, activity) + delegate.initialize(coroutineScope) + + observeDelegate(delegate) + } + + private fun observeDelegate(delegate: ActionDelegate) { + observeDetails(delegate) + observeExceptions(delegate) + observePermissionRequests(delegate) + observeViewFlow(delegate) } private fun isOld3DS2Flow(action: Action): Boolean { @@ -196,6 +220,11 @@ internal class DefaultGenericActionDelegate( } override fun setOnRedirectListener(listener: () -> Unit) { + _delegate?.let { delegate -> + if (delegate is RedirectableDelegate) { + delegate.setOnRedirectListener(listener) + } + } onRedirectListener = listener } @@ -207,4 +236,9 @@ internal class DefaultGenericActionDelegate( _coroutineScope = null onRedirectListener = null } + + companion object { + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" + } } diff --git a/action-core/src/test/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProviderTest.kt b/action-core/src/test/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProviderTest.kt index 6cd18f7d04..de43aaea6b 100644 --- a/action-core/src/test/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProviderTest.kt +++ b/action-core/src/test/java/com/adyen/checkout/action/core/internal/ui/ActionDelegateProviderTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.adyen3ds2.internal.ui.Adyen3DS2Delegate import com.adyen.checkout.await.internal.ui.AwaitDelegate import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.AwaitAction import com.adyen.checkout.components.core.action.QrCodeAction @@ -14,14 +15,17 @@ import com.adyen.checkout.components.core.action.SdkAction import com.adyen.checkout.components.core.action.Threeds2Action import com.adyen.checkout.components.core.action.Threeds2ChallengeAction import com.adyen.checkout.components.core.action.Threeds2FingerprintAction +import com.adyen.checkout.components.core.action.TwintSdkData import com.adyen.checkout.components.core.action.VoucherAction import com.adyen.checkout.components.core.action.WeChatPaySdkData +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.ActionDelegate import com.adyen.checkout.core.Environment import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.internal.util.LocaleProvider import com.adyen.checkout.qrcode.internal.ui.QRCodeDelegate import com.adyen.checkout.redirect.internal.ui.RedirectDelegate +import com.adyen.checkout.twint.internal.ui.TwintDelegate import com.adyen.checkout.voucher.internal.ui.VoucherDelegate import com.adyen.checkout.wechatpay.internal.ui.WeChatDelegate import org.junit.jupiter.api.Assertions.assertInstanceOf @@ -44,12 +48,18 @@ internal class ActionDelegateProviderTest( @Mock private val localeProvider: LocaleProvider ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var actionDelegateProvider: ActionDelegateProvider @BeforeEach fun setup() { whenever(localeProvider.getLocale(any())) doReturn Locale.US - actionDelegateProvider = ActionDelegateProvider(null, localeProvider) + analyticsManager = TestAnalyticsManager() + actionDelegateProvider = ActionDelegateProvider( + analyticsManager = analyticsManager, + dropInOverrideParams = null, + localeProvider = localeProvider, + ) } @ParameterizedTest @@ -74,6 +84,20 @@ internal class ActionDelegateProviderTest( } } + @Test + fun `when sdk action with unknown paymentMethodType is used, then an error will be thrown`() { + val configuration = CheckoutConfiguration(Environment.TEST, "") + + assertThrows { + actionDelegateProvider.getDelegate( + action = SdkAction(paymentMethodType = "test"), + checkoutConfiguration = configuration, + savedStateHandle = SavedStateHandle(), + application = Application(), + ) + } + } + companion object { @JvmStatic @@ -85,7 +109,11 @@ internal class ActionDelegateProviderTest( arguments(Threeds2ChallengeAction(), Adyen3DS2Delegate::class.java), arguments(Threeds2FingerprintAction(), Adyen3DS2Delegate::class.java), arguments(VoucherAction(), VoucherDelegate::class.java), - arguments(SdkAction(), WeChatDelegate::class.java), + arguments( + SdkAction(paymentMethodType = PaymentMethodTypes.WECHAT_PAY_SDK), + WeChatDelegate::class.java, + ), + arguments(SdkAction(paymentMethodType = PaymentMethodTypes.TWINT), TwintDelegate::class.java), ) } diff --git a/action-core/src/test/java/com/adyen/checkout/action/core/ui/DefaultGenericActionDelegateTest.kt b/action-core/src/test/java/com/adyen/checkout/action/core/ui/DefaultGenericActionDelegateTest.kt index 08097e92ba..a7c2a62f76 100644 --- a/action-core/src/test/java/com/adyen/checkout/action/core/ui/DefaultGenericActionDelegateTest.kt +++ b/action-core/src/test/java/com/adyen/checkout/action/core/ui/DefaultGenericActionDelegateTest.kt @@ -53,24 +53,13 @@ internal class DefaultGenericActionDelegateTest( @Mock private val activity: Activity, @Mock private val actionDelegateProvider: ActionDelegateProvider, ) { + private lateinit var genericActionDelegate: DefaultGenericActionDelegate private lateinit var testDelegate: TestActionDelegate @BeforeEach fun beforeEach() { - val configuration = CheckoutConfiguration( - shopperLocale = Locale.US, - environment = Environment.TEST, - clientKey = TEST_CLIENT_KEY, - ) - genericActionDelegate = DefaultGenericActionDelegate( - ActionObserverRepository(), - SavedStateHandle(), - configuration, - GenericComponentParamsMapper(CommonComponentParamsMapper()) - .mapToParams(configuration, Locale.US, null, null), - actionDelegateProvider, - ) + genericActionDelegate = createDelegate() whenever(activity.application) doReturn Application() testDelegate = TestActionDelegate() @@ -232,6 +221,39 @@ internal class DefaultGenericActionDelegateTest( assertTrue(adyen3DS2Delegate.handleActionCalled) } + @Test + fun `when process died during handling action, then handleIntent should restore state and continue`() { + val savedStateHandle = SavedStateHandle().apply { + set(DefaultGenericActionDelegate.ACTION_KEY, RedirectAction()) + } + genericActionDelegate = createDelegate(savedStateHandle) + genericActionDelegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + genericActionDelegate.handleIntent(Intent()) + + assertTrue(testDelegate.handleIntentCalled) + } + + private fun createDelegate( + savedStateHandle: SavedStateHandle = SavedStateHandle() + ): DefaultGenericActionDelegate { + val configuration = CheckoutConfiguration( + shopperLocale = Locale.US, + environment = Environment.TEST, + clientKey = TEST_CLIENT_KEY, + ) + + return DefaultGenericActionDelegate( + observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, + checkoutConfiguration = configuration, + componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) + .mapToParams(configuration, Locale.US, null, null), + actionDelegateProvider = actionDelegateProvider, + application = Application(), + ) + } + companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" } diff --git a/action-core/src/test/java/com/adyen/threeds2/ThreeDS2Service.kt b/action-core/src/test/java/com/adyen/threeds2/ThreeDS2Service.kt new file mode 100644 index 0000000000..72bc8d3ad2 --- /dev/null +++ b/action-core/src/test/java/com/adyen/threeds2/ThreeDS2Service.kt @@ -0,0 +1,15 @@ +/* + * 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 26/4/2024. + */ + +package com.adyen.threeds2 + +// Fake ThreeDS2Service that overrides the static instance of the actual library, because it crashes unit tests +@Suppress("unused") +object ThreeDS2Service { + val INSTANCE = this +} diff --git a/await/build.gradle b/await/build.gradle index 6354a30f92..be411de9a4 100644 --- a/await/build.gradle +++ b/await/build.gradle @@ -41,6 +41,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.mockito diff --git a/await/src/main/java/com/adyen/checkout/await/internal/provider/AwaitComponentProvider.kt b/await/src/main/java/com/adyen/checkout/await/internal/provider/AwaitComponentProvider.kt index 41ec02bb66..5ac0c8f15b 100644 --- a/await/src/main/java/com/adyen/checkout/await/internal/provider/AwaitComponentProvider.kt +++ b/await/src/main/java/com/adyen/checkout/await/internal/provider/AwaitComponentProvider.kt @@ -28,6 +28,7 @@ import com.adyen.checkout.components.core.action.AwaitAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.data.api.DefaultStatusRepository import com.adyen.checkout.components.core.internal.data.api.StatusService import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider @@ -42,6 +43,7 @@ import com.adyen.checkout.core.internal.util.LocaleProvider class AwaitComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( + private val analyticsManager: AnalyticsManager? = null, private val dropInOverrideParams: DropInOverrideParams? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : ActionComponentProvider { @@ -62,11 +64,13 @@ constructor( val awaitDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) AwaitComponent( awaitDelegate, - DefaultActionComponentEventHandler(callback), + DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, awaitFactory)[key, AwaitComponent::class.java].also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } @@ -88,9 +92,11 @@ constructor( val paymentDataRepository = PaymentDataRepository(savedStateHandle) return DefaultAwaitDelegate( observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, componentParams = componentParams, statusRepository = statusRepository, paymentDataRepository = paymentDataRepository, + analyticsManager = analyticsManager, ) } diff --git a/await/src/main/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegate.kt b/await/src/main/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegate.kt index cbd5b04ee3..c49d964260 100644 --- a/await/src/main/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegate.kt +++ b/await/src/main/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegate.kt @@ -11,6 +11,7 @@ package com.adyen.checkout.await.internal.ui import android.app.Activity import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.await.internal.ui.model.AwaitOutputData import com.adyen.checkout.components.core.ActionComponentData import com.adyen.checkout.components.core.action.Action @@ -18,6 +19,10 @@ import com.adyen.checkout.components.core.action.AwaitAction import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.data.api.StatusRepository import com.adyen.checkout.components.core.internal.data.model.StatusResponse import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams @@ -46,10 +51,12 @@ import java.util.concurrent.TimeUnit @Suppress("TooManyFunctions") internal class DefaultAwaitDelegate( private val observerRepository: ActionObserverRepository, + override val savedStateHandle: SavedStateHandle, override val componentParams: GenericComponentParams, private val statusRepository: StatusRepository, private val paymentDataRepository: PaymentDataRepository, -) : AwaitDelegate { + private val analyticsManager: AnalyticsManager?, +) : AwaitDelegate, SavedStateHandleContainer { private val _outputDataFlow = MutableStateFlow(createOutputData()) override val outputDataFlow: Flow = _outputDataFlow @@ -72,8 +79,19 @@ internal class DefaultAwaitDelegate( private var statusPollingJob: Job? = null + private var action: AwaitAction? by SavedStateHandleProperty(ACTION_KEY) + override fun initialize(coroutineScope: CoroutineScope) { _coroutineScope = coroutineScope + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + val action: AwaitAction? = action + if (action != null) { + initState(action) + } } override fun observe( @@ -100,18 +118,31 @@ internal class DefaultAwaitDelegate( override fun handleAction(action: Action, activity: Activity) { if (action !is AwaitAction) { - exceptionChannel.trySend(ComponentException("Unsupported action")) + emitError(ComponentException("Unsupported action")) return } + this.action = action + + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + ) + analyticsManager?.trackEvent(event) + + initState(action) + } + + private fun initState(action: AwaitAction) { val paymentData = action.paymentData paymentDataRepository.paymentData = paymentData if (paymentData == null) { adyenLog(AdyenLogLevel.ERROR) { "Payment data is null" } - exceptionChannel.trySend(ComponentException("Payment data is null")) + emitError(ComponentException("Payment data is null")) return } createOutputData(null, action) + startStatusPolling(paymentData, action) } @@ -133,7 +164,7 @@ internal class DefaultAwaitDelegate( }, onFailure = { adyenLog(AdyenLogLevel.ERROR, it) { "Error while polling status" } - exceptionChannel.trySend(ComponentException("Error while polling status", it)) + emitError(ComponentException("Error while polling status", it)) }, ) } @@ -153,10 +184,9 @@ internal class DefaultAwaitDelegate( // Not authorized status should still call /details so that merchant can get more info val payload = statusResponse.payload if (StatusResponseUtils.isFinalResult(statusResponse) && !payload.isNullOrEmpty()) { - val details = createDetails(payload) - detailsChannel.trySend(createActionComponentData(details)) + emitDetails(payload) } else { - exceptionChannel.trySend(ComponentException("Payment was not completed. - " + statusResponse.resultCode)) + emitError(ComponentException("Payment was not completed. - " + statusResponse.resultCode)) } } @@ -172,11 +202,26 @@ internal class DefaultAwaitDelegate( try { jsonObject.put(PAYLOAD_DETAILS_KEY, payload) } catch (e: JSONException) { - exceptionChannel.trySend(ComponentException("Failed to create details.", e)) + emitError(ComponentException("Failed to create details.", e)) } return jsonObject } + private fun emitError(e: CheckoutException) { + exceptionChannel.trySend(e) + clearState() + } + + private fun emitDetails(payload: String) { + val details = createDetails(payload) + detailsChannel.trySend(createActionComponentData(details)) + clearState() + } + + private fun clearState() { + action = null + } + override fun refreshStatus() { val paymentData = paymentDataRepository.paymentData ?: return statusRepository.refreshStatus(paymentData) @@ -192,6 +237,9 @@ internal class DefaultAwaitDelegate( companion object { private val DEFAULT_MAX_POLLING_DURATION = TimeUnit.MINUTES.toMillis(15) + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" + @VisibleForTesting internal const val PAYLOAD_DETAILS_KEY = "payload" } diff --git a/await/src/main/java/com/adyen/checkout/await/internal/ui/view/AwaitView.kt b/await/src/main/java/com/adyen/checkout/await/internal/ui/view/AwaitView.kt index 9ee905df96..6a072bdc13 100644 --- a/await/src/main/java/com/adyen/checkout/await/internal/ui/view/AwaitView.kt +++ b/await/src/main/java/com/adyen/checkout/await/internal/ui/view/AwaitView.kt @@ -27,6 +27,7 @@ import com.adyen.checkout.ui.core.internal.util.setLocalizedTextFromStyle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR internal class AwaitView @JvmOverloads constructor( context: Context, @@ -48,7 +49,7 @@ internal class AwaitView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_double_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_double_margin).toInt() setPadding(padding, padding, padding, padding) } diff --git a/await/src/test/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegateTest.kt b/await/src/test/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegateTest.kt index f609006dfc..44c66da26a 100644 --- a/await/src/test/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegateTest.kt +++ b/await/src/test/java/com/adyen/checkout/await/internal/ui/DefaultAwaitDelegateTest.kt @@ -10,11 +10,13 @@ package com.adyen.checkout.await.internal.ui import android.app.Activity import androidx.lifecycle.SavedStateHandle -import app.cash.turbine.test import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.action.AwaitAction +import com.adyen.checkout.components.core.action.RedirectAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.data.model.StatusResponse import com.adyen.checkout.components.core.internal.test.TestStatusRepository import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -22,6 +24,7 @@ import com.adyen.checkout.components.core.internal.ui.model.GenericComponentPara import com.adyen.checkout.core.Environment import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.test.LoggingExtension +import com.adyen.checkout.test.extensions.test import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -29,8 +32,10 @@ import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import java.io.IOException @@ -40,25 +45,17 @@ import java.util.Locale @ExtendWith(LoggingExtension::class) internal class DefaultAwaitDelegateTest { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var statusRepository: TestStatusRepository private lateinit var paymentDataRepository: PaymentDataRepository private lateinit var delegate: DefaultAwaitDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() statusRepository = TestStatusRepository() paymentDataRepository = PaymentDataRepository(SavedStateHandle()) - val configuration = CheckoutConfiguration( - Environment.TEST, - TEST_CLIENT_KEY, - ) - delegate = DefaultAwaitDelegate( - ActionObserverRepository(), - GenericComponentParamsMapper(CommonComponentParamsMapper()) - .mapToParams(configuration, Locale.US, null, null), - statusRepository, - paymentDataRepository, - ) + delegate = createDelegate() } @Test @@ -68,23 +65,26 @@ internal class DefaultAwaitDelegateTest { Result.success(StatusResponse(resultCode = "finished")), ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val outputDataFlow = delegate.outputDataFlow.test(testScheduler) - delegate.outputDataFlow.test { - delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) - - skipItems(1) + delegate.handleAction( + AwaitAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) - with(awaitItem()) { - assertFalse(isValid) - assertEquals("test", paymentMethodType) - } + // We skip the first output data value as it's the initial value - with(awaitItem()) { - assertTrue(isValid) - assertEquals("test", paymentMethodType) - } + with(outputDataFlow.values[1]) { + assertFalse(isValid) + assertEquals(TEST_PAYMENT_METHOD_TYPE, paymentMethodType) + } - cancelAndIgnoreRemainingEvents() + with(outputDataFlow.values[2]) { + assertTrue(isValid) + assertEquals(TEST_PAYMENT_METHOD_TYPE, paymentMethodType) } } @@ -94,20 +94,23 @@ internal class DefaultAwaitDelegateTest { Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val detailsFlow = delegate.detailsFlow.test(testScheduler) - delegate.detailsFlow.test { - delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) - - val expectedDetails = JSONObject().apply { - put(DefaultAwaitDelegate.PAYLOAD_DETAILS_KEY, "testpayload") - } + delegate.handleAction( + AwaitAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) - with(awaitItem()) { - assertEquals(expectedDetails.toString(), details.toString()) - assertEquals("paymentData", paymentData) - } + val expectedDetails = JSONObject().apply { + put(DefaultAwaitDelegate.PAYLOAD_DETAILS_KEY, "testpayload") + } - cancelAndIgnoreRemainingEvents() + with(detailsFlow.latestValue) { + assertEquals(expectedDetails.toString(), details.toString()) + assertEquals(TEST_PAYMENT_DATA, paymentData) } } @@ -116,14 +119,17 @@ internal class DefaultAwaitDelegateTest { val error = IOException("test") statusRepository.pollingResults = listOf(Result.failure(error)) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) - - assertEquals(error, awaitItem().cause) + delegate.handleAction( + AwaitAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) - cancelAndIgnoreRemainingEvents() - } + assertEquals(error, exceptionFlow.latestValue.cause) } @Test @@ -131,18 +137,129 @@ internal class DefaultAwaitDelegateTest { statusRepository.pollingResults = listOf( Result.success(StatusResponse(resultCode = "finished", payload = "")), ) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.handleAction( + AwaitAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) + + assertTrue(exceptionFlow.latestValue is ComponentException) + assertEquals("Payment was not completed. - finished", exceptionFlow.latestValue.message) + } + + @Test + fun `when a wrongly typed action is used, then an error is propagated`() = runTest { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.handleAction(RedirectAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + + assertTrue(exceptionFlow.latestValue is ComponentException) + assertEquals("Unsupported action", exceptionFlow.latestValue.message) + } + + @Test + fun `when payment data is null, then an error is propagated`() = runTest { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = null), Activity()) + + assertTrue(exceptionFlow.latestValue is ComponentException) + assertEquals("Payment data is null", exceptionFlow.latestValue.message) + } + + @Test + fun `when initializing and action is set, then state is restored`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), + ) + val savedStateHandle = SavedStateHandle().apply { + set(DefaultAwaitDelegate.ACTION_KEY, AwaitAction(paymentMethodType = "test", paymentData = "paymentData")) + } + delegate = createDelegate(savedStateHandle) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.exceptionFlow.test { - delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + assertTrue(detailsFlow.values.isNotEmpty()) + } - assertTrue(awaitItem() is ComponentException) + @Test + fun `when details are emitted, then state is cleared`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), + ) + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) - cancelAndIgnoreRemainingEvents() + assertNull(savedStateHandle[DefaultAwaitDelegate.ACTION_KEY]) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.handleAction(AwaitAction(paymentMethodType = "test", paymentData = null), Activity()) + + assertNull(savedStateHandle[DefaultAwaitDelegate.ACTION_KEY]) + } + + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = AwaitAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + paymentData = TEST_PAYMENT_DATA, + ) + + delegate.handleAction(action, Activity()) + + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + ) + analyticsManager.assertLastEventEquals(expectedEvent) } } + private fun createDelegate( + savedStateHandle: SavedStateHandle = SavedStateHandle() + ): DefaultAwaitDelegate { + val configuration = CheckoutConfiguration( + Environment.TEST, + TEST_CLIENT_KEY, + ) + + return DefaultAwaitDelegate( + observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, + componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) + .mapToParams(configuration, Locale.US, null, null), + statusRepository = statusRepository, + paymentDataRepository = paymentDataRepository, + analyticsManager = analyticsManager, + ) + } + companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_ACTION_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_PAYMENT_DATA = "TEST_PAYMENT_DATA" } } diff --git a/bacs/build.gradle b/bacs/build.gradle index 60fe8a0c86..01132e5fff 100644 --- a/bacs/build.gradle +++ b/bacs/build.gradle @@ -47,6 +47,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.mockito testImplementation testLibraries.kotlinCoroutines diff --git a/bacs/src/main/java/com/adyen/checkout/bacs/internal/provider/BacsDirectDebitComponentProvider.kt b/bacs/src/main/java/com/adyen/checkout/bacs/internal/provider/BacsDirectDebitComponentProvider.kt index d3b02aea63..02ee7debd1 100644 --- a/bacs/src/main/java/com/adyen/checkout/bacs/internal/provider/BacsDirectDebitComponentProvider.kt +++ b/bacs/src/main/java/com/adyen/checkout/bacs/internal/provider/BacsDirectDebitComponentProvider.kt @@ -28,11 +28,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -57,7 +55,7 @@ class BacsDirectDebitComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -95,16 +93,11 @@ constructor( componentConfiguration = checkoutConfiguration.getBacsDirectDebitConfiguration(), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val bacsDelegate = DefaultBacsDirectDebitDelegate( @@ -112,15 +105,16 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) BacsDirectDebitComponent( bacsDelegate = bacsDelegate, @@ -187,17 +181,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val bacsDelegate = DefaultBacsDirectDebitDelegate( @@ -205,15 +193,16 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/DefaultBacsDirectDebitDelegate.kt b/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/DefaultBacsDirectDebitDelegate.kt index 3d0c507ff0..dbadaf3077 100644 --- a/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/DefaultBacsDirectDebitDelegate.kt +++ b/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/DefaultBacsDirectDebitDelegate.kt @@ -20,7 +20,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.paymentmethod.BacsDirectDebitPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -33,7 +34,6 @@ import com.adyen.checkout.ui.core.internal.ui.SubmitHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultBacsDirectDebitDelegate( @@ -41,7 +41,7 @@ internal class DefaultBacsDirectDebitDelegate( override val componentParams: ButtonComponentParams, private val paymentMethod: PaymentMethod, private val order: Order?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, ) : BacsDirectDebitDelegate { @@ -59,21 +59,20 @@ internal class DefaultBacsDirectDebitDelegate( override val uiStateFlow: Flow = submitHandler.uiStateFlow override val uiEventFlow: Flow = submitHandler.uiEventFlow - @VisibleForTesting - @Suppress("VariableNaming", "PropertyName") - internal val _viewFlow: MutableStateFlow = MutableStateFlow(BacsComponentViewType.INPUT) + private val _viewFlow: MutableStateFlow = MutableStateFlow(BacsComponentViewType.INPUT) override val viewFlow: Flow = _viewFlow override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -136,6 +135,9 @@ internal class DefaultBacsDirectDebitDelegate( } BacsDirectDebitMode.CONFIRMATION -> { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + submitHandler.onSubmit(state) } } @@ -191,7 +193,7 @@ internal class DefaultBacsDirectDebitDelegate( ): BacsDirectDebitComponentState { val bacsDirectDebitPaymentMethod = BacsDirectDebitPaymentMethod( type = BacsDirectDebitPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), holderName = outputData.holderNameState.value, bankAccountNumber = outputData.bankAccountNumberState.value, bankLocationId = outputData.sortCodeState.value, @@ -222,5 +224,6 @@ internal class DefaultBacsDirectDebitDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitConfirmationView.kt b/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitConfirmationView.kt index 952e451b12..39cdaae9fb 100644 --- a/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitConfirmationView.kt +++ b/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitConfirmationView.kt @@ -20,6 +20,7 @@ import com.adyen.checkout.components.core.internal.ui.ComponentDelegate import com.adyen.checkout.ui.core.internal.ui.ComponentView import com.adyen.checkout.ui.core.internal.util.setLocalizedHintFromStyle import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR internal class BacsDirectDebitConfirmationView @JvmOverloads constructor( context: Context, @@ -42,7 +43,7 @@ internal class BacsDirectDebitConfirmationView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitInputView.kt b/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitInputView.kt index 09c52a28ef..c7ac9bf2fa 100644 --- a/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitInputView.kt +++ b/bacs/src/main/java/com/adyen/checkout/bacs/internal/ui/view/BacsDirectDebitInputView.kt @@ -35,6 +35,7 @@ import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class BacsDirectDebitInputView @JvmOverloads constructor( @@ -58,7 +59,7 @@ internal class BacsDirectDebitInputView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/bacs/src/test/java/com/adyen/checkout/bacs/internal/DefaultBacsDirectDebitDelegateTest.kt b/bacs/src/test/java/com/adyen/checkout/bacs/internal/DefaultBacsDirectDebitDelegateTest.kt index 92bc7cf4ba..40d6987840 100644 --- a/bacs/src/test/java/com/adyen/checkout/bacs/internal/DefaultBacsDirectDebitDelegateTest.kt +++ b/bacs/src/test/java/com/adyen/checkout/bacs/internal/DefaultBacsDirectDebitDelegateTest.kt @@ -23,7 +23,8 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.FieldState @@ -48,22 +49,21 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultBacsDirectDebitDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultBacsDirectDebitDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createBacsDelegate() } @@ -277,7 +277,6 @@ internal class DefaultBacsDirectDebitDelegateTest( @Test fun `with INPUT parameter while current mode is also INPUT, then no value should be emitted`() = runTest { delegate.updateInputData { mode = BacsDirectDebitMode.INPUT } - delegate._viewFlow.value = BacsComponentViewType.INPUT delegate.viewFlow.test { awaitItem() @@ -292,7 +291,6 @@ internal class DefaultBacsDirectDebitDelegateTest( fun `with CONFIRMATION parameter while current mode is also CONFIRMATION, then no value should be emitted`() = runTest { delegate.updateInputData { mode = BacsDirectDebitMode.CONFIRMATION } - delegate._viewFlow.value = BacsComponentViewType.CONFIRMATION delegate.viewFlow.test { awaitItem() @@ -306,7 +304,6 @@ internal class DefaultBacsDirectDebitDelegateTest( @Test fun `with INPUT parameter while current mode is CONFIRMATION, then INPUT should be emitted`() = runTest { delegate.updateInputData { mode = BacsDirectDebitMode.CONFIRMATION } - delegate._viewFlow.value = BacsComponentViewType.CONFIRMATION delegate.viewFlow.test { awaitItem() @@ -329,7 +326,6 @@ internal class DefaultBacsDirectDebitDelegateTest( isAccountConsentChecked = true mode = BacsDirectDebitMode.INPUT } - delegate._viewFlow.value = BacsComponentViewType.INPUT delegate.viewFlow.test { awaitItem() @@ -344,7 +340,6 @@ internal class DefaultBacsDirectDebitDelegateTest( fun `with CONFIRMATION parameter while current mode is INPUT and input data is invalid, then no value should be emitted`() = runTest { delegate.updateInputData { mode = BacsDirectDebitMode.INPUT } - delegate._viewFlow.value = BacsComponentViewType.INPUT delegate.viewFlow.test { awaitItem() @@ -456,12 +451,6 @@ internal class DefaultBacsDirectDebitDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -561,9 +550,44 @@ internal class DefaultBacsDirectDebitDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called and component is in confirmation mode, then submit event is tracked`() { + delegate.updateInputData { mode = BacsDirectDebitMode.CONFIRMATION } + + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called and component is not in confirmation mode, then submit event is not tracked`() { + delegate.updateInputData { mode = BacsDirectDebitMode.INPUT } + + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventNotEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -580,6 +604,13 @@ internal class DefaultBacsDirectDebitDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createBacsDelegate( @@ -594,9 +625,9 @@ internal class DefaultBacsDirectDebitDelegateTest( componentSessionParams = null, componentConfiguration = configuration.getBacsDirectDebitConfiguration(), ), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, ) @@ -616,6 +647,7 @@ internal class DefaultBacsDirectDebitDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/bcmc/src/main/java/com/adyen/checkout/bcmc/BcmcComponent.kt b/bcmc/src/main/java/com/adyen/checkout/bcmc/BcmcComponent.kt index 7f1a786332..19fba8a926 100644 --- a/bcmc/src/main/java/com/adyen/checkout/bcmc/BcmcComponent.kt +++ b/bcmc/src/main/java/com/adyen/checkout/bcmc/BcmcComponent.kt @@ -20,7 +20,7 @@ import com.adyen.checkout.components.core.internal.PaymentComponent /** * A [PaymentComponent] that supports the [PaymentMethodTypes.BCMC] payment method. */ -class BcmcComponent( +class BcmcComponent internal constructor( cardDelegate: CardDelegate, genericActionDelegate: GenericActionDelegate, actionHandlingComponent: DefaultActionHandlingComponent, 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 2eced9b09a..3834bf4b89 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 @@ -31,11 +31,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.data.api.DefaultPublicKeyRepository import com.adyen.checkout.components.core.internal.data.api.PublicKeyService import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider @@ -66,7 +64,7 @@ class BcmcComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -116,16 +114,11 @@ constructor( val binLookupService = BinLookupService(httpClient) val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncryptor, binLookupService) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val cardDelegate = DefaultCardDelegate( @@ -134,7 +127,7 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, @@ -147,11 +140,12 @@ constructor( ), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) BcmcComponent( cardDelegate = cardDelegate, @@ -225,17 +219,11 @@ constructor( val binLookupService = BinLookupService(httpClient) val detectCardTypeRepository = DefaultDetectCardTypeRepository(cardEncryptor, binLookupService) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val cardDelegate = DefaultCardDelegate( @@ -244,7 +232,7 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, @@ -257,11 +245,12 @@ constructor( ), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapper.kt b/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapper.kt index 464b39777a..93429f74d8 100644 --- a/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapper.kt +++ b/bcmc/src/main/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapper.kt @@ -47,6 +47,7 @@ internal class BcmcComponentParamsMapper( return mapToParams( commonComponentParamsMapperData.commonComponentParams, commonComponentParamsMapperData.sessionParams, + dropInOverrideParams, bcmcConfiguration, paymentMethod, ) @@ -55,12 +56,14 @@ internal class BcmcComponentParamsMapper( private fun mapToParams( commonComponentParams: CommonComponentParams, sessionParams: SessionParams?, + dropInOverrideParams: DropInOverrideParams?, bcmcConfiguration: BcmcConfiguration?, paymentMethod: PaymentMethod, ): CardComponentParams { return CardComponentParams( commonComponentParams = commonComponentParams, - isSubmitButtonVisible = bcmcConfiguration?.isSubmitButtonVisible ?: true, + isSubmitButtonVisible = dropInOverrideParams?.isSubmitButtonVisible + ?: bcmcConfiguration?.isSubmitButtonVisible ?: true, isHolderNameRequired = bcmcConfiguration?.isHolderNameRequired ?: false, shopperReference = bcmcConfiguration?.shopperReference, isStorePaymentFieldVisible = getStorePaymentFieldVisible(sessionParams, bcmcConfiguration), diff --git a/bcmc/src/test/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapperTest.kt b/bcmc/src/test/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapperTest.kt index bc11a8b73f..009c3e3777 100644 --- a/bcmc/src/test/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapperTest.kt +++ b/bcmc/src/test/java/com/adyen/checkout/bcmc/internal/ui/model/BcmcComponentParamsMapperTest.kt @@ -108,7 +108,7 @@ internal class BcmcComponentParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, amount = Amount( currency = "CAD", @@ -119,6 +119,29 @@ internal class BcmcComponentParamsMapperTest { assertEquals(expected, params) } + @Test + fun `when setSubmitButtonVisible is set to false in bcmc configuration and drop-in override params are set then card component params should have isSubmitButtonVisible true`() { + val configuration = CheckoutConfiguration( + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + ) { + bcmc { + setSubmitButtonVisible(false) + } + } + + val dropInOverrideParams = DropInOverrideParams(Amount("CAD", 123L), null) + val params = bcmcComponentParamsMapper.mapToParams( + configuration, + DEVICE_LOCALE, + dropInOverrideParams, + null, + PaymentMethod(), + ) + + assertEquals(true, params.isSubmitButtonVisible) + } + @ParameterizedTest @MethodSource("enableStoreDetailsSource") @Suppress("MaxLineLength") @@ -266,7 +289,7 @@ internal class BcmcComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, amount: Amount? = null, isSubmitButtonVisible: Boolean = true, diff --git a/blik/build.gradle b/blik/build.gradle index 27e5dee456..f540e3e0d0 100644 --- a/blik/build.gradle +++ b/blik/build.gradle @@ -47,6 +47,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines diff --git a/blik/src/main/java/com/adyen/checkout/blik/internal/provider/BlikComponentProvider.kt b/blik/src/main/java/com/adyen/checkout/blik/internal/provider/BlikComponentProvider.kt index b0e7cc1aa8..562e2fc2a2 100644 --- a/blik/src/main/java/com/adyen/checkout/blik/internal/provider/BlikComponentProvider.kt +++ b/blik/src/main/java/com/adyen/checkout/blik/internal/provider/BlikComponentProvider.kt @@ -30,11 +30,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.provider.StoredPaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper @@ -62,7 +60,7 @@ class BlikComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -112,16 +110,11 @@ constructor( componentConfiguration = checkoutConfiguration.getBlikConfiguration(), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val blikDelegate = DefaultBlikDelegate( @@ -129,15 +122,16 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) BlikComponent( blikDelegate = blikDelegate, @@ -201,16 +195,11 @@ constructor( componentConfiguration = checkoutConfiguration.getBlikConfiguration(), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - storedPaymentMethod = storedPaymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(storedPaymentMethod.type.orEmpty()), + sessionId = null, ) val blikDelegate = StoredBlikDelegate( @@ -218,15 +207,16 @@ constructor( componentParams = componentParams, storedPaymentMethod = storedPaymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) BlikComponent( blikDelegate = blikDelegate, @@ -293,17 +283,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val blikDelegate = DefaultBlikDelegate( @@ -311,15 +295,16 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, @@ -405,17 +390,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - storedPaymentMethod = storedPaymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(storedPaymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val blikDelegate = StoredBlikDelegate( @@ -423,15 +402,16 @@ constructor( componentParams = componentParams, storedPaymentMethod = storedPaymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/blik/src/main/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegate.kt b/blik/src/main/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegate.kt index c1e640c110..910ebf01c0 100644 --- a/blik/src/main/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegate.kt +++ b/blik/src/main/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegate.kt @@ -19,7 +19,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.paymentmethod.BlikPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -32,7 +33,6 @@ import com.adyen.checkout.ui.core.internal.ui.SubmitHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultBlikDelegate( @@ -40,7 +40,7 @@ internal class DefaultBlikDelegate( override val componentParams: ButtonComponentParams, private val paymentMethod: PaymentMethod, private val order: OrderRequest?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, ) : BlikDelegate { @@ -70,14 +70,15 @@ internal class DefaultBlikDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -132,7 +133,7 @@ internal class DefaultBlikDelegate( ): BlikComponentState { val paymentMethod = BlikPaymentMethod( type = BlikPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), blikCode = outputData.blikCodeField.value, ) @@ -150,6 +151,9 @@ internal class DefaultBlikDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } @@ -164,5 +168,6 @@ internal class DefaultBlikDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/blik/src/main/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegate.kt b/blik/src/main/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegate.kt index 20beadb7d8..1b76cadc1a 100644 --- a/blik/src/main/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegate.kt +++ b/blik/src/main/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegate.kt @@ -18,7 +18,8 @@ import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.paymentmethod.BlikPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -31,7 +32,6 @@ import com.adyen.checkout.ui.core.internal.ui.SubmitHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class StoredBlikDelegate( @@ -39,7 +39,7 @@ internal class StoredBlikDelegate( override val componentParams: ButtonComponentParams, private val storedPaymentMethod: StoredPaymentMethod, private val order: OrderRequest?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, ) : BlikDelegate { @@ -63,14 +63,18 @@ internal class StoredBlikDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered( + component = storedPaymentMethod.type.orEmpty(), + isStoredPaymentMethod = true, + ) + analyticsManager.trackEvent(event) } override fun observe( @@ -101,16 +105,22 @@ internal class StoredBlikDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(storedPaymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } private fun createOutputData() = BlikOutputData(blikCode = "") + @Suppress("ForbiddenComment") + // TODO: Here we only call this method on initialization. The checkoutAttemptId will only be available if it is + // passed by drop-in. This should be fixed as part of state refactoring. private fun createComponentState(): BlikComponentState { val paymentMethod = BlikPaymentMethod( type = BlikPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), storedPaymentMethodId = storedPaymentMethod.id, ) @@ -137,5 +147,6 @@ internal class StoredBlikDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/blik/src/main/java/com/adyen/checkout/blik/internal/ui/view/BlikView.kt b/blik/src/main/java/com/adyen/checkout/blik/internal/ui/view/BlikView.kt index de98632798..8c37328105 100644 --- a/blik/src/main/java/com/adyen/checkout/blik/internal/ui/view/BlikView.kt +++ b/blik/src/main/java/com/adyen/checkout/blik/internal/ui/view/BlikView.kt @@ -26,6 +26,7 @@ import com.adyen.checkout.ui.core.internal.util.setLocalizedHintFromStyle import com.adyen.checkout.ui.core.internal.util.setLocalizedTextFromStyle import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR internal class BlikView @JvmOverloads constructor( context: Context, @@ -47,7 +48,7 @@ internal class BlikView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/blik/src/test/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegateTest.kt b/blik/src/test/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegateTest.kt index 7dd6b6feba..5eb99626cc 100644 --- a/blik/src/test/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegateTest.kt +++ b/blik/src/test/java/com/adyen/checkout/blik/internal/ui/DefaultBlikDelegateTest.kt @@ -19,7 +19,8 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment @@ -42,22 +43,21 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultBlikDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultBlikDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createBlikDelegate() } @@ -192,12 +192,6 @@ internal class DefaultBlikDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -254,9 +248,32 @@ internal class DefaultBlikDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -268,6 +285,13 @@ internal class DefaultBlikDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createBlikDelegate( @@ -281,9 +305,9 @@ internal class DefaultBlikDelegateTest( componentSessionParams = null, componentConfiguration = configuration.getBlikConfiguration(), ), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = TEST_ORDER, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, ) @@ -303,6 +327,7 @@ internal class DefaultBlikDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/blik/src/test/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegateTest.kt b/blik/src/test/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegateTest.kt new file mode 100644 index 0000000000..66c9ab06e5 --- /dev/null +++ b/blik/src/test/java/com/adyen/checkout/blik/internal/ui/StoredBlikDelegateTest.kt @@ -0,0 +1,128 @@ +/* + * 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 ararat on 18/3/2024. + */ + +package com.adyen.checkout.blik.internal.ui + +import com.adyen.checkout.blik.BlikComponentState +import com.adyen.checkout.blik.BlikConfiguration +import com.adyen.checkout.blik.blik +import com.adyen.checkout.blik.getBlikConfiguration +import com.adyen.checkout.components.core.Amount +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.OrderRequest +import com.adyen.checkout.components.core.StoredPaymentMethod +import com.adyen.checkout.components.core.internal.PaymentObserverRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager +import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper +import com.adyen.checkout.core.Environment +import com.adyen.checkout.test.LoggingExtension +import com.adyen.checkout.ui.core.internal.ui.SubmitHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import java.util.Locale + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class, LoggingExtension::class) +class StoredBlikDelegateTest( + @Mock private val submitHandler: SubmitHandler, +) { + + private lateinit var analyticsManager: TestAnalyticsManager + private lateinit var delegate: StoredBlikDelegate + + @BeforeEach + fun beforeEach() { + analyticsManager = TestAnalyticsManager() + delegate = createStoredBlikDelegate() + } + + @Nested + inner class AnalyticsTest { + + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered( + component = TEST_PAYMENT_METHOD_TYPE, + isStoredPaymentMethod = true, + ) + analyticsManager.assertHasEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } + } + + private fun createStoredBlikDelegate( + configuration: CheckoutConfiguration = createCheckoutConfiguration() + ) = StoredBlikDelegate( + observerRepository = PaymentObserverRepository(), + componentParams = ButtonComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( + checkoutConfiguration = configuration, + deviceLocale = Locale.US, + dropInOverrideParams = null, + componentSessionParams = null, + componentConfiguration = configuration.getBlikConfiguration(), + ), + storedPaymentMethod = StoredPaymentMethod( + id = STORED_ID, + type = TEST_PAYMENT_METHOD_TYPE, + ), + order = TEST_ORDER, + analyticsManager = analyticsManager, + submitHandler = submitHandler, + ) + + private fun createCheckoutConfiguration( + amount: Amount? = null, + configuration: BlikConfiguration.Builder.() -> Unit = {} + ) = CheckoutConfiguration( + shopperLocale = Locale.US, + environment = Environment.TEST, + clientKey = TEST_CLIENT_KEY, + amount = amount, + ) { + blik(configuration) + } + + companion object { + private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") + private const val STORED_ID = "Stored_id" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + } +} diff --git a/boleto/build.gradle b/boleto/build.gradle index db3eb10a96..a1060b7a70 100644 --- a/boleto/build.gradle +++ b/boleto/build.gradle @@ -46,6 +46,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/boleto/src/main/java/com/adyen/checkout/boleto/internal/provider/BoletoComponentProvider.kt b/boleto/src/main/java/com/adyen/checkout/boleto/internal/provider/BoletoComponentProvider.kt index 0c2fc1aa39..e1d2261e2e 100644 --- a/boleto/src/main/java/com/adyen/checkout/boleto/internal/provider/BoletoComponentProvider.kt +++ b/boleto/src/main/java/com/adyen/checkout/boleto/internal/provider/BoletoComponentProvider.kt @@ -28,11 +28,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -58,7 +56,7 @@ class BoletoComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -97,16 +95,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val addressService = AddressService(httpClient) @@ -114,7 +107,7 @@ constructor( val boletoDelegate = DefaultBoletoDelegate( submitHandler = SubmitHandler(savedStateHandle), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = order, @@ -122,11 +115,12 @@ constructor( addressRepository = addressRepository, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) BoletoComponent( boletoDelegate = boletoDelegate, @@ -192,24 +186,18 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val addressService = AddressService(httpClient) val addressRepository = DefaultAddressRepository(addressService) val boletoDelegate = DefaultBoletoDelegate( submitHandler = SubmitHandler(savedStateHandle), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = checkoutSession.order, @@ -217,11 +205,12 @@ constructor( addressRepository = addressRepository, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegate.kt b/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegate.kt index 97d0b10dca..2b596506f3 100644 --- a/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegate.kt +++ b/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegate.kt @@ -22,7 +22,8 @@ import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.ShopperName import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel import com.adyen.checkout.components.core.paymentmethod.GenericPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -49,12 +50,11 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch @Suppress("TooManyFunctions", "LongParameterList") internal class DefaultBoletoDelegate( private val submitHandler: SubmitHandler, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val observerRepository: PaymentObserverRepository, private val paymentMethod: PaymentMethod, private val order: OrderRequest?, @@ -94,7 +94,7 @@ internal class DefaultBoletoDelegate( _coroutineScope = coroutineScope submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) if (componentParams.addressParams is AddressParams.FullAddress) { subscribeToStatesList() @@ -103,11 +103,12 @@ internal class DefaultBoletoDelegate( } } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } private fun subscribeToStatesList() { @@ -234,7 +235,8 @@ internal class DefaultBoletoDelegate( val paymentComponentData = PaymentComponentData( paymentMethod = GenericPaymentMethod( type = paymentMethod.type, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), + subtype = null, ), order = order, amount = componentParams.amount, @@ -291,6 +293,9 @@ internal class DefaultBoletoDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + submitHandler.onSubmit(_componentStateFlow.value) } @@ -300,5 +305,6 @@ internal class DefaultBoletoDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapper.kt b/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapper.kt index 26bf1bffcc..943aabb5bc 100644 --- a/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapper.kt +++ b/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapper.kt @@ -37,7 +37,8 @@ internal class BoletoComponentParamsMapper( return BoletoComponentParams( commonComponentParams = commonComponentParams, - isSubmitButtonVisible = boletoConfiguration?.isSubmitButtonVisible ?: true, + isSubmitButtonVisible = dropInOverrideParams?.isSubmitButtonVisible + ?: boletoConfiguration?.isSubmitButtonVisible ?: true, addressParams = AddressParams.FullAddress( defaultCountryCode = BRAZIL_COUNTRY_CODE, supportedCountryCodes = DEFAULT_SUPPORTED_COUNTRY_LIST, diff --git a/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/view/BoletoView.kt b/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/view/BoletoView.kt index f806eb0be9..733d92f384 100644 --- a/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/view/BoletoView.kt +++ b/boleto/src/main/java/com/adyen/checkout/boleto/internal/ui/view/BoletoView.kt @@ -30,6 +30,7 @@ import com.adyen.checkout.ui.core.internal.util.setLocalizedTextFromStyle import com.adyen.checkout.ui.core.internal.util.showError import com.adyen.checkout.ui.core.internal.util.showKeyboard import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class BoletoView @JvmOverloads constructor( @@ -47,7 +48,7 @@ internal class BoletoView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegateTest.kt b/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegateTest.kt index 9e4e33f97d..d10ab9e81a 100644 --- a/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegateTest.kt +++ b/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/DefaultBoletoDelegateTest.kt @@ -19,7 +19,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment @@ -45,35 +47,26 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultBoletoDelegateTest( @Mock private val submitHandler: SubmitHandler, - @Mock private val analyticsRepository: AnalyticsRepository, ) { - private lateinit var delegate: DefaultBoletoDelegate - private lateinit var addressRepository: TestAddressRepository + private lateinit var analyticsManager: TestAnalyticsManager + private lateinit var delegate: DefaultBoletoDelegate @BeforeEach fun beforeEach() { addressRepository = TestAddressRepository() + analyticsManager = TestAnalyticsManager() delegate = createBoletoDelegate() } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - - verify(analyticsRepository).setupAnalytics() - } - @Nested @DisplayName("when input data changes and") inner class InputDataChangedTest { @@ -491,9 +484,32 @@ internal class DefaultBoletoDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -505,19 +521,26 @@ internal class DefaultBoletoDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } @Suppress("LongParameterList") private fun createBoletoDelegate( submitHandler: SubmitHandler = this.submitHandler, - analyticsRepository: AnalyticsRepository = this.analyticsRepository, - paymentMethod: PaymentMethod = PaymentMethod(), + analyticsManager: AnalyticsManager = this.analyticsManager, + paymentMethod: PaymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), addressRepository: TestAddressRepository = this.addressRepository, order: Order? = TEST_ORDER, configuration: CheckoutConfiguration = createCheckoutConfiguration(), ) = DefaultBoletoDelegate( submitHandler = submitHandler, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = order, @@ -562,6 +585,7 @@ internal class DefaultBoletoDelegateTest( private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val BRAZIL_COUNTRY_CODE = "BR" private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapperTest.kt b/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapperTest.kt index 6e8b1f9795..07a38106f9 100644 --- a/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapperTest.kt +++ b/boleto/src/test/java/com/adyen/checkout/boleto/internal/ui/model/BoletoComponentParamsMapperTest.kt @@ -90,7 +90,7 @@ internal class BoletoComponentParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, amount = Amount( currency = "EUR", @@ -101,6 +101,26 @@ internal class BoletoComponentParamsMapperTest { assertEquals(expected, params) } + @Test + fun `when setSubmitButtonVisible is set to false in boleto configuration and drop-in override params are set then card component params should have isSubmitButtonVisible true`() { + val configuration = CheckoutConfiguration( + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + ) { + boleto { + setSubmitButtonVisible(false) + } + } + + val dropInOverrideParams = DropInOverrideParams(Amount("EUR", 20L), null) + val params = mapParams( + configuration = configuration, + dropInOverrideParams = dropInOverrideParams, + ) + + assertEquals(true, params.isSubmitButtonVisible) + } + @Test fun `when send email is set, them params should match`() { val configuration = createCheckoutConfiguration { @@ -237,7 +257,7 @@ internal class BoletoComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, amount: Amount? = null, addressParams: AddressParams = AddressParams.FullAddress( diff --git a/build.gradle b/build.gradle index 6816fbf9bd..4fa8247595 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ plugins { id 'com.android.application' version "$android_gradle_plugin_version" apply false id 'com.android.library' version "$android_gradle_plugin_version" apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false + id 'com.google.devtools.ksp' version "$ksp_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" @@ -25,10 +26,28 @@ subprojects { plugins.withType(com.android.build.gradle.BasePlugin).configureEach { android { + if (project.hasProperty("strip-resources") && project.property("strip-resources") == "true") { + defaultConfig { + resConfigs "en", "xxhdpi" + } + + buildTypes { + debug { + crunchPngs false + } + } + } + compileOptions { sourceCompatibility = javaVersion targetCompatibility = javaVersion } + + packagingOptions { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + merges += "META-INF/LICENSE.md" + merges += "META-INF/LICENSE-notice.md" + } } } diff --git a/card/build.gradle b/card/build.gradle index ae7ecbcb5d..68602532a1 100644 --- a/card/build.gradle +++ b/card/build.gradle @@ -56,6 +56,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines diff --git a/card/src/main/java/com/adyen/checkout/card/CardComponent.kt b/card/src/main/java/com/adyen/checkout/card/CardComponent.kt index d4c6aa6fec..024b0182a1 100644 --- a/card/src/main/java/com/adyen/checkout/card/CardComponent.kt +++ b/card/src/main/java/com/adyen/checkout/card/CardComponent.kt @@ -40,7 +40,9 @@ import kotlinx.coroutines.flow.Flow * A [PaymentComponent] that supports the [PaymentMethodTypes.SCHEME] payment method. */ @Suppress("TooManyFunctions") -open class CardComponent constructor( +open class CardComponent +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +constructor( private val cardDelegate: CardDelegate, private val genericActionDelegate: GenericActionDelegate, private val actionHandlingComponent: DefaultActionHandlingComponent, 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 9a72fc7a80..fce090c372 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 @@ -33,11 +33,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.data.api.DefaultPublicKeyRepository import com.adyen.checkout.components.core.internal.data.api.PublicKeyService import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider @@ -71,7 +69,7 @@ class CardComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -136,16 +134,11 @@ constructor( val addressRepository = DefaultAddressRepository(addressService) val cardValidationMapper = CardValidationMapper() - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val cardDelegate = DefaultCardDelegate( @@ -154,7 +147,7 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, @@ -167,11 +160,12 @@ constructor( ), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) CardComponent( cardDelegate = cardDelegate, @@ -250,17 +244,11 @@ constructor( val addressRepository = DefaultAddressRepository(addressService) val cardValidationMapper = CardValidationMapper() - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val cardDelegate = DefaultCardDelegate( @@ -269,7 +257,7 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, addressRepository = addressRepository, detectCardTypeRepository = detectCardTypeRepository, cardValidationMapper = cardValidationMapper, @@ -282,11 +270,12 @@ constructor( ), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, @@ -376,16 +365,11 @@ constructor( val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val cardEncryptor = CardEncryptorFactory.provide() - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - storedPaymentMethod = storedPaymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(storedPaymentMethod.type.orEmpty()), + sessionId = null, ) val cardDelegate = StoredCardDelegate( @@ -393,17 +377,18 @@ constructor( storedPaymentMethod = storedPaymentMethod, order = order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, cardEncryptor = cardEncryptor, publicKeyRepository = publicKeyRepository, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) CardComponent( cardDelegate = cardDelegate, @@ -476,17 +461,11 @@ constructor( val publicKeyRepository = DefaultPublicKeyRepository(publicKeyService) val cardEncryptor = CardEncryptorFactory.provide() - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - storedPaymentMethod = storedPaymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(storedPaymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val cardDelegate = StoredCardDelegate( @@ -494,17 +473,18 @@ constructor( storedPaymentMethod = storedPaymentMethod, order = checkoutSession.order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, cardEncryptor = cardEncryptor, publicKeyRepository = publicKeyRepository, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = 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 09525bf5be..9197e66969 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 @@ -44,7 +44,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.data.api.PublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel import com.adyen.checkout.components.core.internal.ui.model.FieldState @@ -97,7 +98,7 @@ class DefaultCardDelegate( override val componentParams: CardComponentParams, private val paymentMethod: PaymentMethod, private val order: OrderRequest?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val addressRepository: AddressRepository, private val detectCardTypeRepository: DetectCardTypeRepository, private val cardValidationMapper: CardValidationMapper, @@ -151,7 +152,7 @@ class DefaultCardDelegate( submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) fetchPublicKey() subscribeToDetectedCardTypes() @@ -171,11 +172,12 @@ class DefaultCardDelegate( .launchIn(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -476,6 +478,9 @@ class DefaultCardDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state = state) } @@ -688,7 +693,7 @@ class DefaultCardDelegate( ): CardComponentState { val cardPaymentMethod = CardPaymentMethod( type = CardPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), ).apply { encryptedCardNumber = encryptedCard.encryptedCardNumber encryptedExpiryMonth = encryptedCard.encryptedExpiryMonth @@ -834,6 +839,7 @@ class DefaultCardDelegate( onBinValueListener = null onBinLookupListener = null addressLookupDelegate.clear() + analyticsManager.clear(this) } companion object { 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 68b91590b1..1955f79e36 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 @@ -32,7 +32,8 @@ import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.data.api.PublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.AddressInputModel import com.adyen.checkout.components.core.internal.ui.model.FieldState @@ -72,7 +73,7 @@ internal class StoredCardDelegate( private val storedPaymentMethod: StoredPaymentMethod, private val order: OrderRequest?, override val componentParams: CardComponentParams, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val cardEncryptor: BaseCardEncryptor, private val publicKeyRepository: PublicKeyRepository, private val submitHandler: SubmitHandler, @@ -132,7 +133,7 @@ internal class StoredCardDelegate( submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) initializeInputData() fetchPublicKey() @@ -151,11 +152,15 @@ internal class StoredCardDelegate( } } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered( + component = storedPaymentMethod.type.orEmpty(), + isStoredPaymentMethod = true, + ) + analyticsManager.trackEvent(event) } override fun observe( @@ -304,6 +309,9 @@ internal class StoredCardDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(storedPaymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } @@ -334,7 +342,7 @@ internal class StoredCardDelegate( ): CardComponentState { val cardPaymentMethod = CardPaymentMethod( type = CardPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), ).apply { storedPaymentMethodId = getPaymentMethodId() @@ -430,6 +438,7 @@ internal class StoredCardDelegate( override fun onCleared() { removeObserver() coroutineScope = null + analyticsManager.clear(this) } companion object { diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/model/CardComponentParamsMapper.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/model/CardComponentParamsMapper.kt index 44dda24c85..c4c178e5d7 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/model/CardComponentParamsMapper.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/model/CardComponentParamsMapper.kt @@ -83,6 +83,7 @@ internal class CardComponentParamsMapper( return mapToParams( commonComponentParamsMapperData.commonComponentParams, commonComponentParamsMapperData.sessionParams, + dropInOverrideParams, cardConfiguration, paymentMethod, ) @@ -91,13 +92,15 @@ internal class CardComponentParamsMapper( private fun mapToParams( commonComponentParams: CommonComponentParams, sessionParams: SessionParams?, + dropInOverrideParams: DropInOverrideParams?, cardConfiguration: CardConfiguration?, paymentMethod: PaymentMethod?, ): CardComponentParams { return CardComponentParams( commonComponentParams = commonComponentParams, isHolderNameRequired = cardConfiguration?.isHolderNameRequired ?: false, - isSubmitButtonVisible = cardConfiguration?.isSubmitButtonVisible ?: true, + isSubmitButtonVisible = dropInOverrideParams?.isSubmitButtonVisible + ?: cardConfiguration?.isSubmitButtonVisible ?: true, supportedCardBrands = getSupportedCardBrands(cardConfiguration, paymentMethod), shopperReference = cardConfiguration?.shopperReference, isStorePaymentFieldVisible = getStorePaymentFieldVisible(sessionParams, cardConfiguration), diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/view/CardView.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/view/CardView.kt index 6ab1e427c9..94a67ec482 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/view/CardView.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/view/CardView.kt @@ -53,6 +53,7 @@ import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR /** * CardView for [CardComponent]. @@ -82,7 +83,7 @@ class CardView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } @@ -145,7 +146,7 @@ class CardView @JvmOverloads constructor( localizedContext, ) binding.textInputLayoutPostalCode.setLocalizedHintFromStyle( - R.style.AdyenCheckout_PostalCodeInput, + UICoreR.style.AdyenCheckout_PostalCodeInput, localizedContext, ) binding.textInputLayoutAddressLookup.setLocalizedHintFromStyle( @@ -277,6 +278,11 @@ class CardView @JvmOverloads constructor( if (binding.addressFormInput.isVisible && !it.addressState.isValid) { binding.addressFormInput.highlightValidationErrors(isErrorFocused) } + if (binding.textInputLayoutAddressLookup.isVisible && !it.addressState.isValid) { + binding.textInputLayoutAddressLookup.showError( + localizedContext.getString(UICoreR.string.checkout_address_lookup_validation_empty), + ) + } } } @@ -722,9 +728,9 @@ class CardView @JvmOverloads constructor( AddressFormUIState.FULL_ADDRESS -> binding.addressFormInput.updateAddressHint(isOptional) AddressFormUIState.POSTAL_CODE -> { val postalCodeStyleResId = if (isOptional) { - R.style.AdyenCheckout_PostalCodeInput_Optional + UICoreR.style.AdyenCheckout_PostalCodeInput_Optional } else { - R.style.AdyenCheckout_PostalCodeInput + UICoreR.style.AdyenCheckout_PostalCodeInput } binding.textInputLayoutPostalCode.setLocalizedHintFromStyle(postalCodeStyleResId, localizedContext) } diff --git a/card/src/main/java/com/adyen/checkout/card/internal/ui/view/StoredCardView.kt b/card/src/main/java/com/adyen/checkout/card/internal/ui/view/StoredCardView.kt index 941f2f113d..44ae5c95c4 100644 --- a/card/src/main/java/com/adyen/checkout/card/internal/ui/view/StoredCardView.kt +++ b/card/src/main/java/com/adyen/checkout/card/internal/ui/view/StoredCardView.kt @@ -35,6 +35,7 @@ import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR /** * StoredCardView for [CardComponent]. @@ -61,7 +62,7 @@ internal class StoredCardView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/card/src/main/res/values/styles.xml b/card/src/main/res/values/styles.xml index 2cfeb04f71..33475416a8 100644 --- a/card/src/main/res/values/styles.xml +++ b/card/src/main/res/values/styles.xml @@ -103,7 +103,6 @@ +``` diff --git a/dotpay/src/main/java/com/adyen/checkout/dotpay/internal/provider/DotpayComponentProvider.kt b/dotpay/src/main/java/com/adyen/checkout/dotpay/internal/provider/DotpayComponentProvider.kt index d3e8c6bae9..a4a939731a 100644 --- a/dotpay/src/main/java/com/adyen/checkout/dotpay/internal/provider/DotpayComponentProvider.kt +++ b/dotpay/src/main/java/com/adyen/checkout/dotpay/internal/provider/DotpayComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.DotpayPaymentMethod import com.adyen.checkout.dotpay.DotpayComponent @@ -29,11 +29,11 @@ class DotpayComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider( componentClass = DotpayComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponent( diff --git a/drop-in/build.gradle b/drop-in/build.gradle index 085efd47b2..3456563921 100644 --- a/drop-in/build.gradle +++ b/drop-in/build.gradle @@ -74,6 +74,7 @@ dependencies { api project(':sepa') api project(':seven-eleven') api project(':sessions-core') + api project(':twint') api project(':upi') api project(':wechatpay') diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/DropIn.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/DropIn.kt index 3325718186..65d6c468be 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/DropIn.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/DropIn.kt @@ -256,6 +256,6 @@ object DropIn { } else { AdyenLogLevel.NONE } - AdyenLogger.setLogLevel(logLevel) + AdyenLogger.setInitialLogLevel(logLevel) } } diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/DropInConfiguration.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/DropInConfiguration.kt index e6df140c20..f5241f1fb8 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/DropInConfiguration.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/DropInConfiguration.kt @@ -35,6 +35,8 @@ import com.adyen.checkout.eps.EPSConfiguration import com.adyen.checkout.giftcard.GiftCardConfiguration import com.adyen.checkout.googlepay.GooglePayConfiguration import com.adyen.checkout.ideal.IdealConfiguration +import com.adyen.checkout.instant.GLOBAL_INSTANT_CONFIG_KEY +import com.adyen.checkout.instant.InstantPaymentConfiguration import com.adyen.checkout.mbway.MBWayConfiguration import com.adyen.checkout.molpay.MolpayConfiguration import com.adyen.checkout.onlinebankingcz.OnlineBankingCZConfiguration @@ -425,6 +427,18 @@ class DropInConfiguration private constructor( return this } + /** + * Add configuration for instant payment methods. + */ + @JvmOverloads + fun addInstantPaymentConfiguration( + instantPaymentConfiguration: InstantPaymentConfiguration, + paymentMethod: String = GLOBAL_INSTANT_CONFIG_KEY, + ): Builder { + availablePaymentConfigs[paymentMethod] = instantPaymentConfiguration + return this + } + /** * Provide a custom name to be shown in Drop-in for payment methods with a type matching [paymentMethodType]. * For [paymentMethodType] you can pass [PaymentMethodTypes] or any other custom value. diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt index e3b2cc6aa0..9a3e2aec13 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/provider/ComponentProvider.kt @@ -35,7 +35,7 @@ import com.adyen.checkout.components.core.ComponentCallback import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.PaymentComponent -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.conveniencestoresjp.ConvenienceStoresJPComponent @@ -115,12 +115,12 @@ internal fun getComponentFor( checkoutConfiguration: CheckoutConfiguration, dropInOverrideParams: DropInOverrideParams, componentCallback: ComponentCallback<*>, - analyticsRepository: AnalyticsRepository, + analyticsManager: AnalyticsManager, onRedirect: () -> Unit, ): PaymentComponent { return when { checkCompileOnly { ACHDirectDebitComponent.PROVIDER.isPaymentMethodSupported(storedPaymentMethod) } -> { - ACHDirectDebitComponentProvider(dropInOverrideParams, analyticsRepository).get( + ACHDirectDebitComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, storedPaymentMethod = storedPaymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -130,7 +130,7 @@ internal fun getComponentFor( } checkCompileOnly { BlikComponent.PROVIDER.isPaymentMethodSupported(storedPaymentMethod) } -> { - BlikComponentProvider(dropInOverrideParams, analyticsRepository).get( + BlikComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, storedPaymentMethod = storedPaymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -150,7 +150,7 @@ internal fun getComponentFor( } checkCompileOnly { CardComponent.PROVIDER.isPaymentMethodSupported(storedPaymentMethod) } -> { - CardComponentProvider(dropInOverrideParams, analyticsRepository).get( + CardComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, storedPaymentMethod = storedPaymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -181,12 +181,12 @@ internal fun getComponentFor( checkoutConfiguration: CheckoutConfiguration, dropInOverrideParams: DropInOverrideParams, componentCallback: ComponentCallback<*>, - analyticsRepository: AnalyticsRepository, + analyticsManager: AnalyticsManager, onRedirect: () -> Unit, ): PaymentComponent { return when { checkCompileOnly { ACHDirectDebitComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - ACHDirectDebitComponentProvider(dropInOverrideParams, analyticsRepository).get( + ACHDirectDebitComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -195,7 +195,7 @@ internal fun getComponentFor( } checkCompileOnly { BacsDirectDebitComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - BacsDirectDebitComponentProvider(dropInOverrideParams, analyticsRepository).get( + BacsDirectDebitComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -204,7 +204,7 @@ internal fun getComponentFor( } checkCompileOnly { BcmcComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - BcmcComponentProvider(dropInOverrideParams, analyticsRepository).get( + BcmcComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -213,7 +213,7 @@ internal fun getComponentFor( } checkCompileOnly { BlikComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - BlikComponentProvider(dropInOverrideParams, analyticsRepository).get( + BlikComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -222,7 +222,7 @@ internal fun getComponentFor( } checkCompileOnly { BoletoComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - BoletoComponentProvider(dropInOverrideParams, analyticsRepository).get( + BoletoComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -231,7 +231,7 @@ internal fun getComponentFor( } checkCompileOnly { CardComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - CardComponentProvider(dropInOverrideParams, analyticsRepository).get( + CardComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -249,7 +249,7 @@ internal fun getComponentFor( } checkCompileOnly { ConvenienceStoresJPComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - ConvenienceStoresJPComponentProvider(dropInOverrideParams, analyticsRepository).get( + ConvenienceStoresJPComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -258,7 +258,7 @@ internal fun getComponentFor( } checkCompileOnly { DotpayComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - DotpayComponentProvider(dropInOverrideParams, analyticsRepository).get( + DotpayComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -267,7 +267,7 @@ internal fun getComponentFor( } checkCompileOnly { EntercashComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - EntercashComponentProvider(dropInOverrideParams, analyticsRepository).get( + EntercashComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -276,7 +276,7 @@ internal fun getComponentFor( } checkCompileOnly { EPSComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - EPSComponentProvider(dropInOverrideParams, analyticsRepository).get( + EPSComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -285,7 +285,7 @@ internal fun getComponentFor( } checkCompileOnly { GiftCardComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - GiftCardComponentProvider(dropInOverrideParams, analyticsRepository).get( + GiftCardComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -294,7 +294,7 @@ internal fun getComponentFor( } checkCompileOnly { GooglePayComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - GooglePayComponentProvider(dropInOverrideParams, analyticsRepository).get( + GooglePayComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -303,7 +303,7 @@ internal fun getComponentFor( } checkCompileOnly { IdealComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - IdealComponentProvider(dropInOverrideParams, analyticsRepository).get( + IdealComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -312,7 +312,7 @@ internal fun getComponentFor( } checkCompileOnly { MBWayComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - MBWayComponentProvider(dropInOverrideParams, analyticsRepository).get( + MBWayComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -321,7 +321,7 @@ internal fun getComponentFor( } checkCompileOnly { MolpayComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - MolpayComponentProvider(dropInOverrideParams, analyticsRepository).get( + MolpayComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -330,7 +330,7 @@ internal fun getComponentFor( } checkCompileOnly { OnlineBankingCZComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - OnlineBankingCZComponentProvider(dropInOverrideParams, analyticsRepository).get( + OnlineBankingCZComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -339,7 +339,7 @@ internal fun getComponentFor( } checkCompileOnly { OnlineBankingJPComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - OnlineBankingJPComponentProvider(dropInOverrideParams, analyticsRepository).get( + OnlineBankingJPComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -348,7 +348,7 @@ internal fun getComponentFor( } checkCompileOnly { OnlineBankingPLComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - OnlineBankingPLComponentProvider(dropInOverrideParams, analyticsRepository).get( + OnlineBankingPLComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -357,7 +357,7 @@ internal fun getComponentFor( } checkCompileOnly { OnlineBankingSKComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - OnlineBankingSKComponentProvider(dropInOverrideParams, analyticsRepository).get( + OnlineBankingSKComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -366,7 +366,7 @@ internal fun getComponentFor( } checkCompileOnly { OpenBankingComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - OpenBankingComponentProvider(dropInOverrideParams, analyticsRepository).get( + OpenBankingComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -375,7 +375,7 @@ internal fun getComponentFor( } checkCompileOnly { PayByBankComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - PayByBankComponentProvider(dropInOverrideParams, analyticsRepository).get( + PayByBankComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -384,7 +384,7 @@ internal fun getComponentFor( } checkCompileOnly { PayEasyComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - PayEasyComponentProvider(dropInOverrideParams, analyticsRepository).get( + PayEasyComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -393,7 +393,7 @@ internal fun getComponentFor( } checkCompileOnly { SepaComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - SepaComponentProvider(dropInOverrideParams, analyticsRepository).get( + SepaComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -402,7 +402,7 @@ internal fun getComponentFor( } checkCompileOnly { SevenElevenComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - SevenElevenComponentProvider(dropInOverrideParams, analyticsRepository).get( + SevenElevenComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -411,7 +411,7 @@ internal fun getComponentFor( } checkCompileOnly { UPIComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - UPIComponentProvider(dropInOverrideParams, analyticsRepository).get( + UPIComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, @@ -423,7 +423,7 @@ internal fun getComponentFor( // which payment methods it supports. Meaning it could take over a payment method that should be handled by // it's dedicated component. checkCompileOnly { InstantPaymentComponent.PROVIDER.isPaymentMethodSupported(paymentMethod) } -> { - InstantPaymentComponentProvider(dropInOverrideParams, analyticsRepository).get( + InstantPaymentComponentProvider(dropInOverrideParams, analyticsManager).get( fragment = fragment, paymentMethod = paymentMethod, checkoutConfiguration = checkoutConfiguration, diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/service/BaseDropInService.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/service/BaseDropInService.kt index cfd44ddf3e..27fc2b8f69 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/service/BaseDropInService.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/service/BaseDropInService.kt @@ -167,22 +167,7 @@ constructor() : Service(), CoroutineScope, BaseDropInServiceInterface, BaseDropI private const val INTENT_EXTRA_ADDITIONAL_DATA = "ADDITIONAL_DATA" - internal fun startService( - context: Context, - connection: ServiceConnection, - merchantService: ComponentName, - additionalData: Bundle?, - ): Boolean { - adyenLog(AdyenLogLevel.DEBUG) { "startService - ${context::class.simpleName}" } - val intent = Intent().apply { - component = merchantService - } - adyenLog(AdyenLogLevel.DEBUG) { "merchant service: ${merchantService.className}" } - context.startService(intent) - return bindService(context, connection, merchantService, additionalData) - } - - private fun bindService( + internal fun bindService( context: Context, connection: ServiceConnection, merchantService: ComponentName, @@ -196,22 +181,7 @@ constructor() : Service(), CoroutineScope, BaseDropInServiceInterface, BaseDropI return context.bindService(intent, connection, Context.BIND_AUTO_CREATE) } - internal fun stopService( - context: Context, - merchantService: ComponentName, - connection: ServiceConnection, - ) { - unbindService(context, connection) - - adyenLog(AdyenLogLevel.DEBUG) { "stopService - ${context::class.simpleName}" } - - val intent = Intent().apply { - component = merchantService - } - context.stopService(intent) - } - - private fun unbindService(context: Context, connection: ServiceConnection) { + internal fun unbindService(context: Context, connection: ServiceConnection) { adyenLog(AdyenLogLevel.DEBUG) { "unbindService - ${context::class.simpleName}" } context.unbindService(connection) } diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/ActionComponentDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/ActionComponentDialogFragment.kt index 29a5abf14e..c26d8c4af5 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/ActionComponentDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/ActionComponentDialogFragment.kt @@ -38,6 +38,7 @@ import com.adyen.checkout.dropin.internal.util.arguments import com.google.android.material.bottomsheet.BottomSheetBehavior import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR @SuppressWarnings("TooManyFunctions") internal class ActionComponentDialogFragment : @@ -83,7 +84,7 @@ internal class ActionComponentDialogFragment : } override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).apply { - window?.setWindowAnimations(R.style.AdyenCheckout_BottomSheet_NoWindowEnterDialogAnimation) + window?.setWindowAnimations(UICoreR.style.AdyenCheckout_BottomSheet_NoWindowEnterDialogAnimation) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -93,8 +94,9 @@ internal class ActionComponentDialogFragment : binding.header.isVisible = false try { + val analyticsManager = dropInViewModel.analyticsManager val dropInOverrideParams = dropInViewModel.getDropInOverrideParams() - actionComponent = GenericActionComponentProvider(dropInOverrideParams).get( + actionComponent = GenericActionComponentProvider(analyticsManager, dropInOverrideParams).get( fragment = this, checkoutConfiguration = checkoutConfiguration, callback = this, @@ -134,7 +136,7 @@ internal class ActionComponentDialogFragment : adyenLog(AdyenLogLevel.DEBUG) { "Permission $requiredPermission requested" } requestPermissionLauncher.launch(arrayOf(requiredPermission)) } - .setPositiveButton(R.string.error_dialog_button) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(UICoreR.string.error_dialog_button) { dialog, _ -> dialog.dismiss() } .show() } @@ -144,13 +146,17 @@ internal class ActionComponentDialogFragment : .onEach { when (it) { ActionComponentFragmentEvent.HANDLE_ACTION -> { - actionComponent.handleAction(action, requireActivity()) + handleAction(action) } } } .launchIn(viewLifecycleOwner.lifecycleScope) } + fun handleAction(action: Action) { + actionComponent.handleAction(action, requireActivity()) + } + override fun onBackPressed(): Boolean { // polling will be canceled by lifecycle event when { @@ -214,21 +220,19 @@ internal class ActionComponentDialogFragment : } companion object { - const val ACTION = "ACTION" - const val CHECKOUT_CONFIGURATION = "CHECKOUT_CONFIGURATION" + private const val ACTION = "ACTION" + private const val CHECKOUT_CONFIGURATION = "CHECKOUT_CONFIGURATION" fun newInstance( action: Action, checkoutConfiguration: CheckoutConfiguration, ): ActionComponentDialogFragment { - val args = Bundle() - args.putParcelable(ACTION, action) - args.putParcelable(CHECKOUT_CONFIGURATION, checkoutConfiguration) - - val componentDialogFragment = ActionComponentDialogFragment() - componentDialogFragment.arguments = args - - return componentDialogFragment + return ActionComponentDialogFragment().apply { + arguments = Bundle().apply { + putParcelable(ACTION, action) + putParcelable(CHECKOUT_CONFIGURATION, checkoutConfiguration) + } + } } } } diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BacsDirectDebitDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BacsDirectDebitDialogFragment.kt index 33c43ed4d1..68ecad61a8 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BacsDirectDebitDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BacsDirectDebitDialogFragment.kt @@ -21,6 +21,7 @@ import com.adyen.checkout.core.internal.util.adyenLog import com.adyen.checkout.dropin.databinding.FragmentBacsDirectDebitComponentBinding import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.R as MaterialR internal class BacsDirectDebitDialogFragment : BaseComponentDialogFragment() { @@ -68,7 +69,7 @@ internal class BacsDirectDebitDialogFragment : BaseComponentDialogFragment() { dialog.setOnShowListener { val bottomSheetDialog = dialog as BottomSheetDialog val bottomSheet = - bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheetDialog.findViewById(MaterialR.id.design_bottom_sheet) val layoutParams = bottomSheet?.layoutParams val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) } behavior?.isDraggable = false diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BaseComponentDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BaseComponentDialogFragment.kt index c5b60aaae2..8f4da7b370 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BaseComponentDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/BaseComponentDialogFragment.kt @@ -23,8 +23,8 @@ import com.adyen.checkout.components.core.paymentmethod.PaymentMethodDetails import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.internal.util.adyenLog -import com.adyen.checkout.dropin.R import com.adyen.checkout.dropin.internal.provider.getComponentFor +import com.adyen.checkout.ui.core.R as UICoreR private const val STORED_PAYMENT_METHOD = "STORED_PAYMENT_METHOD" private const val NAVIGATED_FROM_PRESELECTED = "NAVIGATED_FROM_PRESELECTED" @@ -109,7 +109,7 @@ internal abstract class BaseComponentDialogFragment : checkoutConfiguration = dropInViewModel.checkoutConfiguration, dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), componentCallback = this, - analyticsRepository = dropInViewModel.analyticsRepository, + analyticsManager = dropInViewModel.analyticsManager, onRedirect = protocol::onRedirect, ) } else { @@ -119,7 +119,7 @@ internal abstract class BaseComponentDialogFragment : checkoutConfiguration = dropInViewModel.checkoutConfiguration, dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), componentCallback = this, - analyticsRepository = dropInViewModel.analyticsRepository, + analyticsManager = dropInViewModel.analyticsManager, onRedirect = protocol::onRedirect, ) } @@ -163,6 +163,6 @@ internal abstract class BaseComponentDialogFragment : fun handleError(componentError: ComponentError) { adyenLog(AdyenLogLevel.ERROR) { componentError.errorMessage } - protocol.showError(null, getString(R.string.component_error), componentError.errorMessage, true) + protocol.showError(null, getString(UICoreR.string.component_error), componentError.errorMessage, true) } } diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/CardComponentDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/CardComponentDialogFragment.kt index b5d1b0801b..891965b3f2 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/CardComponentDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/CardComponentDialogFragment.kt @@ -12,7 +12,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.lifecycleScope import com.adyen.checkout.card.CardComponent import com.adyen.checkout.components.core.AddressLookupCallback import com.adyen.checkout.components.core.LookupAddress @@ -56,13 +56,13 @@ internal class CardComponentDialogFragment : BaseComponentDialogFragment(), Addr binding.cardView.requestFocus() } - dropInViewModel.addressLookupOptionsFlow.onEach { - cardComponent.updateAddressLookupOptions(it) - }.launchIn(dropInViewModel.viewModelScope) + dropInViewModel.addressLookupOptionsFlow + .onEach { cardComponent.updateAddressLookupOptions(it) } + .launchIn(viewLifecycleOwner.lifecycleScope) - dropInViewModel.addressLookupCompleteFlow.onEach { - cardComponent.setAddressLookupResult(it) - }.launchIn(dropInViewModel.viewModelScope) + dropInViewModel.addressLookupCompleteFlow + .onEach { cardComponent.setAddressLookupResult(it) } + .launchIn(viewLifecycleOwner.lifecycleScope) } override fun onQueryChanged(query: String) { diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInActivity.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInActivity.kt index 15dec810c6..106bc6a05f 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInActivity.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInActivity.kt @@ -64,6 +64,7 @@ import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionPaymentResult import com.adyen.checkout.wechatpay.WeChatPayUtils import kotlinx.coroutines.launch +import com.adyen.checkout.ui.core.R as UICoreR /** * Activity that presents the available PaymentMethods to the Shopper. @@ -132,6 +133,7 @@ internal class DropInActivity : override fun onServiceDisconnected(className: ComponentName) { adyenLog(AdyenLogLevel.DEBUG) { "onServiceDisconnected" } + serviceBound = false dropInService = null } } @@ -153,9 +155,7 @@ internal class DropInActivity : return } - if (noDialogPresent()) { - dropInViewModel.onCreated() - } + dropInViewModel.onCreated(noDialogPresent()) handleIntent(intent) @@ -214,7 +214,7 @@ internal class DropInActivity : } private fun startDropInService() { - val bound = BaseDropInService.startService( + val bound = BaseDropInService.bindService( context = this, connection = serviceConnection, merchantService = dropInViewModel.serviceComponentName, @@ -237,9 +237,8 @@ internal class DropInActivity : private fun stopDropInService() { if (serviceBound) { - BaseDropInService.stopService( + BaseDropInService.unbindService( context = this, - merchantService = dropInViewModel.serviceComponentName, connection = serviceConnection, ) serviceBound = false @@ -273,7 +272,7 @@ internal class DropInActivity : override fun showError(dialogTitle: String?, errorMessage: String, reason: String, terminate: Boolean) { adyenLog(AdyenLogLevel.DEBUG) { "showError - message: $errorMessage" } - val title = dialogTitle ?: getString(R.string.error_dialog_title) + val title = dialogTitle ?: getString(UICoreR.string.error_dialog_title) showDialog(title, errorMessage) { errorDialogDismissed(reason, terminate) } @@ -295,6 +294,7 @@ internal class DropInActivity : override fun onDestroy() { adyenLog(AdyenLogLevel.VERBOSE) { "onDestroy" } + stopDropInService() super.onDestroy() } @@ -480,6 +480,10 @@ internal class DropInActivity : private fun handleAction(action: Action) { adyenLog(AdyenLogLevel.DEBUG) { "showActionDialog" } + getExistingActionFragment()?.apply { + handleAction(action) + return + } setLoading(false) hideAllScreens() val actionFragment = ActionComponentDialogFragment.newInstance(action, dropInViewModel.checkoutConfiguration) @@ -561,14 +565,16 @@ internal class DropInActivity : private fun isWeChatPayIntent(intent: Intent): Boolean = checkCompileOnly { WeChatPayUtils.isResultIntent(intent) } private fun handleActionIntentResponse(intent: Intent) { - val actionFragment = getActionFragment() ?: return + val actionFragment = getExistingActionFragment() + if (actionFragment == null) { + adyenLog(AdyenLogLevel.ERROR) { "ActionComponentDialogFragment is not loaded" } + return + } actionFragment.handleIntent(intent) } - private fun getActionFragment(): ActionComponentDialogFragment? { - val fragment = getFragmentByTag(ACTION_FRAGMENT_TAG) as? ActionComponentDialogFragment - if (fragment == null) adyenLog(AdyenLogLevel.ERROR) { "ActionComponentDialogFragment is not loaded" } - return fragment + private fun getExistingActionFragment(): ActionComponentDialogFragment? { + return getFragmentByTag(ACTION_FRAGMENT_TAG) as? ActionComponentDialogFragment } private fun initObservers() { @@ -720,7 +726,7 @@ internal class DropInActivity : .setTitle(title) .setMessage(message) .setOnDismissListener { onDismiss() } - .setPositiveButton(R.string.error_dialog_button) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(UICoreR.string.error_dialog_button) { dialog, _ -> dialog.dismiss() } .show() } diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInBottomSheetDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInBottomSheetDialogFragment.kt index 1913fc218d..91a3fe8148 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInBottomSheetDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInBottomSheetDialogFragment.kt @@ -27,10 +27,12 @@ import com.adyen.checkout.giftcard.GiftCardComponentState import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.R as MaterialR internal abstract class DropInBottomSheetDialogFragment : BottomSheetDialogFragment() { - lateinit var protocol: Protocol + private var _protocol: Protocol? = null + protected val protocol: Protocol get() = requireNotNull(_protocol) private var dialogInitViewState: Int = BottomSheetBehavior.STATE_COLLAPSED protected val dropInViewModel: DropInViewModel by activityViewModels { DropInViewModelFactory(requireActivity()) } @@ -43,7 +45,7 @@ internal abstract class DropInBottomSheetDialogFragment : BottomSheetDialogFragm super.onAttach(context) require(activity is Protocol) - protocol = activity as Protocol + _protocol = activity as Protocol } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -59,7 +61,7 @@ internal abstract class DropInBottomSheetDialogFragment : BottomSheetDialogFragm dialog.setOnShowListener { val bottomSheet = (dialog as BottomSheetDialog).findViewById( - com.google.android.material.R.id.design_bottom_sheet, + MaterialR.id.design_bottom_sheet, ) if (bottomSheet != null) { @@ -88,6 +90,11 @@ internal abstract class DropInBottomSheetDialogFragment : BottomSheetDialogFragm protocol.terminateDropIn() } + override fun onDetach() { + _protocol = null + super.onDetach() + } + /** * Interface for Drop-in fragments to interact with the main Activity */ diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModel.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModel.kt index 8ff6142543..9a8aac5d07 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModel.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModel.kt @@ -24,7 +24,7 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.PaymentMethodsApiResponse import com.adyen.checkout.components.core.StoredPaymentMethod -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.data.api.OrderStatusRepository import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.internal.util.bufferedChannel @@ -58,7 +58,7 @@ import kotlinx.coroutines.launch internal class DropInViewModel( private val bundleHandler: DropInSavedStateHandleContainer, private val orderStatusRepository: OrderStatusRepository, - internal val analyticsRepository: AnalyticsRepository, + internal val analyticsManager: AnalyticsManager, private val initialDropInParams: DropInParams, private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { @@ -184,9 +184,12 @@ internal class DropInViewModel( ) } - fun onCreated() { - navigateToInitialDestination() - setupAnalytics() + fun onCreated(noDialogPresent: Boolean) { + if (noDialogPresent) { + navigateToInitialDestination() + } + + initializeAnalytics() } fun onDropInServiceConnected() { @@ -230,11 +233,9 @@ internal class DropInViewModel( sendEvent(DropInActivityEvent.NavigateTo(destination)) } - private fun setupAnalytics() { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - viewModelScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics() { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, viewModelScope) } /** @@ -468,4 +469,9 @@ internal class DropInViewModel( eventChannel.send(event) } } + + override fun onCleared() { + super.onCleared() + analyticsManager.clear(this) + } } diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModelFactory.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModelFactory.kt index a3e87505b4..752f575ac2 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModelFactory.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/DropInViewModelFactory.kt @@ -8,19 +8,16 @@ package com.adyen.checkout.dropin.internal.ui +import android.app.Application import androidx.activity.ComponentActivity import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.adyen.checkout.components.core.CheckoutConfiguration -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.data.api.OrderStatusRepository import com.adyen.checkout.components.core.internal.data.api.OrderStatusService -import com.adyen.checkout.components.core.internal.data.model.AnalyticsSource -import com.adyen.checkout.components.core.internal.util.screenWidthPixels import com.adyen.checkout.core.internal.data.api.HttpClientFactory import com.adyen.checkout.core.internal.util.LocaleProvider import com.adyen.checkout.dropin.internal.ui.model.DropInParams @@ -36,9 +33,8 @@ internal class DropInViewModelFactory( localeProvider: LocaleProvider = LocaleProvider(), ) : AbstractSavedStateViewModelFactory(activity, activity.intent.extras) { - private val packageName: String = activity.packageName - private val screenWidth: Int = activity.screenWidthPixels private val deviceLocale: Locale = localeProvider.getLocale(activity) + private val application: Application = activity.application override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { val bundleHandler = DropInSavedStateHandleContainer(handle) @@ -54,26 +50,25 @@ internal class DropInViewModelFactory( val httpClient = HttpClientFactory.getHttpClient(dropInParams.environment) val orderStatusRepository = OrderStatusRepository(OrderStatusService(httpClient)) - val analyticsRepository = DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - level = dropInParams.analyticsParams.level, - packageName = packageName, - locale = dropInParams.shopperLocale, - source = AnalyticsSource.DropIn(), - clientKey = dropInParams.clientKey, - amount = dropInParams.amount, - screenWidth = screenWidth, - paymentMethods = paymentMethods, - sessionId = bundleHandler.sessionDetails?.id, - ), - analyticsService = AnalyticsService( - httpClient = HttpClientFactory.getAnalyticsHttpClient(dropInParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = AnalyticsManagerFactory().provide( + shopperLocale = dropInParams.shopperLocale, + environment = dropInParams.environment, + clientKey = dropInParams.clientKey, + analyticsParams = dropInParams.analyticsParams, + isCreatedByDropIn = true, + amount = dropInParams.amount, + application = application, + source = AnalyticsSource.DropIn(paymentMethods), + sessionId = bundleHandler.sessionDetails?.id, ) @Suppress("UNCHECKED_CAST") - return DropInViewModel(bundleHandler, orderStatusRepository, analyticsRepository, dropInParams) as T + return DropInViewModel( + bundleHandler, + orderStatusRepository, + analyticsManager, + dropInParams, + ) as T } private fun getDropInParams( diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardComponentDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardComponentDialogFragment.kt index 0bd45ebe6d..8eda0c50b3 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardComponentDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardComponentDialogFragment.kt @@ -20,7 +20,6 @@ import com.adyen.checkout.components.core.internal.PaymentComponent import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.internal.util.adyenLog -import com.adyen.checkout.dropin.R import com.adyen.checkout.dropin.databinding.FragmentGiftcardComponentBinding import com.adyen.checkout.dropin.internal.provider.getComponentFor import com.adyen.checkout.giftcard.GiftCardComponent @@ -28,6 +27,7 @@ import com.adyen.checkout.giftcard.GiftCardComponentCallback import com.adyen.checkout.giftcard.GiftCardComponentState import com.adyen.checkout.ui.core.internal.ui.ViewableComponent import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class GiftCardComponentDialogFragment : DropInBottomSheetDialogFragment(), GiftCardComponentCallback { @@ -73,7 +73,7 @@ internal class GiftCardComponentDialogFragment : DropInBottomSheetDialogFragment checkoutConfiguration = dropInViewModel.checkoutConfiguration, dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), componentCallback = this, - analyticsRepository = dropInViewModel.analyticsRepository, + analyticsManager = dropInViewModel.analyticsManager, onRedirect = protocol::onRedirect, ) as GiftCardComponent } catch (e: CheckoutException) { @@ -125,7 +125,7 @@ internal class GiftCardComponentDialogFragment : DropInBottomSheetDialogFragment private fun handleError(componentError: ComponentError) { adyenLog(AdyenLogLevel.ERROR) { componentError.errorMessage } - protocol.showError(null, getString(R.string.component_error), componentError.errorMessage, true) + protocol.showError(null, getString(UICoreR.string.component_error), componentError.errorMessage, true) } override fun onBackPressed(): Boolean { diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardPaymentConfirmationDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardPaymentConfirmationDialogFragment.kt index 689267562e..df7ff2f9a0 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardPaymentConfirmationDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GiftCardPaymentConfirmationDialogFragment.kt @@ -20,6 +20,7 @@ import com.adyen.checkout.dropin.R import com.adyen.checkout.dropin.databinding.FragmentGiftCardPaymentConfirmationBinding import com.adyen.checkout.dropin.internal.ui.model.GiftCardPaymentConfirmationData import com.adyen.checkout.dropin.internal.ui.model.GiftCardPaymentMethodModel +import com.adyen.checkout.ui.core.R as UICoreR internal class GiftCardPaymentConfirmationDialogFragment : DropInBottomSheetDialogFragment() { @@ -52,7 +53,7 @@ internal class GiftCardPaymentConfirmationDialogFragment : DropInBottomSheetDial giftCardPaymentConfirmationData.amountPaid, giftCardPaymentConfirmationData.shopperLocale, ) - binding.payButton.text = String.format(resources.getString(R.string.pay_button_with_value), amountToPay) + binding.payButton.text = String.format(resources.getString(UICoreR.string.pay_button_with_value), amountToPay) val remainingBalance = CurrencyUtils.formatAmount( giftCardPaymentConfirmationData.remainingBalance, diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GooglePayComponentDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GooglePayComponentDialogFragment.kt index 1c3b7fbee7..e3f2365467 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GooglePayComponentDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/GooglePayComponentDialogFragment.kt @@ -76,7 +76,7 @@ internal class GooglePayComponentDialogFragment : checkoutConfiguration = dropInViewModel.checkoutConfiguration, dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), componentCallback = this, - analyticsRepository = dropInViewModel.analyticsRepository, + analyticsManager = dropInViewModel.analyticsManager, onRedirect = protocol::onRedirect, ) as GooglePayComponent } catch (e: CheckoutException) { diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PaymentMethodListDialogFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PaymentMethodListDialogFragment.kt index a8d19604a0..681f967e9c 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PaymentMethodListDialogFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PaymentMethodListDialogFragment.kt @@ -15,11 +15,13 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.core.view.children +import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.adyen.checkout.components.core.StoredPaymentMethod import com.adyen.checkout.components.core.internal.PaymentComponent +import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.internal.util.adyenLog import com.adyen.checkout.dropin.R @@ -31,11 +33,11 @@ import com.adyen.checkout.dropin.internal.ui.model.PaymentMethodModel import com.adyen.checkout.dropin.internal.ui.model.StoredACHDirectDebitModel import com.adyen.checkout.dropin.internal.ui.model.StoredCardModel import com.adyen.checkout.dropin.internal.ui.model.StoredPaymentMethodModel -import com.adyen.checkout.dropin.internal.util.getViewModel import com.adyen.checkout.ui.core.internal.ui.view.AdyenSwipeToRevealLayout import com.adyen.checkout.ui.core.internal.util.PayButtonFormatter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class PaymentMethodListDialogFragment : @@ -46,7 +48,19 @@ internal class PaymentMethodListDialogFragment : private var _binding: FragmentPaymentMethodsListBinding? = null private val binding: FragmentPaymentMethodsListBinding get() = requireNotNull(_binding) - private lateinit var paymentMethodsListViewModel: PaymentMethodsListViewModel + private val paymentMethodsListViewModel: PaymentMethodsListViewModel by viewModels { + viewModelFactory { + PaymentMethodsListViewModel( + application = requireActivity().application, + paymentMethods = dropInViewModel.getPaymentMethods(), + storedPaymentMethods = dropInViewModel.getStoredPaymentMethods(), + order = dropInViewModel.currentOrder, + checkoutConfiguration = dropInViewModel.checkoutConfiguration, + dropInParams = dropInViewModel.dropInParams, + dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), + ) + } + } private var paymentMethodAdapter: PaymentMethodAdapter? = null private var component: PaymentComponent? = null @@ -58,17 +72,6 @@ internal class PaymentMethodListDialogFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { adyenLog(AdyenLogLevel.DEBUG) { "onCreateView" } - paymentMethodsListViewModel = getViewModel { - PaymentMethodsListViewModel( - application = requireActivity().application, - paymentMethods = dropInViewModel.getPaymentMethods(), - storedPaymentMethods = dropInViewModel.getStoredPaymentMethods(), - order = dropInViewModel.currentOrder, - checkoutConfiguration = dropInViewModel.checkoutConfiguration, - dropInParams = dropInViewModel.dropInParams, - dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), - ) - } _binding = FragmentPaymentMethodsListBinding.inflate(inflater, container, false) return binding.root } @@ -96,7 +99,8 @@ internal class PaymentMethodListDialogFragment : .onEach { paymentMethods -> adyenLog(AdyenLogLevel.DEBUG) { "paymentMethods changed" } paymentMethodAdapter?.submitList(paymentMethods) - }.launchIn(lifecycleScope) + } + .launchIn(viewLifecycleOwner.lifecycleScope) paymentMethodsListViewModel .eventsFlow @@ -119,7 +123,7 @@ internal class PaymentMethodListDialogFragment : adyenLog(AdyenLogLevel.ERROR) { event.componentError.errorMessage } protocol.showError( dialogTitle = null, - errorMessage = getString(R.string.component_error), + errorMessage = getString(UICoreR.string.component_error), reason = event.componentError.errorMessage, terminate = true, ) @@ -130,7 +134,7 @@ internal class PaymentMethodListDialogFragment : } } } - .launchIn(lifecycleScope) + .launchIn(viewLifecycleOwner.lifecycleScope) } override fun onDestroyView() { @@ -158,7 +162,7 @@ internal class PaymentMethodListDialogFragment : checkoutConfiguration = dropInViewModel.checkoutConfiguration, dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), componentCallback = paymentMethodsListViewModel, - analyticsRepository = dropInViewModel.analyticsRepository, + analyticsManager = dropInViewModel.analyticsManager, onRedirect = protocol::onRedirect, ) paymentMethodsListViewModel.onClickStoredItem(storedPaymentMethod, storedPaymentMethodModel) diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PreselectedStoredPaymentMethodFragment.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PreselectedStoredPaymentMethodFragment.kt index ee7cb32148..e352d594e5 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PreselectedStoredPaymentMethodFragment.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/PreselectedStoredPaymentMethodFragment.kt @@ -14,11 +14,12 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.adyen.checkout.components.core.ComponentError import com.adyen.checkout.components.core.StoredPaymentMethod -import com.adyen.checkout.components.core.internal.PaymentComponent import com.adyen.checkout.components.core.internal.util.DateUtils +import com.adyen.checkout.components.core.internal.util.viewModelFactory import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.exception.ComponentException @@ -31,26 +32,27 @@ import com.adyen.checkout.dropin.internal.ui.model.StoredACHDirectDebitModel import com.adyen.checkout.dropin.internal.ui.model.StoredCardModel import com.adyen.checkout.dropin.internal.ui.model.StoredPaymentMethodModel import com.adyen.checkout.dropin.internal.util.arguments -import com.adyen.checkout.dropin.internal.util.viewModelsFactory import com.adyen.checkout.ui.core.internal.ui.loadLogo import com.adyen.checkout.ui.core.internal.util.PayButtonFormatter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class PreselectedStoredPaymentMethodFragment : DropInBottomSheetDialogFragment() { - private val storedPaymentViewModel: PreselectedStoredPaymentViewModel by viewModelsFactory { - PreselectedStoredPaymentViewModel( - storedPaymentMethod, - dropInViewModel.dropInParams, - ) + private val storedPaymentViewModel: PreselectedStoredPaymentViewModel by viewModels { + viewModelFactory { + PreselectedStoredPaymentViewModel( + storedPaymentMethod, + dropInViewModel.dropInParams, + ) + } } private var _binding: FragmentStoredPaymentMethodBinding? = null private val binding: FragmentStoredPaymentMethodBinding get() = requireNotNull(_binding) private val storedPaymentMethod: StoredPaymentMethod by arguments(STORED_PAYMENT_KEY) - private lateinit var component: PaymentComponent override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { if (storedPaymentMethod.type.isNullOrEmpty()) { @@ -73,13 +75,13 @@ internal class PreselectedStoredPaymentMethodFragment : DropInBottomSheetDialogF private fun loadComponent() { try { - component = getComponentFor( + getComponentFor( fragment = this, storedPaymentMethod = storedPaymentMethod, checkoutConfiguration = dropInViewModel.checkoutConfiguration, dropInOverrideParams = dropInViewModel.getDropInOverrideParams(), componentCallback = storedPaymentViewModel, - analyticsRepository = dropInViewModel.analyticsRepository, + analyticsManager = dropInViewModel.analyticsManager, onRedirect = protocol::onRedirect, ) } catch (e: CheckoutException) { @@ -202,7 +204,7 @@ internal class PreselectedStoredPaymentMethodFragment : DropInBottomSheetDialogF private fun handleError(componentError: ComponentError) { adyenLog(AdyenLogLevel.ERROR) { componentError.errorMessage } - protocol.showError(null, getString(R.string.component_error), componentError.errorMessage, true) + protocol.showError(null, getString(UICoreR.string.component_error), componentError.errorMessage, true) } private fun showRemoveStoredPaymentDialog() { diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapper.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapper.kt index 1d74b86cd2..288e51cb07 100644 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapper.kt +++ b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapper.kt @@ -27,7 +27,10 @@ internal class DropInParamsMapper { shopperLocale = getShopperLocale(checkoutConfiguration, sessionParams) ?: deviceLocale, environment = sessionParams?.environment ?: checkoutConfiguration.environment, clientKey = sessionParams?.clientKey ?: checkoutConfiguration.clientKey, - analyticsParams = AnalyticsParams(checkoutConfiguration.analyticsConfiguration), + analyticsParams = AnalyticsParams( + analyticsConfiguration = checkoutConfiguration.analyticsConfiguration, + clientKey = checkoutConfiguration.clientKey, + ), amount = sessionParams?.amount ?: checkoutConfiguration.amount, showPreselectedStoredPaymentMethod = dropInConfiguration?.showPreselectedStoredPaymentMethod ?: true, skipListWhenSinglePaymentMethod = dropInConfiguration?.skipListWhenSinglePaymentMethod ?: false, diff --git a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/util/DropInExt.kt b/drop-in/src/main/java/com/adyen/checkout/dropin/internal/util/DropInExt.kt deleted file mode 100644 index 8e3a68c68f..0000000000 --- a/drop-in/src/main/java/com/adyen/checkout/dropin/internal/util/DropInExt.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2020 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 1/12/2020. - */ - -package com.adyen.checkout.dropin.internal.util - -import androidx.activity.viewModels -import androidx.annotation.MainThread -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.adyen.checkout.components.core.internal.util.viewModelFactory - -@MainThread -internal inline fun AppCompatActivity.getViewModel( - crossinline factoryProducer: () -> ViewModelT -): ViewModelT { - return ViewModelProvider(this, viewModelFactory(factoryProducer))[ViewModelT::class.java] -} - -@MainThread -internal inline fun Fragment.getViewModel( - crossinline f: () -> ViewModelT -): ViewModelT { - return ViewModelProvider(this, viewModelFactory(f))[ViewModelT::class.java] -} - -@MainThread -internal inline fun Fragment.getActivityViewModel( - crossinline f: () -> ViewModelT -): ViewModelT { - return ViewModelProvider(requireActivity(), viewModelFactory(f))[ViewModelT::class.java] -} - -@MainThread -internal inline fun AppCompatActivity.viewModelsFactory( - crossinline factoryProducer: () -> VM -): Lazy { - return viewModels { viewModelFactory(factoryProducer) } -} - -@MainThread -internal inline fun Fragment.viewModelsFactory( - crossinline factoryProducer: () -> VM -): Lazy { - return viewModels { viewModelFactory(factoryProducer) } -} diff --git a/drop-in/src/test/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapperTest.kt b/drop-in/src/test/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapperTest.kt index 7b7179c765..7181f45c37 100644 --- a/drop-in/src/test/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapperTest.kt +++ b/drop-in/src/test/java/com/adyen/checkout/dropin/internal/ui/model/DropInParamsMapperTest.kt @@ -97,7 +97,7 @@ internal class DropInParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), amount = Amount( currency = "EUR", value = 49_00L, @@ -246,7 +246,7 @@ internal class DropInParamsMapperTest { environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, shopperLocale: Locale = DEVICE_LOCALE, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), amount: Amount? = null, showPreselectedStoredPaymentMethod: Boolean = true, skipListWhenSinglePaymentMethod: Boolean = false, diff --git a/econtext/build.gradle b/econtext/build.gradle index 67715bcc74..9a162bd6ec 100644 --- a/econtext/build.gradle +++ b/econtext/build.gradle @@ -48,7 +48,9 @@ dependencies { //Tests testImplementation project(':3ds2') testImplementation project(':test-core') + testImplementation project(':twint') testImplementation project(':wechatpay') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/econtext/src/main/java/com/adyen/checkout/econtext/internal/provider/EContextComponentProvider.kt b/econtext/src/main/java/com/adyen/checkout/econtext/internal/provider/EContextComponentProvider.kt index d9dd909bda..cd384bb5d7 100644 --- a/econtext/src/main/java/com/adyen/checkout/econtext/internal/provider/EContextComponentProvider.kt +++ b/econtext/src/main/java/com/adyen/checkout/econtext/internal/provider/EContextComponentProvider.kt @@ -26,11 +26,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.ComponentEventHandler import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -67,7 +65,7 @@ abstract class EContextComponentProvider< constructor( private val componentClass: Class, private val dropInOverrideParams: DropInOverrideParams?, - private val analyticsRepository: AnalyticsRepository?, + private val analyticsManager: AnalyticsManager?, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider>, SessionPaymentComponentProvider< @@ -100,33 +98,30 @@ constructor( componentConfiguration = getConfiguration(checkoutConfiguration), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) + val eContextDelegate = DefaultEContextDelegate( observerRepository = PaymentObserverRepository(), componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), typedPaymentMethodFactory = { createPaymentMethod() }, componentStateFactory = ::createComponentState, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) createComponent( delegate = eContextDelegate, @@ -192,34 +187,30 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) + val eContextDelegate = DefaultEContextDelegate( observerRepository = PaymentObserverRepository(), componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), typedPaymentMethodFactory = { createPaymentMethod() }, componentStateFactory = ::createComponentState, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegate.kt b/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegate.kt index d820f87b40..55ff652f7f 100644 --- a/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegate.kt +++ b/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegate.kt @@ -17,7 +17,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.internal.ui.model.FieldState import com.adyen.checkout.components.core.internal.ui.model.Validation @@ -33,10 +34,12 @@ import com.adyen.checkout.ui.core.internal.ui.ComponentViewType import com.adyen.checkout.ui.core.internal.ui.PaymentComponentUIEvent import com.adyen.checkout.ui.core.internal.ui.PaymentComponentUIState import com.adyen.checkout.ui.core.internal.ui.SubmitHandler +import com.adyen.checkout.ui.core.internal.ui.model.CountryModel +import com.adyen.checkout.ui.core.internal.util.CountryUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch +import java.util.Locale @Suppress("TooManyFunctions", "LongParameterList") internal class DefaultEContextDelegate< @@ -47,7 +50,7 @@ internal class DefaultEContextDelegate< override val componentParams: ButtonComponentParams, private val paymentMethod: PaymentMethod, private val order: Order?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, private val typedPaymentMethodFactory: () -> EContextPaymentMethodT, private val componentStateFactory: ( @@ -76,14 +79,15 @@ internal class DefaultEContextDelegate< override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun updateInputData(update: EContextInputData.() -> Unit) { @@ -117,7 +121,7 @@ internal class DefaultEContextDelegate< ): EContextComponentStateT { val eContextPaymentMethod = typedPaymentMethodFactory().apply { type = getPaymentMethodType() - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId() + checkoutAttemptId = analyticsManager.getCheckoutAttemptId() firstName = outputData.firstNameState.value lastName = outputData.lastNameState.value telephoneNumber = outputData.phoneNumberState.value @@ -195,13 +199,25 @@ internal class DefaultEContextDelegate< override fun onCleared() { removeObserver() + analyticsManager.clear(this) } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } + override fun getSupportedCountries(): List = + CountryUtils.getLocalizedCountries(componentParams.shopperLocale) + + override fun getInitiallySelectedCountry(): CountryModel? { + val countries = getSupportedCountries() + return countries.firstOrNull { it.isoCode == Locale.JAPAN.country } ?: countries.firstOrNull() + } + override fun isConfirmationRequired(): Boolean { return _viewFlow.value is ButtonComponentViewType } diff --git a/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/EContextDelegate.kt b/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/EContextDelegate.kt index 635f3587f2..ba6b35578d 100644 --- a/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/EContextDelegate.kt +++ b/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/EContextDelegate.kt @@ -17,6 +17,7 @@ import com.adyen.checkout.econtext.internal.ui.model.EContextOutputData import com.adyen.checkout.ui.core.internal.ui.ButtonDelegate import com.adyen.checkout.ui.core.internal.ui.UIStateDelegate import com.adyen.checkout.ui.core.internal.ui.ViewProvidingDelegate +import com.adyen.checkout.ui.core.internal.ui.model.CountryModel import kotlinx.coroutines.flow.Flow @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @@ -38,4 +39,8 @@ interface EContextDelegate< fun updateInputData(update: EContextInputData.() -> Unit) fun setInteractionBlocked(isInteractionBlocked: Boolean) + + fun getSupportedCountries(): List + + fun getInitiallySelectedCountry(): CountryModel? } diff --git a/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/view/EContextView.kt b/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/view/EContextView.kt index 9cb4c47d41..f85100c305 100644 --- a/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/view/EContextView.kt +++ b/econtext/src/main/java/com/adyen/checkout/econtext/internal/ui/view/EContextView.kt @@ -17,7 +17,6 @@ import android.widget.AdapterView import android.widget.LinearLayout import com.adyen.checkout.components.core.internal.ui.ComponentDelegate import com.adyen.checkout.components.core.internal.ui.model.Validation -import com.adyen.checkout.components.core.internal.util.CountryUtils import com.adyen.checkout.econtext.R import com.adyen.checkout.econtext.databinding.EcontextViewBinding import com.adyen.checkout.econtext.internal.ui.EContextDelegate @@ -27,7 +26,7 @@ import com.adyen.checkout.ui.core.internal.ui.model.CountryModel import com.adyen.checkout.ui.core.internal.ui.view.AdyenTextInputEditText import com.adyen.checkout.ui.core.internal.util.setLocalizedHintFromStyle import kotlinx.coroutines.CoroutineScope -import java.util.Locale +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class EContextView @JvmOverloads constructor( @@ -46,7 +45,7 @@ internal class EContextView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } @@ -104,19 +103,19 @@ internal class EContextView @JvmOverloads constructor( private fun initLocalizedStrings(localizedContext: Context) { binding.textInputLayoutFirstName.setLocalizedHintFromStyle( R.style.AdyenCheckout_EContext_FirstNameInput, - localizedContext + localizedContext, ) binding.textInputLayoutLastName.setLocalizedHintFromStyle( R.style.AdyenCheckout_EContext_LastNameInput, - localizedContext + localizedContext, ) binding.textInputLayoutMobileNumber.setLocalizedHintFromStyle( R.style.AdyenCheckout_EContext_PhoneNumberInput, - localizedContext + localizedContext, ) binding.textInputLayoutEmailAddress.setLocalizedHintFromStyle( R.style.AdyenCheckout_EContext_ShopperEmailInput, - localizedContext + localizedContext, ) } @@ -158,14 +157,7 @@ internal class EContextView @JvmOverloads constructor( private fun initCountryCodeInput() { val countryAutoCompleteTextView = binding.autoCompleteTextViewCountry - val countries = CountryUtils.getCountries().map { - CountryModel( - isoCode = it.isoCode, - countryName = CountryUtils.getCountryName(it.isoCode, delegate.componentParams.shopperLocale), - callingCode = it.callingCode, - emoji = it.emoji - ) - } + val countries = delegate.getSupportedCountries() countryAdapter = CountryAdapter(context, localizedContext).apply { setItems(countries) } @@ -176,8 +168,7 @@ internal class EContextView @JvmOverloads constructor( val country = countryAdapter?.getItem(position) ?: return@OnItemClickListener onCountrySelected(country) } - val initialCountry = countries.firstOrNull { it.isoCode == Locale.JAPAN.country } ?: countries.firstOrNull() - initialCountry?.let { + delegate.getInitiallySelectedCountry()?.let { setText(it.toShortString()) onCountrySelected(it) } diff --git a/econtext/src/main/res/layout/econtext_view.xml b/econtext/src/main/res/layout/econtext_view.xml index b620a336fd..8610342b9f 100644 --- a/econtext/src/main/res/layout/econtext_view.xml +++ b/econtext/src/main/res/layout/econtext_view.xml @@ -9,7 +9,7 @@ @@ -22,6 +22,7 @@ + + + + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_height="match_parent" + android:dropDownAnchor="@id/layout_container" /> + + + diff --git a/econtext/src/test/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegateTest.kt b/econtext/src/test/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegateTest.kt index d28cdfa034..ce2542bc71 100644 --- a/econtext/src/test/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegateTest.kt +++ b/econtext/src/test/java/com/adyen/checkout/econtext/internal/ui/DefaultEContextDelegateTest.kt @@ -15,7 +15,8 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.FieldState @@ -45,21 +46,20 @@ import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.Mockito.verify import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultEContextDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultEContextDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createEContextDelegate() } @@ -197,12 +197,6 @@ internal class DefaultEContextDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -259,9 +253,32 @@ internal class DefaultEContextDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -277,6 +294,20 @@ internal class DefaultEContextDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } + } + + @Test + fun `when getting initially selected country, then Japan should be returned`() { + val result = delegate.getInitiallySelectedCountry() + + assertEquals("JP", result?.isoCode) } private fun createEContextDelegate( @@ -291,9 +322,9 @@ internal class DefaultEContextDelegateTest( componentSessionParams = null, componentConfiguration = configuration.getConfiguration(TEST_CONFIGURATION_KEY), ), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, typedPaymentMethodFactory = { TestEContextPaymentMethod() }, componentStateFactory = { data, isInputValid, isReady -> @@ -325,6 +356,7 @@ internal class DefaultEContextDelegateTest( private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" private const val TEST_CONFIGURATION_KEY = "TEST_CONFIGURATION_KEY" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/entercash/src/main/java/com/adyen/checkout/entercash/internal/provider/EntercashComponentProvider.kt b/entercash/src/main/java/com/adyen/checkout/entercash/internal/provider/EntercashComponentProvider.kt index 844cbdf7ed..865375ffac 100644 --- a/entercash/src/main/java/com/adyen/checkout/entercash/internal/provider/EntercashComponentProvider.kt +++ b/entercash/src/main/java/com/adyen/checkout/entercash/internal/provider/EntercashComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.EntercashPaymentMethod import com.adyen.checkout.entercash.EntercashComponent @@ -29,7 +29,7 @@ class EntercashComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider< EntercashComponent, EntercashConfiguration, @@ -38,7 +38,7 @@ constructor( >( componentClass = EntercashComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponent( diff --git a/eps/src/main/java/com/adyen/checkout/eps/internal/provider/EPSComponentProvider.kt b/eps/src/main/java/com/adyen/checkout/eps/internal/provider/EPSComponentProvider.kt index f87ed9ccee..11dfb6cc55 100644 --- a/eps/src/main/java/com/adyen/checkout/eps/internal/provider/EPSComponentProvider.kt +++ b/eps/src/main/java/com/adyen/checkout/eps/internal/provider/EPSComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.EPSPaymentMethod import com.adyen.checkout.eps.EPSComponent @@ -29,11 +29,11 @@ class EPSComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider( componentClass = EPSComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, hideIssuerLogosDefaultValue = true, ) { diff --git a/example-app/build.gradle b/example-app/build.gradle index 9d5b04bcdd..1390092971 100644 --- a/example-app/build.gradle +++ b/example-app/build.gradle @@ -9,7 +9,7 @@ plugins { id 'com.android.application' id 'kotlin-android' - id 'kotlin-kapt' + id 'com.google.devtools.ksp' id 'dagger.hilt.android.plugin' } @@ -17,9 +17,11 @@ apply from: "${rootDir}/config/gradle/codeQuality.gradle" if (file("local.gradle").exists()) { apply from: "local.gradle" +} else if (System.getenv("CI") == "true") { + apply from: "ci.local.gradle" } else if (!isIDEBuild()) { - // if not building from an IDE, use example file as it is to ensure the build passes (for CI, renovate, etc) - apply from: "example.local.gradle" + // Renovate doesn't set the CI variable, so this way we can still make sure the build succeeds. + apply from: "ci.local.gradle" } else { throw new GradleException("File example-app/local.gradle not found. Check example-app/README.md for more instructions.") } @@ -40,7 +42,7 @@ android { versionCode version_code versionName version_name - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + testInstrumentationRunner 'com.adyen.checkout.HiltTestRunner' } testOptions { @@ -69,8 +71,8 @@ dependencies { // Checkout implementation project(':drop-in') implementation project(':components-compose') -// implementation "com.adyen.checkout:drop-in:5.3.1" -// implementation "com.adyen.checkout:components-compose:5.3.1" +// implementation "com.adyen.checkout:drop-in:5.4.0" +// implementation "com.adyen.checkout:components-compose:5.4.0" // Dependencies implementation libraries.kotlinCoroutines @@ -94,7 +96,7 @@ dependencies { implementation libraries.okhttpLogging implementation libraries.hilt - kapt libraries.hiltCompiler + ksp libraries.hiltCompiler implementation libraries.googlePay.composeButton @@ -105,6 +107,11 @@ dependencies { // Tests testImplementation testLibraries.junit5 testImplementation testLibraries.mockito + androidTestImplementation testLibraries.androidTest + androidTestImplementation testLibraries.barista androidTestImplementation testLibraries.espresso + androidTestImplementation testLibraries.hilt + + kspAndroidTest testLibraries.hiltCompiler } diff --git a/example-app/ci.local.gradle b/example-app/ci.local.gradle new file mode 100644 index 0000000000..bb2806dfbc --- /dev/null +++ b/example-app/ci.local.gradle @@ -0,0 +1,24 @@ +android { + buildTypes { + + def merchantAccount = System.getenv('ADYEN_ANDROID_MERCHANT_ACCOUNT') + def merchantServerUrl = System.getenv('ADYEN_ANDROID_MERCHANT_SERVER_URL') + def authorizationHeaderName = System.getenv('ADYEN_ANDROID_AUTHORIZATION_HEADER_NAME') + def authorizationHeaderValue = System.getenv('ADYEN_ANDROID_AUTHORIZATION_HEADER_VALUE') + def clientKey = System.getenv('ADYEN_ANDROID_CLIENT_KEY') + + debug { + buildConfigField "String", "MERCHANT_ACCOUNT", "\"$merchantAccount\"" + buildConfigField "String", "MERCHANT_SERVER_URL", "\"$merchantServerUrl\"" + buildConfigField "String", "AUTHORIZATION_HEADER_NAME", "\"$authorizationHeaderName\"" + buildConfigField "String", "AUTHORIZATION_HEADER_VALUE", "\"$authorizationHeaderValue\"" + buildConfigField "String", "CLIENT_KEY", "\"$clientKey\"" + buildConfigField "String", "MERCHANT_RECURRING_SERVER_URL", "\"$merchantServerUrl\"" + } + + release { + initWith debug + matchingFallbacks = ['debug'] + } + } +} diff --git a/example-app/src/androidTest/AndroidManifest.xml b/example-app/src/androidTest/AndroidManifest.xml deleted file mode 100644 index b5325c75b1..0000000000 --- a/example-app/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/example-app/src/androidTest/java/com/adyen/checkout/HiltTestRunner.kt b/example-app/src/androidTest/java/com/adyen/checkout/HiltTestRunner.kt new file mode 100644 index 0000000000..8597e1e407 --- /dev/null +++ b/example-app/src/androidTest/java/com/adyen/checkout/HiltTestRunner.kt @@ -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 19/4/2024. + */ + +package com.adyen.checkout + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +// This class is used from build.gradle +@Suppress("Unused") +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/example-app/src/androidTest/java/com/adyen/checkout/MainTest.kt b/example-app/src/androidTest/java/com/adyen/checkout/MainTest.kt new file mode 100644 index 0000000000..449f49543f --- /dev/null +++ b/example-app/src/androidTest/java/com/adyen/checkout/MainTest.kt @@ -0,0 +1,32 @@ +/* + * 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 19/4/2024. + */ + +package com.adyen.checkout + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed +import com.adyen.checkout.example.ui.main.MainActivity +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +internal class MainTest { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var activityRule: ActivityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + + @Test + fun whenMainActivityIsOpened_thenIntegrationOptionsAreShown() { + assertDisplayed("Drop In") + } +} diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsViewModel.kt index a74892d543..cb4e665c34 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/bacs/BacsViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.Locale import javax.inject.Inject +import com.adyen.checkout.ui.core.R as UICoreR @HiltViewModel internal class BacsViewModel @Inject constructor( @@ -86,7 +87,7 @@ internal class BacsViewModel @Inject constructor( ?.firstOrNull { BacsDirectDebitComponent.PROVIDER.isPaymentMethodSupported(it) } if (paymentMethod == null) { - _viewState.emit(BacsViewState.Error(R.string.error_dialog_title)) + _viewState.emit(BacsViewState.Error(UICoreR.string.error_dialog_title)) } else { _bacsComponentDataFlow.emit( BacsComponentData( diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/blik/BlikViewModel.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/blik/BlikViewModel.kt index 3c2d541217..257a5f117d 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/blik/BlikViewModel.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/blik/BlikViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject import javax.inject.Inject +import com.adyen.checkout.ui.core.R as UICoreR @HiltViewModel class BlikViewModel @Inject constructor( @@ -77,7 +78,7 @@ class BlikViewModel @Inject constructor( ?.firstOrNull { BlikComponent.PROVIDER.isPaymentMethodSupported(it) } if (blikPaymentMethod == null) { - BlikViewState.Error(R.string.error_dialog_title) + BlikViewState.Error(UICoreR.string.error_dialog_title) } else { val componentData = BlikComponentData( paymentMethod = blikPaymentMethod, 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 4206855e70..f13bc2fba4 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 @@ -22,6 +22,8 @@ import com.adyen.checkout.example.data.storage.CardInstallmentOptionsMode import com.adyen.checkout.example.data.storage.KeyValueStorage import com.adyen.checkout.giftcard.giftCard import com.adyen.checkout.googlepay.googlePay +import com.adyen.checkout.instant.ActionHandlingMethod +import com.adyen.checkout.instant.instantPayment import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale import javax.inject.Inject @@ -83,6 +85,10 @@ internal class CheckoutConfigurationProvider @Inject constructor( setCountryCode(keyValueStorage.getCountry()) } + instantPayment { + setActionHandlingMethod(ActionHandlingMethod.PREFER_NATIVE) + } + // Actions adyen3DS2 { setThreeDSRequestorAppURL("https://www.adyen.com") 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 875ba5da16..6854a64c21 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 @@ -38,6 +38,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject import javax.inject.Inject +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") @HiltViewModel @@ -83,7 +84,7 @@ internal class GooglePayViewModel @Inject constructor( ?.firstOrNull { GooglePayComponent.PROVIDER.isPaymentMethodSupported(it) } if (paymentMethod == null) { - _viewState.emit(GooglePayViewState.Error(R.string.error_dialog_title)) + _viewState.emit(GooglePayViewState.Error(UICoreR.string.error_dialog_title)) return@withContext } diff --git a/example-app/src/main/java/com/adyen/checkout/example/ui/instant/InstantFragment.kt b/example-app/src/main/java/com/adyen/checkout/example/ui/instant/InstantFragment.kt index ef71dd936d..92345c370d 100644 --- a/example-app/src/main/java/com/adyen/checkout/example/ui/instant/InstantFragment.kt +++ b/example-app/src/main/java/com/adyen/checkout/example/ui/instant/InstantFragment.kt @@ -61,6 +61,7 @@ class InstantFragment : BottomSheetDialogFragment() { arguments = (arguments ?: bundleOf()).apply { putString(RETURN_URL_EXTRA, returnUrl) } + _binding = FragmentInstantBinding.inflate(inflater, container, false) return binding.root } diff --git a/example-app/src/main/res/color/text_color_secondary.xml b/example-app/src/main/res/color/text_color_secondary.xml new file mode 100644 index 0000000000..495d5097da --- /dev/null +++ b/example-app/src/main/res/color/text_color_secondary.xml @@ -0,0 +1,4 @@ + + + + diff --git a/example-app/src/main/res/values/strings.xml b/example-app/src/main/res/values/strings.xml index a3bfde5856..a790cc154d 100644 --- a/example-app/src/main/res/values/strings.xml +++ b/example-app/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Gift Card Gift Card - Your country code must be %s + Your country/region code must be %s Your currency code must be %s Google Pay is unavailable on this device @@ -45,7 +45,7 @@ threeds_mode 3D Secure shopper_country - Country + Country/region shopper_locale Shopper Locale shopper_email diff --git a/example-app/src/main/res/values/styles.xml b/example-app/src/main/res/values/styles.xml index 82f8242747..ef0d1e9681 100644 --- a/example-app/src/main/res/values/styles.xml +++ b/example-app/src/main/res/values/styles.xml @@ -18,7 +18,7 @@ @color/text_color_primary @color/text_color_primary - @color/text_color_primary + @color/text_color_secondary @color/text_color_primary @color/textColorLink @color/color_background diff --git a/giftcard/build.gradle b/giftcard/build.gradle index 2182400f76..af43e658ca 100644 --- a/giftcard/build.gradle +++ b/giftcard/build.gradle @@ -51,6 +51,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito 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 85ec671395..68df28ecdb 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 @@ -19,11 +19,9 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.data.api.DefaultPublicKeyRepository import com.adyen.checkout.components.core.internal.data.api.PublicKeyService import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider @@ -59,7 +57,7 @@ class GiftCardComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -100,34 +98,30 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) val publicKeyService = PublicKeyService(httpClient) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val giftCardDelegate = DefaultGiftCardDelegate( observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, publicKeyRepository = DefaultPublicKeyRepository(publicKeyService), componentParams = componentParams, cardEncryptor = cardEncryptor, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) GiftCardComponent( giftCardDelegate = giftCardDelegate, @@ -195,35 +189,30 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) val publicKeyService = PublicKeyService(httpClient) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val giftCardDelegate = DefaultGiftCardDelegate( observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, publicKeyRepository = DefaultPublicKeyRepository(publicKeyService), componentParams = componentParams, cardEncryptor = cardEncryptor, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = 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 da2611d89e..ec2463a5c7 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 @@ -19,7 +19,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.data.api.PublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.FieldState import com.adyen.checkout.components.core.internal.ui.model.Validation @@ -60,7 +61,7 @@ internal class DefaultGiftCardDelegate( private val observerRepository: PaymentObserverRepository, private val paymentMethod: PaymentMethod, private val order: OrderRequest?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val publicKeyRepository: PublicKeyRepository, override val componentParams: GiftCardComponentParams, private val cardEncryptor: BaseCardEncryptor, @@ -95,15 +96,16 @@ internal class DefaultGiftCardDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) fetchPublicKey(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } private fun fetchPublicKey(coroutineScope: CoroutineScope) { @@ -209,7 +211,7 @@ internal class DefaultGiftCardDelegate( val giftCardPaymentMethod = GiftCardPaymentMethod( type = GiftCardPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), encryptedCardNumber = encryptedCard.encryptedCardNumber, encryptedSecurityCode = encryptedCard.encryptedSecurityCode, brand = paymentMethod.brand, @@ -233,6 +235,9 @@ internal class DefaultGiftCardDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } @@ -346,6 +351,7 @@ internal class DefaultGiftCardDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } companion object { diff --git a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapper.kt b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapper.kt index c98065711d..f41ad7a2d7 100644 --- a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapper.kt +++ b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapper.kt @@ -33,10 +33,10 @@ internal class GiftCardComponentParamsMapper( ) val commonComponentParams = commonComponentParamsMapperData.commonComponentParams val giftCardConfiguration = checkoutConfiguration.getGiftCardConfiguration() - return GiftCardComponentParams( commonComponentParams = commonComponentParams, - isSubmitButtonVisible = giftCardConfiguration?.isSubmitButtonVisible ?: true, + isSubmitButtonVisible = dropInOverrideParams?.isSubmitButtonVisible + ?: giftCardConfiguration?.isSubmitButtonVisible ?: true, isPinRequired = giftCardConfiguration?.isPinRequired ?: true, ) } diff --git a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/view/GiftCardView.kt b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/view/GiftCardView.kt index 9b6bdd3403..ef02e34a3d 100644 --- a/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/view/GiftCardView.kt +++ b/giftcard/src/main/java/com/adyen/checkout/giftcard/internal/ui/view/GiftCardView.kt @@ -27,6 +27,7 @@ import com.adyen.checkout.ui.core.internal.util.isVisible import com.adyen.checkout.ui.core.internal.util.setLocalizedHintFromStyle import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR internal class GiftCardView @JvmOverloads constructor( context: Context, @@ -48,7 +49,7 @@ internal class GiftCardView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } 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 0a6ad6e5e5..4f7c60116f 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 @@ -15,7 +15,8 @@ import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.OrderResponse import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.test.TestPublicKeyRepository import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment @@ -51,26 +52,25 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, TestDispatcherExtension::class) internal class DefaultGiftCardDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { private lateinit var cardEncryptor: TestCardEncryptor private lateinit var publicKeyRepository: TestPublicKeyRepository + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultGiftCardDelegate @BeforeEach fun before() { cardEncryptor = TestCardEncryptor() publicKeyRepository = TestPublicKeyRepository() + analyticsManager = TestAnalyticsManager() delegate = createGiftCardDelegate() } @@ -179,12 +179,6 @@ internal class DefaultGiftCardDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -360,9 +354,32 @@ internal class DefaultGiftCardDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -375,6 +392,13 @@ internal class DefaultGiftCardDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } @Test @@ -402,13 +426,13 @@ internal class DefaultGiftCardDelegateTest( order: OrderRequest? = TEST_ORDER ) = DefaultGiftCardDelegate( observerRepository = PaymentObserverRepository(), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = order, publicKeyRepository = publicKeyRepository, componentParams = GiftCardComponentParamsMapper(CommonComponentParamsMapper()) .mapToParams(configuration, Locale.US, null, null), cardEncryptor = cardEncryptor, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, ) @@ -436,6 +460,7 @@ internal class DefaultGiftCardDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapperTest.kt b/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapperTest.kt index 43d6097f6e..6abfbead7b 100644 --- a/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapperTest.kt +++ b/giftcard/src/test/java/com/adyen/checkout/giftcard/internal/ui/model/GiftCardComponentParamsMapperTest.kt @@ -67,7 +67,7 @@ internal class GiftCardComponentParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, amount = Amount( currency = "CAD", @@ -80,6 +80,23 @@ internal class GiftCardComponentParamsMapperTest { assertEquals(expected, params) } + @Test + fun `when setSubmitButtonVisible is set to false in gift card configuration and drop-in override params are set then card component params should have isSubmitButtonVisible true`() { + val configuration = CheckoutConfiguration( + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + ) { + giftCard { + setSubmitButtonVisible(false) + } + } + + val dropInOverrideParams = DropInOverrideParams(Amount("CAD", 123L), null) + val params = giftCardComponentParamsMapper.mapToParams(configuration, DEVICE_LOCALE, dropInOverrideParams, null) + + assertEquals(true, params.isSubmitButtonVisible) + } + @ParameterizedTest @MethodSource("amountSource") fun `amount should match value set in sessions then drop in then component configuration`( @@ -198,7 +215,7 @@ internal class GiftCardComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, amount: Amount? = null, isSubmitButtonVisible: Boolean = true, diff --git a/googlepay/build.gradle b/googlepay/build.gradle index 1d499b1379..46ea4de64d 100644 --- a/googlepay/build.gradle +++ b/googlepay/build.gradle @@ -44,6 +44,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines diff --git a/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/provider/GooglePayComponentProvider.kt b/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/provider/GooglePayComponentProvider.kt index a308067728..be68f24472 100644 --- a/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/provider/GooglePayComponentProvider.kt +++ b/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/provider/GooglePayComponentProvider.kt @@ -23,11 +23,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentMethodAvailabilityCheck import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -64,7 +62,7 @@ class GooglePayComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -103,16 +101,11 @@ constructor( paymentMethod = paymentMethod, ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val googlePayDelegate = DefaultGooglePayDelegate( @@ -120,14 +113,15 @@ constructor( paymentMethod = paymentMethod, order = order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) GooglePayComponent( googlePayDelegate = googlePayDelegate, @@ -194,17 +188,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val googlePayDelegate = DefaultGooglePayDelegate( @@ -212,14 +200,15 @@ constructor( paymentMethod = paymentMethod, order = checkoutSession.order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegate.kt b/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegate.kt index 8ab1f14270..af2b87eb71 100644 --- a/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegate.kt +++ b/googlepay/src/main/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegate.kt @@ -18,7 +18,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.util.bufferedChannel import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.exception.CheckoutException @@ -40,7 +41,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultGooglePayDelegate( @@ -48,7 +48,7 @@ internal class DefaultGooglePayDelegate( private val paymentMethod: PaymentMethod, private val order: OrderRequest?, override val componentParams: GooglePayComponentParams, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, ) : GooglePayDelegate { private val _componentStateFlow = MutableStateFlow(createComponentState()) @@ -61,22 +61,26 @@ internal class DefaultGooglePayDelegate( override val submitFlow: Flow = submitChannel.receiveAsFlow() override fun initialize(coroutineScope: CoroutineScope) { - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) componentStateFlow.onEach { onState(it) }.launchIn(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } private fun onState(state: GooglePayComponentState) { if (state.isValid) { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + submitChannel.trySend(state) } } @@ -115,7 +119,7 @@ internal class DefaultGooglePayDelegate( val paymentMethod = GooglePayUtils.createGooglePayPaymentMethod( paymentData = paymentData, paymentMethodType = paymentMethod.type, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), ) val paymentComponentData = PaymentComponentData( paymentMethod = paymentMethod, @@ -181,5 +185,6 @@ internal class DefaultGooglePayDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegateTest.kt b/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegateTest.kt index dff2e9fdf9..2d4b3c08ac 100644 --- a/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegateTest.kt +++ b/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/DefaultGooglePayDelegateTest.kt @@ -15,7 +15,8 @@ import com.adyen.checkout.components.core.Configuration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.paymentmethod.GooglePayPaymentMethod import com.adyen.checkout.core.Environment @@ -38,19 +39,14 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource -import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class) -internal class DefaultGooglePayDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, -) { +internal class DefaultGooglePayDelegateTest { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultGooglePayDelegate private val paymentData: PaymentData @@ -58,6 +54,7 @@ internal class DefaultGooglePayDelegateTest( @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createGooglePayDelegate() } @@ -129,18 +126,37 @@ internal class DefaultGooglePayDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when component state updates amd the data is valid, then submit event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.updateComponentState(paymentData) + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -150,6 +166,13 @@ internal class DefaultGooglePayDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createCheckoutConfiguration( @@ -172,17 +195,18 @@ internal class DefaultGooglePayDelegateTest( ): DefaultGooglePayDelegate { return DefaultGooglePayDelegate( observerRepository = PaymentObserverRepository(), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = TEST_ORDER, componentParams = GooglePayComponentParamsMapper(CommonComponentParamsMapper()) .mapToParams(configuration, Locale.US, null, null, paymentMethod), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) } companion object { private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/model/GooglePayComponentParamsMapperTest.kt b/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/model/GooglePayComponentParamsMapperTest.kt index 25bf35d10c..244d2b9558 100644 --- a/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/model/GooglePayComponentParamsMapperTest.kt +++ b/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/ui/model/GooglePayComponentParamsMapperTest.kt @@ -110,6 +110,7 @@ internal class GooglePayComponentParamsMapperTest { shopperLocale = Locale.FRANCE, environment = Environment.APSE, clientKey = TEST_CLIENT_KEY_2, + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_2), gatewayMerchantId = "MERCHANT_ACCOUNT", googlePayEnvironment = WalletConstants.ENVIRONMENT_PRODUCTION, amount = amount, @@ -165,7 +166,7 @@ internal class GooglePayComponentParamsMapperTest { environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, googlePayEnvironment = WalletConstants.ENVIRONMENT_PRODUCTION, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, amount = Amount( currency = "CAD", @@ -340,6 +341,7 @@ internal class GooglePayComponentParamsMapperTest { shopperLocale = Locale.CHINA, environment = Environment.UNITED_STATES, clientKey = TEST_CLIENT_KEY_2, + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_2), googlePayEnvironment = WalletConstants.ENVIRONMENT_PRODUCTION, ) @@ -361,7 +363,7 @@ internal class GooglePayComponentParamsMapperTest { val expected = getGooglePayComponentParams( environment = Environment.TEST, clientKey = TEST_CLIENT_KEY_1, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn = false, ) @@ -386,7 +388,7 @@ internal class GooglePayComponentParamsMapperTest { val expected = getGooglePayComponentParams( environment = Environment.TEST, clientKey = TEST_CLIENT_KEY_1, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn = false, ) @@ -520,7 +522,7 @@ internal class GooglePayComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, gatewayMerchantId: String = TEST_GATEWAY_MERCHANT_ID, googlePayEnvironment: Int = WalletConstants.ENVIRONMENT_TEST, diff --git a/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/util/GooglePayUtilsTest.kt b/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/util/GooglePayUtilsTest.kt index 3e245d4110..a5f45707d4 100644 --- a/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/util/GooglePayUtilsTest.kt +++ b/googlepay/src/test/java/com/adyen/checkout/googlepay/internal/util/GooglePayUtilsTest.kt @@ -243,7 +243,7 @@ internal class GooglePayUtilsTest { shopperLocale = Locale.US, environment = Environment.TEST, clientKey = "CLIENT_KEY", - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, "CLIENT_KEY"), isCreatedByDropIn = false, amount = null, ), @@ -273,7 +273,7 @@ internal class GooglePayUtilsTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = "CLIENT_KEY_CUSTOM", - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, "CLIENT_KEY_CUSTOM"), isCreatedByDropIn = true, amount = Amount("EUR", 13_37), ), diff --git a/gradle.properties b/gradle.properties index abee3a5a78..1265f2dc4b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ android.enableJetifier=false android.useAndroidX=true -android.nonTransitiveRClass=false +android.nonTransitiveRClass=true # Disable some unused build features in the Android Gradle Plugin to improve build speed. android.defaults.buildfeatures.aidl=false @@ -33,9 +33,5 @@ org.gradle.configureondemand=true # https://docs.gradle.org/current/userguide/build_cache.html org.gradle.caching=true -# Enable caching for KAPT. -# Size should be equal to amount of modules that use kapt. -kapt.classloaders.cache.size=1 - -# Enable compile avoidance for KAPT to improve incremental compile time when there are no changes. -kapt.include.compile.classpath=false +# Enable configuration cache, so Gradle will try to re-use configuration outputs from previous builds. +org.gradle.configuration-cache=true diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8dbd49e9bd..625c02a654 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -21,6 +21,9 @@ + + + @@ -57,6 +60,14 @@ + + + + + + + + @@ -65,6 +76,14 @@ + + + + + + + + @@ -94,6 +113,14 @@ + + + + + + + + @@ -126,6 +153,11 @@ + + + + + @@ -314,6 +346,14 @@ + + + + + + + + @@ -375,6 +415,16 @@ + + + + + + + + + + @@ -383,6 +433,22 @@ + + + + + + + + + + + + + + + + @@ -391,6 +457,22 @@ + + + + + + + + + + + + + + + + @@ -399,6 +481,22 @@ + + + + + + + + + + + + + + + + @@ -407,6 +505,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -423,6 +553,22 @@ + + + + + + + + + + + + + + + + @@ -431,6 +577,22 @@ + + + + + + + + + + + + + + + + @@ -439,6 +601,22 @@ + + + + + + + + + + + + + + + + @@ -447,11 +625,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -460,6 +664,22 @@ + + + + + + + + + + + + + + + + @@ -468,6 +688,22 @@ + + + + + + + + + + + + + + + + @@ -476,6 +712,22 @@ + + + + + + + + + + + + + + + + @@ -484,6 +736,22 @@ + + + + + + + + + + + + + + + + @@ -492,6 +760,22 @@ + + + + + + + + + + + + + + + + @@ -500,6 +784,14 @@ + + + + + + + + @@ -508,6 +800,14 @@ + + + + + + + + @@ -521,6 +821,22 @@ + + + + + + + + + + + + + + + + @@ -529,6 +845,22 @@ + + + + + + + + + + + + + + + + @@ -537,6 +869,22 @@ + + + + + + + + + + + + + + + + @@ -545,6 +893,22 @@ + + + + + + + + + + + + + + + + @@ -553,6 +917,22 @@ + + + + + + + + + + + + + + + + @@ -561,6 +941,22 @@ + + + + + + + + + + + + + + + + @@ -569,6 +965,22 @@ + + + + + + + + + + + + + + + + @@ -577,6 +989,22 @@ + + + + + + + + + + + + + + + + @@ -585,6 +1013,22 @@ + + + + + + + + + + + + + + + + @@ -593,6 +1037,22 @@ + + + + + + + + + + + + + + + + @@ -601,6 +1061,22 @@ + + + + + + + + + + + + + + + + @@ -609,6 +1085,22 @@ + + + + + + + + + + + + + + + + @@ -617,11 +1109,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -630,11 +1148,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -643,6 +1187,22 @@ + + + + + + + + + + + + + + + + @@ -651,6 +1211,22 @@ + + + + + + + + + + + + + + + + @@ -659,6 +1235,22 @@ + + + + + + + + + + + + + + + + @@ -667,6 +1259,22 @@ + + + + + + + + + + + + + + + + @@ -675,6 +1283,22 @@ + + + + + + + + + + + + + + + + @@ -683,6 +1307,22 @@ + + + + + + + + + + + + + + + + @@ -691,6 +1331,22 @@ + + + + + + + + + + + + + + + + @@ -773,6 +1429,14 @@ + + + + + + + + @@ -828,6 +1492,14 @@ + + + + + + + + @@ -897,6 +1569,14 @@ + + + + + + + + @@ -913,6 +1593,14 @@ + + + + + + + + @@ -929,6 +1617,14 @@ + + + + + + + + @@ -1118,6 +1814,14 @@ + + + + + + + + @@ -1147,6 +1851,14 @@ + + + + + + + + @@ -1216,6 +1928,14 @@ + + + + + + + + @@ -1303,6 +2023,14 @@ + + + + + + + + @@ -1327,6 +2055,14 @@ + + + + + + + + @@ -1372,6 +2108,14 @@ + + + + + + + + @@ -1404,6 +2148,14 @@ + + + + + + + + @@ -1433,6 +2185,14 @@ + + + + + + + + @@ -1457,6 +2217,14 @@ + + + + + + + + @@ -1529,6 +2297,14 @@ + + + + + + + + @@ -1688,6 +2464,14 @@ + + + + + + + + @@ -1696,6 +2480,14 @@ + + + + + + + + @@ -1704,6 +2496,14 @@ + + + + + + + + @@ -1728,6 +2528,14 @@ + + + + + + + + @@ -1736,6 +2544,14 @@ + + + + + + + + @@ -1744,6 +2560,14 @@ + + + + + + + + @@ -1760,6 +2584,14 @@ + + + + + + + + @@ -1768,6 +2600,14 @@ + + + + + + + + @@ -1880,6 +2720,14 @@ + + + + + + + + @@ -1888,6 +2736,22 @@ + + + + + + + + + + + + + + + + @@ -1896,6 +2760,14 @@ + + + + + + + + @@ -1904,11 +2776,32 @@ + + + + + + + + + + + + + + + + + + + + + @@ -1917,6 +2810,22 @@ + + + + + + + + + + + + + + + + @@ -1941,6 +2850,14 @@ + + + + + + + + @@ -1957,6 +2874,14 @@ + + + + + + + + @@ -1967,6 +2892,11 @@ + + + + + @@ -1983,6 +2913,14 @@ + + + + + + + + @@ -1993,6 +2931,11 @@ + + + + + @@ -2009,6 +2952,14 @@ + + + + + + + + @@ -2025,6 +2976,14 @@ + + + + + + + + @@ -2041,6 +3000,14 @@ + + + + + + + + @@ -2057,6 +3024,14 @@ + + + + + + + + @@ -2073,6 +3048,14 @@ + + + + + + + + @@ -2089,6 +3072,14 @@ + + + + + + + + @@ -2105,6 +3096,14 @@ + + + + + + + + @@ -2121,6 +3120,14 @@ + + + + + + + + @@ -2137,6 +3144,14 @@ + + + + + + + + @@ -2153,6 +3168,14 @@ + + + + + + + + @@ -2169,6 +3192,14 @@ + + + + + + + + @@ -2191,6 +3222,17 @@ + + + + + + + + + + + @@ -2207,6 +3249,14 @@ + + + + + + + + @@ -2223,6 +3273,14 @@ + + + + + + + + @@ -2239,6 +3297,14 @@ + + + + + + + + @@ -2255,6 +3321,14 @@ + + + + + + + + @@ -2271,6 +3345,14 @@ + + + + + + + + @@ -2287,6 +3369,14 @@ + + + + + + + + @@ -2303,6 +3393,14 @@ + + + + + + + + @@ -2327,6 +3425,14 @@ + + + + + + + + @@ -2343,6 +3449,14 @@ + + + + + + + + @@ -2359,6 +3473,14 @@ + + + + + + + + @@ -2375,6 +3497,14 @@ + + + + + + + + @@ -2415,6 +3545,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2431,6 +3585,14 @@ + + + + + + + + @@ -2447,6 +3609,14 @@ + + + + + + + + @@ -2463,6 +3633,14 @@ + + + + + + + + @@ -2479,6 +3657,14 @@ + + + + + + + + @@ -2495,6 +3681,14 @@ + + + + + + + + @@ -2511,6 +3705,14 @@ + + + + + + + + @@ -2527,6 +3729,14 @@ + + + + + + + + @@ -2543,6 +3753,14 @@ + + + + + + + + @@ -2559,6 +3777,14 @@ + + + + + + + + @@ -2575,6 +3801,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2591,6 +3841,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2607,6 +3881,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2623,20 +3921,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + @@ -2655,6 +4001,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2671,6 +4073,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2687,6 +4113,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2703,6 +4153,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2719,6 +4193,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2813,6 +4311,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2842,6 +4379,14 @@ + + + + + + + + @@ -2850,6 +4395,14 @@ + + + + + + + + @@ -2874,6 +4427,14 @@ + + + + + + + + @@ -2882,6 +4443,14 @@ + + + + + + + + @@ -2990,6 +4559,22 @@ + + + + + + + + + + + + + + + + @@ -2998,6 +4583,22 @@ + + + + + + + + + + + + + + + + @@ -3006,6 +4607,22 @@ + + + + + + + + + + + + + + + + @@ -3014,6 +4631,22 @@ + + + + + + + + + + + + + + + + @@ -3022,6 +4655,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3030,6 +4695,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3038,6 +4735,22 @@ + + + + + + + + + + + + + + + + @@ -3046,11 +4759,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3059,6 +4811,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3091,6 +4875,14 @@ + + + + + + + + @@ -3132,6 +4924,11 @@ + + + + + @@ -3184,6 +4981,14 @@ + + + + + + + + @@ -3216,6 +5021,14 @@ + + + + + + + + @@ -3241,6 +5054,11 @@ + + + + + @@ -3294,6 +5112,22 @@ + + + + + + + + + + + + + + + + @@ -3333,6 +5167,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3341,6 +5207,14 @@ + + + + + + + + @@ -3357,6 +5231,19 @@ + + + + + + + + + + + + + @@ -3373,6 +5260,14 @@ + + + + + + + + @@ -3381,6 +5276,14 @@ + + + + + + + + @@ -3389,6 +5292,14 @@ + + + + + + + + @@ -3397,6 +5308,14 @@ + + + + + + + + @@ -3405,6 +5324,14 @@ + + + + + + + + @@ -3413,6 +5340,14 @@ + + + + + + + + @@ -3421,6 +5356,14 @@ + + + + + + + + @@ -3429,6 +5372,14 @@ + + + + + + + + @@ -3437,6 +5388,14 @@ + + + + + + + + @@ -3445,6 +5404,14 @@ + + + + + + + + @@ -3461,6 +5428,14 @@ + + + + + + + + @@ -3477,6 +5452,14 @@ + + + + + + + + @@ -3485,6 +5468,14 @@ + + + + + + + + @@ -3501,6 +5492,14 @@ + + + + + + + + @@ -3517,6 +5516,14 @@ + + + + + + + + @@ -3525,6 +5532,14 @@ + + + + + + + + @@ -3706,6 +5721,14 @@ + + + + + + + + @@ -3719,6 +5742,14 @@ + + + + + + + + @@ -3735,6 +5766,14 @@ + + + + + + + + @@ -3784,6 +5823,14 @@ + + + + + + + + @@ -3792,6 +5839,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3800,6 +5871,22 @@ + + + + + + + + + + + + + + + + @@ -3917,6 +6004,11 @@ + + + + + @@ -3925,6 +6017,14 @@ + + + + + + + + @@ -3983,6 +6083,14 @@ + + + + + + + + @@ -3991,6 +6099,14 @@ + + + + + + + + @@ -3999,6 +6115,14 @@ + + + + + + + + @@ -4007,6 +6131,14 @@ + + + + + + + + @@ -4015,6 +6147,14 @@ + + + + + + + + @@ -4023,6 +6163,14 @@ + + + + + + + + @@ -4031,6 +6179,14 @@ + + + + + + + + @@ -4039,6 +6195,14 @@ + + + + + + + + @@ -4047,6 +6211,14 @@ + + + + + + + + @@ -4055,6 +6227,14 @@ + + + + + + + + @@ -4063,6 +6243,14 @@ + + + + + + + + @@ -4071,6 +6259,14 @@ + + + + + + + + @@ -4079,6 +6275,14 @@ + + + + + + + + @@ -4087,6 +6291,14 @@ + + + + + + + + @@ -4095,6 +6307,14 @@ + + + + + + + + @@ -4103,6 +6323,14 @@ + + + + + + + + @@ -4111,6 +6339,14 @@ + + + + + + + + @@ -4119,6 +6355,14 @@ + + + + + + + + @@ -4127,6 +6371,14 @@ + + + + + + + + @@ -4135,6 +6387,14 @@ + + + + + + + + @@ -4143,6 +6403,14 @@ + + + + + + + + @@ -4151,6 +6419,14 @@ + + + + + + + + @@ -4159,6 +6435,14 @@ + + + + + + + + @@ -4167,6 +6451,14 @@ + + + + + + + + @@ -4175,6 +6467,14 @@ + + + + + + + + @@ -4183,11 +6483,24 @@ + + + + + + + + + + + + + @@ -4353,6 +6666,14 @@ + + + + + + + + @@ -4406,6 +6727,22 @@ + + + + + + + + + + + + + + + + @@ -4414,6 +6751,22 @@ + + + + + + + + + + + + + + + + @@ -4422,11 +6775,29 @@ + + + + + + + + + + + + + + + + + + @@ -4437,6 +6808,14 @@ + + + + + + + + @@ -4626,6 +7005,14 @@ + + + + + + + + @@ -4650,6 +7037,14 @@ + + + + + + + + @@ -4658,6 +7053,14 @@ + + + + + + + + @@ -4695,6 +7098,14 @@ + + + + + + + + @@ -4768,6 +7179,14 @@ + + + + + + + + @@ -4863,6 +7282,14 @@ + + + + + + + + @@ -4890,6 +7317,14 @@ + + + + + + + + @@ -4911,6 +7346,11 @@ + + + + + @@ -4929,6 +7369,14 @@ + + + + + + + + @@ -4953,6 +7401,14 @@ + + + + + + + + @@ -4998,6 +7454,11 @@ + + + + + @@ -5006,6 +7467,14 @@ + + + + + + + + @@ -5014,6 +7483,14 @@ + + + + + + + + @@ -5022,6 +7499,14 @@ + + + + + + + + @@ -5030,6 +7515,14 @@ + + + + + + + + @@ -5038,6 +7531,14 @@ + + + + + + + + @@ -5046,6 +7547,14 @@ + + + + + + + + @@ -5054,6 +7563,14 @@ + + + + + + + + @@ -5062,6 +7579,14 @@ + + + + + + + + @@ -5070,6 +7595,14 @@ + + + + + + + + @@ -5078,6 +7611,14 @@ + + + + + + + + @@ -5086,6 +7627,14 @@ + + + + + + + + @@ -5094,6 +7643,14 @@ + + + + + + + + @@ -5102,11 +7659,24 @@ + + + + + + + + + + + + + @@ -5115,6 +7685,14 @@ + + + + + + + + @@ -5131,6 +7709,14 @@ + + + + + + + + @@ -5139,6 +7725,14 @@ + + + + + + + + @@ -5147,6 +7741,14 @@ + + + + + + + + @@ -5165,6 +7767,14 @@ + + + + + + + + @@ -5173,6 +7783,14 @@ + + + + + + + + @@ -5181,6 +7799,14 @@ + + + + + + + + @@ -5197,6 +7823,14 @@ + + + + + + + + @@ -5205,6 +7839,14 @@ + + + + + + + + @@ -5213,6 +7855,14 @@ + + + + + + + + @@ -5229,6 +7879,14 @@ + + + + + + + + @@ -5237,6 +7895,14 @@ + + + + + + + + @@ -5245,6 +7911,14 @@ + + + + + + + + @@ -5253,6 +7927,14 @@ + + + + + + + + @@ -5261,6 +7943,14 @@ + + + + + + + + @@ -5269,6 +7959,14 @@ + + + + + + + + @@ -5277,6 +7975,14 @@ + + + + + + + + @@ -5285,6 +7991,14 @@ + + + + + + + + @@ -5293,6 +8007,14 @@ + + + + + + + + @@ -5301,6 +8023,14 @@ + + + + + + + + @@ -5309,6 +8039,14 @@ + + + + + + + + @@ -5317,6 +8055,14 @@ + + + + + + + + @@ -5325,6 +8071,14 @@ + + + + + + + + @@ -5333,6 +8087,14 @@ + + + + + + + + @@ -5354,6 +8116,14 @@ + + + + + + + + @@ -5386,6 +8156,14 @@ + + + + + + + + @@ -5402,6 +8180,14 @@ + + + + + + + + @@ -5410,6 +8196,14 @@ + + + + + + + + @@ -5418,6 +8212,14 @@ + + + + + + + + @@ -5426,6 +8228,14 @@ + + + + + + + + @@ -5434,6 +8244,14 @@ + + + + + + + + @@ -5459,6 +8277,9 @@ + + + @@ -5466,6 +8287,14 @@ + + + + + + + + @@ -5477,6 +8306,17 @@ + + + + + + + + + + + @@ -5511,6 +8351,11 @@ + + + + + @@ -5529,11 +8374,31 @@ + + + + + + + + + + + + + + + + + + + + @@ -5572,6 +8437,9 @@ + + + @@ -5600,6 +8468,11 @@ + + + + + @@ -5648,6 +8521,9 @@ + + + @@ -5684,6 +8560,14 @@ + + + + + + + + @@ -5692,6 +8576,14 @@ + + + + + + + + @@ -5700,11 +8592,24 @@ + + + + + + + + + + + + + @@ -5729,6 +8634,14 @@ + + + + + + + + @@ -5753,6 +8666,19 @@ + + + + + + + + + + + + + @@ -5773,6 +8699,16 @@ + + + + + + + + + + @@ -5802,6 +8738,19 @@ + + + + + + + + + + + + + @@ -5834,6 +8783,22 @@ + + + + + + + + + + + + + + + + @@ -5847,6 +8812,14 @@ + + + + + + + + @@ -5863,6 +8836,14 @@ + + + + + + + + @@ -5892,11 +8873,24 @@ + + + + + + + + + + + + + @@ -5907,6 +8901,11 @@ + + + + + @@ -5923,6 +8922,14 @@ + + + + + + + + @@ -5933,6 +8940,11 @@ + + + + + @@ -5949,6 +8961,14 @@ + + + + + + + + @@ -5957,6 +8977,14 @@ + + + + + + + + @@ -5973,6 +9001,22 @@ + + + + + + + + + + + + + + + + @@ -5997,6 +9041,14 @@ + + + + + + + + @@ -6005,6 +9057,14 @@ + + + + + + + + @@ -6013,6 +9073,14 @@ + + + + + + + + @@ -6021,6 +9089,14 @@ + + + + + + + + @@ -6029,6 +9105,14 @@ + + + + + + + + @@ -6037,6 +9121,14 @@ + + + + + + + + @@ -6061,6 +9153,14 @@ + + + + + + + + @@ -6074,6 +9174,22 @@ + + + + + + + + + + + + + + + + @@ -6090,6 +9206,14 @@ + + + + + + + + @@ -6098,6 +9222,22 @@ + + + + + + + + + + + + + + + + @@ -6119,6 +9259,14 @@ + + + + + + + + @@ -6153,6 +9301,14 @@ + + + + + + + + @@ -6169,6 +9325,14 @@ + + + + + + + + @@ -6179,6 +9343,11 @@ + + + + + @@ -6203,6 +9372,14 @@ + + + + + + + + @@ -6227,6 +9404,14 @@ + + + + + + + + @@ -6235,6 +9420,14 @@ + + + + + + + + @@ -6243,6 +9436,14 @@ + + + + + + + + @@ -6251,6 +9452,14 @@ + + + + + + + + @@ -6259,6 +9468,14 @@ + + + + + + + + @@ -6267,6 +9484,14 @@ + + + + + + + + @@ -6275,6 +9500,14 @@ + + + + + + + + @@ -6283,6 +9516,14 @@ + + + + + + + + @@ -6291,6 +9532,14 @@ + + + + + + + + @@ -6299,6 +9548,14 @@ + + + + + + + + @@ -6307,6 +9564,14 @@ + + + + + + + + @@ -6315,6 +9580,14 @@ + + + + + + + + @@ -6323,6 +9596,14 @@ + + + + + + + + @@ -6331,6 +9612,14 @@ + + + + + + + + @@ -6339,6 +9628,14 @@ + + + + + + + + @@ -6347,6 +9644,14 @@ + + + + + + + + @@ -6379,6 +9684,14 @@ + + + + + + + + @@ -6399,6 +9712,11 @@ + + + + + @@ -6412,6 +9730,11 @@ + + + + + @@ -6438,6 +9761,14 @@ + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e0930b..a80b22ce5c 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.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew.bat b/gradlew.bat index 6689b85bee..7101f8e467 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -43,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail diff --git a/ideal/src/main/java/com/adyen/checkout/ideal/internal/provider/IdealComponentProvider.kt b/ideal/src/main/java/com/adyen/checkout/ideal/internal/provider/IdealComponentProvider.kt index 2c88bd6328..a919c8b124 100644 --- a/ideal/src/main/java/com/adyen/checkout/ideal/internal/provider/IdealComponentProvider.kt +++ b/ideal/src/main/java/com/adyen/checkout/ideal/internal/provider/IdealComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.IdealPaymentMethod import com.adyen.checkout.ideal.IdealComponent @@ -29,11 +29,11 @@ class IdealComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider( componentClass = IdealComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponent( diff --git a/instant/build.gradle b/instant/build.gradle index 3e641b5001..2861d446b7 100644 --- a/instant/build.gradle +++ b/instant/build.gradle @@ -37,6 +37,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.mockito testImplementation testLibraries.kotlinCoroutines diff --git a/instant/src/main/java/com/adyen/checkout/instant/ActionHandlingMethod.kt b/instant/src/main/java/com/adyen/checkout/instant/ActionHandlingMethod.kt new file mode 100644 index 0000000000..24684e28d5 --- /dev/null +++ b/instant/src/main/java/com/adyen/checkout/instant/ActionHandlingMethod.kt @@ -0,0 +1,26 @@ +/* + * 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 28/11/2023. + */ + +package com.adyen.checkout.instant + +/** + * Used in [InstantPaymentConfiguration.Builder.setActionHandlingMethod] to set the method used to handle actions. + */ +enum class ActionHandlingMethod { + /** + * The action will be handled in a native way (e.g. using a SDK). **If** there is no way to handle the action + * natively, then a fallback method will be used (e.g. a web flow). + */ + PREFER_NATIVE, + + /** + * The action will be handled with a web flow. **If** there is no way to handle the action with a web flow, then + * native method will be used. + */ + PREFER_WEB, +} diff --git a/instant/src/main/java/com/adyen/checkout/instant/InstantPaymentConfiguration.kt b/instant/src/main/java/com/adyen/checkout/instant/InstantPaymentConfiguration.kt index 5523eb3058..b56ae21b8d 100644 --- a/instant/src/main/java/com/adyen/checkout/instant/InstantPaymentConfiguration.kt +++ b/instant/src/main/java/com/adyen/checkout/instant/InstantPaymentConfiguration.kt @@ -9,7 +9,7 @@ package com.adyen.checkout.instant import android.content.Context -import androidx.annotation.VisibleForTesting +import androidx.annotation.RestrictTo import com.adyen.checkout.action.core.GenericActionConfiguration import com.adyen.checkout.action.core.internal.ActionHandlingPaymentMethodConfigurationBuilder import com.adyen.checkout.components.core.Amount @@ -25,12 +25,15 @@ import java.util.Locale * Configuration class for the [InstantPaymentComponent]. */ @Parcelize -class InstantPaymentConfiguration private constructor( +class InstantPaymentConfiguration +@Suppress("LongParameterList") +private constructor( override val shopperLocale: Locale?, override val environment: Environment, override val clientKey: String, override val analyticsConfiguration: AnalyticsConfiguration?, override val amount: Amount?, + val actionHandlingMethod: ActionHandlingMethod?, internal val genericActionConfiguration: GenericActionConfiguration, ) : Configuration { @@ -39,6 +42,8 @@ class InstantPaymentConfiguration private constructor( */ class Builder : ActionHandlingPaymentMethodConfigurationBuilder { + private var actionHandlingMethod: ActionHandlingMethod? = null + /** * Initialize a configuration builder with the required fields. * @@ -82,6 +87,16 @@ class InstantPaymentConfiguration private constructor( clientKey, ) + /** + * Sets the method used to handle actions. See [ActionHandlingMethod] for the available options. + * + * Default is [ActionHandlingMethod.PREFER_NATIVE]. + */ + fun setActionHandlingMethod(actionHandlingMethod: ActionHandlingMethod): Builder { + this.actionHandlingMethod = actionHandlingMethod + return this + } + override fun buildInternal(): InstantPaymentConfiguration { return InstantPaymentConfiguration( shopperLocale = shopperLocale, @@ -89,13 +104,15 @@ class InstantPaymentConfiguration private constructor( clientKey = clientKey, analyticsConfiguration = analyticsConfiguration, amount = amount, + actionHandlingMethod = actionHandlingMethod, genericActionConfiguration = genericActionConfigurationBuilder.build(), ) } } } -private const val GLOBAL_INSTANT_CONFIG_KEY = "GLOBAL_INSTANT_CONFIG_KEY" +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +const val GLOBAL_INSTANT_CONFIG_KEY = "GLOBAL_INSTANT_CONFIG_KEY" fun CheckoutConfiguration.instantPayment( paymentMethod: String = GLOBAL_INSTANT_CONFIG_KEY, @@ -113,7 +130,6 @@ fun CheckoutConfiguration.instantPayment( return this } -@VisibleForTesting internal fun CheckoutConfiguration.getInstantPaymentConfiguration( paymentMethod: String = GLOBAL_INSTANT_CONFIG_KEY, ): InstantPaymentConfiguration? { diff --git a/instant/src/main/java/com/adyen/checkout/instant/internal/provider/InstantPaymentComponentProvider.kt b/instant/src/main/java/com/adyen/checkout/instant/internal/provider/InstantPaymentComponentProvider.kt index 6928b8b3ad..a62802107c 100644 --- a/instant/src/main/java/com/adyen/checkout/instant/internal/provider/InstantPaymentComponentProvider.kt +++ b/instant/src/main/java/com/adyen/checkout/instant/internal/provider/InstantPaymentComponentProvider.kt @@ -23,15 +23,12 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams -import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper 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 @@ -41,6 +38,7 @@ import com.adyen.checkout.instant.InstantComponentState import com.adyen.checkout.instant.InstantPaymentComponent import com.adyen.checkout.instant.InstantPaymentConfiguration import com.adyen.checkout.instant.internal.ui.DefaultInstantPaymentDelegate +import com.adyen.checkout.instant.internal.ui.model.InstantComponentParamsMapper import com.adyen.checkout.instant.toCheckoutConfiguration import com.adyen.checkout.sessions.core.CheckoutSession import com.adyen.checkout.sessions.core.SessionComponentCallback @@ -56,7 +54,7 @@ class InstantPaymentComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -86,23 +84,19 @@ constructor( assertSupported(paymentMethod) val genericFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> - val componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( + val componentParams = InstantComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( checkoutConfiguration = checkoutConfiguration, deviceLocale = localeProvider.getLocale(application), dropInOverrideParams = dropInOverrideParams, componentSessionParams = null, + paymentMethod = paymentMethod, ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val instantPaymentDelegate = DefaultInstantPaymentDelegate( @@ -110,14 +104,15 @@ constructor( paymentMethod = paymentMethod, order = order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) InstantPaymentComponent( instantPaymentDelegate = instantPaymentDelegate, @@ -174,26 +169,21 @@ constructor( assertSupported(paymentMethod) val genericFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> - val componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( + val componentParams = InstantComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( checkoutConfiguration = checkoutConfiguration, deviceLocale = localeProvider.getLocale(application), dropInOverrideParams = dropInOverrideParams, componentSessionParams = SessionParamsFactory.create(checkoutSession), + paymentMethod = paymentMethod, ) val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val instantPaymentDelegate = DefaultInstantPaymentDelegate( @@ -201,14 +191,15 @@ constructor( paymentMethod = paymentMethod, order = checkoutSession.order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/instant/src/main/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegate.kt b/instant/src/main/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegate.kt index c623dafb3d..55a1172cdb 100644 --- a/instant/src/main/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegate.kt +++ b/instant/src/main/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegate.kt @@ -15,28 +15,29 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.ui.model.GenericComponentParams +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.util.bufferedChannel import com.adyen.checkout.components.core.paymentmethod.GenericPaymentMethod import com.adyen.checkout.components.core.paymentmethod.PaymentMethodDetails import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.internal.util.adyenLog +import com.adyen.checkout.instant.ActionHandlingMethod import com.adyen.checkout.instant.InstantComponentState +import com.adyen.checkout.instant.internal.ui.model.InstantComponentParams import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch internal class DefaultInstantPaymentDelegate( private val observerRepository: PaymentObserverRepository, private val paymentMethod: PaymentMethod, private val order: Order?, - override val componentParams: GenericComponentParams, - private val analyticsRepository: AnalyticsRepository, + override val componentParams: InstantComponentParams, + private val analyticsManager: AnalyticsManager, ) : InstantPaymentDelegate { override val componentStateFlow: StateFlow = MutableStateFlow(createComponentState()) @@ -54,7 +55,8 @@ internal class DefaultInstantPaymentDelegate( val paymentComponentData = PaymentComponentData( paymentMethod = GenericPaymentMethod( type = paymentMethod.type, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), + subtype = getSubtype(paymentMethod), ), order = order, amount = componentParams.amount, @@ -62,15 +64,32 @@ internal class DefaultInstantPaymentDelegate( return InstantComponentState(paymentComponentData, isInputValid = true, isReady = true) } + private fun getSubtype(paymentMethod: PaymentMethod): String? { + return when (componentParams.actionHandlingMethod) { + ActionHandlingMethod.PREFER_NATIVE -> { + when (paymentMethod.type) { + PaymentMethodTypes.TWINT -> SDK_SUBTYPE + else -> null + } + } + + ActionHandlingMethod.PREFER_WEB -> null + } + } + override fun initialize(coroutineScope: CoroutineScope) { - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val renderedEvent = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(renderedEvent) + + val submitEvent = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(submitEvent) } override fun observe( @@ -94,5 +113,10 @@ internal class DefaultInstantPaymentDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) + } + + companion object { + private const val SDK_SUBTYPE = "sdk" } } diff --git a/instant/src/main/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParams.kt b/instant/src/main/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParams.kt new file mode 100644 index 0000000000..769f4651e8 --- /dev/null +++ b/instant/src/main/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParams.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 oscars on 21/11/2023. + */ + +package com.adyen.checkout.instant.internal.ui.model + +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParams +import com.adyen.checkout.components.core.internal.ui.model.ComponentParams +import com.adyen.checkout.instant.ActionHandlingMethod + +internal data class InstantComponentParams( + private val commonComponentParams: CommonComponentParams, + val actionHandlingMethod: ActionHandlingMethod, +) : ComponentParams by commonComponentParams diff --git a/instant/src/main/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParamsMapper.kt b/instant/src/main/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParamsMapper.kt new file mode 100644 index 0000000000..6dbaa8d693 --- /dev/null +++ b/instant/src/main/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParamsMapper.kt @@ -0,0 +1,48 @@ +/* + * 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 21/11/2023. + */ + +package com.adyen.checkout.instant.internal.ui.model + +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper +import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams +import com.adyen.checkout.components.core.internal.ui.model.SessionParams +import com.adyen.checkout.instant.ActionHandlingMethod +import com.adyen.checkout.instant.getInstantPaymentConfiguration +import java.util.Locale + +internal class InstantComponentParamsMapper( + private val commonComponentParamsMapper: CommonComponentParamsMapper, +) { + + fun mapToParams( + checkoutConfiguration: CheckoutConfiguration, + deviceLocale: Locale, + dropInOverrideParams: DropInOverrideParams?, + componentSessionParams: SessionParams?, + paymentMethod: PaymentMethod, + ): InstantComponentParams { + val commonComponentParamsMapperData = commonComponentParamsMapper.mapToParams( + checkoutConfiguration, + deviceLocale, + dropInOverrideParams, + componentSessionParams, + ) + + val instantActionConfiguration = + paymentMethod.type?.let { checkoutConfiguration.getInstantPaymentConfiguration(it) } + ?: checkoutConfiguration.getInstantPaymentConfiguration() + + return InstantComponentParams( + commonComponentParams = commonComponentParamsMapperData.commonComponentParams, + actionHandlingMethod = instantActionConfiguration?.actionHandlingMethod + ?: ActionHandlingMethod.PREFER_NATIVE, + ) + } +} diff --git a/instant/src/test/java/com/adyen/checkout/instant/InstantPaymentConfigurationTest.kt b/instant/src/test/java/com/adyen/checkout/instant/InstantPaymentConfigurationTest.kt index 787b1ad1ee..4f612825d7 100644 --- a/instant/src/test/java/com/adyen/checkout/instant/InstantPaymentConfigurationTest.kt +++ b/instant/src/test/java/com/adyen/checkout/instant/InstantPaymentConfigurationTest.kt @@ -20,7 +20,9 @@ internal class InstantPaymentConfigurationTest { amount = Amount("EUR", 123L), analyticsConfiguration = AnalyticsConfiguration(AnalyticsLevel.ALL), ) { - instantPayment("paypal") + instantPayment("paypal") { + setActionHandlingMethod(ActionHandlingMethod.PREFER_WEB) + } } val actual = checkoutConfiguration.getInstantPaymentConfiguration("paypal") @@ -32,6 +34,7 @@ internal class InstantPaymentConfigurationTest { ) .setAmount(Amount("EUR", 123L)) .setAnalyticsConfiguration(AnalyticsConfiguration(AnalyticsLevel.ALL)) + .setActionHandlingMethod(ActionHandlingMethod.PREFER_WEB) .build() assertEquals(expected.shopperLocale, actual?.shopperLocale) @@ -39,6 +42,7 @@ internal class InstantPaymentConfigurationTest { assertEquals(expected.clientKey, actual?.clientKey) assertEquals(expected.amount, actual?.amount) assertEquals(expected.analyticsConfiguration, actual?.analyticsConfiguration) + assertEquals(expected.actionHandlingMethod, actual?.actionHandlingMethod) } @Test @@ -50,6 +54,7 @@ internal class InstantPaymentConfigurationTest { ) .setAmount(Amount("EUR", 123L)) .setAnalyticsConfiguration(AnalyticsConfiguration(AnalyticsLevel.ALL)) + .setActionHandlingMethod(ActionHandlingMethod.PREFER_WEB) .build() val actual = config.toCheckoutConfiguration() @@ -60,7 +65,11 @@ internal class InstantPaymentConfigurationTest { clientKey = TEST_CLIENT_KEY, amount = Amount("EUR", 123L), analyticsConfiguration = AnalyticsConfiguration(AnalyticsLevel.ALL), - ) + ) { + instantPayment { + setActionHandlingMethod(ActionHandlingMethod.PREFER_WEB) + } + } assertEquals(expected.shopperLocale, actual.shopperLocale) assertEquals(expected.environment, actual.environment) @@ -74,6 +83,7 @@ internal class InstantPaymentConfigurationTest { assertEquals(config.clientKey, actualInstantConfig?.clientKey) assertEquals(config.amount, actualInstantConfig?.amount) assertEquals(config.analyticsConfiguration, actualInstantConfig?.analyticsConfiguration) + assertEquals(config.actionHandlingMethod, actualInstantConfig?.actionHandlingMethod) } companion object { diff --git a/instant/src/test/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegateTest.kt b/instant/src/test/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegateTest.kt index d16c1bfca4..c92ebb4274 100644 --- a/instant/src/test/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegateTest.kt +++ b/instant/src/test/java/com/adyen/checkout/instant/internal/ui/DefaultInstantPaymentDelegateTest.kt @@ -14,10 +14,11 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper -import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper import com.adyen.checkout.core.Environment +import com.adyen.checkout.instant.internal.ui.model.InstantComponentParamsMapper import com.adyen.checkout.test.LoggingExtension import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,23 +33,19 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource -import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) -class DefaultInstantPaymentDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, -) { +class DefaultInstantPaymentDelegateTest { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultInstantPaymentDelegate @BeforeEach fun before() { + analyticsManager = TestAnalyticsManager() delegate = createInstantPaymentDelegate() } @@ -56,7 +53,7 @@ class DefaultInstantPaymentDelegateTest( fun `when subscribed then component state flow should propagate a valid state`() = runTest { delegate.componentStateFlow.test { with(awaitItem()) { - assertEquals(TYPE, data.paymentMethod?.type) + assertEquals(TEST_PAYMENT_METHOD_TYPE, data.paymentMethod?.type) assertEquals(TEST_ORDER, data.order) assertTrue(isInputValid) assertTrue(isValid) @@ -82,18 +79,35 @@ class DefaultInstantPaymentDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertHasEventEquals(expectedEvent) + } + + @Test + fun `when delegate is initialized, then submit event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate = createInstantPaymentDelegate() @@ -101,6 +115,13 @@ class DefaultInstantPaymentDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createCheckoutConfiguration( @@ -117,17 +138,17 @@ class DefaultInstantPaymentDelegateTest( ): DefaultInstantPaymentDelegate { return DefaultInstantPaymentDelegate( observerRepository = PaymentObserverRepository(), - paymentMethod = PaymentMethod(type = TYPE), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = TEST_ORDER, - componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) - .mapToParams(configuration, Locale.US, null, null), - analyticsRepository = analyticsRepository, + componentParams = InstantComponentParamsMapper(CommonComponentParamsMapper()) + .mapToParams(configuration, Locale.US, null, null, PaymentMethod(type = "paypal")), + analyticsManager = analyticsManager, ) } companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" - private const val TYPE = "txVariant" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" diff --git a/instant/src/test/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParamsMapperTest.kt b/instant/src/test/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParamsMapperTest.kt new file mode 100644 index 0000000000..31f7083226 --- /dev/null +++ b/instant/src/test/java/com/adyen/checkout/instant/internal/ui/model/InstantComponentParamsMapperTest.kt @@ -0,0 +1,252 @@ +/* + * 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 28/11/2023. + */ + +package com.adyen.checkout.instant.internal.ui.model + +import com.adyen.checkout.components.core.Amount +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.internal.ui.model.AnalyticsParams +import com.adyen.checkout.components.core.internal.ui.model.AnalyticsParamsLevel +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParams +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper +import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams +import com.adyen.checkout.components.core.internal.ui.model.SessionInstallmentConfiguration +import com.adyen.checkout.components.core.internal.ui.model.SessionParams +import com.adyen.checkout.core.Environment +import com.adyen.checkout.instant.ActionHandlingMethod +import com.adyen.checkout.instant.instantPayment +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.Locale + +internal class InstantComponentParamsMapperTest { + + private val instantComponentParamsMapper = InstantComponentParamsMapper(CommonComponentParamsMapper()) + + @Test + fun `when drop-in override params are null then params should match the component configuration`() { + val configuration = createCheckoutConfiguration() + + val params = instantComponentParamsMapper.mapToParams(configuration, DEVICE_LOCALE, null, null, PAYMENT_METHOD) + + val expected = getInstantComponentParams() + + assertEquals(expected, params) + } + + @Test + fun `when parent configuration is set, then parent configuration fields should override component configuration fields`() { + val configuration = createCheckoutConfiguration( + shopperLocale = Locale.GERMAN, + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + amount = Amount( + currency = "EUR", + value = 49_00L, + ), + ) + + val dropInOverrideParams = DropInOverrideParams(Amount("CAD", 123L), null) + + val params = instantComponentParamsMapper.mapToParams( + checkoutConfiguration = configuration, + deviceLocale = DEVICE_LOCALE, + dropInOverrideParams = dropInOverrideParams, + componentSessionParams = null, + paymentMethod = PAYMENT_METHOD, + ) + + val expected = getInstantComponentParams( + shopperLocale = Locale.GERMAN, + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_2), + isCreatedByDropIn = true, + amount = Amount( + currency = "CAD", + value = 123L, + ), + ) + + assertEquals(expected, params) + } + + @ParameterizedTest + @MethodSource("amountSource") + fun `amount should match value set in sessions if it exists, then should match drop in value, then configuration`( + configurationValue: Amount, + dropInValue: Amount?, + sessionsValue: Amount?, + expectedValue: Amount + ) { + val configuration = createCheckoutConfiguration(amount = configurationValue) + + val dropInOverrideParams = dropInValue?.let { DropInOverrideParams(it, null) } + + val sessionParams = createSessionParams( + amount = sessionsValue, + ) + + val params = instantComponentParamsMapper.mapToParams( + configuration, + DEVICE_LOCALE, + dropInOverrideParams, + sessionParams, + PAYMENT_METHOD, + ) + + val expected = getInstantComponentParams( + amount = expectedValue, + isCreatedByDropIn = dropInOverrideParams != null, + ) + + assertEquals(expected, params) + } + + @ParameterizedTest + @MethodSource("shopperLocaleSource") + fun `shopper locale should match value set in configuration then sessions then device locale`( + configurationValue: Locale?, + sessionsValue: Locale?, + deviceLocaleValue: Locale, + expectedValue: Locale, + ) { + val configuration = createCheckoutConfiguration(shopperLocale = configurationValue) + + val sessionParams = createSessionParams( + shopperLocale = sessionsValue, + ) + + val params = instantComponentParamsMapper.mapToParams( + checkoutConfiguration = configuration, + deviceLocale = deviceLocaleValue, + dropInOverrideParams = null, + componentSessionParams = sessionParams, + paymentMethod = PAYMENT_METHOD, + ) + + val expected = getInstantComponentParams( + shopperLocale = expectedValue, + ) + + assertEquals(expected, params) + } + + @Test + fun `environment and client key should match value set in sessions`() { + val configuration = createCheckoutConfiguration() + + val sessionParams = createSessionParams( + environment = Environment.INDIA, + clientKey = TEST_CLIENT_KEY_2, + ) + + val params = instantComponentParamsMapper.mapToParams( + checkoutConfiguration = configuration, + deviceLocale = DEVICE_LOCALE, + dropInOverrideParams = null, + componentSessionParams = sessionParams, + paymentMethod = PAYMENT_METHOD, + ) + + val expected = getInstantComponentParams( + environment = Environment.INDIA, + clientKey = TEST_CLIENT_KEY_2, + ) + + assertEquals(expected, params) + } + + @Suppress("LongParameterList") + private fun getInstantComponentParams( + shopperLocale: Locale = DEVICE_LOCALE, + environment: Environment = Environment.TEST, + clientKey: String = TEST_CLIENT_KEY_1, + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), + isCreatedByDropIn: Boolean = false, + amount: Amount? = null, + ): InstantComponentParams { + return InstantComponentParams( + commonComponentParams = CommonComponentParams( + shopperLocale = shopperLocale, + environment = environment, + clientKey = clientKey, + analyticsParams = analyticsParams, + isCreatedByDropIn = isCreatedByDropIn, + amount = amount, + ), + actionHandlingMethod = ActionHandlingMethod.PREFER_NATIVE, + ) + } + + private fun createCheckoutConfiguration( + shopperLocale: Locale? = null, + environment: Environment = Environment.TEST, + clientKey: String = TEST_CLIENT_KEY_1, + amount: Amount? = null, + actionHandlingMethod: ActionHandlingMethod? = null, + ) = CheckoutConfiguration( + shopperLocale = shopperLocale, + environment = environment, + clientKey = clientKey, + amount = amount, + ) { + instantPayment { + actionHandlingMethod?.let { setActionHandlingMethod(it) } + } + } + + @Suppress("LongParameterList") + private fun createSessionParams( + environment: Environment = Environment.TEST, + clientKey: String = TEST_CLIENT_KEY_1, + enableStoreDetails: Boolean? = null, + installmentConfiguration: SessionInstallmentConfiguration? = null, + showRemovePaymentMethodButton: Boolean? = null, + amount: Amount? = null, + returnUrl: String? = "", + shopperLocale: Locale? = null, + ) = SessionParams( + environment = environment, + clientKey = clientKey, + enableStoreDetails = enableStoreDetails, + installmentConfiguration = installmentConfiguration, + showRemovePaymentMethodButton = showRemovePaymentMethodButton, + amount = amount, + returnUrl = returnUrl, + shopperLocale = shopperLocale, + ) + + companion object { + private const val TEST_CLIENT_KEY_1 = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_CLIENT_KEY_2 = "live_qwertyui34566776787zxcvbnmqwerty" + private val DEVICE_LOCALE = Locale("nl", "NL") + private val PAYMENT_METHOD = PaymentMethod(type = "paypal") + + @JvmStatic + fun amountSource() = listOf( + // configurationValue, dropInValue, sessionsValue, expectedValue + arguments(Amount("EUR", 100), Amount("USD", 200), Amount("CAD", 300), Amount("CAD", 300)), + arguments(Amount("EUR", 100), Amount("USD", 200), null, Amount("USD", 200)), + arguments(Amount("EUR", 100), null, null, Amount("EUR", 100)), + ) + + @JvmStatic + fun shopperLocaleSource() = listOf( + // configurationValue, sessionsValue, deviceLocaleValue, expectedValue + arguments(null, null, Locale.US, Locale.US), + arguments(Locale.GERMAN, null, Locale.US, Locale.GERMAN), + arguments(null, Locale.CHINESE, Locale.US, Locale.CHINESE), + arguments(Locale.GERMAN, Locale.CHINESE, Locale.US, Locale.GERMAN), + ) + } +} diff --git a/issuer-list/build.gradle b/issuer-list/build.gradle index fa5cabdb27..caaae3da09 100644 --- a/issuer-list/build.gradle +++ b/issuer-list/build.gradle @@ -49,7 +49,9 @@ dependencies { //Tests testImplementation project(':3ds2') testImplementation project(':test-core') + testImplementation project(':twint') testImplementation project(':wechatpay') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/provider/IssuerListComponentProvider.kt b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/provider/IssuerListComponentProvider.kt index 29eeae3fad..56e1ea09fe 100644 --- a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/provider/IssuerListComponentProvider.kt +++ b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/provider/IssuerListComponentProvider.kt @@ -27,11 +27,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.ComponentEventHandler import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -69,7 +67,7 @@ abstract class IssuerListComponentProvider< constructor( private val componentClass: Class, private val dropInOverrideParams: DropInOverrideParams?, - private val analyticsRepository: AnalyticsRepository?, + private val analyticsManager: AnalyticsManager? = null, private val hideIssuerLogosDefaultValue: Boolean = false, private val localeProvider: LocaleProvider = LocaleProvider(), ) : @@ -104,30 +102,27 @@ constructor( componentConfiguration = getConfiguration(checkoutConfiguration), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) + val issuerListDelegate = createDefaultDelegate( componentParams = componentParams, paymentMethod = paymentMethod, order = order, savedStateHandle = savedStateHandle, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) createComponent( issuerListDelegate, @@ -195,31 +190,26 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val issuerListDelegate = createDefaultDelegate( componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, savedStateHandle = savedStateHandle, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, @@ -284,14 +274,14 @@ constructor( paymentMethod: PaymentMethod, order: Order?, savedStateHandle: SavedStateHandle, - analyticsRepository: AnalyticsRepository, + analyticsManager: AnalyticsManager, ): DefaultIssuerListDelegate { return DefaultIssuerListDelegate( observerRepository = PaymentObserverRepository(), componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), typedPaymentMethodFactory = ::createPaymentMethod, componentStateFactory = ::createComponentState, diff --git a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegate.kt b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegate.kt index d4ff7685ea..c438256fd5 100644 --- a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegate.kt +++ b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegate.kt @@ -17,7 +17,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.paymentmethod.IssuerListPaymentMethod import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.internal.util.adyenLog @@ -34,7 +35,6 @@ import com.adyen.checkout.ui.core.internal.ui.SubmitHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions", "LongParameterList") internal class DefaultIssuerListDelegate< @@ -45,7 +45,7 @@ internal class DefaultIssuerListDelegate< override val componentParams: IssuerListComponentParams, private val paymentMethod: PaymentMethod, private val order: Order?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, private val typedPaymentMethodFactory: () -> IssuerListPaymentMethodT, private val componentStateFactory: ( @@ -74,14 +74,15 @@ internal class DefaultIssuerListDelegate< override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -121,6 +122,9 @@ internal class DefaultIssuerListDelegate< } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } @@ -131,6 +135,13 @@ internal class DefaultIssuerListDelegate< _outputDataFlow.tryEmit(outputData) updateComponentState(outputData) + + val event = GenericEvents.selected( + component = paymentMethod.type.orEmpty(), + target = ANALYTICS_TARGET, + issuer = outputData.selectedIssuer?.name.orEmpty(), + ) + analyticsManager.trackEvent(event) } private fun createOutputData() = IssuerListOutputData(inputData.selectedIssuer) @@ -146,7 +157,7 @@ internal class DefaultIssuerListDelegate< ): ComponentStateT { val issuerListPaymentMethod = typedPaymentMethodFactory().apply { type = getPaymentMethodType() - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId() + checkoutAttemptId = analyticsManager.getCheckoutAttemptId() issuer = outputData.selectedIssuer?.id.orEmpty() } @@ -173,5 +184,11 @@ internal class DefaultIssuerListDelegate< override fun onCleared() { removeObserver() + analyticsManager.clear(this) + } + + companion object { + @VisibleForTesting + internal const val ANALYTICS_TARGET = "list" } } diff --git a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapper.kt b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapper.kt index 88e69846ae..a631b199c6 100644 --- a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapper.kt +++ b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapper.kt @@ -37,9 +37,11 @@ class IssuerListComponentParamsMapper( dropInOverrideParams, componentSessionParams, ) + val commonComponentParams = commonComponentParamsMapperData.commonComponentParams return IssuerListComponentParams( - commonComponentParams = commonComponentParamsMapperData.commonComponentParams, - isSubmitButtonVisible = componentConfiguration?.isSubmitButtonVisible ?: true, + commonComponentParams = commonComponentParams, + isSubmitButtonVisible = dropInOverrideParams?.isSubmitButtonVisible + ?: componentConfiguration?.isSubmitButtonVisible ?: true, viewType = componentConfiguration?.viewType ?: IssuerListViewType.RECYCLER_VIEW, hideIssuerLogos = componentConfiguration?.hideIssuerLogos ?: hideIssuerLogosDefaultValue, ) diff --git a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/view/IssuerListSpinnerAdapter.kt b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/view/IssuerListSpinnerAdapter.kt index c3bf016b3d..3509a3efb1 100644 --- a/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/view/IssuerListSpinnerAdapter.kt +++ b/issuer-list/src/main/java/com/adyen/checkout/issuerlist/internal/ui/view/IssuerListSpinnerAdapter.kt @@ -18,7 +18,7 @@ import com.adyen.checkout.issuerlist.internal.ui.model.IssuerModel import com.adyen.checkout.ui.core.databinding.SpinnerListWithImageBinding import com.adyen.checkout.ui.core.internal.ui.loadLogo -internal class IssuerListSpinnerAdapter constructor( +internal class IssuerListSpinnerAdapter( private val context: Context, private var issuerList: List, private val paymentMethod: String, diff --git a/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegateTest.kt b/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegateTest.kt index b9d877d93b..ce91016bae 100644 --- a/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegateTest.kt +++ b/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/DefaultIssuerListDelegateTest.kt @@ -13,8 +13,10 @@ import com.adyen.checkout.components.core.Amount import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod +import com.adyen.checkout.components.core.PaymentMethodTypes 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment import com.adyen.checkout.issuerlist.IssuerListViewType @@ -44,22 +46,21 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultIssuerListDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultIssuerListDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createIssuerListDelegate() } @@ -180,7 +181,7 @@ internal class DefaultIssuerListDelegateTest( ), paymentMethod = PaymentMethod(), order = TEST_ORDER, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, typedPaymentMethodFactory = { TestIssuerPaymentMethod() }, componentStateFactory = { data, isInputValid, isReady -> @@ -215,7 +216,7 @@ internal class DefaultIssuerListDelegateTest( ), paymentMethod = PaymentMethod(), order = TEST_ORDER, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, typedPaymentMethodFactory = { TestIssuerPaymentMethod() }, componentStateFactory = { data, isInputValid, isReady -> @@ -231,12 +232,6 @@ internal class DefaultIssuerListDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -295,9 +290,46 @@ internal class DefaultIssuerListDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(PaymentMethodTypes.IDEAL) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(PaymentMethodTypes.IDEAL) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when updateInputData is called, then selected event is tracked`() { + delegate.updateInputData { + selectedIssuer = IssuerModel(id = "id", name = "test", environment = Environment.TEST) + } + + val expectedEvent = GenericEvents.selected( + component = PaymentMethodTypes.IDEAL, + target = DefaultIssuerListDelegate.ANALYTICS_TARGET, + issuer = "test", + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -309,6 +341,13 @@ internal class DefaultIssuerListDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createIssuerListDelegate( @@ -323,9 +362,9 @@ internal class DefaultIssuerListDelegateTest( componentConfiguration = configuration.getConfiguration(TEST_CONFIGURATION_KEY), hideIssuerLogosDefaultValue = false, ), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = PaymentMethodTypes.IDEAL), order = TEST_ORDER, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, typedPaymentMethodFactory = { TestIssuerPaymentMethod() }, componentStateFactory = { data, isInputValid, isReady -> diff --git a/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapperTest.kt b/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapperTest.kt index f4d53c0e51..35b4ea78c5 100644 --- a/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapperTest.kt +++ b/issuer-list/src/test/java/com/adyen/checkout/issuerlist/internal/ui/model/IssuerListComponentParamsMapperTest.kt @@ -72,7 +72,7 @@ internal class IssuerListComponentParamsMapperTest { shopperLocale = DEVICE_LOCALE, environment = Environment.TEST, clientKey = TEST_CLIENT_KEY_1, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn = false, viewType = IssuerListViewType.SPINNER_VIEW, hideIssuerLogos = true, @@ -118,7 +118,7 @@ internal class IssuerListComponentParamsMapperTest { shopperLocale = Locale.GERMAN, environment = Environment.EUROPE, clientKey = TEST_CLIENT_KEY_2, - analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE), + analyticsParams = AnalyticsParams(AnalyticsParamsLevel.NONE, TEST_CLIENT_KEY_2), isCreatedByDropIn = true, viewType = IssuerListViewType.SPINNER_VIEW, hideIssuerLogos = true, @@ -132,6 +132,31 @@ internal class IssuerListComponentParamsMapperTest { assertEquals(expected, params) } + @Test + fun `when setSubmitButtonVisible is set to false in issuers list configuration and drop-in override params are set then card component params should have isSubmitButtonVisible true`() { + val configuration = CheckoutConfiguration( + environment = Environment.EUROPE, + clientKey = TEST_CLIENT_KEY_2, + ) { + val issuerListConfiguration = TestIssuerListConfiguration.Builder(shopperLocale, environment, clientKey) + .setSubmitButtonVisible(false) + .build() + addConfiguration(TEST_CONFIGURATION_KEY, issuerListConfiguration) + } + + val dropInOverrideParams = DropInOverrideParams(Amount("CAD", 123L), null) + val params = issuerListComponentParamsMapper.mapToParams( + checkoutConfiguration = configuration, + deviceLocale = DEVICE_LOCALE, + dropInOverrideParams = dropInOverrideParams, + componentSessionParams = null, + componentConfiguration = configuration.getConfiguration(TEST_CONFIGURATION_KEY), + hideIssuerLogosDefaultValue = false, + ) + + assertEquals(true, params.isSubmitButtonVisible) + } + @ParameterizedTest @MethodSource("amountSource") fun `amount should match value set in sessions then drop in then component configuration`( @@ -261,7 +286,7 @@ internal class IssuerListComponentParamsMapperTest { shopperLocale: Locale = DEVICE_LOCALE, environment: Environment = Environment.TEST, clientKey: String = TEST_CLIENT_KEY_1, - analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL), + analyticsParams: AnalyticsParams = AnalyticsParams(AnalyticsParamsLevel.ALL, TEST_CLIENT_KEY_1), isCreatedByDropIn: Boolean = false, amount: Amount? = null, isSubmitButtonVisible: Boolean = true, diff --git a/mbway/build.gradle b/mbway/build.gradle index dc51b3a268..08982a4d83 100644 --- a/mbway/build.gradle +++ b/mbway/build.gradle @@ -46,6 +46,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/mbway/src/main/java/com/adyen/checkout/mbway/internal/provider/MBWayComponentProvider.kt b/mbway/src/main/java/com/adyen/checkout/mbway/internal/provider/MBWayComponentProvider.kt index 8577f025d4..74e0e524b9 100644 --- a/mbway/src/main/java/com/adyen/checkout/mbway/internal/provider/MBWayComponentProvider.kt +++ b/mbway/src/main/java/com/adyen/checkout/mbway/internal/provider/MBWayComponentProvider.kt @@ -22,11 +22,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -57,7 +55,7 @@ class MBWayComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -95,16 +93,11 @@ constructor( componentConfiguration = checkoutConfiguration.getMBWayConfiguration(), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val mbWayDelegate = DefaultMBWayDelegate( @@ -112,15 +105,16 @@ constructor( paymentMethod = paymentMethod, order = order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) MBWayComponent( mbWayDelegate = mbWayDelegate, @@ -187,17 +181,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val mbWayDelegate = DefaultMBWayDelegate( @@ -205,15 +193,16 @@ constructor( paymentMethod = paymentMethod, order = checkoutSession.order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegate.kt b/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegate.kt index 1070f294a6..344bbc1d18 100644 --- a/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegate.kt +++ b/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegate.kt @@ -16,10 +16,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams -import com.adyen.checkout.components.core.internal.util.CountryInfo -import com.adyen.checkout.components.core.internal.util.CountryUtils import com.adyen.checkout.components.core.paymentmethod.MBWayPaymentMethod import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.internal.util.adyenLog @@ -31,10 +30,11 @@ import com.adyen.checkout.ui.core.internal.ui.ComponentViewType import com.adyen.checkout.ui.core.internal.ui.PaymentComponentUIEvent import com.adyen.checkout.ui.core.internal.ui.PaymentComponentUIState import com.adyen.checkout.ui.core.internal.ui.SubmitHandler +import com.adyen.checkout.ui.core.internal.ui.model.CountryModel +import com.adyen.checkout.ui.core.internal.util.CountryUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultMBWayDelegate( @@ -42,7 +42,7 @@ internal class DefaultMBWayDelegate( private val paymentMethod: PaymentMethod, private val order: OrderRequest?, override val componentParams: ButtonComponentParams, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, ) : MBWayDelegate { @@ -71,14 +71,15 @@ internal class DefaultMBWayDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -136,7 +137,7 @@ internal class DefaultMBWayDelegate( ): MBWayComponentState { val paymentMethod = MBWayPaymentMethod( type = MBWayPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), telephoneNumber = outputData.mobilePhoneNumberFieldState.value, ) @@ -157,9 +158,18 @@ internal class DefaultMBWayDelegate( _componentStateFlow.tryEmit(componentState) } - override fun getSupportedCountries(): List = CountryUtils.getCountries(SUPPORTED_COUNTRIES) + override fun getSupportedCountries(): List = + CountryUtils.getLocalizedCountries(componentParams.shopperLocale, SUPPORTED_COUNTRIES) + + override fun getInitiallySelectedCountry(): CountryModel? { + val countries = getSupportedCountries() + return countries.firstOrNull { it.isoCode == ISO_CODE_PORTUGAL } ?: countries.firstOrNull() + } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state) } @@ -174,6 +184,7 @@ internal class DefaultMBWayDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } companion object { diff --git a/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/MBWayDelegate.kt b/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/MBWayDelegate.kt index 11ad631185..0bcfbf8015 100644 --- a/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/MBWayDelegate.kt +++ b/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/MBWayDelegate.kt @@ -9,13 +9,13 @@ package com.adyen.checkout.mbway.internal.ui import com.adyen.checkout.components.core.internal.ui.PaymentComponentDelegate -import com.adyen.checkout.components.core.internal.util.CountryInfo import com.adyen.checkout.mbway.MBWayComponentState import com.adyen.checkout.mbway.internal.ui.model.MBWayInputData import com.adyen.checkout.mbway.internal.ui.model.MBWayOutputData import com.adyen.checkout.ui.core.internal.ui.ButtonDelegate import com.adyen.checkout.ui.core.internal.ui.UIStateDelegate import com.adyen.checkout.ui.core.internal.ui.ViewProvidingDelegate +import com.adyen.checkout.ui.core.internal.ui.model.CountryModel import kotlinx.coroutines.flow.Flow internal interface MBWayDelegate : @@ -30,7 +30,9 @@ internal interface MBWayDelegate : val componentStateFlow: Flow - fun getSupportedCountries(): List + fun getSupportedCountries(): List + + fun getInitiallySelectedCountry(): CountryModel? fun updateInputData(update: MBWayInputData.() -> Unit) diff --git a/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/view/MbWayView.kt b/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/view/MbWayView.kt index 96a3bd898b..0343cdef4f 100644 --- a/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/view/MbWayView.kt +++ b/mbway/src/main/java/com/adyen/checkout/mbway/internal/ui/view/MbWayView.kt @@ -16,11 +16,8 @@ import android.view.View.OnFocusChangeListener import android.widget.LinearLayout import com.adyen.checkout.components.core.internal.ui.ComponentDelegate import com.adyen.checkout.components.core.internal.ui.model.Validation -import com.adyen.checkout.components.core.internal.util.CountryInfo -import com.adyen.checkout.components.core.internal.util.CountryUtils import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.internal.util.adyenLog -import com.adyen.checkout.mbway.R import com.adyen.checkout.mbway.databinding.MbwayViewBinding import com.adyen.checkout.mbway.internal.ui.MBWayDelegate import com.adyen.checkout.ui.core.internal.ui.ComponentView @@ -29,6 +26,7 @@ import com.adyen.checkout.ui.core.internal.ui.model.CountryModel import com.adyen.checkout.ui.core.internal.util.hideError import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR internal class MbWayView @JvmOverloads constructor( context: Context, @@ -46,7 +44,7 @@ internal class MbWayView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } @@ -80,7 +78,7 @@ internal class MbWayView @JvmOverloads constructor( } private fun initCountryInput() { - val countries = delegate.getSupportedCountries().mapToCountryModel() + val countries = delegate.getSupportedCountries() val adapter = CountryAdapter(context, localizedContext) adapter.setItems(countries) binding.autoCompleteTextViewCountry.apply { @@ -92,10 +90,9 @@ internal class MbWayView @JvmOverloads constructor( onCountrySelected(country) } } - val firstCountry = countries.firstOrNull() - if (firstCountry != null) { - binding.autoCompleteTextViewCountry.setText(firstCountry.toShortString()) - onCountrySelected(firstCountry) + delegate.getInitiallySelectedCountry()?.let { + binding.autoCompleteTextViewCountry.setText(it.toShortString()) + onCountrySelected(it) } } @@ -116,13 +113,4 @@ internal class MbWayView @JvmOverloads constructor( countryCode = countryModel.callingCode } } - - private fun List.mapToCountryModel() = map { - CountryModel( - isoCode = it.isoCode, - countryName = CountryUtils.getCountryName(it.isoCode, delegate.componentParams.shopperLocale), - callingCode = it.callingCode, - emoji = it.emoji, - ) - } } diff --git a/mbway/src/main/res/layout/mbway_view.xml b/mbway/src/main/res/layout/mbway_view.xml index 28ffdef409..6649df4d85 100644 --- a/mbway/src/main/res/layout/mbway_view.xml +++ b/mbway/src/main/res/layout/mbway_view.xml @@ -1,5 +1,4 @@ - - - @@ -19,8 +17,8 @@ android:id="@+id/layout_container" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - tools:ignore="DisableBaselineAlignment"> + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_height="match_parent" + android:dropDownAnchor="@id/layout_container" /> + + + diff --git a/mbway/src/test/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegateTest.kt b/mbway/src/test/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegateTest.kt index 3f18312f7f..2cc1edbdb4 100644 --- a/mbway/src/test/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegateTest.kt +++ b/mbway/src/test/java/com/adyen/checkout/mbway/internal/ui/DefaultMBWayDelegateTest.kt @@ -14,7 +14,8 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment @@ -41,22 +42,21 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class) internal class DefaultMBWayDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultMBWayDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createMBWayDelegate() } @@ -197,12 +197,6 @@ internal class DefaultMBWayDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -259,9 +253,32 @@ internal class DefaultMBWayDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -274,13 +291,27 @@ internal class DefaultMBWayDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } + } + + @Test + fun `when getting initially selected country, then Portugal should be returned`() { + val result = delegate.getInitiallySelectedCountry() + + assertEquals("PT", result?.isoCode) } private fun createMBWayDelegate( configuration: CheckoutConfiguration = createCheckoutConfiguration(), ) = DefaultMBWayDelegate( observerRepository = PaymentObserverRepository(), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = TEST_ORDER, componentParams = ButtonComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( checkoutConfiguration = configuration, @@ -289,7 +320,7 @@ internal class DefaultMBWayDelegateTest( componentSessionParams = null, componentConfiguration = configuration.getMBWayConfiguration(), ), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, ) @@ -309,6 +340,7 @@ internal class DefaultMBWayDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/molpay/src/main/java/com/adyen/checkout/molpay/internal/provider/MolpayComponentProvider.kt b/molpay/src/main/java/com/adyen/checkout/molpay/internal/provider/MolpayComponentProvider.kt index 45885eecb0..1b1e9e4fc8 100644 --- a/molpay/src/main/java/com/adyen/checkout/molpay/internal/provider/MolpayComponentProvider.kt +++ b/molpay/src/main/java/com/adyen/checkout/molpay/internal/provider/MolpayComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.MolpayPaymentMethod import com.adyen.checkout.issuerlist.internal.provider.IssuerListComponentProvider @@ -29,11 +29,11 @@ class MolpayComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider( componentClass = MolpayComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponent( diff --git a/online-banking-core/build.gradle b/online-banking-core/build.gradle index c3a05feca8..b3f1661e47 100644 --- a/online-banking-core/build.gradle +++ b/online-banking-core/build.gradle @@ -49,6 +49,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/provider/OnlineBankingComponentProvider.kt b/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/provider/OnlineBankingComponentProvider.kt index 9ba3010645..5d4db5912f 100644 --- a/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/provider/OnlineBankingComponentProvider.kt +++ b/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/provider/OnlineBankingComponentProvider.kt @@ -26,11 +26,9 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.ComponentEventHandler import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -68,7 +66,7 @@ abstract class OnlineBankingComponentProvider< constructor( private val componentClass: Class, private val dropInOverrideParams: DropInOverrideParams?, - private val analyticsRepository: AnalyticsRepository?, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider>, @@ -101,16 +99,11 @@ constructor( componentConfiguration = getConfiguration(checkoutConfiguration), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val onlineBankingDelegate = DefaultOnlineBankingDelegate( @@ -119,18 +112,19 @@ constructor( paymentMethod = paymentMethod, order = order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, termsAndConditionsUrl = getTermsAndConditionsUrl(), submitHandler = SubmitHandler(savedStateHandle), paymentMethodFactory = { createPaymentMethod() }, componentStateFactory = ::createComponentState, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) createComponent( delegate = onlineBankingDelegate, @@ -199,17 +193,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val onlineBankingDelegate = DefaultOnlineBankingDelegate( @@ -218,18 +206,19 @@ constructor( paymentMethod = paymentMethod, order = checkoutSession.order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, termsAndConditionsUrl = getTermsAndConditionsUrl(), submitHandler = SubmitHandler(savedStateHandle), paymentMethodFactory = { createPaymentMethod() }, componentStateFactory = ::createComponentState, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegate.kt b/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegate.kt index d4633d09de..4ce120e348 100644 --- a/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegate.kt +++ b/online-banking-core/src/main/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegate.kt @@ -18,7 +18,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.internal.util.bufferedChannel import com.adyen.checkout.components.core.paymentmethod.IssuerListPaymentMethod @@ -41,7 +42,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions", "LongParameterList") internal class DefaultOnlineBankingDelegate< @@ -53,7 +53,7 @@ internal class DefaultOnlineBankingDelegate< private val paymentMethod: PaymentMethod, private val order: Order?, override val componentParams: ButtonComponentParams, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val termsAndConditionsUrl: String, private val submitHandler: SubmitHandler, private val paymentMethodFactory: () -> IssuerListPaymentMethodT, @@ -92,14 +92,15 @@ internal class DefaultOnlineBankingDelegate< override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -154,7 +155,7 @@ internal class DefaultOnlineBankingDelegate< ): ComponentStateT { val issuerListPaymentMethod = paymentMethodFactory().apply { type = getPaymentMethodType() - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId() + checkoutAttemptId = analyticsManager.getCheckoutAttemptId() issuer = outputData.selectedIssuer?.id } @@ -176,6 +177,9 @@ internal class DefaultOnlineBankingDelegate< } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state = state) } @@ -190,5 +194,6 @@ internal class DefaultOnlineBankingDelegate< override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/online-banking-core/src/test/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegateTest.kt b/online-banking-core/src/test/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegateTest.kt index a6505d1887..bd1eb44666 100644 --- a/online-banking-core/src/test/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegateTest.kt +++ b/online-banking-core/src/test/java/com/adyen/checkout/onlinebankingcore/internal/ui/DefaultOnlineBankingDelegateTest.kt @@ -15,7 +15,8 @@ import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment @@ -45,7 +46,6 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -54,12 +54,12 @@ import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class) internal class DefaultOnlineBankingDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val context: Context, @Mock private val pdfOpener: PdfOpener, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultOnlineBankingDelegate< TestOnlineBankingPaymentMethod, TestOnlineBankingComponentState, @@ -67,6 +67,7 @@ internal class DefaultOnlineBankingDelegateTest( @BeforeEach fun setup() { + analyticsManager = TestAnalyticsManager() delegate = createOnlineBankingDelegate() } @@ -177,12 +178,6 @@ internal class DefaultOnlineBankingDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -239,9 +234,32 @@ internal class DefaultOnlineBankingDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -253,6 +271,13 @@ internal class DefaultOnlineBankingDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createOnlineBankingDelegate( @@ -261,9 +286,9 @@ internal class DefaultOnlineBankingDelegateTest( ) = DefaultOnlineBankingDelegate( observerRepository = PaymentObserverRepository(), pdfOpener = pdfOpener, - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, componentParams = ButtonComponentParamsMapper(CommonComponentParamsMapper()) .mapToParams( checkoutConfiguration = configuration, @@ -305,6 +330,7 @@ internal class DefaultOnlineBankingDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/online-banking-cz/src/main/java/com/adyen/checkout/onlinebankingcz/internal/provider/OnlineBankingCZComponentProvider.kt b/online-banking-cz/src/main/java/com/adyen/checkout/onlinebankingcz/internal/provider/OnlineBankingCZComponentProvider.kt index 467851265b..2f971e0eac 100644 --- a/online-banking-cz/src/main/java/com/adyen/checkout/onlinebankingcz/internal/provider/OnlineBankingCZComponentProvider.kt +++ b/online-banking-cz/src/main/java/com/adyen/checkout/onlinebankingcz/internal/provider/OnlineBankingCZComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.OnlineBankingCZPaymentMethod import com.adyen.checkout.onlinebankingcore.internal.provider.OnlineBankingComponentProvider @@ -29,7 +29,7 @@ class OnlineBankingCZComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : OnlineBankingComponentProvider< OnlineBankingCZComponent, OnlineBankingCZConfiguration, @@ -38,7 +38,7 @@ constructor( >( componentClass = OnlineBankingCZComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createPaymentMethod(): OnlineBankingCZPaymentMethod { diff --git a/online-banking-jp/src/main/java/com/adyen/checkout/onlinebankingjp/internal/provider/OnlineBankingJPComponentProvider.kt b/online-banking-jp/src/main/java/com/adyen/checkout/onlinebankingjp/internal/provider/OnlineBankingJPComponentProvider.kt index 3474600925..106fbab491 100644 --- a/online-banking-jp/src/main/java/com/adyen/checkout/onlinebankingjp/internal/provider/OnlineBankingJPComponentProvider.kt +++ b/online-banking-jp/src/main/java/com/adyen/checkout/onlinebankingjp/internal/provider/OnlineBankingJPComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.OnlineBankingJPPaymentMethod import com.adyen.checkout.econtext.internal.provider.EContextComponentProvider @@ -29,7 +29,7 @@ class OnlineBankingJPComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : EContextComponentProvider< OnlineBankingJPComponent, OnlineBankingJPConfiguration, @@ -38,7 +38,7 @@ constructor( >( componentClass = OnlineBankingJPComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponentState( diff --git a/online-banking-pl/src/main/java/com/adyen/checkout/onlinebankingpl/internal/provider/OnlineBankingPLComponentProvider.kt b/online-banking-pl/src/main/java/com/adyen/checkout/onlinebankingpl/internal/provider/OnlineBankingPLComponentProvider.kt index 207acfc642..158accf2f6 100644 --- a/online-banking-pl/src/main/java/com/adyen/checkout/onlinebankingpl/internal/provider/OnlineBankingPLComponentProvider.kt +++ b/online-banking-pl/src/main/java/com/adyen/checkout/onlinebankingpl/internal/provider/OnlineBankingPLComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.OnlineBankingPLPaymentMethod import com.adyen.checkout.issuerlist.internal.provider.IssuerListComponentProvider @@ -29,16 +29,16 @@ class OnlineBankingPLComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider< OnlineBankingPLComponent, OnlineBankingPLConfiguration, OnlineBankingPLPaymentMethod, - OnlineBankingPLComponentState + OnlineBankingPLComponentState, >( componentClass = OnlineBankingPLComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponent( diff --git a/online-banking-sk/src/main/java/com/adyen/checkout/onlinebankingsk/internal/provider/OnlineBankingSKComponentProvider.kt b/online-banking-sk/src/main/java/com/adyen/checkout/onlinebankingsk/internal/provider/OnlineBankingSKComponentProvider.kt index 69dc9425aa..63e2ca76fd 100644 --- a/online-banking-sk/src/main/java/com/adyen/checkout/onlinebankingsk/internal/provider/OnlineBankingSKComponentProvider.kt +++ b/online-banking-sk/src/main/java/com/adyen/checkout/onlinebankingsk/internal/provider/OnlineBankingSKComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.OnlineBankingSKPaymentMethod import com.adyen.checkout.onlinebankingcore.internal.provider.OnlineBankingComponentProvider @@ -29,7 +29,7 @@ class OnlineBankingSKComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : OnlineBankingComponentProvider< OnlineBankingSKComponent, OnlineBankingSKConfiguration, @@ -38,7 +38,7 @@ constructor( >( componentClass = OnlineBankingSKComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createPaymentMethod(): OnlineBankingSKPaymentMethod { diff --git a/openbanking/src/main/java/com/adyen/checkout/openbanking/internal/provider/OpenBankingComponentProvider.kt b/openbanking/src/main/java/com/adyen/checkout/openbanking/internal/provider/OpenBankingComponentProvider.kt index c0e49f8f40..8f8d515e05 100644 --- a/openbanking/src/main/java/com/adyen/checkout/openbanking/internal/provider/OpenBankingComponentProvider.kt +++ b/openbanking/src/main/java/com/adyen/checkout/openbanking/internal/provider/OpenBankingComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.OpenBankingPaymentMethod import com.adyen.checkout.issuerlist.internal.provider.IssuerListComponentProvider @@ -29,7 +29,7 @@ class OpenBankingComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : IssuerListComponentProvider< OpenBankingComponent, OpenBankingConfiguration, @@ -38,7 +38,7 @@ constructor( >( componentClass = OpenBankingComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponent( diff --git a/paybybank/build.gradle b/paybybank/build.gradle index c53dd0e344..75cadeb1de 100644 --- a/paybybank/build.gradle +++ b/paybybank/build.gradle @@ -40,6 +40,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.mockito testImplementation testLibraries.kotlinCoroutines diff --git a/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/provider/PayByBankComponentProvider.kt b/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/provider/PayByBankComponentProvider.kt index ab01e7ffa8..30efc33ebe 100644 --- a/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/provider/PayByBankComponentProvider.kt +++ b/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/provider/PayByBankComponentProvider.kt @@ -22,11 +22,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -56,7 +54,7 @@ class PayByBankComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -93,16 +91,11 @@ constructor( componentSessionParams = null, ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val payByBankDelegate = DefaultPayByBankDelegate( @@ -110,15 +103,16 @@ constructor( paymentMethod = paymentMethod, order = order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) PayByBankComponent( payByBankDelegate = payByBankDelegate, @@ -184,17 +178,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val payByBankDelegate = DefaultPayByBankDelegate( @@ -202,15 +190,16 @@ constructor( paymentMethod = paymentMethod, order = checkoutSession.order, componentParams = componentParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegate.kt b/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegate.kt index f199dc34c8..08d0646634 100644 --- a/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegate.kt +++ b/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegate.kt @@ -18,7 +18,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams import com.adyen.checkout.components.core.paymentmethod.PayByBankPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -34,7 +35,6 @@ import com.adyen.checkout.ui.core.internal.ui.SubmitHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultPayByBankDelegate( @@ -42,7 +42,7 @@ internal class DefaultPayByBankDelegate( private val paymentMethod: PaymentMethod, private val order: Order?, override val componentParams: GenericComponentParams, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, ) : PayByBankDelegate { @@ -76,14 +76,15 @@ internal class DefaultPayByBankDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -144,7 +145,7 @@ internal class DefaultPayByBankDelegate( ): PayByBankComponentState { val payByBankPaymentMethod = PayByBankPaymentMethod( type = getPaymentMethodType(), - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), issuer = outputData?.selectedIssuer?.id, ) @@ -186,6 +187,9 @@ internal class DefaultPayByBankDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state = state) } @@ -196,5 +200,6 @@ internal class DefaultPayByBankDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/view/PayByBankView.kt b/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/view/PayByBankView.kt index 9551fe7a4c..4c65c0587a 100644 --- a/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/view/PayByBankView.kt +++ b/paybybank/src/main/java/com/adyen/checkout/paybybank/internal/ui/view/PayByBankView.kt @@ -28,6 +28,7 @@ import com.adyen.checkout.ui.core.internal.util.setLocalizedTextFromStyle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR internal class PayByBankView @JvmOverloads constructor( context: Context, @@ -45,7 +46,7 @@ internal class PayByBankView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(0, padding, 0, 0) } diff --git a/paybybank/src/test/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegateTest.kt b/paybybank/src/test/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegateTest.kt index 7a3d149cba..3dc22356a7 100644 --- a/paybybank/src/test/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegateTest.kt +++ b/paybybank/src/test/java/com/adyen/checkout/paybybank/internal/ui/DefaultPayByBankDelegateTest.kt @@ -16,7 +16,8 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper import com.adyen.checkout.core.Environment @@ -44,22 +45,21 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultPayByBankDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultPayByBankDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createPayByBankDelegate( issuers = listOf( Issuer(id = "issuer-id", name = "issuer-name"), @@ -206,12 +206,6 @@ internal class DefaultPayByBankDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitHandlerTest { @@ -252,9 +246,32 @@ internal class DefaultPayByBankDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -266,6 +283,13 @@ internal class DefaultPayByBankDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createCheckoutConfiguration( @@ -289,10 +313,11 @@ internal class DefaultPayByBankDelegateTest( componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) .mapToParams(configuration, Locale.US, null, null), paymentMethod = PaymentMethod( + type = TEST_PAYMENT_METHOD_TYPE, issuers = issuers, ), order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, ) } @@ -301,6 +326,7 @@ internal class DefaultPayByBankDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/payeasy/src/main/java/com/adyen/checkout/payeasy/internal/provider/PayEasyComponentProvider.kt b/payeasy/src/main/java/com/adyen/checkout/payeasy/internal/provider/PayEasyComponentProvider.kt index 7b4839a19b..5b04a5835d 100644 --- a/payeasy/src/main/java/com/adyen/checkout/payeasy/internal/provider/PayEasyComponentProvider.kt +++ b/payeasy/src/main/java/com/adyen/checkout/payeasy/internal/provider/PayEasyComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.PayEasyPaymentMethod import com.adyen.checkout.econtext.internal.provider.EContextComponentProvider @@ -29,11 +29,11 @@ class PayEasyComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : EContextComponentProvider( componentClass = PayEasyComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponentState( diff --git a/qr-code/build.gradle b/qr-code/build.gradle index a46d987823..952af14e57 100644 --- a/qr-code/build.gradle +++ b/qr-code/build.gradle @@ -48,6 +48,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines diff --git a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/provider/QRCodeComponentProvider.kt b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/provider/QRCodeComponentProvider.kt index 787954eef8..be479c309b 100644 --- a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/provider/QRCodeComponentProvider.kt +++ b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/provider/QRCodeComponentProvider.kt @@ -22,6 +22,7 @@ import com.adyen.checkout.components.core.action.QrCodeAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.data.api.DefaultStatusRepository import com.adyen.checkout.components.core.internal.data.api.StatusService import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider @@ -44,6 +45,7 @@ import com.adyen.checkout.ui.core.internal.util.ImageSaver class QRCodeComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( + private val analyticsManager: AnalyticsManager? = null, private val dropInOverrideParams: DropInOverrideParams? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : ActionComponentProvider { @@ -61,12 +63,14 @@ constructor( val qrCodeDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) QRCodeComponent( delegate = qrCodeDelegate, - actionComponentEventHandler = DefaultActionComponentEventHandler(callback), + actionComponentEventHandler = DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, qrCodeFactory)[key, QRCodeComponent::class.java] .also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } @@ -91,12 +95,14 @@ constructor( return DefaultQRCodeDelegate( observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, componentParams = componentParams, statusRepository = statusRepository, statusCountDownTimer = countDownTimer, redirectHandler = redirectHandler, paymentDataRepository = paymentDataRepository, imageSaver = ImageSaver(), + analyticsManager = analyticsManager, ) } diff --git a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegate.kt b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegate.kt index 0eac440161..b6069df77f 100644 --- a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegate.kt +++ b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegate.kt @@ -14,6 +14,7 @@ import android.content.Intent import android.net.Uri import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.components.core.ActionComponentData import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.action.Action @@ -22,6 +23,10 @@ import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository import com.adyen.checkout.components.core.internal.PermissionRequestData +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.data.api.StatusRepository import com.adyen.checkout.components.core.internal.data.model.StatusResponse import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams @@ -61,13 +66,15 @@ import kotlin.time.Duration.Companion.seconds @Suppress("TooManyFunctions", "LongParameterList") internal class DefaultQRCodeDelegate( private val observerRepository: ActionObserverRepository, + override val savedStateHandle: SavedStateHandle, override val componentParams: GenericComponentParams, private val statusRepository: StatusRepository, private val statusCountDownTimer: QRCodeCountDownTimer, private val redirectHandler: RedirectHandler, private val paymentDataRepository: PaymentDataRepository, - private val imageSaver: ImageSaver -) : QRCodeDelegate { + private val imageSaver: ImageSaver, + private val analyticsManager: AnalyticsManager?, +) : QRCodeDelegate, SavedStateHandleContainer { private val _outputDataFlow = MutableStateFlow(createOutputData()) override val outputDataFlow: Flow = _outputDataFlow @@ -99,6 +106,8 @@ internal class DefaultQRCodeDelegate( private var maxPollingDurationMillis = DEFAULT_MAX_POLLING_DURATION + private var action: QrCodeAction? by SavedStateHandleProperty(ACTION_KEY) + private fun attachStatusTimer() { statusCountDownTimer.attach( millisInFuture = maxPollingDurationMillis, @@ -115,6 +124,15 @@ internal class DefaultQRCodeDelegate( override fun initialize(coroutineScope: CoroutineScope) { _coroutineScope = coroutineScope + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + val action: QrCodeAction? = action + if (action != null) { + initState(action) + } } override fun observe( @@ -139,43 +157,59 @@ internal class DefaultQRCodeDelegate( observerRepository.removeObservers() } - @Suppress("ReturnCount") override fun handleAction(action: Action, activity: Activity) { if (action !is QrCodeAction) { - exceptionChannel.trySend(ComponentException("Unsupported action")) + emitError(ComponentException("Unsupported action")) return } - val paymentData = action.paymentData - paymentDataRepository.paymentData = paymentData - if (paymentData == null) { - adyenLog(AdyenLogLevel.ERROR) { "Payment data is null" } - exceptionChannel.trySend(ComponentException("Payment data is null")) - return - } + this.action = action + paymentDataRepository.paymentData = action.paymentData + + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + ) + analyticsManager?.trackEvent(event) + + launchAction(action, activity) + initState(action) + } + private fun launchAction(action: QrCodeAction, activity: Activity) { if (shouldLaunchRedirect(action)) { - adyenLog(AdyenLogLevel.DEBUG) { "Action does not require a view, redirecting." } - _viewFlow.tryEmit(QrCodeComponentViewType.REDIRECT) makeRedirect(activity, action) - return } + } - var viewType = QrCodeComponentViewType.SIMPLE_QR_CODE - - action.paymentMethodType?.let { - val qrConfig = QRCodePaymentMethodConfig.getByPaymentMethodType(it) - viewType = qrConfig.viewType - maxPollingDurationMillis = qrConfig.maxPollingDurationMillis + private fun initState(action: QrCodeAction) { + if (shouldLaunchRedirect(action)) { + adyenLog(AdyenLogLevel.DEBUG) { "Action does not require a view, redirecting." } + _viewFlow.tryEmit(QrCodeComponentViewType.REDIRECT) + } else { + val paymentData = action.paymentData + if (paymentData == null) { + adyenLog(AdyenLogLevel.ERROR) { "Payment data is null" } + emitError(ComponentException("Payment data is null")) + return + } + + var viewType = QrCodeComponentViewType.SIMPLE_QR_CODE + + action.paymentMethodType?.let { + val qrConfig = QRCodePaymentMethodConfig.getByPaymentMethodType(it) + viewType = qrConfig.viewType + maxPollingDurationMillis = qrConfig.maxPollingDurationMillis + } + _viewFlow.tryEmit(viewType) + + // Notify UI to get the logo. + createOutputData(null, action) + + attachStatusTimer() + startStatusPolling(paymentData, action) + statusCountDownTimer.start() } - _viewFlow.tryEmit(viewType) - - // Notify UI to get the logo. - createOutputData(null, action) - - attachStatusTimer() - startStatusPolling(paymentData, action) - statusCountDownTimer.start() } private fun makeRedirect(activity: Activity, action: QrCodeAction) { @@ -184,7 +218,7 @@ internal class DefaultQRCodeDelegate( adyenLog(AdyenLogLevel.DEBUG) { "makeRedirect - $url" } redirectHandler.launchUriRedirect(activity, url) } catch (ex: CheckoutException) { - exceptionChannel.trySend(ex) + emitError(ex) } } @@ -206,7 +240,7 @@ internal class DefaultQRCodeDelegate( }, onFailure = { adyenLog(AdyenLogLevel.ERROR, it) { "Error while polling status" } - exceptionChannel.trySend(ComponentException("Error while polling status", it)) + emitError(ComponentException("Error while polling status", it)) }, ) } @@ -245,9 +279,9 @@ internal class DefaultQRCodeDelegate( // Not authorized status should still call /details so that merchant can get more info if (StatusResponseUtils.isFinalResult(statusResponse) && !payload.isNullOrEmpty()) { val details = createDetails(payload) - detailsChannel.trySend(createActionComponentData(details)) + emitDetails(details) } else { - exceptionChannel.trySend(ComponentException("Payment was not completed. - " + statusResponse.resultCode)) + emitError(ComponentException("Payment was not completed. - " + statusResponse.resultCode)) } } @@ -263,9 +297,9 @@ internal class DefaultQRCodeDelegate( override fun handleIntent(intent: Intent) { try { val details = redirectHandler.parseRedirectResult(intent.data) - detailsChannel.trySend(createActionComponentData(details)) - } catch (ex: CheckoutException) { - exceptionChannel.trySend(ex) + emitDetails(details) + } catch (e: CheckoutException) { + emitError(e) } } @@ -281,13 +315,13 @@ internal class DefaultQRCodeDelegate( try { jsonObject.put(PAYLOAD_DETAILS_KEY, payload) } catch (e: JSONException) { - exceptionChannel.trySend(ComponentException("Failed to create details.", e)) + emitError(ComponentException("Failed to create details.", e)) } return jsonObject } override fun onError(e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } private fun createOutputData() = QRCodeOutputData( @@ -301,6 +335,12 @@ internal class DefaultQRCodeDelegate( val timestamp = DateUtils.formatDateToString(Calendar.getInstance()) val imageName = String.format(IMAGE_NAME_FORMAT, paymentMethodType, timestamp) + val event = GenericEvents.download( + component = paymentMethodType, + target = ANALYTICS_TARGET_QR_BUTTON + ) + analyticsManager?.trackEvent(event) + coroutineScope.launch { imageSaver.saveImageFromUrl( context = context, @@ -332,6 +372,20 @@ internal class DefaultQRCodeDelegate( redirectHandler.setOnRedirectListener(listener) } + private fun emitError(e: CheckoutException) { + exceptionChannel.trySend(e) + clearState() + } + + private fun emitDetails(details: JSONObject) { + detailsChannel.trySend(createActionComponentData(details)) + clearState() + } + + private fun clearState() { + action = null + } + override fun onCleared() { removeObserver() statusPollingJob?.cancel() @@ -357,7 +411,13 @@ internal class DefaultQRCodeDelegate( private val DEFAULT_MAX_POLLING_DURATION = 15.minutes.inWholeMilliseconds private const val HUNDRED = 100 + @VisibleForTesting + internal const val ANALYTICS_TARGET_QR_BUTTON = "qr_download_button" + private const val IMAGE_NAME_FORMAT = "%s-%s.png" private const val QR_IMAGE_BASE_PATH = "%sbarcode.shtml?barcodeType=qrCode&fileType=png&data=%s" + + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" } } diff --git a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/FullQRCodeView.kt b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/FullQRCodeView.kt index 9faf8f93ce..d72acb319c 100644 --- a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/FullQRCodeView.kt +++ b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/FullQRCodeView.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import java.util.concurrent.TimeUnit +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class FullQRCodeView @JvmOverloads constructor( @@ -55,7 +56,7 @@ internal class FullQRCodeView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, padding) } diff --git a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/SimpleQRCodeView.kt b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/SimpleQRCodeView.kt index 1d5f95e457..942883349a 100644 --- a/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/SimpleQRCodeView.kt +++ b/qr-code/src/main/java/com/adyen/checkout/qrcode/internal/ui/view/SimpleQRCodeView.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import java.util.concurrent.TimeUnit +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class SimpleQRCodeView @JvmOverloads constructor( @@ -53,7 +54,7 @@ internal class SimpleQRCodeView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_double_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_double_margin).toInt() setPadding(padding, padding, padding, padding) } diff --git a/qr-code/src/test/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegateTest.kt b/qr-code/src/test/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegateTest.kt index 819a4c7ff9..65cccb483d 100644 --- a/qr-code/src/test/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegateTest.kt +++ b/qr-code/src/test/java/com/adyen/checkout/qrcode/internal/ui/DefaultQRCodeDelegateTest.kt @@ -14,7 +14,6 @@ import android.content.Intent import android.os.Parcel import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle -import app.cash.turbine.test import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.action.Action @@ -22,6 +21,8 @@ import com.adyen.checkout.components.core.action.QrCodeAction import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.data.api.StatusRepository import com.adyen.checkout.components.core.internal.data.model.StatusResponse import com.adyen.checkout.components.core.internal.test.TestStatusRepository @@ -37,12 +38,14 @@ import com.adyen.checkout.qrcode.internal.QRCodeCountDownTimer import com.adyen.checkout.qrcode.internal.ui.model.QrCodeUIEvent import com.adyen.checkout.qrcode.qrCode import com.adyen.checkout.test.LoggingExtension +import com.adyen.checkout.test.extensions.test import com.adyen.checkout.ui.core.internal.RedirectHandler import com.adyen.checkout.ui.core.internal.exception.PermissionRequestException import com.adyen.checkout.ui.core.internal.test.TestRedirectHandler import com.adyen.checkout.ui.core.internal.util.ImageSaver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -58,6 +61,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -74,6 +78,7 @@ internal class DefaultQRCodeDelegateTest( @Mock private val imageSaver: ImageSaver ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var redirectHandler: TestRedirectHandler private lateinit var statusRepository: TestStatusRepository private lateinit var paymentDataRepository: PaymentDataRepository @@ -81,6 +86,7 @@ internal class DefaultQRCodeDelegateTest( @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() statusRepository = TestStatusRepository() redirectHandler = TestRedirectHandler() paymentDataRepository = PaymentDataRepository(SavedStateHandle()) @@ -140,26 +146,25 @@ internal class DefaultQRCodeDelegateTest( @Test fun `when handleAction is called with unsupported action, then an error should be emitted`() = runTest { - delegate.exceptionFlow.test { - delegate.handleAction( - createTestAction(), - mock(), - ) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - assert(expectMostRecentItem() is ComponentException) - } + delegate.handleAction( + createTestAction(), + mock(), + ) + + assert(exceptionFlow.latestValue is ComponentException) } @Test fun `when handleAction is called with null payment data, then an error should be emitted`() = runTest { - delegate.exceptionFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = null), - mock(), - ) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = null), + mock(), + ) - assert(expectMostRecentItem() is ComponentException) - } + assert(exceptionFlow.latestValue is ComponentException) } @Nested @@ -170,15 +175,10 @@ internal class DefaultQRCodeDelegateTest( fun `timer ticks, then left over time and progress are emitted`() = runTest { delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.timerFlow.test { - delegate.onTimerTick(10000) - - skipItems(1) + val timerFlow = delegate.timerFlow.test(testScheduler) + delegate.onTimerTick(10000) - assertEquals(TimerData(10000, 1), awaitItem()) - - cancelAndIgnoreRemainingEvents() - } + assertEquals(TimerData(10000, 1), timerFlow.latestValue) } @Test @@ -189,31 +189,26 @@ internal class DefaultQRCodeDelegateTest( ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.outputDataFlow.test { - delegate.handleAction( - QrCodeAction( - paymentMethodType = PaymentMethodTypes.PIX, - qrCodeData = "qrData", - paymentData = "paymentData", - ), - Activity(), - ) - - skipItems(1) - - with(awaitItem()) { - assertFalse(isValid) - assertEquals(PaymentMethodTypes.PIX, paymentMethodType) - assertEquals("qrData", qrCodeData) - } + val outputDataFlow = delegate.outputDataFlow.test(testScheduler) + delegate.handleAction( + QrCodeAction( + paymentMethodType = PaymentMethodTypes.PIX, + qrCodeData = "qrData", + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) - with(awaitItem()) { - assertTrue(isValid) - assertEquals(PaymentMethodTypes.PIX, paymentMethodType) - assertEquals("qrData", qrCodeData) - } + with(outputDataFlow.values[1]) { + assertFalse(isValid) + assertEquals(PaymentMethodTypes.PIX, paymentMethodType) + assertEquals("qrData", qrCodeData) + } - cancelAndIgnoreRemainingEvents() + with(outputDataFlow.values[2]) { + assertTrue(isValid) + assertEquals(PaymentMethodTypes.PIX, paymentMethodType) + assertEquals("qrData", qrCodeData) } } @@ -223,17 +218,17 @@ internal class DefaultQRCodeDelegateTest( Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val detailsFlow = delegate.detailsFlow.test(testScheduler) - delegate.detailsFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = "paymentData"), - Activity(), - ) - - assertEquals("testpayload", awaitItem().details?.getString(DefaultQRCodeDelegate.PAYLOAD_DETAILS_KEY)) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - cancelAndIgnoreRemainingEvents() - } + assertEquals( + "testpayload", + detailsFlow.latestValue.details?.getString(DefaultQRCodeDelegate.PAYLOAD_DETAILS_KEY), + ) } @Test @@ -241,17 +236,14 @@ internal class DefaultQRCodeDelegateTest( val error = IOException("test") statusRepository.pollingResults = listOf(Result.failure(error)) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = "paymentData"), - Activity(), - ) - - assertEquals(error, awaitItem().cause) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - cancelAndIgnoreRemainingEvents() - } + assertEquals(error, exceptionFlow.latestValue.cause) } @Test @@ -260,33 +252,29 @@ internal class DefaultQRCodeDelegateTest( Result.success(StatusResponse(resultCode = "finished", payload = "")), ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = "paymentData"), - Activity(), - ) - - assertTrue(awaitItem() is ComponentException) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - cancelAndIgnoreRemainingEvents() - } + assertTrue(exceptionFlow.latestValue is ComponentException) } @Test fun `handleAction is called, then simple qr view flow is updated`() = runTest { delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val viewFlow = delegate.viewFlow.test(testScheduler) - delegate.viewFlow.test { - assertNull(awaitItem()) + assertNull(viewFlow.latestValue) - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = "paymentData"), - Activity(), - ) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - assertEquals(QrCodeComponentViewType.SIMPLE_QR_CODE, awaitItem()) - } + assertEquals(QrCodeComponentViewType.SIMPLE_QR_CODE, viewFlow.latestValue) } @Test @@ -297,29 +285,26 @@ internal class DefaultQRCodeDelegateTest( ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.outputDataFlow.test { - delegate.handleAction( - QrCodeAction( - paymentMethodType = PaymentMethodTypes.PAY_NOW, - qrCodeData = "qrData", - paymentData = "paymentData", - ), - Activity(), - ) - - skipItems(1) + val outputDataFlow = delegate.outputDataFlow.test(testScheduler) + delegate.handleAction( + QrCodeAction( + paymentMethodType = PaymentMethodTypes.PAY_NOW, + qrCodeData = "qrData", + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) - with(awaitItem()) { - assertFalse(isValid) - assertEquals(PaymentMethodTypes.PAY_NOW, paymentMethodType) - assertEquals("qrData", qrCodeData) - } + with(outputDataFlow.values[1]) { + assertFalse(isValid) + assertEquals(PaymentMethodTypes.PAY_NOW, paymentMethodType) + assertEquals("qrData", qrCodeData) + } - with(expectMostRecentItem()) { - assertTrue(isValid) - assertEquals(PaymentMethodTypes.PAY_NOW, paymentMethodType) - assertEquals("qrData", qrCodeData) - } + with(outputDataFlow.values[2]) { + assertTrue(isValid) + assertEquals(PaymentMethodTypes.PAY_NOW, paymentMethodType) + assertEquals("qrData", qrCodeData) } } @@ -330,16 +315,16 @@ internal class DefaultQRCodeDelegateTest( ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.detailsFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = "paymentData"), - Activity(), - ) - - assertEquals("testpayload", awaitItem().details?.getString(DefaultQRCodeDelegate.PAYLOAD_DETAILS_KEY)) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - cancelAndIgnoreRemainingEvents() - } + assertEquals( + "testpayload", + detailsFlow.latestValue.details?.getString(DefaultQRCodeDelegate.PAYLOAD_DETAILS_KEY), + ) } @Test @@ -348,17 +333,14 @@ internal class DefaultQRCodeDelegateTest( Result.success(StatusResponse(resultCode = "finished", payload = "")), ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = "paymentData"), - Activity(), - ) - - assertTrue(awaitItem() is ComponentException) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - cancelAndIgnoreRemainingEvents() - } + assertTrue(exceptionFlow.latestValue is ComponentException) } @Test @@ -366,31 +348,29 @@ internal class DefaultQRCodeDelegateTest( val error = IOException("test") statusRepository.pollingResults = listOf(Result.failure(error)) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = "paymentData"), - Activity(), - ) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - assertEquals(error, expectMostRecentItem().cause) - } + assertEquals(error, exceptionFlow.latestValue.cause) } @Test fun `handleAction is called, then full qr view flow is updated`() = runTest { delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val viewFlow = delegate.viewFlow.test(testScheduler) - delegate.viewFlow.test { - assertNull(awaitItem()) + assertNull(viewFlow.latestValue) - delegate.handleAction( - QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = "paymentData"), - Activity(), - ) + delegate.handleAction( + QrCodeAction(paymentMethodType = PaymentMethodTypes.PAY_NOW, paymentData = TEST_PAYMENT_DATA), + Activity(), + ) - assertEquals(QrCodeComponentViewType.FULL_QR_CODE, awaitItem()) - } + assertEquals(QrCodeComponentViewType.FULL_QR_CODE, viewFlow.latestValue) } } @@ -402,63 +382,74 @@ internal class DefaultQRCodeDelegateTest( fun `handleAction is called and RedirectHandler returns an error, then the error is propagated`() = runTest { val error = ComponentException("Failed to make redirect.") redirectHandler.exception = error + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleAction(QrCodeAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + delegate.handleAction( + QrCodeAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) - assertEquals(error, awaitItem()) - } + assertEquals(error, exceptionFlow.latestValue) } @Test fun `handleAction is called with valid data, then no error is propagated`() = runTest { - delegate.exceptionFlow.test { - delegate.handleAction(QrCodeAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - expectNoEvents() - } + delegate.handleAction( + QrCodeAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + paymentData = TEST_PAYMENT_DATA, + ), + Activity(), + ) + + assertTrue(exceptionFlow.values.isEmpty()) } @Test fun `handleIntent is called and RedirectHandler returns an error, then the error is propagated`() = runTest { val error = ComponentException("Failed to parse redirect result.") redirectHandler.exception = error + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) - delegate.exceptionFlow.test { - delegate.handleIntent(Intent()) + delegate.handleIntent(Intent()) - assertEquals(error, awaitItem()) - } + assertEquals(error, exceptionFlow.latestValue) } @Test fun `handleIntent is called with valid data, then the details are emitted`() = runTest { - delegate.detailsFlow.test { - delegate.handleAction(QrCodeAction(paymentData = "paymentData"), Activity()) - delegate.handleIntent(Intent()) - - with(awaitItem()) { - assertEquals(TestRedirectHandler.REDIRECT_RESULT, details) - assertEquals("paymentData", paymentData) - } + val detailsFlow = delegate.detailsFlow.test(testScheduler) + delegate.handleAction(QrCodeAction(paymentData = TEST_PAYMENT_DATA), Activity()) + delegate.handleIntent(Intent()) + + with(detailsFlow.latestValue) { + assertEquals(TestRedirectHandler.REDIRECT_RESULT, details) + assertEquals(TEST_PAYMENT_DATA, paymentData) } } @Test fun `handleAction is called, then the view flow is updated`() = runTest { - delegate.viewFlow.test { - assertNull(awaitItem()) + val viewFlow = delegate.viewFlow.test(testScheduler) - delegate.handleAction(QrCodeAction(paymentData = "paymentData"), Activity()) + assertNull(viewFlow.latestValue) - assertEquals(awaitItem(), QrCodeComponentViewType.REDIRECT) - } + delegate.handleAction(QrCodeAction(paymentData = TEST_PAYMENT_DATA), Activity()) + + assertEquals(QrCodeComponentViewType.REDIRECT, viewFlow.latestValue) } } @Test fun `when refreshStatus is called, then status for statusRepository gets refreshed`() = runTest { - val statusRepository = mock() + val statusRepository = mock { + on { poll(any(), any()) } doReturn flowOf() + } val paymentData = "Payment Data" val delegate = createDelegate( statusRepository = statusRepository, @@ -502,13 +493,12 @@ internal class DefaultQRCodeDelegateTest( ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.eventFlow.test { - val expectedResult = QrCodeUIEvent.QrImageDownloadResult.Success + val eventFlow = delegate.eventFlow.test(testScheduler) + val expectedResult = QrCodeUIEvent.QrImageDownloadResult.Success - delegate.downloadQRImage(context) + delegate.downloadQRImage(context) - assertEquals(expectedResult, expectMostRecentItem()) - } + assertEquals(expectedResult, eventFlow.latestValue) } @Test @@ -518,13 +508,13 @@ internal class DefaultQRCodeDelegateTest( ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.eventFlow.test { - val expectedResult = QrCodeUIEvent.QrImageDownloadResult.PermissionDenied + val eventFlow = delegate.eventFlow.test(testScheduler) - delegate.downloadQRImage(context) + val expectedResult = QrCodeUIEvent.QrImageDownloadResult.PermissionDenied - assertEquals(expectedResult, expectMostRecentItem()) - } + delegate.downloadQRImage(context) + + assertEquals(expectedResult, eventFlow.latestValue) } @Test @@ -535,13 +525,12 @@ internal class DefaultQRCodeDelegateTest( ) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - delegate.eventFlow.test { - val expectedResult = QrCodeUIEvent.QrImageDownloadResult.Failure(throwable) + val eventFlow = delegate.eventFlow.test(testScheduler) + val expectedResult = QrCodeUIEvent.QrImageDownloadResult.Failure(throwable) - delegate.downloadQRImage(context) + delegate.downloadQRImage(context) - assertEquals(expectedResult, expectMostRecentItem()) - } + assertEquals(expectedResult, eventFlow.latestValue) } @Test @@ -549,13 +538,63 @@ internal class DefaultQRCodeDelegateTest( val requiredPermission = "Required Permission" val permissionCallback = mock() - delegate.permissionFlow.test { - delegate.requestPermission(context, requiredPermission, permissionCallback) + val permissionFlow = delegate.permissionFlow.test(testScheduler) + delegate.requestPermission(context, requiredPermission, permissionCallback) + + val mostRecentValue = permissionFlow.latestValue + assertEquals(requiredPermission, mostRecentValue.requiredPermission) + assertEquals(permissionCallback, mostRecentValue.permissionCallback) + } - val mostRecentValue = expectMostRecentItem() - assertEquals(requiredPermission, mostRecentValue.requiredPermission) - assertEquals(permissionCallback, mostRecentValue.permissionCallback) + @Test + fun `when initializing and action is set, then state is restored`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), + ) + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultQRCodeDelegate.ACTION_KEY, + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = "paymentData"), + ) } + delegate = createDelegate(savedStateHandle = savedStateHandle) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertTrue(detailsFlow.values.isNotEmpty()) + } + + @Test + fun `when details are emitted, then state is cleared`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), + ) + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultQRCodeDelegate.ACTION_KEY, + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = "paymentData"), + ) + } + delegate = createDelegate(savedStateHandle = savedStateHandle) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertNull(savedStateHandle[DefaultQRCodeDelegate.ACTION_KEY]) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultQRCodeDelegate.ACTION_KEY, + QrCodeAction(paymentMethodType = PaymentMethodTypes.PIX, paymentData = null), + ) + } + delegate = createDelegate(savedStateHandle = savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertNull(savedStateHandle[DefaultQRCodeDelegate.ACTION_KEY]) } @Test @@ -576,6 +615,49 @@ internal class DefaultQRCodeDelegateTest( verify(redirectHandler).removeOnRedirectListener() } + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = QrCodeAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + paymentData = TEST_PAYMENT_DATA, + ) + + delegate.handleAction(action, mock()) + + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when downloadQRImage is called, then download event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + delegate.handleAction( + QrCodeAction( + paymentMethodType = PaymentMethodTypes.PIX, + qrCodeData = TEST_QR_CODE_DATA, + paymentData = TEST_PAYMENT_DATA, + ), + mock(), + ) + + delegate.downloadQRImage(context) + + val expectedEvent = GenericEvents.download( + component = PaymentMethodTypes.PIX, + target = DefaultQRCodeDelegate.ANALYTICS_TARGET_QR_BUTTON, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + } + private fun createTestAction( type: String = "test", paymentData: String = "paymentData", @@ -591,22 +673,29 @@ internal class DefaultQRCodeDelegateTest( private fun createDelegate( observerRepository: ActionObserverRepository = mock(), componentParams: GenericComponentParams = mock(), - statusRepository: StatusRepository = mock(), + statusRepository: StatusRepository = this.statusRepository, statusCountDownTimer: QRCodeCountDownTimer = mock(), redirectHandler: RedirectHandler = mock(), paymentDataRepository: PaymentDataRepository = mock(), imageSaver: ImageSaver = mock(), + savedStateHandle: SavedStateHandle = SavedStateHandle() ) = DefaultQRCodeDelegate( observerRepository = observerRepository, + savedStateHandle = savedStateHandle, componentParams = componentParams, statusRepository = statusRepository, statusCountDownTimer = statusCountDownTimer, redirectHandler = redirectHandler, paymentDataRepository = paymentDataRepository, imageSaver = imageSaver, + analyticsManager = analyticsManager, ) companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_ACTION_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_PAYMENT_DATA = "TEST_PAYMENT_DATA" + private const val TEST_QR_CODE_DATA = "TEST_QR_CODE_DATA" } } diff --git a/redirect/build.gradle b/redirect/build.gradle index 4345164b38..df0dbfc9c2 100644 --- a/redirect/build.gradle +++ b/redirect/build.gradle @@ -45,6 +45,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines diff --git a/redirect/src/main/java/com/adyen/checkout/redirect/internal/provider/RedirectComponentProvider.kt b/redirect/src/main/java/com/adyen/checkout/redirect/internal/provider/RedirectComponentProvider.kt index 07a742fc9d..59e000d2f5 100644 --- a/redirect/src/main/java/com/adyen/checkout/redirect/internal/provider/RedirectComponentProvider.kt +++ b/redirect/src/main/java/com/adyen/checkout/redirect/internal/provider/RedirectComponentProvider.kt @@ -23,6 +23,7 @@ import com.adyen.checkout.components.core.action.RedirectAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -42,6 +43,7 @@ import com.adyen.checkout.ui.core.internal.DefaultRedirectHandler class RedirectComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( + private val analyticsManager: AnalyticsManager? = null, private val dropInOverrideParams: DropInOverrideParams? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : ActionComponentProvider { @@ -59,12 +61,14 @@ constructor( val redirectDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) RedirectComponent( delegate = redirectDelegate, - actionComponentEventHandler = DefaultActionComponentEventHandler(callback), + actionComponentEventHandler = DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, redirectFactory)[key, RedirectComponent::class.java] .also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } @@ -87,10 +91,12 @@ constructor( return DefaultRedirectDelegate( observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, componentParams = componentParams, redirectHandler = redirectHandler, paymentDataRepository = paymentDataRepository, nativeRedirectService = nativeRedirectService, + analyticsManager = analyticsManager, ) } diff --git a/redirect/src/main/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegate.kt b/redirect/src/main/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegate.kt index 3ed7fa0af0..375cd2a270 100644 --- a/redirect/src/main/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegate.kt +++ b/redirect/src/main/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegate.kt @@ -10,7 +10,9 @@ package com.adyen.checkout.redirect.internal.ui import android.app.Activity import android.content.Intent +import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.components.core.ActionComponentData import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.ActionTypes @@ -18,6 +20,10 @@ import com.adyen.checkout.components.core.action.RedirectAction import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams import com.adyen.checkout.components.core.internal.util.bufferedChannel import com.adyen.checkout.core.AdyenLogLevel @@ -40,13 +46,17 @@ import kotlinx.coroutines.launch import org.json.JSONObject @Suppress("TooManyFunctions") -internal class DefaultRedirectDelegate( +internal class DefaultRedirectDelegate +@Suppress("LongParameterList") +constructor( private val observerRepository: ActionObserverRepository, + override val savedStateHandle: SavedStateHandle, override val componentParams: GenericComponentParams, private val redirectHandler: RedirectHandler, private val paymentDataRepository: PaymentDataRepository, private val nativeRedirectService: NativeRedirectService, -) : RedirectDelegate { + private val analyticsManager: AnalyticsManager?, +) : RedirectDelegate, SavedStateHandleContainer { private val detailsChannel: Channel = bufferedChannel() override val detailsFlow: Flow = detailsChannel.receiveAsFlow() @@ -59,8 +69,16 @@ internal class DefaultRedirectDelegate( private var _coroutineScope: CoroutineScope? = null private val coroutineScope: CoroutineScope get() = requireNotNull(_coroutineScope) + private var action: RedirectAction? by SavedStateHandleProperty(ACTION_KEY) + override fun initialize(coroutineScope: CoroutineScope) { _coroutineScope = coroutineScope + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + action?.let { initState(it) } } override fun observe( @@ -84,10 +102,23 @@ internal class DefaultRedirectDelegate( override fun handleAction(action: Action, activity: Activity) { if (action !is RedirectAction) { - exceptionChannel.trySend(ComponentException("Unsupported action")) + emitError(ComponentException("Unsupported action")) return } + this.action = action + + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + ) + analyticsManager?.trackEvent(event) + + initState(action) + launchAction(activity, action.url) + } + + private fun initState(action: RedirectAction) { when (action.type) { ActionTypes.NATIVE_REDIRECT -> { paymentDataRepository.nativeRedirectData = action.nativeRedirectData @@ -97,11 +128,9 @@ internal class DefaultRedirectDelegate( paymentDataRepository.paymentData = action.paymentData } } - - makeRedirect(activity, action.url) } - private fun makeRedirect(activity: Activity, url: String?) { + private fun launchAction(activity: Activity, url: String?) { try { adyenLog(AdyenLogLevel.DEBUG) { "makeRedirect - $url" } // TODO look into emitting a value to tell observers that a redirect was launched so they can track its @@ -109,7 +138,7 @@ internal class DefaultRedirectDelegate( // PaymentComponentState for actions. redirectHandler.launchUriRedirect(activity, url) } catch (ex: CheckoutException) { - exceptionChannel.trySend(ex) + emitError(ex) } } @@ -123,11 +152,11 @@ internal class DefaultRedirectDelegate( } else -> { - detailsChannel.trySend(createActionComponentData(details)) + emitDetails(details) } } } catch (ex: CheckoutException) { - exceptionChannel.trySend(ex) + emitError(ex) } } @@ -147,23 +176,37 @@ internal class DefaultRedirectDelegate( try { val response = nativeRedirectService.makeNativeRedirect(request, componentParams.clientKey) val detailsJson = NativeRedirectResponse.SERIALIZER.serialize(response) - detailsChannel.trySend(createActionComponentData(detailsJson)) + emitDetails(detailsJson) } catch (e: HttpException) { - onError(e) + emitError(e) } catch (e: ModelSerializationException) { - onError(e) + emitError(e) } } } override fun onError(e: CheckoutException) { - exceptionChannel.trySend(e) + emitError(e) } override fun setOnRedirectListener(listener: () -> Unit) { redirectHandler.setOnRedirectListener(listener) } + private fun emitError(e: CheckoutException) { + exceptionChannel.trySend(e) + clearState() + } + + private fun emitDetails(details: JSONObject) { + detailsChannel.trySend(createActionComponentData(details)) + clearState() + } + + private fun clearState() { + action = null + } + override fun onCleared() { removeObserver() redirectHandler.removeOnRedirectListener() @@ -172,5 +215,8 @@ internal class DefaultRedirectDelegate( companion object { private const val RETURN_URL_QUERY_STRING_PARAMETER = "returnUrlQueryString" + + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" } } diff --git a/redirect/src/test/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegateTest.kt b/redirect/src/test/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegateTest.kt index 212d6f1568..eeddf1fe54 100644 --- a/redirect/src/test/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegateTest.kt +++ b/redirect/src/test/java/com/adyen/checkout/redirect/internal/ui/DefaultRedirectDelegateTest.kt @@ -17,6 +17,8 @@ import com.adyen.checkout.components.core.action.ActionTypes import com.adyen.checkout.components.core.action.RedirectAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper import com.adyen.checkout.core.Environment @@ -27,11 +29,14 @@ import com.adyen.checkout.redirect.internal.data.api.NativeRedirectService import com.adyen.checkout.redirect.internal.data.model.NativeRedirectResponse import com.adyen.checkout.redirect.redirect import com.adyen.checkout.ui.core.internal.test.TestRedirectHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest @@ -53,12 +58,14 @@ internal class DefaultRedirectDelegateTest( @Mock private val nativeRedirectService: NativeRedirectService, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var redirectHandler: TestRedirectHandler private lateinit var paymentDataRepository: PaymentDataRepository private lateinit var delegate: DefaultRedirectDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() redirectHandler = TestRedirectHandler() paymentDataRepository = PaymentDataRepository(SavedStateHandle()) redirectHandler = TestRedirectHandler() @@ -173,8 +180,69 @@ internal class DefaultRedirectDelegateTest( redirectHandler.assertRemoveOnRedirectListenerCalled() } + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = RedirectAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + ) + + delegate.handleAction(action, Activity()) + + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + } + + @Test + fun `when initializing and action is set, then state is restored`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultRedirectDelegate.ACTION_KEY, + RedirectAction(paymentMethodType = "test", paymentData = "paymentData"), + ) + } + delegate = createDelegate(savedStateHandle = savedStateHandle) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertEquals("paymentData", paymentDataRepository.paymentData) + } + + @Test + fun `when details are emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle = savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + delegate.handleAction(RedirectAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + + delegate.handleIntent(Intent()) + + assertNull(savedStateHandle[DefaultRedirectDelegate.ACTION_KEY]) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle = savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + redirectHandler.exception = ComponentException("Test") + + delegate.handleAction(RedirectAction(paymentMethodType = "test", paymentData = "paymentData"), Activity()) + + assertNull(savedStateHandle[DefaultRedirectDelegate.ACTION_KEY]) + } + private fun createDelegate( - observerRepository: ActionObserverRepository = ActionObserverRepository() + observerRepository: ActionObserverRepository = ActionObserverRepository(), + savedStateHandle: SavedStateHandle = SavedStateHandle(), ): DefaultRedirectDelegate { val configuration = CheckoutConfiguration( Environment.TEST, @@ -184,6 +252,7 @@ internal class DefaultRedirectDelegateTest( } return DefaultRedirectDelegate( observerRepository = observerRepository, + savedStateHandle = savedStateHandle, componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( configuration, Locale.US, @@ -193,11 +262,14 @@ internal class DefaultRedirectDelegateTest( redirectHandler = redirectHandler, paymentDataRepository = paymentDataRepository, nativeRedirectService = nativeRedirectService, + analyticsManager = analyticsManager, ) } companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_ACTION_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun errorSource() = listOf( diff --git a/renovate.json b/renovate.json index 36980018cc..1a1d02784a 100644 --- a/renovate.json +++ b/renovate.json @@ -19,7 +19,7 @@ }, { "matchPackagePatterns" : ["*"], - "minimumReleaseAge" : "30 days", + "minimumReleaseAge" : "14 days", "schedule" : ["on the first day of the month"] } ], diff --git a/sepa/build.gradle b/sepa/build.gradle index d1fa975eae..ad00323c42 100644 --- a/sepa/build.gradle +++ b/sepa/build.gradle @@ -51,6 +51,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/sepa/src/main/java/com/adyen/checkout/sepa/internal/provider/SepaComponentProvider.kt b/sepa/src/main/java/com/adyen/checkout/sepa/internal/provider/SepaComponentProvider.kt index e2a4eba75f..4c03c9b607 100644 --- a/sepa/src/main/java/com/adyen/checkout/sepa/internal/provider/SepaComponentProvider.kt +++ b/sepa/src/main/java/com/adyen/checkout/sepa/internal/provider/SepaComponentProvider.kt @@ -22,11 +22,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -57,7 +55,7 @@ class SepaComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider< @@ -96,16 +94,11 @@ constructor( componentConfiguration = checkoutConfiguration.getSepaConfiguration(), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val sepaDelegate = DefaultSepaDelegate( @@ -113,15 +106,16 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) SepaComponent( sepaDelegate = sepaDelegate, @@ -188,17 +182,11 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val sepaDelegate = DefaultSepaDelegate( @@ -206,15 +194,16 @@ constructor( componentParams = componentParams, paymentMethod = paymentMethod, order = checkoutSession.order, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = SubmitHandler(savedStateHandle), ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegate.kt b/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegate.kt index ad521141d1..66d0be7204 100644 --- a/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegate.kt +++ b/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegate.kt @@ -16,7 +16,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.paymentmethod.SepaPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -32,7 +33,6 @@ import com.adyen.checkout.ui.core.internal.ui.SubmitHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultSepaDelegate( @@ -40,7 +40,7 @@ internal class DefaultSepaDelegate( override val componentParams: ButtonComponentParams, private val paymentMethod: PaymentMethod, private val order: Order?, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val submitHandler: SubmitHandler, ) : SepaDelegate { @@ -63,14 +63,15 @@ internal class DefaultSepaDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -123,7 +124,7 @@ internal class DefaultSepaDelegate( ): SepaComponentState { val paymentMethod = SepaPaymentMethod( type = SepaPaymentMethod.PAYMENT_METHOD_TYPE, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), ownerName = outputData.ownerNameField.value, iban = outputData.ibanNumberField.value, ) @@ -140,6 +141,9 @@ internal class DefaultSepaDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + val state = _componentStateFlow.value submitHandler.onSubmit(state = state) } @@ -154,5 +158,6 @@ internal class DefaultSepaDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/view/SepaView.kt b/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/view/SepaView.kt index bdfc530abf..f150df2f1d 100644 --- a/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/view/SepaView.kt +++ b/sepa/src/main/java/com/adyen/checkout/sepa/internal/ui/view/SepaView.kt @@ -26,6 +26,7 @@ import com.adyen.checkout.ui.core.internal.util.hideError import com.adyen.checkout.ui.core.internal.util.setLocalizedHintFromStyle import com.adyen.checkout.ui.core.internal.util.showError import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR internal class SepaView @JvmOverloads constructor( context: Context, @@ -48,7 +49,7 @@ internal class SepaView @JvmOverloads constructor( // Regular View constructor init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/sepa/src/test/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegateTest.kt b/sepa/src/test/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegateTest.kt index 2b7849ec65..5f9a0ad68a 100644 --- a/sepa/src/test/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegateTest.kt +++ b/sepa/src/test/java/com/adyen/checkout/sepa/internal/ui/DefaultSepaDelegateTest.kt @@ -15,7 +15,8 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.paymentmethod.SepaPaymentMethod @@ -43,22 +44,21 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class) internal class DefaultSepaDelegateTest( - @Mock private val analyticsRepository: AnalyticsRepository, @Mock private val submitHandler: SubmitHandler, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultSepaDelegate @BeforeEach fun before() { + analyticsManager = TestAnalyticsManager() delegate = createSepaDelegate() } @@ -124,12 +124,6 @@ internal class DefaultSepaDelegateTest( } } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - verify(analyticsRepository).setupAnalytics() - } - @Nested inner class SubmitButtonVisibilityTest { @@ -186,9 +180,32 @@ internal class DefaultSepaDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -201,6 +218,13 @@ internal class DefaultSepaDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createSepaDelegate( @@ -208,7 +232,7 @@ internal class DefaultSepaDelegateTest( order: Order? = TEST_ORDER, ) = DefaultSepaDelegate( observerRepository = PaymentObserverRepository(), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = order, componentParams = ButtonComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( checkoutConfiguration = configuration, @@ -217,7 +241,7 @@ internal class DefaultSepaDelegateTest( componentSessionParams = null, componentConfiguration = configuration.getSepaConfiguration(), ), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, submitHandler = submitHandler, ) @@ -237,6 +261,7 @@ internal class DefaultSepaDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/sessions-core/src/main/java/com/adyen/checkout/sessions/core/SessionSetupConfiguration.kt b/sessions-core/src/main/java/com/adyen/checkout/sessions/core/SessionSetupConfiguration.kt index 336950b982..74cfa2f6be 100644 --- a/sessions-core/src/main/java/com/adyen/checkout/sessions/core/SessionSetupConfiguration.kt +++ b/sessions-core/src/main/java/com/adyen/checkout/sessions/core/SessionSetupConfiguration.kt @@ -10,6 +10,7 @@ package com.adyen.checkout.sessions.core import com.adyen.checkout.core.exception.ModelSerializationException import com.adyen.checkout.core.internal.data.model.ModelObject +import com.adyen.checkout.core.internal.data.model.getBooleanOrNull import com.adyen.checkout.core.internal.data.model.jsonToMap import kotlinx.parcelize.Parcelize import org.json.JSONException @@ -20,7 +21,7 @@ data class SessionSetupConfiguration( val enableStoreDetails: Boolean? = null, val showInstallmentAmount: Boolean = false, val installmentOptions: Map? = null, - val showRemovePaymentMethodButton: Boolean = false, + val showRemovePaymentMethodButton: Boolean? = null, ) : ModelObject() { companion object { @@ -50,11 +51,11 @@ data class SessionSetupConfiguration( override fun deserialize(jsonObject: JSONObject): SessionSetupConfiguration { return try { SessionSetupConfiguration( - enableStoreDetails = jsonObject.optBoolean(ENABLE_STORE_DETAILS), + enableStoreDetails = jsonObject.getBooleanOrNull(ENABLE_STORE_DETAILS), showInstallmentAmount = jsonObject.optBoolean(SHOW_INSTALLMENT_AMOUNT), installmentOptions = jsonObject.optJSONObject(INSTALLMENT_OPTIONS) ?.jsonToMap(SessionSetupInstallmentOptions.SERIALIZER), - showRemovePaymentMethodButton = jsonObject.optBoolean(SHOW_REMOVE_PAYMENT_METHOD_BUTTON), + showRemovePaymentMethodButton = jsonObject.getBooleanOrNull(SHOW_REMOVE_PAYMENT_METHOD_BUTTON), ) } catch (e: JSONException) { throw ModelSerializationException(SessionSetupConfiguration::class.java, e) diff --git a/settings.gradle b/settings.gradle index d7905d565b..3af0126e34 100644 --- a/settings.gradle +++ b/settings.gradle @@ -62,6 +62,7 @@ include ':3ds2', ':sepa', ':sessions-core', ':test-core', + ':twint', ':ui-core', ':upi', ':voucher', diff --git a/seven-eleven/src/main/java/com/adyen/checkout/seveneleven/internal/provider/SevenElevenComponentProvider.kt b/seven-eleven/src/main/java/com/adyen/checkout/seveneleven/internal/provider/SevenElevenComponentProvider.kt index 871e3316c6..4abd5212bd 100644 --- a/seven-eleven/src/main/java/com/adyen/checkout/seveneleven/internal/provider/SevenElevenComponentProvider.kt +++ b/seven-eleven/src/main/java/com/adyen/checkout/seveneleven/internal/provider/SevenElevenComponentProvider.kt @@ -14,7 +14,7 @@ import com.adyen.checkout.action.core.internal.ui.GenericActionDelegate import com.adyen.checkout.components.core.CheckoutConfiguration import com.adyen.checkout.components.core.PaymentComponentData import com.adyen.checkout.components.core.internal.ComponentEventHandler -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams import com.adyen.checkout.components.core.paymentmethod.SevenElevenPaymentMethod import com.adyen.checkout.econtext.internal.provider.EContextComponentProvider @@ -29,7 +29,7 @@ class SevenElevenComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( dropInOverrideParams: DropInOverrideParams? = null, - analyticsRepository: AnalyticsRepository? = null, + analyticsManager: AnalyticsManager? = null, ) : EContextComponentProvider< SevenElevenComponent, SevenElevenConfiguration, @@ -38,7 +38,7 @@ constructor( >( componentClass = SevenElevenComponent::class.java, dropInOverrideParams = dropInOverrideParams, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, ) { override fun createComponentState( diff --git a/test-core/src/main/java/com/adyen/checkout/test/extensions/ViewModelExtensions.kt b/test-core/src/main/java/com/adyen/checkout/test/extensions/ViewModelExtensions.kt index 6897897300..2a2631fb18 100644 --- a/test-core/src/main/java/com/adyen/checkout/test/extensions/ViewModelExtensions.kt +++ b/test-core/src/main/java/com/adyen/checkout/test/extensions/ViewModelExtensions.kt @@ -18,9 +18,9 @@ import androidx.lifecycle.ViewModel */ @RestrictTo(RestrictTo.Scope.TESTS) fun ViewModel.invokeOnCleared() { - var clazz = javaClass as Class + var clazz: Class<*> = javaClass while (clazz.declaredMethods.toList().none { it.name == "onCleared" }) { - clazz = clazz.superclass as Class + clazz = clazz.superclass } with(clazz.getDeclaredMethod("onCleared")) { isAccessible = true diff --git a/twint/build.gradle b/twint/build.gradle new file mode 100644 index 0000000000..89d994f27f --- /dev/null +++ b/twint/build.gradle @@ -0,0 +1,54 @@ +/* + * 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 18/10/2023. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' +} + +ext.mavenArtifactId = "twint" +ext.mavenArtifactName = "Adyen checkout Twint component" +ext.mavenArtifactDescription = "Adyen checkout Twint component client for Adyen's Checkout API." + +apply from: "${rootDir}/config/gradle/sharedTasks.gradle" + +android { + namespace 'com.adyen.checkout.twint' + compileSdkVersion compile_sdk_version + + defaultConfig { + minSdkVersion min_sdk_version + targetSdkVersion target_sdk_version + versionCode version_code + versionName version_name + + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + consumerProguardFiles "consumer-rules.pro" + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + // Checkout + api project(':ui-core') + + // Dependencies + implementation files('libs/TwintSdk-android-8.0.0.jar') + + //Tests + testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) + testImplementation testLibraries.json + testImplementation testLibraries.junit5 + testImplementation testLibraries.kotlinCoroutines + testImplementation testLibraries.mockito +} diff --git a/twint/consumer-rules.pro b/twint/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/twint/libs/TwintSdk-android-8.0.0.jar b/twint/libs/TwintSdk-android-8.0.0.jar new file mode 100644 index 0000000000..f076a446ac Binary files /dev/null and b/twint/libs/TwintSdk-android-8.0.0.jar differ diff --git a/twint/src/main/AndroidManifest.xml b/twint/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7667ca66dd --- /dev/null +++ b/twint/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/twint/src/main/java/com/adyen/checkout/twint/TwintActionComponent.kt b/twint/src/main/java/com/adyen/checkout/twint/TwintActionComponent.kt new file mode 100644 index 0000000000..5c0895adfe --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/TwintActionComponent.kt @@ -0,0 +1,67 @@ +/* + * 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 18/10/2023. + */ + +package com.adyen.checkout.twint + +import android.app.Activity +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.components.core.internal.ActionComponent +import com.adyen.checkout.components.core.internal.ActionComponentEvent +import com.adyen.checkout.components.core.internal.ActionComponentEventHandler +import com.adyen.checkout.core.AdyenLogLevel +import com.adyen.checkout.core.internal.util.adyenLog +import com.adyen.checkout.twint.internal.provider.TwintActionComponentProvider +import com.adyen.checkout.twint.internal.ui.TwintDelegate +import com.adyen.checkout.ui.core.internal.ui.ComponentViewType +import com.adyen.checkout.ui.core.internal.ui.ViewableComponent +import kotlinx.coroutines.flow.Flow + +class TwintActionComponent internal constructor( + override val delegate: TwintDelegate, + internal val actionComponentEventHandler: ActionComponentEventHandler, +) : ViewModel(), + ActionComponent, + ViewableComponent { + + override val viewFlow: Flow = delegate.viewFlow + + init { + delegate.initialize(viewModelScope) + } + + internal fun observe(lifecycleOwner: LifecycleOwner, callback: (ActionComponentEvent) -> Unit) { + delegate.observe(lifecycleOwner, viewModelScope, callback) + } + + internal fun removeObserver() { + delegate.removeObserver() + } + + override fun canHandleAction(action: Action): Boolean { + return PROVIDER.canHandleAction(action) + } + + override fun handleAction(action: Action, activity: Activity) { + delegate.handleAction(action, activity) + } + + override fun onCleared() { + adyenLog(AdyenLogLevel.DEBUG) { "onCleared" } + super.onCleared() + delegate.onCleared() + } + + companion object { + + @JvmField + val PROVIDER = TwintActionComponentProvider() + } +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/TwintActionConfiguration.kt b/twint/src/main/java/com/adyen/checkout/twint/TwintActionConfiguration.kt new file mode 100644 index 0000000000..b01898a6f7 --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/TwintActionConfiguration.kt @@ -0,0 +1,121 @@ +/* + * 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 20/10/2023. + */ + +package com.adyen.checkout.twint + +import android.content.Context +import com.adyen.checkout.components.core.Amount +import com.adyen.checkout.components.core.AnalyticsConfiguration +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.internal.BaseConfigurationBuilder +import com.adyen.checkout.components.core.internal.Configuration +import com.adyen.checkout.components.core.internal.util.CheckoutConfigurationMarker +import com.adyen.checkout.core.Environment +import kotlinx.parcelize.Parcelize +import java.util.Locale + +/** + * Configuration class for the [TwintActionComponent]. + */ +@Parcelize +class TwintActionConfiguration private constructor( + override val shopperLocale: Locale?, + override val environment: Environment, + override val clientKey: String, + override val analyticsConfiguration: AnalyticsConfiguration?, + override val amount: Amount?, +) : Configuration { + + class Builder : BaseConfigurationBuilder { + + /** + * Initialize a configuration builder with the required fields. + * + * The shopper locale will match the value passed to the API with the sessions flow, or the primary user locale + * on the device otherwise. Check out the + * [Sessions API documentation](https://docs.adyen.com/api-explorer/Checkout/latest/post/sessions) on how to set + * this value. + * + * @param environment The [Environment] to be used for internal network calls from the SDK to Adyen. + * @param clientKey Your Client Key used for internal network calls from the SDK to Adyen. + */ + constructor(environment: Environment, clientKey: String) : super( + environment, + clientKey, + ) + + /** + * Alternative constructor that uses the [context] to fetch the user locale and use it as a shopper locale. + * + * @param context A context + * @param environment The [Environment] to be used for internal network calls from the SDK to Adyen. + * @param clientKey Your Client Key used for internal network calls from the SDK to Adyen. + */ + @Suppress("DEPRECATION") + @Deprecated("You can omit the context parameter") + constructor(context: Context, environment: Environment, clientKey: String) : super( + context, + environment, + clientKey, + ) + + /** + * Initialize a configuration builder with the required fields. + * + * @param shopperLocale The [Locale] of the shopper. + * @param environment The [Environment] to be used for internal network calls from the SDK to Adyen. + * @param clientKey Your Client Key used for internal network calls from the SDK to Adyen. + */ + constructor( + shopperLocale: Locale, + environment: Environment, + clientKey: String + ) : super(shopperLocale, environment, clientKey) + + override fun buildInternal(): TwintActionConfiguration { + return TwintActionConfiguration( + shopperLocale = shopperLocale, + environment = environment, + clientKey = clientKey, + analyticsConfiguration = analyticsConfiguration, + amount = amount, + ) + } + } +} + +fun CheckoutConfiguration.twintAction( + configuration: @CheckoutConfigurationMarker TwintActionConfiguration.Builder.() -> Unit = {} +): CheckoutConfiguration { + val config = TwintActionConfiguration.Builder(environment, clientKey) + .apply { + shopperLocale?.let { setShopperLocale(it) } + amount?.let { setAmount(it) } + analyticsConfiguration?.let { setAnalyticsConfiguration(it) } + } + .apply(configuration) + .build() + addActionConfiguration(config) + return this +} + +internal fun CheckoutConfiguration.getTwintActionConfiguration(): TwintActionConfiguration? { + return getActionConfiguration(TwintActionConfiguration::class.java) +} + +internal fun TwintActionConfiguration.toCheckoutConfiguration(): CheckoutConfiguration { + return CheckoutConfiguration( + shopperLocale = shopperLocale, + environment = environment, + clientKey = clientKey, + amount = amount, + analyticsConfiguration = analyticsConfiguration, + ) { + addActionConfiguration(this@toCheckoutConfiguration) + } +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/internal/provider/TwintActionComponentProvider.kt b/twint/src/main/java/com/adyen/checkout/twint/internal/provider/TwintActionComponentProvider.kt new file mode 100644 index 0000000000..079ffcb7e5 --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/internal/provider/TwintActionComponentProvider.kt @@ -0,0 +1,135 @@ +/* + * 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 20/10/2023. + */ + +package com.adyen.checkout.twint.internal.provider + +import android.app.Application +import androidx.annotation.RestrictTo +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.savedstate.SavedStateRegistryOwner +import com.adyen.checkout.components.core.ActionComponentCallback +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.PaymentMethodTypes +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.components.core.action.SdkAction +import com.adyen.checkout.components.core.internal.ActionObserverRepository +import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler +import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.data.api.DefaultStatusRepository +import com.adyen.checkout.components.core.internal.data.api.StatusService +import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper +import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams +import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper +import com.adyen.checkout.components.core.internal.util.get +import com.adyen.checkout.components.core.internal.util.viewModelFactory +import com.adyen.checkout.core.internal.data.api.HttpClientFactory +import com.adyen.checkout.core.internal.util.LocaleProvider +import com.adyen.checkout.twint.TwintActionComponent +import com.adyen.checkout.twint.TwintActionConfiguration +import com.adyen.checkout.twint.internal.ui.DefaultTwintDelegate +import com.adyen.checkout.twint.internal.ui.TwintDelegate +import com.adyen.checkout.twint.toCheckoutConfiguration + +class TwintActionComponentProvider +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +constructor( + private val analyticsManager: AnalyticsManager? = null, + private val dropInOverrideParams: DropInOverrideParams? = null, + private val localeProvider: LocaleProvider = LocaleProvider(), +) : ActionComponentProvider { + + override fun get( + savedStateRegistryOwner: SavedStateRegistryOwner, + viewModelStoreOwner: ViewModelStoreOwner, + lifecycleOwner: LifecycleOwner, + application: Application, + checkoutConfiguration: CheckoutConfiguration, + callback: ActionComponentCallback, + key: String? + ): TwintActionComponent { + val twintFactory = viewModelFactory(savedStateRegistryOwner, null) { savedStateHandle -> + val twintDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) + TwintActionComponent( + delegate = twintDelegate, + actionComponentEventHandler = DefaultActionComponentEventHandler(), + ) + } + + return ViewModelProvider(viewModelStoreOwner, twintFactory)[key, TwintActionComponent::class.java] + .also { component -> + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } + } + } + + override fun getDelegate( + checkoutConfiguration: CheckoutConfiguration, + savedStateHandle: SavedStateHandle, + application: Application, + ): TwintDelegate { + val componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( + checkoutConfiguration = checkoutConfiguration, + deviceLocale = localeProvider.getLocale(application), + dropInOverrideParams = dropInOverrideParams, + componentSessionParams = null, + ) + + val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) + val statusService = StatusService(httpClient) + val statusRepository = DefaultStatusRepository(statusService, componentParams.clientKey) + + return DefaultTwintDelegate( + observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, + componentParams = componentParams, + paymentDataRepository = PaymentDataRepository(savedStateHandle), + statusRepository = statusRepository, + analyticsManager = analyticsManager, + ) + } + + override fun get( + savedStateRegistryOwner: SavedStateRegistryOwner, + viewModelStoreOwner: ViewModelStoreOwner, + lifecycleOwner: LifecycleOwner, + application: Application, + configuration: TwintActionConfiguration, + callback: ActionComponentCallback, + key: String?, + ): TwintActionComponent { + return get( + savedStateRegistryOwner = savedStateRegistryOwner, + viewModelStoreOwner = viewModelStoreOwner, + lifecycleOwner = lifecycleOwner, + application = application, + checkoutConfiguration = configuration.toCheckoutConfiguration(), + callback = callback, + key = key, + ) + } + + override val supportedActionTypes: List = listOf(SdkAction.ACTION_TYPE) + + override fun canHandleAction(action: Action): Boolean { + return supportedActionTypes.contains(action.type) && PAYMENT_METHODS.contains(action.paymentMethodType) + } + + override fun providesDetails(action: Action): Boolean { + return true + } + + companion object { + private val PAYMENT_METHODS = listOf(PaymentMethodTypes.TWINT) + } +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/internal/ui/DefaultTwintDelegate.kt b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/DefaultTwintDelegate.kt new file mode 100644 index 0000000000..961606637b --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/DefaultTwintDelegate.kt @@ -0,0 +1,259 @@ +/* + * 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 20/10/2023. + */ + +package com.adyen.checkout.twint.internal.ui + +import android.app.Activity +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import ch.twint.payment.sdk.TwintPayResult +import com.adyen.checkout.components.core.ActionComponentData +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.components.core.action.SdkAction +import com.adyen.checkout.components.core.action.TwintSdkData +import com.adyen.checkout.components.core.internal.ActionComponentEvent +import com.adyen.checkout.components.core.internal.ActionObserverRepository +import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.data.api.StatusRepository +import com.adyen.checkout.components.core.internal.data.model.StatusResponse +import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams +import com.adyen.checkout.components.core.internal.ui.model.TimerData +import com.adyen.checkout.components.core.internal.util.StatusResponseUtils +import com.adyen.checkout.components.core.internal.util.bufferedChannel +import com.adyen.checkout.core.AdyenLogLevel +import com.adyen.checkout.core.exception.CheckoutException +import com.adyen.checkout.core.exception.ComponentException +import com.adyen.checkout.core.internal.util.adyenLog +import com.adyen.checkout.ui.core.internal.ui.ComponentViewType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +@Suppress("TooManyFunctions") +internal class DefaultTwintDelegate( + private val observerRepository: ActionObserverRepository, + override val savedStateHandle: SavedStateHandle, + override val componentParams: GenericComponentParams, + private val paymentDataRepository: PaymentDataRepository, + private val statusRepository: StatusRepository, + private val analyticsManager: AnalyticsManager?, +) : TwintDelegate, SavedStateHandleContainer { + + private val detailsChannel: Channel = bufferedChannel() + override val detailsFlow: Flow = detailsChannel.receiveAsFlow() + + private val exceptionChannel: Channel = bufferedChannel() + override val exceptionFlow: Flow = exceptionChannel.receiveAsFlow() + + override val viewFlow: Flow = MutableStateFlow(TwintComponentViewType) + + // Not used for Twint action + override val timerFlow: Flow = flow {} + + private val payEventChannel: Channel = bufferedChannel() + override val payEventFlow: Flow = payEventChannel.receiveAsFlow() + + private var _coroutineScope: CoroutineScope? = null + private val coroutineScope: CoroutineScope get() = requireNotNull(_coroutineScope) + + private var statusPollingJob: Job? = null + + private var action: SdkAction? by SavedStateHandleProperty(ACTION_KEY) + private var isPolling: Boolean? by SavedStateHandleProperty(IS_POLLING_KEY) + + override fun initialize(coroutineScope: CoroutineScope) { + _coroutineScope = coroutineScope + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + action?.let { initState(it) } + } + + override fun observe( + lifecycleOwner: LifecycleOwner, + coroutineScope: CoroutineScope, + callback: (ActionComponentEvent) -> Unit + ) { + observerRepository.addObservers( + detailsFlow = detailsFlow, + exceptionFlow = exceptionFlow, + permissionFlow = null, + lifecycleOwner = lifecycleOwner, + coroutineScope = coroutineScope, + callback = callback, + ) + } + + override fun removeObserver() { + observerRepository.removeObservers() + } + + override fun handleAction(action: Action, activity: Activity) { + if (action !is SdkAction<*>) { + emitError(ComponentException("Unsupported action")) + return + } + + val sdkData = action.sdkData + if (action.sdkData == null || sdkData !is TwintSdkData) { + emitError(ComponentException("SDK Data is null or of wrong type")) + return + } + + @Suppress("UNCHECKED_CAST") + this.action = action as SdkAction + + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + ) + analyticsManager?.trackEvent(event) + + initState(action) + launchAction(sdkData) + } + + private fun initState(action: SdkAction) { + val paymentData = action.paymentData + paymentDataRepository.paymentData = paymentData + if (paymentData == null) { + adyenLog(AdyenLogLevel.ERROR) { "Payment data is null" } + emitError(ComponentException("Payment data is null")) + return + } + + if (isPolling == true) { + startStatusPolling() + } + } + + private fun launchAction(sdkData: TwintSdkData) { + payEventChannel.trySend(sdkData.token) + } + + override fun handleTwintResult(result: TwintPayResult) { + when (result) { + TwintPayResult.TW_B_SUCCESS -> { + startStatusPolling() + } + + TwintPayResult.TW_B_ERROR -> { + onError(ComponentException("Twint encountered an error.")) + } + + TwintPayResult.TW_B_APP_NOT_INSTALLED -> { + onError(ComponentException("Twint app not installed.")) + } + } + } + + private fun startStatusPolling() { + isPolling = true + statusPollingJob?.cancel() + + val paymentData = paymentDataRepository.paymentData + if (paymentData == null) { + emitError(ComponentException("PaymentData should not be null.")) + return + } + + statusPollingJob = statusRepository.poll(paymentData, DEFAULT_MAX_POLLING_DURATION) + .onEach { onStatus(it) } + .launchIn(coroutineScope) + } + + private fun onStatus(result: Result) { + result.fold( + onSuccess = { response -> + adyenLog(AdyenLogLevel.VERBOSE) { "Status changed - ${response.resultCode}" } + onPollingSuccessful(response) + }, + onFailure = { + adyenLog(AdyenLogLevel.ERROR, it) { "Error while polling status" } + emitError(ComponentException("Error while polling status.", it)) + }, + ) + } + + private fun onPollingSuccessful(statusResponse: StatusResponse) { + val payload = statusResponse.payload + // Not authorized status should still call /details so that merchant can get more info + if (StatusResponseUtils.isFinalResult(statusResponse)) { + if (!payload.isNullOrEmpty()) { + emitDetails(payload) + } else { + emitError(ComponentException("Payload is missing from StatusResponse.")) + } + } + } + + private fun createActionComponentData(payload: String): ActionComponentData { + return ActionComponentData( + details = JSONObject().put(PAYLOAD_DETAILS_KEY, payload), + // We don't share paymentData on purpose, so merchant will not use it to build their own polling. + paymentData = null, + ) + } + + override fun onError(e: CheckoutException) { + emitError(e) + } + + override fun refreshStatus() { + if (statusPollingJob == null) return + val paymentData = paymentDataRepository.paymentData ?: return + statusRepository.refreshStatus(paymentData) + } + + private fun emitError(e: CheckoutException) { + exceptionChannel.trySend(e) + clearState() + } + + private fun emitDetails(payload: String) { + detailsChannel.trySend(createActionComponentData(payload)) + clearState() + } + + private fun clearState() { + action = null + isPolling = null + } + + override fun onCleared() { + removeObserver() + } + + companion object { + private val DEFAULT_MAX_POLLING_DURATION = TimeUnit.MINUTES.toMillis(15) + + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" + + @VisibleForTesting + internal const val IS_POLLING_KEY = "IS_POLLING_KEY" + + @VisibleForTesting + internal const val PAYLOAD_DETAILS_KEY = "payload" + } +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintDelegate.kt b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintDelegate.kt new file mode 100644 index 0000000000..217ab22bb2 --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintDelegate.kt @@ -0,0 +1,29 @@ +/* + * 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 18/10/2023. + */ + +package com.adyen.checkout.twint.internal.ui + +import androidx.annotation.RestrictTo +import ch.twint.payment.sdk.TwintPayResult +import com.adyen.checkout.components.core.internal.ui.ActionDelegate +import com.adyen.checkout.components.core.internal.ui.DetailsEmittingDelegate +import com.adyen.checkout.components.core.internal.ui.StatusPollingDelegate +import com.adyen.checkout.ui.core.internal.ui.ViewProvidingDelegate +import kotlinx.coroutines.flow.Flow + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +interface TwintDelegate : + ActionDelegate, + DetailsEmittingDelegate, + StatusPollingDelegate, + ViewProvidingDelegate { + + val payEventFlow: Flow + + fun handleTwintResult(result: TwintPayResult) +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintFragment.kt b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintFragment.kt new file mode 100644 index 0000000000..4192756b53 --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintFragment.kt @@ -0,0 +1,77 @@ +/* + * 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 7/5/2024. + */ + +package com.adyen.checkout.twint.internal.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import ch.twint.payment.sdk.Twint +import ch.twint.payment.sdk.TwintPayResult +import com.adyen.checkout.core.AdyenLogLevel +import com.adyen.checkout.core.internal.util.adyenLog +import com.adyen.checkout.twint.databinding.FragmentTwintBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal class TwintFragment : Fragment() { + + private var _binding: FragmentTwintBinding? = null + private val binding: FragmentTwintBinding get() = requireNotNull(_binding) + + private var twintDelegate: TwintDelegate? = null + + private var twint: Twint? = Twint(this, ::onTwintResult) + + private var queuedTwintResult: TwintPayResult? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentTwintBinding.inflate(inflater, container, false) + return binding.root + } + + fun initialize(delegate: TwintDelegate, coroutineScope: CoroutineScope, localizedContext: Context) { + adyenLog(AdyenLogLevel.DEBUG) { "initialize" } + + binding.paymentInProgressView.initView(delegate, coroutineScope, localizedContext) + + twintDelegate = delegate + delegate.payEventFlow + .onEach { twint?.payWithCode(it) } + .launchIn(viewLifecycleOwner.lifecycleScope) + + queuedTwintResult?.let { + adyenLog(AdyenLogLevel.DEBUG) { "initialize: executing queue" } + onTwintResult(it) + } + } + + private fun onTwintResult(result: TwintPayResult) { + adyenLog(AdyenLogLevel.DEBUG) { "onTwintResult" } + twintDelegate + ?.handleTwintResult(result) + ?.also { + adyenLog(AdyenLogLevel.DEBUG) { "onTwintResult: clearing queue" } + queuedTwintResult = null + } ?: run { + adyenLog(AdyenLogLevel.DEBUG) { "onTwintResult: setting queue" } + queuedTwintResult = result + } + } + + override fun onDestroyView() { + twint = null + _binding = null + super.onDestroyView() + } +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintView.kt b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintView.kt new file mode 100644 index 0000000000..9020f47937 --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintView.kt @@ -0,0 +1,51 @@ +/* + * 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 7/5/2024. + */ + +package com.adyen.checkout.twint.internal.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import com.adyen.checkout.components.core.internal.ui.ComponentDelegate +import com.adyen.checkout.twint.databinding.ViewTwintBinding +import com.adyen.checkout.ui.core.internal.ui.ComponentView +import kotlinx.coroutines.CoroutineScope + +internal class TwintView internal constructor( + layoutInflater: LayoutInflater, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(layoutInflater.context, attrs, defStyleAttr), ComponentView { + + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 + ) : this(LayoutInflater.from(context), attrs, defStyleAttr) + + private var binding = ViewTwintBinding.inflate(layoutInflater, this) + + private var delegate: TwintDelegate? = null + + override fun initView(delegate: ComponentDelegate, coroutineScope: CoroutineScope, localizedContext: Context) { + require(delegate is TwintDelegate) { "Unsupported delegate type" } + this.delegate = delegate + initializeFragment(delegate, coroutineScope, localizedContext) + } + + private fun initializeFragment(delegate: TwintDelegate, coroutineScope: CoroutineScope, localizedContext: Context) { + binding.fragmentContainer.getFragment()?.initialize(delegate, coroutineScope, localizedContext) + } + + override fun highlightValidationErrors() = Unit + + override fun getView(): View = this +} diff --git a/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintViewProvider.kt b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintViewProvider.kt new file mode 100644 index 0000000000..be026247b4 --- /dev/null +++ b/twint/src/main/java/com/adyen/checkout/twint/internal/ui/TwintViewProvider.kt @@ -0,0 +1,38 @@ +/* + * 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 31/10/2023. + */ + +package com.adyen.checkout.twint.internal.ui + +import android.content.Context +import android.view.LayoutInflater +import com.adyen.checkout.ui.core.internal.ui.ComponentView +import com.adyen.checkout.ui.core.internal.ui.ComponentViewType +import com.adyen.checkout.ui.core.internal.ui.ViewProvider + +internal object TwintViewProvider : ViewProvider { + + override fun getView( + viewType: ComponentViewType, + context: Context, + ): ComponentView = when (viewType) { + TwintComponentViewType -> TwintView(context) + else -> throw IllegalArgumentException("Unsupported view type") + } + + override fun getView( + viewType: ComponentViewType, + layoutInflater: LayoutInflater + ): ComponentView = when (viewType) { + TwintComponentViewType -> TwintView(layoutInflater) + else -> throw IllegalArgumentException("Unsupported view type") + } +} + +internal object TwintComponentViewType : ComponentViewType { + override val viewProvider: ViewProvider = TwintViewProvider +} diff --git a/twint/src/main/res/layout/fragment_twint.xml b/twint/src/main/res/layout/fragment_twint.xml new file mode 100644 index 0000000000..af5eaf7da2 --- /dev/null +++ b/twint/src/main/res/layout/fragment_twint.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/twint/src/main/res/layout/view_twint.xml b/twint/src/main/res/layout/view_twint.xml new file mode 100644 index 0000000000..e2402a57f9 --- /dev/null +++ b/twint/src/main/res/layout/view_twint.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/twint/src/test/java/com/adyen/checkout/twint/TwintActionComponentTest.kt b/twint/src/test/java/com/adyen/checkout/twint/TwintActionComponentTest.kt new file mode 100644 index 0000000000..2509b1cf26 --- /dev/null +++ b/twint/src/test/java/com/adyen/checkout/twint/TwintActionComponentTest.kt @@ -0,0 +1,103 @@ +package com.adyen.checkout.twint + +import android.app.Activity +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import com.adyen.checkout.components.core.action.SdkAction +import com.adyen.checkout.components.core.action.SdkData +import com.adyen.checkout.components.core.internal.ActionComponentEvent +import com.adyen.checkout.components.core.internal.ActionComponentEventHandler +import com.adyen.checkout.test.LoggingExtension +import com.adyen.checkout.test.extensions.invokeOnCleared +import com.adyen.checkout.test.extensions.test +import com.adyen.checkout.twint.internal.ui.TwintComponentViewType +import com.adyen.checkout.twint.internal.ui.TwintDelegate +import com.adyen.checkout.ui.core.internal.test.TestComponentViewType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class, LoggingExtension::class) +internal class TwintActionComponentTest( + @Mock private val twintDelegate: TwintDelegate, + @Mock private val actionComponentEventHandler: ActionComponentEventHandler, +) { + + private lateinit var component: TwintActionComponent + + @BeforeEach + fun beforeEach() { + whenever(twintDelegate.viewFlow) doReturn MutableStateFlow(TwintComponentViewType) + component = TwintActionComponent(twintDelegate, actionComponentEventHandler) + } + + @Test + fun `when component is created, then delegate is initialized`() { + verify(twintDelegate).initialize(component.viewModelScope) + } + + @Test + fun `when component is cleared, then delegate is cleared`() { + component.invokeOnCleared() + + verify(twintDelegate).onCleared() + } + + @Test + fun `when observe is called, then observe in delegate is called`() { + val lifecycleOwner = mock() + val callback: (ActionComponentEvent) -> Unit = {} + + component.observe(lifecycleOwner, callback) + + verify(twintDelegate).observe(lifecycleOwner, component.viewModelScope, callback) + } + + @Test + fun `when removeObserver is called, then removeObserver in delegate is called`() { + component.removeObserver() + + verify(twintDelegate).removeObserver() + } + + @Test + fun `when component is initialized, then view flow should match delegate view flow`() = runTest { + val testFlow = component.viewFlow.test(testScheduler) + + assertEquals(TwintComponentViewType, testFlow.latestValue) + } + + @Test + fun `when delegate view flow emits a value, then component view flow should match that value`() = runTest { + val delegateViewFlow = MutableStateFlow(TestComponentViewType.VIEW_TYPE_1) + whenever(twintDelegate.viewFlow) doReturn delegateViewFlow + component = TwintActionComponent(twintDelegate, actionComponentEventHandler) + + val testFlow = component.viewFlow.test(testScheduler) + + assertEquals(TestComponentViewType.VIEW_TYPE_1, testFlow.latestValue) + + delegateViewFlow.emit(TestComponentViewType.VIEW_TYPE_2) + assertEquals(TestComponentViewType.VIEW_TYPE_2, testFlow.latestValue) + } + + @Test + fun `when handleAction is called, then handleAction in delegate is called`() { + val action = SdkAction() + val activity = Activity() + component.handleAction(action, activity) + + verify(twintDelegate).handleAction(action, activity) + } +} diff --git a/twint/src/test/java/com/adyen/checkout/twint/TwintActionConfigurationTest.kt b/twint/src/test/java/com/adyen/checkout/twint/TwintActionConfigurationTest.kt new file mode 100644 index 0000000000..548cdeb62f --- /dev/null +++ b/twint/src/test/java/com/adyen/checkout/twint/TwintActionConfigurationTest.kt @@ -0,0 +1,82 @@ +package com.adyen.checkout.twint + +import com.adyen.checkout.components.core.Amount +import com.adyen.checkout.components.core.AnalyticsConfiguration +import com.adyen.checkout.components.core.AnalyticsLevel +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.core.Environment +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +internal class TwintActionConfigurationTest { + + @Test + fun `when creating the configuration through CheckoutConfiguration, then it should be the same as when the builder is used`() { + val checkoutConfiguration = CheckoutConfiguration( + shopperLocale = Locale.US, + environment = Environment.TEST, + clientKey = TEST_CLIENT_KEY, + amount = Amount("EUR", 123L), + analyticsConfiguration = AnalyticsConfiguration(AnalyticsLevel.ALL), + ) { + twintAction() + } + + val actual = checkoutConfiguration.getTwintActionConfiguration() + + val expected = TwintActionConfiguration.Builder( + shopperLocale = Locale.US, + environment = Environment.TEST, + clientKey = TEST_CLIENT_KEY, + ) + .setAmount(Amount("EUR", 123L)) + .setAnalyticsConfiguration(AnalyticsConfiguration(AnalyticsLevel.ALL)) + .build() + + assertEquals(expected.shopperLocale, actual?.shopperLocale) + assertEquals(expected.environment, actual?.environment) + assertEquals(expected.clientKey, actual?.clientKey) + assertEquals(expected.amount, actual?.amount) + assertEquals(expected.analyticsConfiguration, actual?.analyticsConfiguration) + } + + @Test + fun `when the configuration is mapped to CheckoutConfiguration, then CheckoutConfiguration is created correctly`() { + val config = TwintActionConfiguration.Builder( + shopperLocale = Locale.US, + environment = Environment.TEST, + clientKey = TEST_CLIENT_KEY, + ) + .setAmount(Amount("EUR", 123L)) + .setAnalyticsConfiguration(AnalyticsConfiguration(AnalyticsLevel.ALL)) + .build() + + val actual = config.toCheckoutConfiguration() + + val expected = CheckoutConfiguration( + shopperLocale = Locale.US, + environment = Environment.TEST, + clientKey = TEST_CLIENT_KEY, + amount = Amount("EUR", 123L), + analyticsConfiguration = AnalyticsConfiguration(AnalyticsLevel.ALL), + ) + + assertEquals(expected.shopperLocale, actual.shopperLocale) + assertEquals(expected.environment, actual.environment) + assertEquals(expected.clientKey, actual.clientKey) + assertEquals(expected.amount, actual.amount) + assertEquals(expected.analyticsConfiguration, actual.analyticsConfiguration) + + val actualAwaitConfig = actual.getTwintActionConfiguration() + assertEquals(config.shopperLocale, actualAwaitConfig?.shopperLocale) + assertEquals(config.environment, actualAwaitConfig?.environment) + assertEquals(config.clientKey, actualAwaitConfig?.clientKey) + assertEquals(config.amount, actualAwaitConfig?.amount) + assertEquals(config.analyticsConfiguration, actualAwaitConfig?.analyticsConfiguration) + } + + companion object { + private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + } +} diff --git a/twint/src/test/java/com/adyen/checkout/twint/internal/ui/DefaultTwintDelegateTest.kt b/twint/src/test/java/com/adyen/checkout/twint/internal/ui/DefaultTwintDelegateTest.kt new file mode 100644 index 0000000000..2652e12bfc --- /dev/null +++ b/twint/src/test/java/com/adyen/checkout/twint/internal/ui/DefaultTwintDelegateTest.kt @@ -0,0 +1,330 @@ +/* + * 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 21/11/2023. + */ + +package com.adyen.checkout.twint.internal.ui + +import android.app.Activity +import androidx.lifecycle.SavedStateHandle +import ch.twint.payment.sdk.TwintPayResult +import com.adyen.checkout.components.core.ActionComponentData +import com.adyen.checkout.components.core.CheckoutConfiguration +import com.adyen.checkout.components.core.action.Action +import com.adyen.checkout.components.core.action.AwaitAction +import com.adyen.checkout.components.core.action.RedirectAction +import com.adyen.checkout.components.core.action.SdkAction +import com.adyen.checkout.components.core.action.TwintSdkData +import com.adyen.checkout.components.core.action.WeChatPaySdkData +import com.adyen.checkout.components.core.internal.ActionObserverRepository +import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager +import com.adyen.checkout.components.core.internal.data.model.StatusResponse +import com.adyen.checkout.components.core.internal.test.TestStatusRepository +import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper +import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper +import com.adyen.checkout.components.core.internal.util.StatusResponseUtils +import com.adyen.checkout.core.Environment +import com.adyen.checkout.test.LoggingExtension +import com.adyen.checkout.test.extensions.test +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.junit.jupiter.MockitoExtension +import java.io.IOException +import java.util.Locale + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class, LoggingExtension::class) +internal class DefaultTwintDelegateTest { + + private lateinit var analyticsManager: TestAnalyticsManager + private lateinit var statusRepository: TestStatusRepository + private lateinit var delegate: DefaultTwintDelegate + + @BeforeEach + fun beforeEach() { + analyticsManager = TestAnalyticsManager() + statusRepository = TestStatusRepository() + delegate = createDelegate() + } + + @Test + fun `when handling action successfully, then a pay event should be emitted`() = runTest { + val payEventFlow = delegate.payEventFlow.test(testScheduler) + val action = SdkAction(paymentData = TEST_PAYMENT_DATA, sdkData = TwintSdkData("token")) + + delegate.handleAction(action, Activity()) + + assertEquals(action.sdkData?.token, payEventFlow.latestValue) + } + + @ParameterizedTest + @MethodSource("handleActionSource") + fun `when handling action, then expect`(action: Action, expectedErrorMessage: String) = runTest { + val testFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.handleAction(action, Activity()) + + assertEquals(expectedErrorMessage, testFlow.latestValue.message) + } + + @ParameterizedTest + @MethodSource("handleTwintResult") + fun `when handling twint result, then expect`(result: TwintPayResult, testResult: TwintTestResult) = runTest { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = StatusResponseUtils.RESULT_AUTHORIZED, payload = TEST_PAYLOAD)), + ) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + delegate.handleAction(SdkAction(paymentData = TEST_PAYMENT_DATA, sdkData = TwintSdkData("token")), Activity()) + + delegate.handleTwintResult(result) + + when (testResult) { + is TwintTestResult.Error -> { + assertEquals(testResult.expectedMessage, exceptionFlow.latestValue.message) + } + + is TwintTestResult.Success -> { + with(detailsFlow.latestValue) { + assertEquals(testResult.expectedActionComponentData.paymentData, paymentData) + assertEquals(testResult.expectedActionComponentData.details.toString(), details.toString()) + } + } + } + } + + @Nested + @DisplayName("when polling and") + inner class PollingTest { + + @Test + fun `paymentData is missing, then an error is propagated`() = runTest { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + + delegate.handleTwintResult(TwintPayResult.TW_B_SUCCESS) + + val expectedErrorMessage = "PaymentData should not be null." + assertEquals(expectedErrorMessage, exceptionFlow.latestValue.message) + } + + @Test + fun `polling fails, then an error is propagated`() = runTest { + statusRepository.pollingResults = listOf(Result.failure(IOException("Test"))) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + delegate.handleAction( + action = SdkAction(paymentData = TEST_PAYMENT_DATA, sdkData = TwintSdkData("token")), + activity = Activity(), + ) + + delegate.handleTwintResult(TwintPayResult.TW_B_SUCCESS) + + val expectedErrorMessage = "Error while polling status." + assertEquals(expectedErrorMessage, exceptionFlow.latestValue.message) + } + + @Test + fun `polling succeeds and payload is missing, then an error is propagated`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = StatusResponseUtils.RESULT_AUTHORIZED, payload = null)), + ) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val exceptionFlow = delegate.exceptionFlow.test(testScheduler) + delegate.handleAction( + action = SdkAction(paymentData = TEST_PAYMENT_DATA, sdkData = TwintSdkData("token")), + activity = Activity(), + ) + + delegate.handleTwintResult(TwintPayResult.TW_B_SUCCESS) + + val expectedErrorMessage = "Payload is missing from StatusResponse." + assertEquals(expectedErrorMessage, exceptionFlow.latestValue.message) + } + + @Test + fun `polling succeeds and payload is available, then details are emitted`() = runTest { + statusRepository.pollingResults = listOf( + Result.success( + StatusResponse( + resultCode = StatusResponseUtils.RESULT_AUTHORIZED, + payload = TEST_PAYLOAD, + ), + ), + ) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + delegate.handleAction( + action = SdkAction(paymentData = TEST_PAYMENT_DATA, sdkData = TwintSdkData("token")), + activity = Activity(), + ) + + delegate.handleTwintResult(TwintPayResult.TW_B_SUCCESS) + + val expected = ActionComponentData( + paymentData = null, + details = JSONObject().put(DefaultTwintDelegate.PAYLOAD_DETAILS_KEY, TEST_PAYLOAD), + ) + with(detailsFlow.latestValue) { + assertNull(paymentData) + assertEquals(expected.details.toString(), details.toString()) + } + } + } + + @Test + fun `when initializing and action is set, then state is restored`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), + ) + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultTwintDelegate.ACTION_KEY, + SdkAction(paymentData = TEST_PAYMENT_DATA, sdkData = TwintSdkData("token")), + ) + set(DefaultTwintDelegate.IS_POLLING_KEY, true) + } + delegate = createDelegate(savedStateHandle) + val detailsFlow = delegate.detailsFlow.test(testScheduler) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertTrue(detailsFlow.values.isNotEmpty()) + } + + @Test + fun `when details are emitted, then state is cleared`() = runTest { + statusRepository.pollingResults = listOf( + Result.success(StatusResponse(resultCode = "finished", payload = "testpayload")), + ) + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.handleTwintResult(TwintPayResult.TW_B_SUCCESS) + + assertNull(savedStateHandle[DefaultTwintDelegate.ACTION_KEY]) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set(DefaultTwintDelegate.ACTION_KEY, SdkAction(paymentData = "test", sdkData = TwintSdkData("token"))) + } + delegate = createDelegate(savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.handleAction( + action = RedirectAction(paymentMethodType = TEST_PAYMENT_METHOD_TYPE, paymentData = TEST_PAYMENT_DATA), + activity = Activity(), + ) + + assertNull(savedStateHandle[DefaultTwintDelegate.ACTION_KEY]) + } + + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = SdkAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + paymentData = TEST_PAYMENT_DATA, + sdkData = TwintSdkData("token"), + ) + + delegate.handleAction(action, Activity()) + + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + } + + private fun createDelegate( + savedStateHandle: SavedStateHandle = SavedStateHandle() + ): DefaultTwintDelegate { + val configuration = CheckoutConfiguration(Environment.TEST, TEST_CLIENT_KEY) + + return DefaultTwintDelegate( + observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, + componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) + .mapToParams(configuration, Locale.US, null, null), + paymentDataRepository = PaymentDataRepository(SavedStateHandle()), + statusRepository = statusRepository, + analyticsManager = analyticsManager, + ) + } + + companion object { + private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_PAYLOAD = "TEST_PAYLOAD" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_ACTION_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_PAYMENT_DATA = "TEST_PAYMENT_DATA" + + @JvmStatic + fun handleActionSource() = listOf( + arguments(AwaitAction(), "Unsupported action"), + arguments( + SdkAction(paymentData = "something", sdkData = WeChatPaySdkData()), + "SDK Data is null or of wrong type", + ), + arguments(SdkAction(paymentData = "something"), "SDK Data is null or of wrong type"), + arguments(SdkAction(paymentData = null, sdkData = TwintSdkData("")), "Payment data is null"), + arguments( + SdkAction(paymentData = "something", sdkData = null), + "SDK Data is null or of wrong type", + ), + ) + + @JvmStatic + fun handleTwintResult() = listOf( + arguments( + TwintPayResult.TW_B_SUCCESS, + TwintTestResult.Success( + ActionComponentData(null, JSONObject().put(DefaultTwintDelegate.PAYLOAD_DETAILS_KEY, TEST_PAYLOAD)), + ), + ), + arguments( + TwintPayResult.TW_B_ERROR, + TwintTestResult.Error("Twint encountered an error."), + ), + arguments( + TwintPayResult.TW_B_APP_NOT_INSTALLED, + TwintTestResult.Error("Twint app not installed."), + ), + ) + } + + sealed class TwintTestResult { + data class Success(val expectedActionComponentData: ActionComponentData) : TwintTestResult() + + data class Error(val expectedMessage: String) : TwintTestResult() + } +} diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/AdyenComponentView.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/AdyenComponentView.kt index 9f311f21e0..8988f179d0 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/AdyenComponentView.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/AdyenComponentView.kt @@ -14,7 +14,10 @@ import android.view.MotionEvent import android.widget.LinearLayout import androidx.core.view.children import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.findFragment import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.adyen.checkout.components.core.internal.Component import com.adyen.checkout.components.core.internal.ui.ComponentDelegate @@ -108,6 +111,7 @@ class AdyenComponentView @JvmOverloads constructor( coroutineScope = lifecycleOwner.lifecycleScope, ) } + .flowWithLifecycle(lifecycleOwner.lifecycle) .launchIn(lifecycleOwner.lifecycleScope) isVisible = true } @@ -118,7 +122,8 @@ class AdyenComponentView @JvmOverloads constructor( componentParams: ComponentParams, coroutineScope: CoroutineScope, ) { - val componentView = viewType.viewProvider.getView(viewType, context) + val layoutInflater = getLayoutInflater() + val componentView = viewType.viewProvider.getView(viewType, layoutInflater) this.componentView = componentView val localizedContext = context.createLocalizedContext(componentParams.shopperLocale) @@ -152,6 +157,18 @@ class AdyenComponentView @JvmOverloads constructor( } } + /** + * Returns the [LayoutInflater] of the parent activity or fragment. Using `LayoutInflater.from(context)` when the + * view's parent is a fragment will return the [LayoutInflater] of the fragment's activity. This causes issues with + * nested fragment's. + */ + @Suppress("SwallowedException") + private fun getLayoutInflater(): LayoutInflater = try { + findFragment().layoutInflater + } catch (e: IllegalStateException) { + LayoutInflater.from(context) + } + private fun setInteractionBlocked(isInteractionBlocked: Boolean) { this.isInteractionBlocked = isInteractionBlocked diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/DefaultRedirectHandler.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/DefaultRedirectHandler.kt index d225ad10fa..ed18556010 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/DefaultRedirectHandler.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/DefaultRedirectHandler.kt @@ -14,20 +14,19 @@ import android.net.Uri import android.os.Build import androidx.annotation.RequiresApi import androidx.annotation.RestrictTo -import androidx.browser.customtabs.CustomTabColorSchemeParams -import androidx.browser.customtabs.CustomTabsIntent import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.exception.ComponentException import com.adyen.checkout.core.internal.util.adyenLog -import com.adyen.checkout.ui.core.internal.util.ThemeUtil +import com.adyen.checkout.ui.core.internal.util.CustomTabsLauncher import org.json.JSONException import org.json.JSONObject +import java.lang.ref.WeakReference @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class DefaultRedirectHandler : RedirectHandler { - private var onRedirectListener: (() -> Unit)? = null + private var onRedirectListener: WeakReference<(() -> Unit)>? = null override fun parseRedirectResult(data: Uri?): JSONObject { adyenLog(AdyenLogLevel.DEBUG) { "parseRedirectResult - $data" } @@ -72,7 +71,7 @@ class DefaultRedirectHandler : RedirectHandler { launchWithCustomTabs(context, uri) || launchBrowser(context, uri) ) { - onRedirectListener?.invoke() + onRedirectListener?.get()?.invoke() return } @@ -152,25 +151,15 @@ class DefaultRedirectHandler : RedirectHandler { private fun launchWithCustomTabs(context: Context, uri: Uri): Boolean { // open in custom tabs if there's no native app for the target uri - val defaultColors = CustomTabColorSchemeParams.Builder() - .setToolbarColor(ThemeUtil.getPrimaryThemeColor(context)) - .build() - - @Suppress("SwallowedException") - return try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .setDefaultColorSchemeParams(defaultColors) - .build() - .launchUrl(context, uri) + val isLaunched = CustomTabsLauncher.launchCustomTab(context, uri) + if (isLaunched) { adyenLog(AdyenLogLevel.DEBUG) { "launchWithCustomTabs - redirect successful with custom tabs" } - true - } catch (e: ActivityNotFoundException) { + } else { adyenLog(AdyenLogLevel.DEBUG) { "launchWithCustomTabs - device doesn't support custom tabs or chrome is disabled" } - false } + return isLaunched } /** @@ -194,10 +183,11 @@ class DefaultRedirectHandler : RedirectHandler { } override fun setOnRedirectListener(listener: () -> Unit) { - onRedirectListener = listener + onRedirectListener = WeakReference(listener) } override fun removeOnRedirectListener() { + onRedirectListener?.clear() onRedirectListener = null } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/CountryViewHolder.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/CountryViewHolder.kt index 7717f8f462..85b83a8eb2 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/CountryViewHolder.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/CountryViewHolder.kt @@ -9,7 +9,6 @@ package com.adyen.checkout.ui.core.internal.ui import androidx.recyclerview.widget.RecyclerView -import com.adyen.checkout.ui.core.R import com.adyen.checkout.ui.core.databinding.CountryViewBinding import com.adyen.checkout.ui.core.internal.ui.model.CountryModel @@ -17,12 +16,8 @@ internal class CountryViewHolder(private val binding: CountryViewBinding) : Recy fun bindItem(country: CountryModel) { with(binding) { - textViewFlag.text = country.emoji - textViewCountry.text = root.context.getString( - R.string.checkout_country_name_format, - country.countryName, - country.callingCode - ) + textViewCountryCode.text = country.callingCode + textViewCountry.text = country.countryName } } } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/ViewProvider.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/ViewProvider.kt index 1c4a592f52..a503f5a38f 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/ViewProvider.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/ViewProvider.kt @@ -9,6 +9,7 @@ package com.adyen.checkout.ui.core.internal.ui import android.content.Context +import android.view.LayoutInflater import androidx.annotation.RestrictTo @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @@ -17,4 +18,9 @@ interface ViewProvider { viewType: ComponentViewType, context: Context, ): ComponentView + + fun getView( + viewType: ComponentViewType, + layoutInflater: LayoutInflater + ): ComponentView = getView(viewType, layoutInflater.context) } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/AddressOutputData.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/AddressOutputData.kt index 89843aabcf..2f1b1f7bcc 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/AddressOutputData.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/AddressOutputData.kt @@ -42,7 +42,7 @@ data class AddressOutputData( postalCode.value, city.value, stateOrProvince.value, - country.value + country.value, ).filter { it.isNotBlank() }.joinToString(" ") } } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/CountryModel.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/CountryModel.kt index 869fef5cef..815033b45d 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/CountryModel.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/model/CountryModel.kt @@ -15,9 +15,8 @@ data class CountryModel( val isoCode: String, val countryName: String, val callingCode: String, - val emoji: String ) { fun toShortString(): String { - return "$emoji $callingCode" + return "$isoCode $callingCode" } } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/view/PaymentInProgressView.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/view/PaymentInProgressView.kt index 14d760682d..155bbf6735 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/view/PaymentInProgressView.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/ui/view/PaymentInProgressView.kt @@ -23,8 +23,10 @@ import com.adyen.checkout.ui.core.internal.ui.ComponentView import com.adyen.checkout.ui.core.internal.util.setLocalizedTextFromStyle import kotlinx.coroutines.CoroutineScope +class PaymentInProgressView +@JvmOverloads @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class PaymentInProgressView @JvmOverloads constructor( +constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 @@ -32,7 +34,7 @@ class PaymentInProgressView @JvmOverloads constructor( ConstraintLayout( context, attrs, - defStyleAttr + defStyleAttr, ), ComponentView { @@ -52,15 +54,15 @@ class PaymentInProgressView @JvmOverloads constructor( with(binding) { textViewPaymentInProgressTitle.setLocalizedTextFromStyle( R.style.AdyenCheckout_PaymentInProgressView_TitleTextView, - localizedContext + localizedContext, ) textViewPaymentInProgressDescription.setLocalizedTextFromStyle( R.style.AdyenCheckout_PaymentInProgressView_DescriptionTextView, - localizedContext + localizedContext, ) buttonPaymentInProgressCancel.setLocalizedTextFromStyle( R.style.AdyenCheckout_PaymentInProgressView_CancelButton, - localizedContext + localizedContext, ) } } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/CountryUtils.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/CountryUtils.kt new file mode 100644 index 0000000000..b4a7f9133a --- /dev/null +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/CountryUtils.kt @@ -0,0 +1,34 @@ +/* + * 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 22/5/2024. + */ + +package com.adyen.checkout.ui.core.internal.util + +import androidx.annotation.RestrictTo +import com.adyen.checkout.components.core.internal.util.CountryUtils +import com.adyen.checkout.ui.core.internal.ui.model.CountryModel +import java.util.Locale + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object CountryUtils { + + fun getLocalizedCountries( + shopperLocale: Locale, + allowedISOCodes: List? = null, + comparator: Comparator = compareBy { it.countryName }, + ): List { + return CountryUtils.getCountries(allowedISOCodes) + .map { + CountryModel( + isoCode = it.isoCode, + countryName = CountryUtils.getCountryName(it.isoCode, shopperLocale), + callingCode = it.callingCode, + ) + } + .sortedWith(comparator) + } +} diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/CustomTabsLauncher.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/CustomTabsLauncher.kt new file mode 100644 index 0000000000..6ebe7ed2b5 --- /dev/null +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/CustomTabsLauncher.kt @@ -0,0 +1,56 @@ +/* + * 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 josephj on 6/5/2024. + */ + +package com.adyen.checkout.ui.core.internal.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.net.Uri +import androidx.annotation.AttrRes +import androidx.annotation.RestrictTo +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import com.adyen.checkout.ui.core.R + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object CustomTabsLauncher { + fun launchCustomTab(context: Context, uri: Uri): Boolean { + @Suppress("SwallowedException") + return try { + CustomTabsIntent.Builder() + .setShowTitle(true) + .setDefaultColorSchemeParams(getDefaultColorSchemeParams(context)) + .build() + .launchUrl(context, uri) + true + } catch (e: ActivityNotFoundException) { + false + } + } + + private fun getDefaultColorSchemeParams(context: Context): CustomTabColorSchemeParams { + val toolbarColor = context.getColorOrNull(R.attr.adyenCustomTabsToolbarColor) + val secondaryToolbarColor = context.getColorOrNull(R.attr.adyenCustomTabsSecondaryToolbarColor) + val navigationBarColor = context.getColorOrNull(R.attr.adyenCustomTabsNavigationBarColor) + val navigationBarDividerColor = context.getColorOrNull(R.attr.adyenCustomTabsNavigationBarDividerColor) + + return CustomTabColorSchemeParams.Builder().apply { + toolbarColor?.let { setToolbarColor(it) } + secondaryToolbarColor?.let { setSecondaryToolbarColor(it) } + navigationBarColor?.let { setNavigationBarColor(it) } + navigationBarDividerColor?.let { setNavigationBarDividerColor(it) } + }.build() + } + + private fun Context.getColorOrNull(@AttrRes attribute: Int): Int? { + val typedArray = obtainStyledAttributes(R.style.AdyenCheckout_CustomTabs, intArrayOf(attribute)) + val color = typedArray.getColor(0, -1).takeIf { it != -1 } + typedArray.recycle() + return color + } +} diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ImageSaver.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ImageSaver.kt index 99738f0ae3..f6cd571e5c 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ImageSaver.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ImageSaver.kt @@ -45,6 +45,7 @@ import java.io.FileOutputStream import java.io.IOException import java.net.MalformedURLException import java.net.URL +import com.google.android.material.R as MaterialR @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class ImageSaver( @@ -67,7 +68,7 @@ class ImageSaver( ?: view.background?.draw(canvas) ?: run { val defaultColor = ContextCompat.getColor(context, R.color.white) - val defaultBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, defaultColor) + val defaultBackgroundColor = MaterialColors.getColor(context, MaterialR.attr.colorSurface, defaultColor) canvas.drawColor(defaultBackgroundColor) } diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/PdfOpener.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/PdfOpener.kt index 9d7705fa28..091531e8d9 100644 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/PdfOpener.kt +++ b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/PdfOpener.kt @@ -14,8 +14,6 @@ import android.content.Intent import android.net.Uri import android.os.Build import androidx.annotation.RestrictTo -import androidx.browser.customtabs.CustomTabColorSchemeParams -import androidx.browser.customtabs.CustomTabsIntent import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.internal.util.adyenLog @@ -63,23 +61,13 @@ class PdfOpener { private fun openInCustomTab(context: Context, uri: Uri): Boolean { // open in custom tabs if there's no native app for the target uri - val defaultColors = CustomTabColorSchemeParams.Builder() - .setToolbarColor(ThemeUtil.getPrimaryThemeColor(context)) - .build() - - return try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .setDefaultColorSchemeParams(defaultColors) - .build() - .launchUrl(context, uri) - + val isLaunched = CustomTabsLauncher.launchCustomTab(context, uri) + if (isLaunched) { adyenLog(AdyenLogLevel.DEBUG) { "Successfully opened pdf in custom tab" } - true - } catch (e: ActivityNotFoundException) { - adyenLog(AdyenLogLevel.DEBUG, e) { "Couldn't open pdf in custom tab" } - false + } else { + adyenLog(AdyenLogLevel.DEBUG) { "Couldn't open pdf in custom tab" } } + return isLaunched } private fun openInBrowser(context: Context, uri: Uri): Boolean { diff --git a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ThemeUtil.kt b/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ThemeUtil.kt deleted file mode 100644 index 26904185dd..0000000000 --- a/ui-core/src/main/java/com/adyen/checkout/ui/core/internal/util/ThemeUtil.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2019 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 2/4/2019. - */ -package com.adyen.checkout.ui.core.internal.util - -import android.content.Context -import android.util.TypedValue -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt -import androidx.annotation.RestrictTo -import com.adyen.checkout.ui.core.R - -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -object ThemeUtil { - - @ColorInt - fun getPrimaryThemeColor(context: Context): Int { - return getAttributeColor(context, R.attr.colorPrimary) - } - - @ColorInt - private fun getAttributeColor(context: Context, @AttrRes attributeColor: Int): Int { - val typedValue = TypedValue() - val typedArray = context.obtainStyledAttributes(typedValue.data, intArrayOf(attributeColor)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() - return color - } -} diff --git a/ui-core/src/main/res/color/text_color_secondary.xml b/ui-core/src/main/res/color/text_color_secondary.xml new file mode 100644 index 0000000000..4da8c00e99 --- /dev/null +++ b/ui-core/src/main/res/color/text_color_secondary.xml @@ -0,0 +1,4 @@ + + + + diff --git a/ui-core/src/main/res/layout/country_view.xml b/ui-core/src/main/res/layout/country_view.xml index cf188f51a4..d3582eec62 100644 --- a/ui-core/src/main/res/layout/country_view.xml +++ b/ui-core/src/main/res/layout/country_view.xml @@ -10,25 +10,26 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - android:padding="@dimen/standard_three_quarters_margin"> + android:orientation="vertical" + android:paddingHorizontal="@dimen/standard_margin" + android:paddingVertical="@dimen/standard_three_quarters_margin"> - + tools:text="+1234" /> - + android:textColor="?android:attr/textColorSecondary" + android:lines="1" + android:textSize="14sp" + tools:text="The Netherlands" /> diff --git a/ui-core/src/main/res/template/values/strings.xml.tt b/ui-core/src/main/res/template/values/strings.xml.tt index a36705d017..518685147b 100644 --- a/ui-core/src/main/res/template/values/strings.xml.tt +++ b/ui-core/src/main/res/template/values/strings.xml.tt @@ -54,7 +54,5 @@ %%address.lookup.search.empty.subtitle.noResults%% %%address.enterManually%% %%address.lookup.submit%% - - - %1$s (%2$s) + %%address.lookup.item.validationFailureMessage.empty%% diff --git a/ui-core/src/main/res/values-ar/strings.xml b/ui-core/src/main/res/values-ar/strings.xml index 29c36247bd..c57f268741 100644 --- a/ui-core/src/main/res/values-ar/strings.xml +++ b/ui-core/src/main/res/values-ar/strings.xml @@ -28,7 +28,7 @@ رقم المنزل الشقة / الجناح المدينة - البلد + البلد/المنطقة الولاية الرمز البريدي الرمز البريدي @@ -40,7 +40,7 @@ رقم المنزل (اختياري) الشقة / الجناح (اختياري) المدينة (اختياري) - البلد (اختياري) + البلد/المنطقة (اختياري) الولاية (اختياري) الرمز البريدي (اختياري) الرمز البريدي (اختياري) @@ -54,5 +54,6 @@ لم يتطابق \'%s\' مع أي شيء، حاول مرة أخرى أو استخدم #إدخال العنوان يدويًا# أدخل العنوان يدويًا استخدم هذا العنوان + العنوان مطلوب \ No newline at end of file diff --git a/ui-core/src/main/res/values-cs-rCZ/strings.xml b/ui-core/src/main/res/values-cs-rCZ/strings.xml index c38348c57c..39bf32fc39 100644 --- a/ui-core/src/main/res/values-cs-rCZ/strings.xml +++ b/ui-core/src/main/res/values-cs-rCZ/strings.xml @@ -28,7 +28,7 @@ Číslo popisné Byt Město - Země + Země/region Stát PSČ PSČ @@ -40,7 +40,7 @@ Číslo popisné (nepovinné) Byt (nepovinné) Město (nepovinné) - Země (nepovinné) + Země/region (nepovinné) Stát (nepovinné) Poštovní směrovací číslo (nepovinné) PSČ (nepovinné) @@ -54,5 +54,6 @@ „%s“ se s ničím neshoduje, zkuste to znovu nebo použijte #ruční zadání adresy# Zadejte adresu ručně Použijte tuto adresu + Požadovaná adresa \ No newline at end of file diff --git a/ui-core/src/main/res/values-da-rDK/strings.xml b/ui-core/src/main/res/values-da-rDK/strings.xml index 32d5fe0038..869578a105 100644 --- a/ui-core/src/main/res/values-da-rDK/strings.xml +++ b/ui-core/src/main/res/values-da-rDK/strings.xml @@ -28,7 +28,7 @@ Husnummer Lejlighed/suite By - Land + Land/region Stat Postnummer Postnummer @@ -40,7 +40,7 @@ Husnummer (valgfrit) Lejlighed/suite (valgfrit) By (valgfrit) - Land (valgfrit) + Land/region (valgfrit) Stat (valgfrit) Postnummer (valgfrit) Postnummer (valgfrit) @@ -54,5 +54,6 @@ Fandt ikke noget match for \'%s\'. Prøv igen eller #indtast adresse manuelt#. Indtast adresse manuelt Brug denne adresse + Adresse er påkrævet \ No newline at end of file diff --git a/ui-core/src/main/res/values-de-rDE/strings.xml b/ui-core/src/main/res/values-de-rDE/strings.xml index 50cf511ac9..5ccb39b496 100644 --- a/ui-core/src/main/res/values-de-rDE/strings.xml +++ b/ui-core/src/main/res/values-de-rDE/strings.xml @@ -28,7 +28,7 @@ Hausnummer Wohnung/Geschoss Stadt - Land + Land/Region Bundesstaat Postleitzahl PLZ @@ -40,7 +40,7 @@ Hausnummer (optional) Wohnung/Geschoss (optional) Stadt (optional) - Land (optional) + Land/Region (optional) Bundesstaat (optional) Postleitzahl (optional) PLZ (optional) @@ -54,5 +54,6 @@ \'%s\' stimmt nicht mit irgendetwas überein, versuchen Sie es erneut oder verwenden Sie #manual address entry# Geben Sie die Adresse manuell ein Diese Adresse verwenden + Adresse erforderlich diff --git a/ui-core/src/main/res/values-el-rGR/strings.xml b/ui-core/src/main/res/values-el-rGR/strings.xml index 07a0503a77..2d121a668f 100644 --- a/ui-core/src/main/res/values-el-rGR/strings.xml +++ b/ui-core/src/main/res/values-el-rGR/strings.xml @@ -28,7 +28,7 @@ Αριθμός οικίας Διαμέρισμα/Γραφείο Πόλη - Χώρα + Χώρα/Περιοχή Πολιτεία Ταχυδρομικός κωδικός Ταχυδρομικός κώδικας @@ -40,7 +40,7 @@ Αριθμός οικίας (προαιρετικό) Διαμέρισμα/Γραφείο (προαιρετικό) Πόλη (προαιρετικό) - Χώρα (προαιρετικό) + Χώρα/Περιοχή (προαιρετικό) Πολιτεία (προαιρετικό) Ταχυδρομικός κωδικός (προαιρετικό) Ταχυδρομικός κωδικός (προαιρετικό) @@ -54,5 +54,6 @@ Το \'%s\' δεν ταιριάζει με τίποτα. Προσπαθήστε ξανά ή χρησιμοποιήστε #μη αυτόματη εισαγωγή διεύθυνσης# Εισαγάγετε τη διεύθυνση μη αυτόματα Χρησιμοποιήστε αυτήν τη διεύθυνση + Απαιτείται διεύθυνση \ No newline at end of file diff --git a/ui-core/src/main/res/values-es-rES/strings.xml b/ui-core/src/main/res/values-es-rES/strings.xml index 189cbb4d2d..fbbe9c31fd 100644 --- a/ui-core/src/main/res/values-es-rES/strings.xml +++ b/ui-core/src/main/res/values-es-rES/strings.xml @@ -28,7 +28,7 @@ Número de vivienda Apartamento/suite Ciudad - País + País o región Estado Código postal Código postal @@ -40,7 +40,7 @@ Número de vivienda (opcional) Apartamento/suite (opcional) Ciudad (opcional) - País (opcional) + País o región (opcional) Estado (opcional) Código postal (opcional) Código postal (opcional) @@ -54,5 +54,6 @@ "%s" no ha coincidido con nada, inténtelo de nuevo o #introduzca su dirección manualmente# Introduzca la dirección manualmente Usar esta dirección + Se necesita la dirección \ No newline at end of file diff --git a/ui-core/src/main/res/values-fi-rFI/strings.xml b/ui-core/src/main/res/values-fi-rFI/strings.xml index b336f5b5bc..e6c4a7e2b5 100644 --- a/ui-core/src/main/res/values-fi-rFI/strings.xml +++ b/ui-core/src/main/res/values-fi-rFI/strings.xml @@ -28,7 +28,7 @@ Talon numero Huoneisto / sviitti Kaupunki - Maa + Maa/alue Osavaltio Postinumero Postinumero @@ -40,7 +40,7 @@ Talon numero (valinnainen) Huoneisto / sviitti (valinnainen) Kaupunki (valinnainen) - Maa (valinnainen) + Maa/alue (valinnainen) Osavaltio (valinnainen) Postinumero (valinnainen) Postinumero (valinnainen) @@ -54,5 +54,6 @@ \'%s\' ei tuottanut tuloksia. Yritä uudelleen tai käytä #manuaalista osoitteen syöttöä# Syötä osoite manuaalisesti Käytä tätä osoitetta + Osoite vaaditaan \ No newline at end of file diff --git a/ui-core/src/main/res/values-fr-rFR/strings.xml b/ui-core/src/main/res/values-fr-rFR/strings.xml index 6b39c805c3..5bb940643f 100644 --- a/ui-core/src/main/res/values-fr-rFR/strings.xml +++ b/ui-core/src/main/res/values-fr-rFR/strings.xml @@ -28,7 +28,7 @@ Numéro de rue Appartement Ville - Pays + Pays/Région État Code postal Code postal @@ -40,7 +40,7 @@ Numéro de rue (facultatif) Appartement (facultatif) Ville (facultatif) - Pays (facultatif) + Pays/Région (facultatif) État (facultatif) Code postal (facultatif) Code postal (facultatif) @@ -54,5 +54,6 @@ Aucune correspondance pour \'%s\', réessayez ou #saisissez votre adresse manuellement# Saisissez l\'adresse manuellement Utiliser cette adresse + Adresse requise \ No newline at end of file diff --git a/ui-core/src/main/res/values-hr-rHR/strings.xml b/ui-core/src/main/res/values-hr-rHR/strings.xml index 62e0f17962..868fb94e57 100644 --- a/ui-core/src/main/res/values-hr-rHR/strings.xml +++ b/ui-core/src/main/res/values-hr-rHR/strings.xml @@ -28,7 +28,7 @@ Kućni broj Stan/apartman Grad - Zemlja + Zemlja/regija Savezna država Poštanski broj Poštanski broj @@ -40,7 +40,7 @@ Kućni broj (neobavezno) Stan/apartman (neobavezno) Grad (nije obvezno) - Zemlja (neobavezno) + Zemlja/regija (neobavezno) Savezna država (neobavezno) Poštanski broj (nije obvezno) Poštanski broj (neobavezno) @@ -54,5 +54,6 @@ \'%s\' nije se podudarao s ničime, pokušajte ponovno ili upotrijebite #ručni unos adrese# Ručno unesite adresu Koristi ovu adresu + Potrebna je adresa diff --git a/ui-core/src/main/res/values-hu-rHU/strings.xml b/ui-core/src/main/res/values-hu-rHU/strings.xml index 3af44abe0f..75ca3bc5ed 100644 --- a/ui-core/src/main/res/values-hu-rHU/strings.xml +++ b/ui-core/src/main/res/values-hu-rHU/strings.xml @@ -28,7 +28,7 @@ Házszám Lakás/ajtószám Város - Ország + Ország/régió Állam Irányítószám Irányítószám @@ -40,7 +40,7 @@ Házszám (nem kötelező) Lakás/ajtószám (nem kötelező) Város (nem kötelező) - Ország (nem kötelező) + Ország/régió (nem kötelező) Állam (nem kötelező) Irányítószám (nem kötelező) Irányítószám (nem kötelező) @@ -54,5 +54,6 @@ A keresett „%s„ kifejezésre nincs találat, próbálkozzon újra, vagy #írja be manuálisan a címet# Manuálisan írjon be egy címet Használja ezt a címet + A cím megadása kötelező \ No newline at end of file diff --git a/ui-core/src/main/res/values-it-rIT/strings.xml b/ui-core/src/main/res/values-it-rIT/strings.xml index f71fb9fdc0..97a85d96aa 100644 --- a/ui-core/src/main/res/values-it-rIT/strings.xml +++ b/ui-core/src/main/res/values-it-rIT/strings.xml @@ -28,7 +28,7 @@ Numero civico Appartamento/suite Città - Paese + Paese/Regione Stato Codice postale CAP @@ -40,7 +40,7 @@ Numero civico (facoltativo) Appartamento / Suite (facoltativo) Città (facoltativo) - Paese (facoltativo) + Paese/Regione (facoltativo) Stato (facoltativo) Codice postale (facoltativo) CAP (facoltativo) @@ -54,5 +54,6 @@ %s non corrisponde a nulla, riprova o usa #inserimento indirizzo manuale# Inserisci l\'indirizzo manualmente Usa questo indirizzo + Indirizzo richiesto \ No newline at end of file diff --git a/ui-core/src/main/res/values-ja-rJP/strings.xml b/ui-core/src/main/res/values-ja-rJP/strings.xml index 7183bb2aaa..62f6476d36 100644 --- a/ui-core/src/main/res/values-ja-rJP/strings.xml +++ b/ui-core/src/main/res/values-ja-rJP/strings.xml @@ -28,7 +28,7 @@ 部屋番号 アパート名/部屋名 市区 - + 国/地域 都道府県 郵便番号 郵便番号 @@ -40,7 +40,7 @@ 部屋番号 (任意) アパート名/部屋名 (任意) 市区町村 (任意) - 国 (任意) + 国/地域 (任意) 都道府県 (任意) 郵便番号 (任意) 郵便番号 (任意) @@ -54,5 +54,6 @@ 「%s」との一致はありませんでした。もう一度試すか、#手動でアドレスを入力#してください 住所を手動で入力してください この住所を使用する + 住所が必要です \ No newline at end of file diff --git a/ui-core/src/main/res/values-ko-rKR/strings.xml b/ui-core/src/main/res/values-ko-rKR/strings.xml index 60d1d4437a..cef2635655 100644 --- a/ui-core/src/main/res/values-ko-rKR/strings.xml +++ b/ui-core/src/main/res/values-ko-rKR/strings.xml @@ -28,7 +28,7 @@ 집 전화번호 아파트/건물 - 국가 + 국가/지역 우편번호 우편번호 @@ -40,7 +40,7 @@ 집 전화번호(선택 사항) 아파트/건물(선택 사항) 도시(선택 사항) - 국가(선택 사항) + 국가/지역(선택 사항) 주(선택 사항) 우편번호(선택 사항) 우편 번호(선택 사항) @@ -54,5 +54,6 @@ \'%s\'와(과) 일치하는 항목이 없습니다. 다시 시도하거나 #수동 주소 입력#을 사용하세요. 수동으로 주소 입력 이 주소 사용 + 주소 필수 \ No newline at end of file diff --git a/ui-core/src/main/res/values-nb-rNO/strings.xml b/ui-core/src/main/res/values-nb-rNO/strings.xml index ace064b24b..65064cb9bf 100644 --- a/ui-core/src/main/res/values-nb-rNO/strings.xml +++ b/ui-core/src/main/res/values-nb-rNO/strings.xml @@ -28,7 +28,7 @@ Husnummer Leilighet/suite Poststed - Land + Land/region Delstat Postnummer Postnummer @@ -40,7 +40,7 @@ Husnummer (valgfritt) Leilighet/suite (valgfritt) Poststed (valgfritt) - Land (valgfritt) + Land/region (valgfritt) Delstat (valgfritt) Postnummer (valgfritt) Postnummer (valgfritt) @@ -54,5 +54,6 @@ Fikk ingen treff på «%s». Prøv igjen eller #skriv inn adressen manuelt# Skriv inn adressen manuelt Bruk denne adressen + Adresse er nødvendig \ No newline at end of file diff --git a/ui-core/src/main/res/values-nl-rNL/strings.xml b/ui-core/src/main/res/values-nl-rNL/strings.xml index c9fbb25dec..d3111c76fc 100644 --- a/ui-core/src/main/res/values-nl-rNL/strings.xml +++ b/ui-core/src/main/res/values-nl-rNL/strings.xml @@ -28,7 +28,7 @@ Huisnummer Appartement/Suite Stad - Land + Land/regio Staat Postcode Postcode @@ -40,7 +40,7 @@ Huisnummer (optioneel) Appartement/Suite (optioneel) Stad (optioneel) - Land (optioneel) + Land/regio (optioneel) Staat (optioneel) Postcode (optioneel) Postcode (optioneel) @@ -54,5 +54,6 @@ Geen resulaten gevonden voor \'%s\', probeer het opnieuw of gebruik #adres handmatig invoeren# Voer het adres handmatig in Dit adres gebruiken + Adres verplicht \ No newline at end of file diff --git a/ui-core/src/main/res/values-pl-rPL/strings.xml b/ui-core/src/main/res/values-pl-rPL/strings.xml index 05b97cec49..9facaad2b0 100644 --- a/ui-core/src/main/res/values-pl-rPL/strings.xml +++ b/ui-core/src/main/res/values-pl-rPL/strings.xml @@ -28,7 +28,7 @@ Numer domu i mieszkania Numer domu/mieszkania Miasto - Kraj + Kraj/Region Stan Kod pocztowy Kod pocztowy @@ -40,7 +40,7 @@ Numer domu i mieszkania (opcjonalnie) Numer domu/mieszkania (opcjonalnie) Miasto (opcjonalnie) - Kraj (opcjonalnie) + Kraj/Region (opcjonalnie) Stan (opcjonalnie) Kod pocztowy (opcjonalnie) Kod pocztowy (opcjonalnie) @@ -54,5 +54,6 @@ Nie znaleziono żadnych dopasowań dla \'%s\', spróbuj ponownie lub #wprowadź adres ręcznie# Wprowadź adres ręcznie Użyj tego adresu + Wymagane jest podanie adresu \ No newline at end of file diff --git a/ui-core/src/main/res/values-pt-rBR/strings.xml b/ui-core/src/main/res/values-pt-rBR/strings.xml index eababec81d..3bf7d128aa 100644 --- a/ui-core/src/main/res/values-pt-rBR/strings.xml +++ b/ui-core/src/main/res/values-pt-rBR/strings.xml @@ -28,7 +28,7 @@ Número da casa Apartamento/Conjunto Cidade - País + País/região Estado CEP Código postal @@ -40,7 +40,7 @@ Número da casa (opcional) Apartamento/Conjunto (opcional) Cidade (opcional) - País (opcional) + País/região (opcional) Estado (opcional) Código postal (opcional) Código postal (opcional) @@ -54,5 +54,6 @@ A pesquisa de \'%s\' não obteve resultados. Tente novamente ou use #inserir endereço manualmente# Inserir endereço manualmente Usar este endereço + O endereço é obrigatório \ No newline at end of file diff --git a/ui-core/src/main/res/values-pt-rPT/strings.xml b/ui-core/src/main/res/values-pt-rPT/strings.xml index 110e10a076..b72805cc6e 100644 --- a/ui-core/src/main/res/values-pt-rPT/strings.xml +++ b/ui-core/src/main/res/values-pt-rPT/strings.xml @@ -28,7 +28,7 @@ Número de porta Apartamento/Suite Cidade - País + País/região Estado Código postal Código postal @@ -40,7 +40,7 @@ Número de porta (opcional) Apartamento/Suite (opcional) Cidade (opcional) - País (opcional) + País/região (opcional) Estado (opcional) Código postal (opcional) Código postal (opcional) @@ -54,5 +54,6 @@ \'%s\' não devolveu resultados, tente novamente ou use #inserção manual do endereço# Introduza o endereço manualmente Utilize este endereço + Endereço necessário \ No newline at end of file diff --git a/ui-core/src/main/res/values-ro-rRO/strings.xml b/ui-core/src/main/res/values-ro-rRO/strings.xml index 852b0b2c11..1660ad9df7 100644 --- a/ui-core/src/main/res/values-ro-rRO/strings.xml +++ b/ui-core/src/main/res/values-ro-rRO/strings.xml @@ -28,7 +28,7 @@ Număr Apartament Oraș - Țară + Țară/regiune Stat Cod poștal Cod poștal @@ -40,7 +40,7 @@ Număr (opțional) Apartament(opțional) Oraș (opțional) - Țară (opțional) + Țară/regiune (opțional) Stat (opțional) Cod poștal (opțional) Cod poștal (opțional) @@ -54,5 +54,6 @@ \'%s\' nu s-a potrivit cu nimic, încercați din nou sau folosiți #completarea manuală a adresei# Introduceți adresa manual Folosiți această adresă + Adresa este necesară \ No newline at end of file diff --git a/ui-core/src/main/res/values-ru-rRU/strings.xml b/ui-core/src/main/res/values-ru-rRU/strings.xml index 2cbda30d55..917f8d1f7d 100644 --- a/ui-core/src/main/res/values-ru-rRU/strings.xml +++ b/ui-core/src/main/res/values-ru-rRU/strings.xml @@ -28,7 +28,7 @@ Номер дома Квартира / помещение Город - Страна + Страна/регион Штат Почтовый индекс Почтовый индекс @@ -40,7 +40,7 @@ Номер дома (необязательно) Квартира / помещение (необязательно) Город (необязательно) - Страна (необязательно) + Страна/регион (необязательно) Штат (необязательно) Почтовый индекс (необязательно) Почтовый индекс (необязательно) @@ -54,5 +54,6 @@ Не найдено соответствий «%s». Повторите попытку или используйте #ручной ввод адреса# Ввести адрес вручную Используйте этот адрес + Требуется адрес \ No newline at end of file diff --git a/ui-core/src/main/res/values-sk-rSK/strings.xml b/ui-core/src/main/res/values-sk-rSK/strings.xml index c6f80e1df9..7d40143cbf 100644 --- a/ui-core/src/main/res/values-sk-rSK/strings.xml +++ b/ui-core/src/main/res/values-sk-rSK/strings.xml @@ -28,7 +28,7 @@ Číslo domu Byt/apartmán Mesto - Krajina + Krajina/región Štát PSČ PSČ @@ -40,7 +40,7 @@ Číslo domu (nepovinné) Byt/apartmán (voliteľné) Mesto (nepovinné) - Krajina (voliteľné) + Krajina/región (voliteľné) Štát (nepovinné) Poštové smerovacie číslo (nepovinné) PSČ (nepovinné) @@ -54,5 +54,6 @@ \'%s\' sa s ničím nezhoduje, skúste to znova alebo použite #ručné zadanie adresy# Manuálne zadajte adresu Použite túto adresu + Adresa sa požaduje \ No newline at end of file diff --git a/ui-core/src/main/res/values-sl-rSI/strings.xml b/ui-core/src/main/res/values-sl-rSI/strings.xml index 9700bb2e5c..d9fb2c0899 100644 --- a/ui-core/src/main/res/values-sl-rSI/strings.xml +++ b/ui-core/src/main/res/values-sl-rSI/strings.xml @@ -28,7 +28,7 @@ Hišna številka Št. apartmaja/stanovanja Mesto - Država + Država/regija Država Poštna številka Poštna številka @@ -40,7 +40,7 @@ Hišna številka (neobvezno) Št. apartmaja/stanovanja (neobvezno) Kraj (neobvezno) - Država (neobvezno) + Država/regija (neobvezno) Država (neobvezno) Poštna številka (neobvezno) Poštna številka (neobvezno) @@ -54,5 +54,6 @@ »%s« se ni ujemalo z ničemer, poskusite znova ali uporabite #ročni vnos naslova# Naslov vnesite ročno Uporabite ta naslov + Naslov je obvezen \ No newline at end of file diff --git a/ui-core/src/main/res/values-sv-rSE/strings.xml b/ui-core/src/main/res/values-sv-rSE/strings.xml index 5e64df2f3b..bebcdb6e60 100644 --- a/ui-core/src/main/res/values-sv-rSE/strings.xml +++ b/ui-core/src/main/res/values-sv-rSE/strings.xml @@ -28,7 +28,7 @@ Husnummer Lägenhetsnummer Stad - Land + Land/region Delstat Postnummer Postnummer @@ -40,7 +40,7 @@ Husnummer (valfritt) Lägenhetsnummer (valfritt) Ort (valfritt) - Land (valfritt) + Land/region (valfritt) Delstat (valfritt) Postnummer (valfritt) Postnummer (valfritt) @@ -54,5 +54,6 @@ Det finns inga matchningar för ”%s”. Försök igen eller använd #manuell adressinmatning# Ange adress manuellt Använd denna adress + Adress krävs \ No newline at end of file diff --git a/ui-core/src/main/res/values-zh-rCN/strings.xml b/ui-core/src/main/res/values-zh-rCN/strings.xml index 70be5b9068..0f7e83796f 100644 --- a/ui-core/src/main/res/values-zh-rCN/strings.xml +++ b/ui-core/src/main/res/values-zh-rCN/strings.xml @@ -54,5 +54,6 @@ “%s”无匹配内容,请重试或者使用#手动地址条目# 手动输入地址 使用此地址 + 地址为必填项 \ No newline at end of file diff --git a/ui-core/src/main/res/values-zh-rTW/strings.xml b/ui-core/src/main/res/values-zh-rTW/strings.xml index 1aa118699b..58b3c0d7b3 100644 --- a/ui-core/src/main/res/values-zh-rTW/strings.xml +++ b/ui-core/src/main/res/values-zh-rTW/strings.xml @@ -28,7 +28,7 @@ 門牌號 公寓/套房 城市 - 國家/地區 + 國家/地區 郵遞區號 郵遞區號 @@ -40,7 +40,7 @@ 門牌號(選用) 公寓/套房(選用) 城市(選用) - 國家/地區(選用) + 國家/地區(選用) 州(選用) 郵遞區號(選用) 郵遞區號(選用) @@ -54,5 +54,6 @@ 「%s」不符合任何搜尋內容,請再試一次或#手動輸入地址# 手動輸入地址 使用此地址 + 必須填寫地址 \ No newline at end of file diff --git a/ui-core/src/main/res/values/attrs.xml b/ui-core/src/main/res/values/attrs.xml index 09d378992e..aa2c582726 100644 --- a/ui-core/src/main/res/values/attrs.xml +++ b/ui-core/src/main/res/values/attrs.xml @@ -1,5 +1,4 @@ - - - %1$s (%2$s) - \ No newline at end of file + Address required + diff --git a/ui-core/src/main/res/values/styles.xml b/ui-core/src/main/res/values/styles.xml index d7e26847b5..f1afd8eb11 100644 --- a/ui-core/src/main/res/values/styles.xml +++ b/ui-core/src/main/res/values/styles.xml @@ -23,7 +23,7 @@ @color/text_color_primary @color/text_color_primary - @color/text_color_primary + @color/text_color_secondary @color/text_color_primary @color/textColorLink @style/AdyenCheckout.BottomSheetDialogTheme @@ -387,4 +387,14 @@ @string/checkout_address_lookup_enter_manually center_vertical + + diff --git a/ui-core/src/test/java/com/adyen/checkout/ui/core/internal/util/CountryUtilsTest.kt b/ui-core/src/test/java/com/adyen/checkout/ui/core/internal/util/CountryUtilsTest.kt new file mode 100644 index 0000000000..0e1242b29a --- /dev/null +++ b/ui-core/src/test/java/com/adyen/checkout/ui/core/internal/util/CountryUtilsTest.kt @@ -0,0 +1,55 @@ +package com.adyen.checkout.ui.core.internal.util + +import com.adyen.checkout.ui.core.internal.ui.model.CountryModel +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Locale +import com.adyen.checkout.components.core.internal.util.CountryUtils as CoreCountryUtils + +internal class CountryUtilsTest { + + @Test + fun `when passing nothing, then all countries are returned`() { + val actual = CountryUtils.getLocalizedCountries(Locale.US) + + val expected = CoreCountryUtils.getCountries().map { + CountryModel(it.isoCode, CoreCountryUtils.getCountryName(it.isoCode, Locale.US), it.callingCode) + }.sortedBy { it.countryName } + assertEquals(expected, actual) + } + + @Test + fun `when passing list of countries, then only specified countries are returned`() { + val specifiedCountries = listOf( + "NL", + "US", + "DE", + ) + val actual = CountryUtils.getLocalizedCountries(Locale.US, specifiedCountries) + + val expected = listOf( + CountryModel("DE", "Germany", "+49"), + CountryModel("NL", "Netherlands", "+31"), + CountryModel("US", "United States", "+1"), + ) + assertEquals(expected, actual) + } + + @Test + fun `when passing sorting, then result is sorted correctly`() { + val specifiedCountries = listOf( + "NL", + "US", + "DE", + ) + val actual = + CountryUtils.getLocalizedCountries(Locale.US, specifiedCountries, compareByDescending { it.isoCode }) + + val expected = listOf( + CountryModel("US", "United States", "+1"), + CountryModel("NL", "Netherlands", "+31"), + CountryModel("DE", "Germany", "+49"), + ) + assertEquals(expected, actual) + } +} diff --git a/upi/build.gradle b/upi/build.gradle index e579e62cb1..84c76085aa 100644 --- a/upi/build.gradle +++ b/upi/build.gradle @@ -46,6 +46,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.junit5 testImplementation testLibraries.kotlinCoroutines testImplementation testLibraries.mockito diff --git a/upi/src/main/java/com/adyen/checkout/upi/internal/provider/UPIComponentProvider.kt b/upi/src/main/java/com/adyen/checkout/upi/internal/provider/UPIComponentProvider.kt index 5dba69e692..6f9acdc20b 100644 --- a/upi/src/main/java/com/adyen/checkout/upi/internal/provider/UPIComponentProvider.kt +++ b/upi/src/main/java/com/adyen/checkout/upi/internal/provider/UPIComponentProvider.kt @@ -22,11 +22,9 @@ import com.adyen.checkout.components.core.Order import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.internal.DefaultComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentObserverRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsMapper -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepository -import com.adyen.checkout.components.core.internal.data.api.AnalyticsRepositoryData -import com.adyen.checkout.components.core.internal.data.api.AnalyticsService -import com.adyen.checkout.components.core.internal.data.api.DefaultAnalyticsRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManagerFactory +import com.adyen.checkout.components.core.internal.analytics.AnalyticsSource import com.adyen.checkout.components.core.internal.provider.PaymentComponentProvider import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper @@ -57,7 +55,7 @@ class UPIComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( private val dropInOverrideParams: DropInOverrideParams? = null, - private val analyticsRepository: AnalyticsRepository? = null, + private val analyticsManager: AnalyticsManager? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : PaymentComponentProvider>, @@ -90,32 +88,28 @@ constructor( componentConfiguration = checkoutConfiguration.getUPIConfiguration(), ) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = null, ) val upiDelegate = DefaultUPIDelegate( submitHandler = SubmitHandler(savedStateHandle), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = order, componentParams = componentParams, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) UPIComponent( upiDelegate = upiDelegate, @@ -182,33 +176,28 @@ constructor( val httpClient = HttpClientFactory.getHttpClient(componentParams.environment) - val analyticsRepository = analyticsRepository ?: DefaultAnalyticsRepository( - analyticsRepositoryData = AnalyticsRepositoryData( - application = application, - componentParams = componentParams, - paymentMethod = paymentMethod, - sessionId = checkoutSession.sessionSetupResponse.id, - ), - analyticsService = AnalyticsService( - HttpClientFactory.getAnalyticsHttpClient(componentParams.environment), - ), - analyticsMapper = AnalyticsMapper(), + val analyticsManager = analyticsManager ?: AnalyticsManagerFactory().provide( + componentParams = componentParams, + application = application, + source = AnalyticsSource.PaymentComponent(paymentMethod.type.orEmpty()), + sessionId = checkoutSession.sessionSetupResponse.id, ) val upiDelegate = DefaultUPIDelegate( submitHandler = SubmitHandler(savedStateHandle), - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, observerRepository = PaymentObserverRepository(), paymentMethod = paymentMethod, order = checkoutSession.order, componentParams = componentParams, ) - val genericActionDelegate = GenericActionComponentProvider(dropInOverrideParams).getDelegate( - checkoutConfiguration = checkoutConfiguration, - savedStateHandle = savedStateHandle, - application = application, - ) + val genericActionDelegate = + GenericActionComponentProvider(analyticsManager, dropInOverrideParams).getDelegate( + checkoutConfiguration = checkoutConfiguration, + savedStateHandle = savedStateHandle, + application = application, + ) val sessionSavedStateHandleContainer = SessionSavedStateHandleContainer( savedStateHandle = savedStateHandle, diff --git a/upi/src/main/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegate.kt b/upi/src/main/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegate.kt index 5aa87e6805..1deb1b7ed7 100644 --- a/upi/src/main/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegate.kt +++ b/upi/src/main/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegate.kt @@ -16,7 +16,8 @@ import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes import com.adyen.checkout.components.core.internal.PaymentComponentEvent 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.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParams import com.adyen.checkout.components.core.paymentmethod.UPIPaymentMethod import com.adyen.checkout.core.AdyenLogLevel @@ -33,12 +34,11 @@ import com.adyen.checkout.upi.internal.ui.model.UPIOutputData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch @Suppress("TooManyFunctions") internal class DefaultUPIDelegate( private val submitHandler: SubmitHandler, - private val analyticsRepository: AnalyticsRepository, + private val analyticsManager: AnalyticsManager, private val observerRepository: PaymentObserverRepository, private val paymentMethod: PaymentMethod, private val order: OrderRequest?, @@ -66,14 +66,15 @@ internal class DefaultUPIDelegate( override fun initialize(coroutineScope: CoroutineScope) { submitHandler.initialize(coroutineScope, componentStateFlow) - setupAnalytics(coroutineScope) + initializeAnalytics(coroutineScope) } - private fun setupAnalytics(coroutineScope: CoroutineScope) { - adyenLog(AdyenLogLevel.VERBOSE) { "setupAnalytics" } - coroutineScope.launch { - analyticsRepository.setupAnalytics() - } + private fun initializeAnalytics(coroutineScope: CoroutineScope) { + adyenLog(AdyenLogLevel.VERBOSE) { "initializeAnalytics" } + analyticsManager.initialize(this, coroutineScope) + + val event = GenericEvents.rendered(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) } override fun observe( @@ -129,7 +130,7 @@ internal class DefaultUPIDelegate( ): UPIComponentState { val paymentMethod = UPIPaymentMethod( type = if (outputData.mode == UPIMode.VPA) PaymentMethodTypes.UPI_COLLECT else PaymentMethodTypes.UPI_QR, - checkoutAttemptId = analyticsRepository.getCheckoutAttemptId(), + checkoutAttemptId = analyticsManager.getCheckoutAttemptId(), virtualPaymentAddress = if (outputData.mode == UPIMode.VPA) { outputData.virtualPaymentAddressFieldState.value } else { @@ -159,6 +160,9 @@ internal class DefaultUPIDelegate( } override fun onSubmit() { + val event = GenericEvents.submit(paymentMethod.type.orEmpty()) + analyticsManager.trackEvent(event) + submitHandler.onSubmit(_componentStateFlow.value) } @@ -172,5 +176,6 @@ internal class DefaultUPIDelegate( override fun onCleared() { removeObserver() + analyticsManager.clear(this) } } diff --git a/upi/src/main/java/com/adyen/checkout/upi/internal/ui/view/UPIView.kt b/upi/src/main/java/com/adyen/checkout/upi/internal/ui/view/UPIView.kt index 19333f985d..76a817c4ca 100644 --- a/upi/src/main/java/com/adyen/checkout/upi/internal/ui/view/UPIView.kt +++ b/upi/src/main/java/com/adyen/checkout/upi/internal/ui/view/UPIView.kt @@ -31,6 +31,7 @@ import com.adyen.checkout.upi.databinding.UpiViewBinding import com.adyen.checkout.upi.internal.ui.UPIDelegate import com.adyen.checkout.upi.internal.ui.model.UPIMode import kotlinx.coroutines.CoroutineScope +import com.adyen.checkout.ui.core.R as UICoreR internal class UPIView @JvmOverloads constructor( context: Context, @@ -48,7 +49,7 @@ internal class UPIView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() setPadding(padding, padding, padding, 0) } diff --git a/upi/src/test/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegateTest.kt b/upi/src/test/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegateTest.kt index 7a3d27144b..a84448524b 100644 --- a/upi/src/test/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegateTest.kt +++ b/upi/src/test/java/com/adyen/checkout/upi/internal/ui/DefaultUPIDelegateTest.kt @@ -16,7 +16,8 @@ import com.adyen.checkout.components.core.OrderRequest import com.adyen.checkout.components.core.PaymentMethod import com.adyen.checkout.components.core.PaymentMethodTypes 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.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.ButtonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.core.Environment @@ -47,32 +48,24 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockitoExtension::class, LoggingExtension::class) internal class DefaultUPIDelegateTest( @Mock private val submitHandler: SubmitHandler, - @Mock private val analyticsRepository: AnalyticsRepository, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultUPIDelegate @BeforeEach fun beforeEach() { + analyticsManager = TestAnalyticsManager() delegate = createUPIDelegate() } - @Test - fun `when delegate is initialized then analytics event is sent`() = runTest { - delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) - - verify(analyticsRepository).setupAnalytics() - } - @Nested @DisplayName("when input data changes and") inner class InputDataChangedTest { @@ -217,9 +210,32 @@ internal class DefaultUPIDelegateTest( @Nested inner class AnalyticsTest { + @Test + fun `when delegate is initialized then analytics manager is initialized`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + analyticsManager.assertIsInitialized() + } + + @Test + fun `when delegate is initialized, then render event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + val expectedEvent = GenericEvents.rendered(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + + @Test + fun `when onSubmit is called, then submit event is tracked`() { + delegate.onSubmit() + + val expectedEvent = GenericEvents.submit(TEST_PAYMENT_METHOD_TYPE) + analyticsManager.assertLastEventEquals(expectedEvent) + } + @Test fun `when component state is valid then PaymentMethodDetails should contain checkoutAttemptId`() = runTest { - whenever(analyticsRepository.getCheckoutAttemptId()) doReturn TEST_CHECKOUT_ATTEMPT_ID + analyticsManager.setCheckoutAttemptId(TEST_CHECKOUT_ATTEMPT_ID) delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) @@ -231,6 +247,13 @@ internal class DefaultUPIDelegateTest( assertEquals(TEST_CHECKOUT_ATTEMPT_ID, expectMostRecentItem().data.paymentMethod?.checkoutAttemptId) } } + + @Test + fun `when delegate is cleared then analytics manager is cleared`() { + delegate.onCleared() + + analyticsManager.assertIsCleared() + } } private fun createCheckoutConfiguration( @@ -249,9 +272,9 @@ internal class DefaultUPIDelegateTest( configuration: CheckoutConfiguration = createCheckoutConfiguration(), ) = DefaultUPIDelegate( submitHandler = submitHandler, - analyticsRepository = analyticsRepository, + analyticsManager = analyticsManager, observerRepository = PaymentObserverRepository(), - paymentMethod = PaymentMethod(), + paymentMethod = PaymentMethod(type = TEST_PAYMENT_METHOD_TYPE), order = order, componentParams = ButtonComponentParamsMapper(CommonComponentParamsMapper()).mapToParams( checkoutConfiguration = configuration, @@ -266,6 +289,7 @@ internal class DefaultUPIDelegateTest( private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" private val TEST_ORDER = OrderRequest("PSP", "ORDER_DATA") private const val TEST_CHECKOUT_ATTEMPT_ID = "TEST_CHECKOUT_ATTEMPT_ID" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun amountSource() = listOf( diff --git a/voucher/build.gradle b/voucher/build.gradle index f61bd7005d..d389d0960e 100644 --- a/voucher/build.gradle +++ b/voucher/build.gradle @@ -49,6 +49,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.mockito diff --git a/voucher/src/main/java/com/adyen/checkout/voucher/internal/provider/VoucherComponentProvider.kt b/voucher/src/main/java/com/adyen/checkout/voucher/internal/provider/VoucherComponentProvider.kt index bc9f857239..2ebc489af1 100644 --- a/voucher/src/main/java/com/adyen/checkout/voucher/internal/provider/VoucherComponentProvider.kt +++ b/voucher/src/main/java/com/adyen/checkout/voucher/internal/provider/VoucherComponentProvider.kt @@ -22,6 +22,7 @@ import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.VoucherAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -40,6 +41,7 @@ import com.adyen.checkout.voucher.toCheckoutConfiguration class VoucherComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( + private val analyticsManager: AnalyticsManager? = null, private val dropInOverrideParams: DropInOverrideParams? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : ActionComponentProvider { @@ -57,12 +59,14 @@ constructor( val voucherDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) VoucherComponent( delegate = voucherDelegate, - actionComponentEventHandler = DefaultActionComponentEventHandler(callback), + actionComponentEventHandler = DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, voucherFactory)[key, VoucherComponent::class.java] .also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } @@ -80,9 +84,11 @@ constructor( return DefaultVoucherDelegate( observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, componentParams = componentParams, pdfOpener = PdfOpener(), imageSaver = ImageSaver(), + analyticsManager = analyticsManager, ) } @@ -131,7 +137,7 @@ constructor( PaymentMethodTypes.ECONTEXT_ONLINE, PaymentMethodTypes.ECONTEXT_SEVEN_ELEVEN, PaymentMethodTypes.ECONTEXT_STORES, - PaymentMethodTypes.MULTIBANCO + PaymentMethodTypes.MULTIBANCO, ) } } diff --git a/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegate.kt b/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegate.kt index 05ce6555e0..656b86f2d0 100644 --- a/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegate.kt +++ b/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegate.kt @@ -11,18 +11,26 @@ package com.adyen.checkout.voucher.internal.ui import android.app.Activity import android.content.Context import android.view.View +import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.VoucherAction import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PermissionRequestData +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams import com.adyen.checkout.components.core.internal.util.DateUtils import com.adyen.checkout.components.core.internal.util.bufferedChannel +import com.adyen.checkout.core.AdyenLogLevel import com.adyen.checkout.core.PermissionHandlerCallback import com.adyen.checkout.core.exception.CheckoutException import com.adyen.checkout.core.exception.ComponentException +import com.adyen.checkout.core.internal.util.adyenLog import com.adyen.checkout.ui.core.internal.exception.PermissionRequestException import com.adyen.checkout.ui.core.internal.ui.ComponentViewType import com.adyen.checkout.ui.core.internal.util.ImageSaver @@ -40,12 +48,15 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import java.util.Calendar +@Suppress("TooManyFunctions") internal class DefaultVoucherDelegate( private val observerRepository: ActionObserverRepository, + override val savedStateHandle: SavedStateHandle, override val componentParams: GenericComponentParams, private val pdfOpener: PdfOpener, private val imageSaver: ImageSaver, -) : VoucherDelegate { + private val analyticsManager: AnalyticsManager?, +) : VoucherDelegate, SavedStateHandleContainer { private val _outputDataFlow = MutableStateFlow(createOutputData()) override val outputDataFlow: Flow = _outputDataFlow @@ -67,8 +78,19 @@ internal class DefaultVoucherDelegate( private var _coroutineScope: CoroutineScope? = null private val coroutineScope: CoroutineScope get() = requireNotNull(_coroutineScope) + private var action: VoucherAction? by SavedStateHandleProperty(ACTION_KEY) + override fun initialize(coroutineScope: CoroutineScope) { _coroutineScope = coroutineScope + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + val action: VoucherAction? = action + if (action != null) { + initState(action) + } } override fun observe( @@ -92,15 +114,25 @@ internal class DefaultVoucherDelegate( override fun handleAction(action: Action, activity: Activity) { if (action !is VoucherAction) { - exceptionChannel.trySend(ComponentException("Unsupported action")) + emitError(ComponentException("Unsupported action")) return } + this.action = action + + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + ) + analyticsManager?.trackEvent(event) + + initState(action) + } + + private fun initState(action: VoucherAction) { val config = VoucherPaymentMethodConfig.getByPaymentMethodType(action.paymentMethodType) if (config == null) { - exceptionChannel.trySend( - ComponentException("Payment method ${action.paymentMethodType} not supported for this action"), - ) + emitError(ComponentException("Payment method ${action.paymentMethodType} not supported for this action")) return } @@ -149,7 +181,7 @@ internal class DefaultVoucherDelegate( try { pdfOpener.open(context, downloadUrl) } catch (e: IllegalStateException) { - exceptionChannel.trySend(ComponentException(e.message ?: "", e.cause)) + emitError(ComponentException(e.message ?: "", e.cause)) } } @@ -183,6 +215,15 @@ internal class DefaultVoucherDelegate( permissionChannel.trySend(requestData) } + private fun emitError(e: CheckoutException) { + exceptionChannel.trySend(e) + clearState() + } + + private fun clearState() { + action = null + } + override fun onCleared() { removeObserver() _coroutineScope = null @@ -190,5 +231,8 @@ internal class DefaultVoucherDelegate( companion object { private const val IMAGE_NAME_FORMAT = "%s-%s.png" + + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" } } diff --git a/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/FullVoucherView.kt b/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/FullVoucherView.kt index 97e452132e..5999f0a762 100644 --- a/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/FullVoucherView.kt +++ b/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/FullVoucherView.kt @@ -8,15 +8,12 @@ package com.adyen.checkout.voucher.internal.ui.view -import android.content.ActivityNotFoundException import android.content.Context import android.net.Uri import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import androidx.annotation.StringRes -import androidx.browser.customtabs.CustomTabColorSchemeParams -import androidx.browser.customtabs.CustomTabsIntent import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible @@ -31,7 +28,7 @@ import com.adyen.checkout.core.internal.util.adyenLog import com.adyen.checkout.ui.core.internal.ui.ComponentView import com.adyen.checkout.ui.core.internal.ui.LogoSize import com.adyen.checkout.ui.core.internal.ui.loadLogo -import com.adyen.checkout.ui.core.internal.util.ThemeUtil +import com.adyen.checkout.ui.core.internal.util.CustomTabsLauncher import com.adyen.checkout.ui.core.internal.util.formatFullStringWithHyperLink import com.adyen.checkout.ui.core.internal.util.setLocalizedTextFromStyle import com.adyen.checkout.voucher.R @@ -47,6 +44,7 @@ import com.adyen.checkout.voucher.internal.ui.model.VoucherUIEvent.Success import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR @Suppress("TooManyFunctions") internal class FullVoucherView @JvmOverloads constructor( @@ -70,7 +68,7 @@ internal class FullVoucherView @JvmOverloads constructor( private var coroutineScope: CoroutineScope? = null init { - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() this.setPadding(padding, padding, padding, padding) } @@ -205,20 +203,11 @@ internal class FullVoucherView @JvmOverloads constructor( } private fun onReadInstructionsClicked(url: String) { - val defaultColors = CustomTabColorSchemeParams.Builder() - .setToolbarColor(ThemeUtil.getPrimaryThemeColor(context)) - .build() - - try { - CustomTabsIntent.Builder() - .setShowTitle(true) - .setDefaultColorSchemeParams(defaultColors) - .build() - .launchUrl(context, Uri.parse(url)) - + val isLaunched = CustomTabsLauncher.launchCustomTab(context, Uri.parse(url)) + if (isLaunched) { adyenLog(AdyenLogLevel.DEBUG) { "Successfully opened instructions in custom tab" } - } catch (e: ActivityNotFoundException) { - adyenLog(AdyenLogLevel.DEBUG, e) { "Couldn't open instructions in custom tab" } + } else { + adyenLog(AdyenLogLevel.ERROR) { "Couldn't open instructions in custom tab" } } } diff --git a/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/SimpleVoucherView.kt b/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/SimpleVoucherView.kt index 5060ad712e..42f0c697e2 100644 --- a/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/SimpleVoucherView.kt +++ b/voucher/src/main/java/com/adyen/checkout/voucher/internal/ui/view/SimpleVoucherView.kt @@ -28,6 +28,7 @@ import com.adyen.checkout.voucher.internal.ui.model.VoucherOutputData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.adyen.checkout.ui.core.R as UICoreR internal open class SimpleVoucherView @JvmOverloads constructor( context: Context, @@ -49,7 +50,7 @@ internal open class SimpleVoucherView @JvmOverloads constructor( init { orientation = VERTICAL - val padding = resources.getDimension(R.dimen.standard_margin).toInt() + val padding = resources.getDimension(UICoreR.dimen.standard_margin).toInt() this.setPadding(padding, padding, padding, padding) } diff --git a/voucher/src/test/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegateTest.kt b/voucher/src/test/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegateTest.kt index 82536a9327..2c0e5de79f 100644 --- a/voucher/src/test/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegateTest.kt +++ b/voucher/src/test/java/com/adyen/checkout/voucher/internal/ui/DefaultVoucherDelegateTest.kt @@ -12,6 +12,7 @@ import android.app.Activity import android.content.Context import android.os.Parcel import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.adyen.checkout.components.core.Amount import com.adyen.checkout.components.core.CheckoutConfiguration @@ -20,11 +21,14 @@ import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.VoucherAction import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper import com.adyen.checkout.core.Environment import com.adyen.checkout.core.PermissionHandlerCallback import com.adyen.checkout.core.exception.ComponentException +import com.adyen.checkout.test.extensions.test import com.adyen.checkout.ui.core.internal.exception.PermissionRequestException import com.adyen.checkout.ui.core.internal.ui.ComponentViewType import com.adyen.checkout.ui.core.internal.util.ImageSaver @@ -37,7 +41,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest @@ -63,20 +70,13 @@ internal class DefaultVoucherDelegateTest( @Mock private val imageSaver: ImageSaver, ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var delegate: DefaultVoucherDelegate @BeforeEach fun beforeEach() { - val configuration = CheckoutConfiguration(Environment.TEST, TEST_CLIENT_KEY) { - voucher() - } - delegate = DefaultVoucherDelegate( - observerRepository, - GenericComponentParamsMapper(CommonComponentParamsMapper()) - .mapToParams(configuration, Locale.US, null, null), - pdfOpener, - imageSaver, - ) + analyticsManager = TestAnalyticsManager() + delegate = createDelegate() } @Test @@ -298,6 +298,37 @@ internal class DefaultVoucherDelegateTest( } } + @Test + fun `when initializing and action is set, then state is restored`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultVoucherDelegate.ACTION_KEY, + VoucherAction(paymentMethodType = PaymentMethodTypes.MULTIBANCO, paymentData = "paymentData"), + ) + } + delegate = createDelegate(savedStateHandle) + val viewFlow = delegate.viewFlow.test(testScheduler) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertNotNull(viewFlow.latestValue) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultVoucherDelegate.ACTION_KEY, + VoucherAction(paymentMethodType = "not a voucher", paymentData = "paymentData"), + ) + } + delegate = createDelegate(savedStateHandle) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertNull(savedStateHandle[DefaultVoucherDelegate.ACTION_KEY]) + } + @Test fun `when onCleared is called, observers are removed`() { delegate.onCleared() @@ -305,6 +336,45 @@ internal class DefaultVoucherDelegateTest( verify(observerRepository).removeObservers() } + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = VoucherAction( + paymentMethodType = PaymentMethodTypes.BACS, + type = TEST_ACTION_TYPE, + ) + + delegate.handleAction(action, activity) + + val expectedEvent = GenericEvents.action( + component = PaymentMethodTypes.BACS, + subType = TEST_ACTION_TYPE, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + } + + private fun createDelegate( + savedStateHandle: SavedStateHandle = SavedStateHandle() + ): DefaultVoucherDelegate { + val configuration = CheckoutConfiguration(Environment.TEST, TEST_CLIENT_KEY) { + voucher() + } + + return DefaultVoucherDelegate( + observerRepository = observerRepository, + savedStateHandle = savedStateHandle, + componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) + .mapToParams(configuration, Locale.US, null, null), + pdfOpener = pdfOpener, + imageSaver = imageSaver, + analyticsManager = analyticsManager, + ) + } + private fun createTestAction( type: String = "test", paymentData: String = "paymentData", @@ -318,6 +388,7 @@ internal class DefaultVoucherDelegateTest( companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_ACTION_TYPE = "TEST_PAYMENT_METHOD_TYPE" @JvmStatic fun viewTypeSource() = listOf( diff --git a/wechatpay/build.gradle b/wechatpay/build.gradle index a35ff51ca2..d58779c8f9 100644 --- a/wechatpay/build.gradle +++ b/wechatpay/build.gradle @@ -44,6 +44,7 @@ dependencies { //Tests testImplementation project(':test-core') + testImplementation testFixtures(project(':components-core')) testImplementation testLibraries.json testImplementation testLibraries.junit5 testImplementation testLibraries.mockito diff --git a/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/provider/WeChatPayActionComponentProvider.kt b/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/provider/WeChatPayActionComponentProvider.kt index a56ddfa77e..249d034cad 100644 --- a/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/provider/WeChatPayActionComponentProvider.kt +++ b/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/provider/WeChatPayActionComponentProvider.kt @@ -23,6 +23,7 @@ import com.adyen.checkout.components.core.action.SdkAction import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.DefaultActionComponentEventHandler import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager import com.adyen.checkout.components.core.internal.provider.ActionComponentProvider import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.DropInOverrideParams @@ -42,6 +43,7 @@ import com.tencent.mm.opensdk.openapi.WXAPIFactory class WeChatPayActionComponentProvider @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor( + private val analyticsManager: AnalyticsManager? = null, private val dropInOverrideParams: DropInOverrideParams? = null, private val localeProvider: LocaleProvider = LocaleProvider(), ) : ActionComponentProvider { @@ -59,13 +61,15 @@ constructor( val weChatDelegate = getDelegate(checkoutConfiguration, savedStateHandle, application) WeChatPayActionComponent( delegate = weChatDelegate, - actionComponentEventHandler = DefaultActionComponentEventHandler(callback), + actionComponentEventHandler = DefaultActionComponentEventHandler(), ) } return ViewModelProvider(viewModelStoreOwner, weChatFactory)[key, WeChatPayActionComponent::class.java] .also { component -> - component.observe(lifecycleOwner, component.actionComponentEventHandler::onActionComponentEvent) + component.observe(lifecycleOwner) { + component.actionComponentEventHandler.onActionComponentEvent(it, callback) + } } } @@ -86,10 +90,12 @@ constructor( val paymentDataRepository = PaymentDataRepository(savedStateHandle) return DefaultWeChatDelegate( observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, componentParams = componentParams, iwxApi = iwxApi, payRequestGenerator = requestGenerator, paymentDataRepository = paymentDataRepository, + analyticsManager = analyticsManager, ) } @@ -113,8 +119,7 @@ constructor( ) } - override val supportedActionTypes: List - get() = listOf(SdkAction.ACTION_TYPE) + override val supportedActionTypes: List = listOf(SdkAction.ACTION_TYPE) override fun canHandleAction(action: Action): Boolean { return supportedActionTypes.contains(action.type) && PAYMENT_METHODS.contains(action.paymentMethodType) diff --git a/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegate.kt b/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegate.kt index 8cfabdc65e..6353ab54da 100644 --- a/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegate.kt +++ b/wechatpay/src/main/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegate.kt @@ -12,6 +12,7 @@ import android.app.Activity import android.content.Intent import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import com.adyen.checkout.components.core.ActionComponentData import com.adyen.checkout.components.core.action.Action import com.adyen.checkout.components.core.action.SdkAction @@ -19,6 +20,10 @@ import com.adyen.checkout.components.core.action.WeChatPaySdkData import com.adyen.checkout.components.core.internal.ActionComponentEvent import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.SavedStateHandleContainer +import com.adyen.checkout.components.core.internal.SavedStateHandleProperty +import com.adyen.checkout.components.core.internal.analytics.AnalyticsManager +import com.adyen.checkout.components.core.internal.analytics.GenericEvents import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParams import com.adyen.checkout.components.core.internal.util.bufferedChannel import com.adyen.checkout.core.AdyenLogLevel @@ -40,13 +45,17 @@ import org.json.JSONException import org.json.JSONObject @Suppress("TooManyFunctions") -internal class DefaultWeChatDelegate( +internal class DefaultWeChatDelegate +@Suppress("LongParameterList") +constructor( private val observerRepository: ActionObserverRepository, + override val savedStateHandle: SavedStateHandle, override val componentParams: GenericComponentParams, private val iwxApi: IWXAPI, private val payRequestGenerator: WeChatRequestGenerator<*>, private val paymentDataRepository: PaymentDataRepository, -) : WeChatDelegate { + private val analyticsManager: AnalyticsManager?, +) : WeChatDelegate, SavedStateHandleContainer { private val detailsChannel: Channel = bufferedChannel() override val detailsFlow: Flow = detailsChannel.receiveAsFlow() @@ -56,8 +65,15 @@ internal class DefaultWeChatDelegate( override val viewFlow: Flow = MutableStateFlow(WeChatComponentViewType) + private var action: SdkAction? by SavedStateHandleProperty(ACTION_KEY) + override fun initialize(coroutineScope: CoroutineScope) { - // no ops + restoreState() + } + + private fun restoreState() { + adyenLog(AdyenLogLevel.DEBUG) { "Restoring state" } + action?.let { initState(it) } } override fun observe( @@ -90,7 +106,7 @@ internal class DefaultWeChatDelegate( @VisibleForTesting internal fun onResponse(baseResponse: BaseResp) { parseResult(baseResponse)?.let { response -> - detailsChannel.trySend(createActionComponentData(response)) + emitDetails(response) } } @@ -99,7 +115,7 @@ internal class DefaultWeChatDelegate( try { result.put(RESULT_CODE, baseResp.errCode) } catch (e: JSONException) { - exceptionChannel.trySend(CheckoutException("Error parsing result.", e)) + emitError(CheckoutException("Error parsing result.", e)) return null } return result @@ -111,35 +127,49 @@ internal class DefaultWeChatDelegate( @SuppressWarnings("ReturnCount") override fun handleAction(action: Action, activity: Activity) { - @Suppress("UNCHECKED_CAST") - val sdkAction = (action as? SdkAction) + val sdkAction = (action as? SdkAction<*>) if (sdkAction == null) { - exceptionChannel.trySend(ComponentException("Unsupported action")) + emitError(ComponentException("Unsupported action")) return } - val activityName = activity.javaClass.name - adyenLog(AdyenLogLevel.DEBUG) { "handleAction: activity - $activityName" } + val sdkData = action.sdkData + if (sdkData == null || sdkData !is WeChatPaySdkData) { + emitError(ComponentException("SDK Data is null")) + return + } + + @Suppress("UNCHECKED_CAST") + this.action = action as SdkAction + + val event = GenericEvents.action( + component = action.paymentMethodType.orEmpty(), + subType = action.type.orEmpty(), + ) + analyticsManager?.trackEvent(event) + + initState(action) + launchAction(sdkData, activity) + } + private fun initState(action: SdkAction) { val paymentData = action.paymentData paymentDataRepository.paymentData = paymentData if (paymentData == null) { adyenLog(AdyenLogLevel.ERROR) { "Payment data is null" } - exceptionChannel.trySend(ComponentException("Payment data is null")) - return + emitError(ComponentException("Payment data is null")) } + } - val sdkData = action.sdkData - if (sdkData == null) { - exceptionChannel.trySend(ComponentException("SDK Data is null")) - return - } + private fun launchAction(sdkData: WeChatPaySdkData, activity: Activity) { + val activityName = activity.javaClass.name + + adyenLog(AdyenLogLevel.DEBUG) { "handleAction: activity - $activityName" } val isWeChatNotInitiated = !initiateWeChatPayRedirect(sdkData, activityName) if (isWeChatNotInitiated) { - exceptionChannel.trySend(ComponentException("Failed to initialize WeChat app")) - return + emitError(ComponentException("Failed to initialize WeChat app")) } } @@ -158,7 +188,21 @@ internal class DefaultWeChatDelegate( } override fun onError(e: CheckoutException) { + emitError(e) + } + + private fun emitError(e: CheckoutException) { exceptionChannel.trySend(e) + clearState() + } + + private fun emitDetails(details: JSONObject) { + detailsChannel.trySend(createActionComponentData(details)) + clearState() + } + + private fun clearState() { + action = null } override fun onCleared() { @@ -167,5 +211,8 @@ internal class DefaultWeChatDelegate( companion object { private const val RESULT_CODE = "resultCode" + + @VisibleForTesting + internal const val ACTION_KEY = "ACTION_KEY" } } diff --git a/wechatpay/src/test/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegateTest.kt b/wechatpay/src/test/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegateTest.kt index 376b51cc79..c717529aa5 100644 --- a/wechatpay/src/test/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegateTest.kt +++ b/wechatpay/src/test/java/com/adyen/checkout/wechatpay/internal/ui/DefaultWeChatDelegateTest.kt @@ -17,6 +17,8 @@ import com.adyen.checkout.components.core.action.SdkAction import com.adyen.checkout.components.core.action.WeChatPaySdkData import com.adyen.checkout.components.core.internal.ActionObserverRepository import com.adyen.checkout.components.core.internal.PaymentDataRepository +import com.adyen.checkout.components.core.internal.analytics.GenericEvents +import com.adyen.checkout.components.core.internal.analytics.TestAnalyticsManager import com.adyen.checkout.components.core.internal.ui.model.CommonComponentParamsMapper import com.adyen.checkout.components.core.internal.ui.model.GenericComponentParamsMapper import com.adyen.checkout.core.Environment @@ -25,12 +27,16 @@ import com.adyen.checkout.wechatpay.internal.util.WeChatRequestGenerator import com.adyen.checkout.wechatpay.weChatPayAction import com.tencent.mm.opensdk.modelpay.PayResp import com.tencent.mm.opensdk.openapi.IWXAPI +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock @@ -49,26 +55,15 @@ internal class DefaultWeChatDelegateTest( @Mock private val weChatRequestGenerator: WeChatRequestGenerator<*> ) { + private lateinit var analyticsManager: TestAnalyticsManager private lateinit var paymentDataRepository: PaymentDataRepository private lateinit var delegate: DefaultWeChatDelegate @BeforeEach fun beforeEach() { - val configuration = CheckoutConfiguration( - Environment.TEST, - TEST_CLIENT_KEY, - ) { - weChatPayAction() - } + analyticsManager = TestAnalyticsManager() paymentDataRepository = PaymentDataRepository(SavedStateHandle()) - delegate = DefaultWeChatDelegate( - observerRepository = ActionObserverRepository(), - componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) - .mapToParams(configuration, Locale.US, null, null), - iwxApi = iwxApi, - payRequestGenerator = weChatRequestGenerator, - paymentDataRepository = paymentDataRepository, - ) + delegate = createDelegate() } @Test @@ -88,7 +83,7 @@ internal class DefaultWeChatDelegateTest( val action = SdkAction( sdkData = WeChatPaySdkData(), - paymentData = "paymentData", + paymentData = TEST_PAYMENT_DATA, ) delegate.detailsFlow.test { @@ -101,7 +96,7 @@ internal class DefaultWeChatDelegateTest( with(awaitItem()) { assertEquals(expected.toString(), details.toString()) - assertEquals("paymentData", paymentData) + assertEquals(TEST_PAYMENT_DATA, paymentData) } cancelAndIgnoreRemainingEvents() @@ -156,7 +151,100 @@ internal class DefaultWeChatDelegateTest( } } + @Nested + inner class AnalyticsTest { + + @Test + fun `when handleAction is called, then action event is tracked`() { + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + val action = SdkAction( + paymentMethodType = TEST_PAYMENT_METHOD_TYPE, + type = TEST_ACTION_TYPE, + paymentData = TEST_PAYMENT_DATA, + sdkData = WeChatPaySdkData(), + ) + + delegate.handleAction(action, Activity()) + + val expectedEvent = GenericEvents.action( + component = TEST_PAYMENT_METHOD_TYPE, + subType = TEST_ACTION_TYPE, + ) + analyticsManager.assertLastEventEquals(expectedEvent) + } + } + + @Test + fun `when initializing and action is set, then state is restored`() = runTest { + val savedStateHandle = SavedStateHandle().apply { + set( + DefaultWeChatDelegate.ACTION_KEY, + SdkAction(paymentMethodType = "test", paymentData = "paymentData", sdkData = WeChatPaySdkData()), + ) + } + delegate = createDelegate(savedStateHandle) + + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + assertEquals("paymentData", paymentDataRepository.paymentData) + } + + @Test + fun `when details are emitted, then state is cleared`() = runTest { + whenever(iwxApi.sendReq(anyOrNull())) doReturn true + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + delegate.handleAction( + SdkAction(paymentMethodType = "test", paymentData = "paymentData", sdkData = WeChatPaySdkData()), + Activity(), + ) + + delegate.onResponse(PayResp().apply { errCode = 1 }) + + assertNull(savedStateHandle[DefaultWeChatDelegate.ACTION_KEY]) + } + + @Test + fun `when an error is emitted, then state is cleared`() = runTest { + val savedStateHandle = SavedStateHandle() + delegate = createDelegate(savedStateHandle) + delegate.initialize(CoroutineScope(UnconfinedTestDispatcher())) + + delegate.handleAction( + SdkAction(paymentMethodType = "test", paymentData = null, sdkData = WeChatPaySdkData()), + Activity(), + ) + + assertNull(savedStateHandle[DefaultWeChatDelegate.ACTION_KEY]) + } + + private fun createDelegate( + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): DefaultWeChatDelegate { + val configuration = CheckoutConfiguration( + Environment.TEST, + TEST_CLIENT_KEY, + ) { + weChatPayAction() + } + + return DefaultWeChatDelegate( + observerRepository = ActionObserverRepository(), + savedStateHandle = savedStateHandle, + componentParams = GenericComponentParamsMapper(CommonComponentParamsMapper()) + .mapToParams(configuration, Locale.US, null, null), + iwxApi = iwxApi, + payRequestGenerator = weChatRequestGenerator, + paymentDataRepository = paymentDataRepository, + analyticsManager = analyticsManager, + ) + } + companion object { private const val TEST_CLIENT_KEY = "test_qwertyuiopasdfghjklzxcvbnmqwerty" + private const val TEST_PAYMENT_METHOD_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_ACTION_TYPE = "TEST_PAYMENT_METHOD_TYPE" + private const val TEST_PAYMENT_DATA = "TEST_PAYMENT_DATA" } }