Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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



Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/java/ProjectConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

object AndroidConfig {
const val compileSdk = 34
const val minSdk = 21
const val minSdk = 23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need to send out comms for this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It'll be a major release

const val targetSdk = 34

const val androidTestInstrumentation = "androidx.test.runner.AndroidJUnitRunner"
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/java/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion checkout/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
-keep class com.checkout.** { *; }
-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
Original file line number Diff line number Diff line change
@@ -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])?$")
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ 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,
environment: Environment,
context: Context,
baseUrlPrefix: String? = null,
): CheckoutApiService {
val validatedBaseUrlPrefix = BaseUrlPrefixValidator.validPrefixOrNull(baseUrlPrefix)
val logger = EventLoggerProvider.provide()
logger.setup(context, environment)
correlationId = logger.correlationId
Expand All @@ -53,7 +64,7 @@ public object CheckoutApiServiceFactory {
)

return CheckoutApiClient(
provideTokenRepository(context, publicKey, environment, baseUrlPrefix),
provideTokenRepository(context, publicKey, environment, validatedBaseUrlPrefix),
provideThreeDSExecutor(logger),
)
}
Expand Down Expand Up @@ -86,6 +97,7 @@ public object CheckoutApiServiceFactory {
publicKey,
riskSDKFramesOptions,
RiskInstanceProvider,
baseUrlPrefix,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal object RiskInstanceProvider {
publicKey: String,
environment: Environment,
framesOptions: FramesOptions,
baseUrlPrefix: String?,
): Risk? {
if (riskInstance != null) {
return riskInstance
Expand All @@ -33,6 +34,7 @@ internal object RiskInstanceProvider {
publicKey = publicKey,
environment = riskEnvironment,
framesOptions = framesOptions,
mssd = baseUrlPrefix,
),
)
return riskInstance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
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 -> {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 8 additions & 35 deletions checkout/src/test/java/com/checkout/EnvironmentExtensionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RiskConfig>()

@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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -41,14 +43,15 @@ public class RiskSdkUseCaseTest {
publicKey = PUBLIC_KEY,
riskInstanceProvider = riskInstanceProvider,
framesOptions = framesOptions,
baseUrlPrefix = "mssd",
)
}

@Test
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) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading