diff --git a/README.md b/README.md index 07580bb78..a044959a3 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,7 @@ Start accepting online payments in just a few minutes with our Android SDK. It's ## Requirements -- Android minimum SDK 21 - -> Compatibility verified with `targetSdk` versions 21 to 34 +- Android minimum SDK 23 diff --git a/buildSrc/src/main/java/ProjectConfig.kt b/buildSrc/src/main/java/ProjectConfig.kt index b3463b2c4..44be6c896 100644 --- a/buildSrc/src/main/java/ProjectConfig.kt +++ b/buildSrc/src/main/java/ProjectConfig.kt @@ -2,7 +2,7 @@ object AndroidConfig { const val compileSdk = 34 - const val minSdk = 21 + const val minSdk = 23 const val targetSdk = 34 const val androidTestInstrumentation = "androidx.test.runner.AndroidJUnitRunner" diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt index 62eb2cc51..5e924f019 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -33,7 +33,7 @@ object Versions { const val moshi = "1.15.1" // Risk SDK Dependencies - const val riskSdk = "2.1.0" + const val riskSdk = "3.0.1" // Unit Testing Dependencies const val junit5Jupiter = "5.13.4" diff --git a/checkout/consumer-rules.pro b/checkout/consumer-rules.pro index 77097b6b3..2476b3b3a 100644 --- a/checkout/consumer-rules.pro +++ b/checkout/consumer-rules.pro @@ -1 +1,8 @@ --keep class com.checkout.** { *; } \ No newline at end of file +-keep class com.checkout.** { *; } + +# Risk SDK leaves Fingerprint's optional device-location collection disabled. +-dontwarn com.google.android.gms.location.** +-dontwarn com.google.android.gms.tasks.CancellationToken +-dontwarn com.google.android.gms.tasks.OnFailureListener +-dontwarn com.google.android.gms.tasks.OnSuccessListener +-dontwarn com.google.android.gms.tasks.Task \ No newline at end of file diff --git a/checkout/src/main/java/com/checkout/BaseUrlPrefixValidator.kt b/checkout/src/main/java/com/checkout/BaseUrlPrefixValidator.kt new file mode 100644 index 000000000..a084ab761 --- /dev/null +++ b/checkout/src/main/java/com/checkout/BaseUrlPrefixValidator.kt @@ -0,0 +1,8 @@ +package com.checkout + +internal object BaseUrlPrefixValidator { + fun validPrefixOrNull(baseUrlPrefix: String?): String? = + baseUrlPrefix?.takeIf { LABEL_REGEX.matches(it) } + + private val LABEL_REGEX = Regex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$") +} diff --git a/checkout/src/main/java/com/checkout/CheckoutApiServiceFactory.kt b/checkout/src/main/java/com/checkout/CheckoutApiServiceFactory.kt index 3f6d05054..77ea52a43 100644 --- a/checkout/src/main/java/com/checkout/CheckoutApiServiceFactory.kt +++ b/checkout/src/main/java/com/checkout/CheckoutApiServiceFactory.kt @@ -36,6 +36,16 @@ public object CheckoutApiServiceFactory { private lateinit var correlationId: String private lateinit var riskSDKFramesOptions: FramesOptions + /** + * Creates [CheckoutApiService]. + * + * @param publicKey the public API key used for client-side authentication. + * @param environment the environment for tokenization. + * @param context the Android context. + * @param baseUrlPrefix an optional merchant-specific subdomain label (e.g., "mssd"), not a URL or hostname. + * Must contain 1 to 63 ASCII letters, digits, or hyphens, starting and ending with a letter or digit. + * Applied to both tokenization and Risk requests. Null, empty, or invalid values use the default hosts. + */ @JvmStatic public fun create( publicKey: String, @@ -43,6 +53,7 @@ public object CheckoutApiServiceFactory { context: Context, baseUrlPrefix: String? = null, ): CheckoutApiService { + val validatedBaseUrlPrefix = BaseUrlPrefixValidator.validPrefixOrNull(baseUrlPrefix) val logger = EventLoggerProvider.provide() logger.setup(context, environment) correlationId = logger.correlationId @@ -53,7 +64,7 @@ public object CheckoutApiServiceFactory { ) return CheckoutApiClient( - provideTokenRepository(context, publicKey, environment, baseUrlPrefix), + provideTokenRepository(context, publicKey, environment, validatedBaseUrlPrefix), provideThreeDSExecutor(logger), ) } @@ -86,6 +97,7 @@ public object CheckoutApiServiceFactory { publicKey, riskSDKFramesOptions, RiskInstanceProvider, + baseUrlPrefix, ), ) diff --git a/checkout/src/main/java/com/checkout/logging/utils/EnvironmentExtension.kt b/checkout/src/main/java/com/checkout/logging/utils/EnvironmentExtension.kt index b2cd994cf..04ff7cc63 100644 --- a/checkout/src/main/java/com/checkout/logging/utils/EnvironmentExtension.kt +++ b/checkout/src/main/java/com/checkout/logging/utils/EnvironmentExtension.kt @@ -23,11 +23,6 @@ internal fun Environment.toLoggingName() = when (this) { } private fun String.applyPrefix(prefix: String?): String { - val validatedPrefix = prefix?.baseUrlPrefixValidator() ?: return this - return this.replace(HTTPS_PROTOCOL, "$HTTPS_PROTOCOL$validatedPrefix.") + if (prefix == null) return this + return this.replace(HTTPS_PROTOCOL, "$HTTPS_PROTOCOL$prefix.") } - -private fun String.baseUrlPrefixValidator() = this - .takeIf { prefix -> - prefix.isNotEmpty() && prefix.all { char -> char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' } - } diff --git a/checkout/src/main/java/com/checkout/threedsecure/utils/WebResourcesExtension.kt b/checkout/src/main/java/com/checkout/threedsecure/utils/WebResourcesExtension.kt index cbe041865..235c6cbc4 100644 --- a/checkout/src/main/java/com/checkout/threedsecure/utils/WebResourcesExtension.kt +++ b/checkout/src/main/java/com/checkout/threedsecure/utils/WebResourcesExtension.kt @@ -1,14 +1,10 @@ package com.checkout.threedsecure.utils -import android.os.Build import android.webkit.WebResourceError import android.webkit.WebResourceResponse import com.checkout.threedsecure.error.ThreeDSError internal fun WebResourceResponse.toThreeDSError() = ThreeDSError(this.statusCode.toString(), this.reasonPhrase) -internal fun WebResourceError.toThreeDSError() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { +internal fun WebResourceError.toThreeDSError() = ThreeDSError(this.errorCode.toString(), this.description.toString()) -} else { - null -} diff --git a/checkout/src/main/java/com/checkout/tokenization/usecase/RiskInstanceProvider.kt b/checkout/src/main/java/com/checkout/tokenization/usecase/RiskInstanceProvider.kt index 42aa0d5b1..65f0a65cf 100644 --- a/checkout/src/main/java/com/checkout/tokenization/usecase/RiskInstanceProvider.kt +++ b/checkout/src/main/java/com/checkout/tokenization/usecase/RiskInstanceProvider.kt @@ -15,6 +15,7 @@ internal object RiskInstanceProvider { publicKey: String, environment: Environment, framesOptions: FramesOptions, + baseUrlPrefix: String?, ): Risk? { if (riskInstance != null) { return riskInstance @@ -33,6 +34,7 @@ internal object RiskInstanceProvider { publicKey = publicKey, environment = riskEnvironment, framesOptions = framesOptions, + mssd = baseUrlPrefix, ), ) return riskInstance diff --git a/checkout/src/main/java/com/checkout/tokenization/usecase/RiskSdkUseCase.kt b/checkout/src/main/java/com/checkout/tokenization/usecase/RiskSdkUseCase.kt index ccf0dac02..6d803ad94 100644 --- a/checkout/src/main/java/com/checkout/tokenization/usecase/RiskSdkUseCase.kt +++ b/checkout/src/main/java/com/checkout/tokenization/usecase/RiskSdkUseCase.kt @@ -11,9 +11,10 @@ internal class RiskSdkUseCase( private val publicKey: String, private val framesOptions: FramesOptions, private val riskInstanceProvider: RiskInstanceProvider, + private val baseUrlPrefix: String? = null, ) { suspend fun execute(data: TokenResult) { - val riskInstance = riskInstanceProvider.provide(context, publicKey, environment, framesOptions) + val riskInstance = riskInstanceProvider.provide(context, publicKey, environment, framesOptions, baseUrlPrefix) when (data) { is TokenResult.Success -> riskInstance?.publishData(cardToken = data.result) is TokenResult.Failure -> {} diff --git a/checkout/src/test/java/com/checkout/CheckoutApiServiceFactoryTest.kt b/checkout/src/test/java/com/checkout/CheckoutApiServiceFactoryTest.kt index 1db5f878b..17eabb093 100644 --- a/checkout/src/test/java/com/checkout/CheckoutApiServiceFactoryTest.kt +++ b/checkout/src/test/java/com/checkout/CheckoutApiServiceFactoryTest.kt @@ -5,12 +5,17 @@ import com.checkout.base.model.Environment import com.checkout.logging.EventLoggerProvider import com.checkout.logging.Logger import com.checkout.logging.model.LoggingEvent +import com.checkout.logging.utils.toBaseUrl import io.mockk.every import io.mockk.impl.annotations.RelaxedMockK import io.mockk.junit5.MockKExtension import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic import io.mockk.verify +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -24,9 +29,33 @@ internal class CheckoutApiServiceFactoryTest { @BeforeEach fun setUp() { mockkObject(EventLoggerProvider) + mockkStatic(Environment::toBaseUrl) every { EventLoggerProvider.provide() } returns mockLogger } + @AfterEach + fun tearDown() { + unmockkObject(EventLoggerProvider) + unmockkStatic(Environment::toBaseUrl) + } + + @Test + fun `valid base URL prefixes are preserved during client creation`() { + listOf("a", "0", "Ab", "merchant-123", "a--b", "A" + "-".repeat(61) + "9").forEach { prefix -> + CheckoutApiServiceFactory.create("", Environment.SANDBOX, mockk(), prefix) + verify(exactly = 1) { Environment.SANDBOX.toBaseUrl(prefix) } + } + } + + @Test + fun `null empty and invalid base URL prefixes are normalized to null during client creation`() { + val prefixes = listOf(null, "", " ", "-", "-merchant", "merchant-", "a.b", "a_b", "é", "a\n", "a".repeat(64)) + prefixes.forEach { prefix -> + CheckoutApiServiceFactory.create("", Environment.SANDBOX, mockk(), prefix) + } + verify(exactly = prefixes.size) { Environment.SANDBOX.toBaseUrl(null) } + } + @Test fun `when checkout API service is created then logger is set up`() { // Given diff --git a/checkout/src/test/java/com/checkout/EnvironmentExtensionTest.kt b/checkout/src/test/java/com/checkout/EnvironmentExtensionTest.kt index 1c78f4625..b0f6ca544 100644 --- a/checkout/src/test/java/com/checkout/EnvironmentExtensionTest.kt +++ b/checkout/src/test/java/com/checkout/EnvironmentExtensionTest.kt @@ -2,7 +2,6 @@ package com.checkout import com.checkout.base.model.Environment import com.checkout.base.util.EnvironmentConstants -import com.checkout.base.util.EnvironmentConstants.PRODUCTION_SERVER_URL import com.checkout.logging.utils.toBaseUrl import org.junit.Assert.assertEquals import org.junit.Test @@ -33,6 +32,14 @@ internal class EnvironmentExtensionTest { assertEquals("https://mySubdomain123.api.checkout.com/tokens", result) } + @Test + fun `valid subdomain labels route both environments to prefixed hosts`() { + listOf("a", "0", "Ab", "merchant-123", "a--b", "A" + "-".repeat(61) + "9").forEach { prefix -> + assertEquals("https://$prefix.api.checkout.com/tokens", Environment.PRODUCTION.toBaseUrl(prefix)) + assertEquals("https://$prefix.api.sandbox.checkout.com/tokens", Environment.SANDBOX.toBaseUrl(prefix)) + } + } + @Test fun `when toBaseUrl SANDBOX with null subDomainPrefix returns sandbox server URL`() { val result = Environment.SANDBOX.toBaseUrl(null) @@ -56,38 +63,4 @@ internal class EnvironmentExtensionTest { val result = Environment.SANDBOX.toBaseUrl("test99") assertEquals("https://test99.api.sandbox.checkout.com/tokens", result) } - - @Test - fun `when toBaseUrl with empty string subDomainPrefix uses default URL for production`() { - val result = Environment.PRODUCTION.toBaseUrl("") - assertEquals("https://api.checkout.com/tokens", result) - } - - @Test - fun `toBaseUrl PRODUCTION returns default URL for any non-alphanumeric prefix`() { - val invalidPrefixList = listOf( - "invalid_prefix", - "invalid-prefix", - "invalid.prefix", - "invalid@prefix", - "invalid!prefix", - "invalid#prefix", - "invalid\$prefix", - "invalid%prefix", - "invalid^prefix", - "invalid&prefix", - "invalid*prefix", - "invalid(prefix", - "invalid)prefix", - "invalid prefix", - ) - - invalidPrefixList.forEach { invalidPrefix -> - val result = Environment.PRODUCTION.toBaseUrl(invalidPrefix) - assertEquals( - PRODUCTION_SERVER_URL, - result, - ) - } - } } diff --git a/checkout/src/test/java/com/checkout/threedsecure/webview/ThreeDSWebViewClientTest.kt b/checkout/src/test/java/com/checkout/threedsecure/webview/ThreeDSWebViewClientTest.kt index 09d9125a6..9568f8ac0 100644 --- a/checkout/src/test/java/com/checkout/threedsecure/webview/ThreeDSWebViewClientTest.kt +++ b/checkout/src/test/java/com/checkout/threedsecure/webview/ThreeDSWebViewClientTest.kt @@ -1,8 +1,6 @@ package com.checkout.threedsecure.webview -import android.os.Build import android.webkit.WebViewClient -import androidx.test.filters.SdkSuppress import com.checkout.threedsecure.logging.ThreeDSLogger import io.mockk.MockKAnnotations import io.mockk.impl.annotations.RelaxedMockK @@ -38,7 +36,6 @@ internal class ThreeDSWebViewClientTest { verify(exactly = 1) { logger.logPresentedEvent() } } - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) @Test fun `when page committed visible for the first time then log loaded event`() { // When diff --git a/checkout/src/test/java/com/checkout/tokenization/RiskInstanceProviderTest.kt b/checkout/src/test/java/com/checkout/tokenization/RiskInstanceProviderTest.kt new file mode 100644 index 000000000..ad0ed3f42 --- /dev/null +++ b/checkout/src/test/java/com/checkout/tokenization/RiskInstanceProviderTest.kt @@ -0,0 +1,61 @@ +package com.checkout.tokenization + +import android.content.Context +import com.checkout.base.model.Environment +import com.checkout.risk.FramesOptions +import com.checkout.risk.Risk +import com.checkout.risk.RiskConfig +import com.checkout.risk.RiskEnvironment +import com.checkout.tokenization.usecase.RiskInstanceProvider +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class RiskInstanceProviderTest { + private val context: Context = mockk() + private val framesOptions = FramesOptions("1.0.0", "productIdentifier", "correlationId") + private val riskConfigs = mutableListOf() + + @BeforeEach + fun setup() { + mockkObject(Risk.Companion) + coEvery { Risk.getInstance(context, capture(riskConfigs)) } returns null + } + + @AfterEach + fun tearDown() { + unmockkObject(Risk.Companion) + } + + @Test + fun `valid subdomain labels are forwarded to Risk in both environments`() = runTest { + listOf("a", "0", "Ab", "merchant-123", "a--b", "A" + "-".repeat(61) + "9").forEach { prefix -> + provideInBothEnvironments(prefix) + assertEquals(prefix, riskConfigs[riskConfigs.lastIndex - 1].mssd) + assertEquals(prefix, riskConfigs.last().mssd) + } + } + + @Test + fun `null prefix is forwarded to Risk in both environments`() = runTest { + provideInBothEnvironments(null) + assertEquals(null, riskConfigs[riskConfigs.lastIndex - 1].mssd) + assertEquals(null, riskConfigs.last().mssd) + } + + private suspend fun provideInBothEnvironments(prefix: String?) { + listOf(Environment.SANDBOX, Environment.PRODUCTION).forEach { environment -> + RiskInstanceProvider.provide(context, "publicKey", environment, framesOptions, prefix) + } + assertEquals(RiskEnvironment.SANDBOX, riskConfigs[riskConfigs.lastIndex - 1].environment) + assertEquals(RiskEnvironment.PRODUCTION, riskConfigs.last().environment) + assertEquals("publicKey", riskConfigs.last().publicKey) + assertEquals(framesOptions, riskConfigs.last().framesOptions) + } +} diff --git a/checkout/src/test/java/com/checkout/tokenization/RiskSdkUseCaseTest.kt b/checkout/src/test/java/com/checkout/tokenization/RiskSdkUseCaseTest.kt index a3b785504..a03d39bd5 100644 --- a/checkout/src/test/java/com/checkout/tokenization/RiskSdkUseCaseTest.kt +++ b/checkout/src/test/java/com/checkout/tokenization/RiskSdkUseCaseTest.kt @@ -32,7 +32,9 @@ public class RiskSdkUseCaseTest { @BeforeEach public fun setup() { coEvery { tokenDetails.token } returns TOKEN - coEvery { riskInstanceProvider.provide(context, PUBLIC_KEY, environment, framesOptions) } returns riskInstance + coEvery { + riskInstanceProvider.provide(context, PUBLIC_KEY, environment, framesOptions, "mssd") + } returns riskInstance coEvery { riskInstance.publishData(any()) } returns mockk() useCase = RiskSdkUseCase( @@ -41,6 +43,7 @@ public class RiskSdkUseCaseTest { publicKey = PUBLIC_KEY, riskInstanceProvider = riskInstanceProvider, framesOptions = framesOptions, + baseUrlPrefix = "mssd", ) } @@ -48,7 +51,7 @@ public class RiskSdkUseCaseTest { public fun `Success result should trigger publishData`() { runBlocking { useCase.execute(TokenResult.Success(tokenDetails.token)) - coVerify { riskInstanceProvider.provide(context, PUBLIC_KEY, environment, framesOptions) } + coVerify { riskInstanceProvider.provide(context, PUBLIC_KEY, environment, framesOptions, "mssd") } coVerify { riskInstance.publishData(TOKEN) } } } diff --git a/checkout/src/test/java/com/checkout/tokenization/repository/TokenRepositoryImplTest.kt b/checkout/src/test/java/com/checkout/tokenization/repository/TokenRepositoryImplTest.kt index b898cc060..73670bb01 100644 --- a/checkout/src/test/java/com/checkout/tokenization/repository/TokenRepositoryImplTest.kt +++ b/checkout/src/test/java/com/checkout/tokenization/repository/TokenRepositoryImplTest.kt @@ -77,18 +77,7 @@ internal class TokenRepositoryImplTest { @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) - tokenRepositoryImpl = TokenRepositoryImpl( - networkApiClient = mockTokenNetworkApiClient, - cardToTokenRequestMapper = CardToTokenRequestMapper(), - cvvToTokenNetworkRequestMapper = CVVToTokenNetworkRequestMapper(), - cardTokenizationNetworkDataMapper = CardTokenizationNetworkDataMapper(), - validateTokenizationDataUseCase = mockValidateTokenizationDataUseCase, - validateCVVTokenizationDataUseCase = mockValidateCVVTokenizationDataUseCase, - logger = mockTokenizationLogger, - publicKey = "test_key", - cvvTokenizationNetworkDataMapper = CVVTokenizationNetworkDataMapper(), - riskSdkUseCase = mockRiskSdkUseCase, - ) + tokenRepositoryImpl = createTokenRepository(testDispatcher) } @AfterEach diff --git a/frames/src/main/java/com/checkout/frames/cvvinputfield/CVVComponentApiFactory.kt b/frames/src/main/java/com/checkout/frames/cvvinputfield/CVVComponentApiFactory.kt index f56a372be..560684a9a 100644 --- a/frames/src/main/java/com/checkout/frames/cvvinputfield/CVVComponentApiFactory.kt +++ b/frames/src/main/java/com/checkout/frames/cvvinputfield/CVVComponentApiFactory.kt @@ -16,7 +16,9 @@ public object CVVComponentApiFactory { * @param publicKey - used for client-side authentication in the SDK * @param environment - [Environment] represent the environment for tokenization * @param context - represent the application context - * @param baseUrlPrefix - an optional alphanumeric prefix used to route requests to a specific regional or merchant-specific subdomain (e.g., "msdd"). Must be alphanumeric. + * @param baseUrlPrefix - an optional merchant-specific subdomain label (e.g., "mssd"). + * Must contain 1 to 63 ASCII letters, digits, or hyphens, starting and ending with a letter or digit. + * Null, empty, or invalid values are ignored and use the default hosts. */ @JvmStatic public fun create( diff --git a/frames/src/main/java/com/checkout/frames/screen/paymentform/model/PaymentFormConfig.kt b/frames/src/main/java/com/checkout/frames/screen/paymentform/model/PaymentFormConfig.kt index cec579d7c..f4c7732f6 100644 --- a/frames/src/main/java/com/checkout/frames/screen/paymentform/model/PaymentFormConfig.kt +++ b/frames/src/main/java/com/checkout/frames/screen/paymentform/model/PaymentFormConfig.kt @@ -14,7 +14,9 @@ import com.checkout.frames.style.screen.PaymentFormStyle * @param paymentFlowHandler - [PaymentFlowHandler] represent the handler for PaymentForm * @param supportedCardSchemeList - represent the supported card schemes [CardScheme] in PaymentForm * @param prefillData - [PrefillData] represent the data for prefill in the PaymentForm - * @param baseUrlPrefix - an optional alphanumeric prefix used to route requests to a specific regional or merchant-specific subdomain (e.g., "msdd"). Must be alphanumeric. + * @param baseUrlPrefix - an optional merchant-specific subdomain label (e.g., "mssd"). + * Must contain 1 to 63 ASCII letters, digits, or hyphens, starting and ending with a letter or digit. + * Null, empty, or invalid values are ignored and use the default hosts. */ public data class PaymentFormConfig @JvmOverloads constructor( public val publicKey: String,