Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9eff7bc
Migrate BrowserSwitch files to new branch to avoid rebase/merge confl…
sshropshire Dec 4, 2025
c87c477
Restore compilation.
sshropshire Dec 4, 2025
7b326c0
Fix some broken unit tests.
sshropshire Dec 4, 2025
c095f65
Move finish function out of BrowserSwitchClient and into BrowserSwitc…
sshropshire Dec 5, 2025
105c682
Implement capture deep link for PayPalWebLauncher.
sshropshire Dec 8, 2025
5bc5eef
Implement CardAuthLauncher deep linking refactor.
sshropshire Dec 8, 2025
ce15f0f
Fix cyclomatic complexity in captureDeepLink class.
sshropshire Dec 8, 2025
04d3d07
Fix detekt errors.
sshropshire Dec 8, 2025
9599637
Remove obselete unit tests.
sshropshire Dec 8, 2025
8d38719
Address additional detekt linter errors.
sshropshire Dec 8, 2025
a7afad6
Suppress detekt lint error about too many returns.
sshropshire Dec 8, 2025
960f6f9
Restrict all deep link utils.
sshropshire Dec 8, 2025
b818eb8
Add additional restrict annotations.
sshropshire Dec 8, 2025
bebfcf3
Add proper restrict annotations to browser switch api.
sshropshire Dec 8, 2025
a3aa3b4
Update static analysis workflow mac os version.
sshropshire Dec 8, 2025
808e312
Simplify deep link capture logic.
sshropshire Dec 10, 2025
baa7f42
Make helper methods private.
sshropshire Dec 10, 2025
ed72989
Add comment to BrowserSwitchRequestCodes.
sshropshire Dec 10, 2025
3597042
Remove V2 suffixes from deep link parsing classes.
sshropshire Dec 10, 2025
ad2a22d
Fix api breakage.
sshropshire Dec 10, 2025
d7e896a
Modify return types of capture deep link.
sshropshire Dec 10, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:

diffuse:
name: Diffuse AAR Analysis
runs-on: macOS-13
runs-on: macOS-26
steps:
# Set up environment to assemble SDK
- name: Set up Java
Expand Down
1 change: 0 additions & 1 deletion CardPayments/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ dependencies {
implementation libs.androidx.appcompat
implementation libs.kotlinx.coroutinesAndroid
implementation libs.kotlinx.serializationJson
implementation libs.braintree.browserSwitch
implementation libs.lifecycle.commonJava8
implementation libs.lifecycle.runtimeKtx

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.paypal.android.cardpayments

import android.net.Uri
import android.os.Parcelable
import androidx.core.net.toUri
import kotlinx.parcelize.Parcelize

/**
Expand All @@ -17,13 +18,13 @@ sealed class CardAuthChallenge {
internal class ApproveOrder(
override val url: Uri,
val request: CardRequest,
override val returnUrlScheme: String? = Uri.parse(request.returnUrl).scheme
override val returnUrlScheme: String? = request.returnUrl.toUri().scheme
) : CardAuthChallenge(), Parcelable

@Parcelize
internal class Vault(
override val url: Uri,
val request: CardVaultRequest,
override val returnUrlScheme: String? = Uri.parse(request.returnUrl).scheme
override val returnUrlScheme: String? = request.returnUrl?.toUri()?.scheme
) : CardAuthChallenge(), Parcelable
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package com.paypal.android.cardpayments

import android.app.Activity
import android.content.Intent
import com.braintreepayments.api.BrowserSwitchClient
import com.braintreepayments.api.BrowserSwitchFinalResult
import com.braintreepayments.api.BrowserSwitchOptions
import com.braintreepayments.api.BrowserSwitchStartResult
import com.paypal.android.corepayments.BrowserSwitchRequestCodes
import com.paypal.android.corepayments.PayPalSDKError
import com.paypal.android.corepayments.CaptureDeepLinkResult
import com.paypal.android.corepayments.DeepLink
import com.paypal.android.corepayments.browserswitch.BrowserSwitchClient
import com.paypal.android.corepayments.browserswitch.BrowserSwitchOptions
import com.paypal.android.corepayments.browserswitch.BrowserSwitchPendingState
import com.paypal.android.corepayments.browserswitch.BrowserSwitchStartResult
import com.paypal.android.corepayments.captureDeepLink
import org.json.JSONObject

internal class CardAuthLauncher(
Expand Down Expand Up @@ -43,15 +45,18 @@ internal class CardAuthLauncher(
}

// launch the 3DS flow
val browserSwitchOptions = BrowserSwitchOptions()
.url(authChallenge.url)
.requestCode(requestCode)
.returnUrlScheme(authChallenge.returnUrlScheme)
.metadata(metadata)
val browserSwitchOptions = BrowserSwitchOptions(
targetUri = authChallenge.url,
requestCode = requestCode,
returnUrlScheme = authChallenge.returnUrlScheme,
appLinkUrl = null,
metadata = metadata
)

return when (val startResult = browserSwitchClient.start(activity, browserSwitchOptions)) {
is BrowserSwitchStartResult.Started -> {
CardPresentAuthChallengeResult.Success(startResult.pendingRequest)
is BrowserSwitchStartResult.Success -> {
val pendingState = BrowserSwitchPendingState(browserSwitchOptions)
CardPresentAuthChallengeResult.Success(pendingState.toBase64EncodedJSON())
}

is BrowserSwitchStartResult.Failure -> {
Expand All @@ -64,70 +69,50 @@ internal class CardAuthLauncher(
fun completeApproveOrderAuthRequest(
intent: Intent,
authState: String
): CardFinishApproveOrderResult =
when (val finalResult = browserSwitchClient.completeRequest(intent, authState)) {
is BrowserSwitchFinalResult.Success -> parseApproveOrderSuccessResult(finalResult)

is BrowserSwitchFinalResult.Failure -> {
// TODO: remove error codes and error description from project; the built in
// Throwable type already has a message property and error codes are only required
// for iOS Error protocol conformance
val message = "Browser switch failed"
val browserSwitchError = PayPalSDKError(0, message, reason = finalResult.error)
CardFinishApproveOrderResult.Failure(browserSwitchError)
}

BrowserSwitchFinalResult.NoResult -> CardFinishApproveOrderResult.NoResult
): CardFinishApproveOrderResult {
val requestCode = BrowserSwitchRequestCodes.CARD_APPROVE_ORDER
return when (val result = captureDeepLink(requestCode, intent, authState)) {
is CaptureDeepLinkResult.Success -> parseApproveOrderSuccessResult(result.deepLink)
is CaptureDeepLinkResult.Failure -> CardFinishApproveOrderResult.Failure(result.reason)
is CaptureDeepLinkResult.Ignore -> CardFinishApproveOrderResult.NoResult
}
}

fun completeVaultAuthRequest(intent: Intent, authState: String): CardFinishVaultResult =
when (val finalResult = browserSwitchClient.completeRequest(intent, authState)) {
is BrowserSwitchFinalResult.Success -> parseVaultSuccessResult(finalResult)

is BrowserSwitchFinalResult.Failure -> {
// TODO: remove error codes and error description from project; the built in
// Throwable type already has a message property and error codes are only required
// for iOS Error protocol conformance
val message = "Browser switch failed"
val browserSwitchError = PayPalSDKError(0, message, reason = finalResult.error)
CardFinishVaultResult.Failure(browserSwitchError)
}

BrowserSwitchFinalResult.NoResult -> CardFinishVaultResult.NoResult
fun completeVaultAuthRequest(intent: Intent, authState: String): CardFinishVaultResult {
val requestCode = BrowserSwitchRequestCodes.CARD_VAULT
return when (val result = captureDeepLink(requestCode, intent, authState)) {
is CaptureDeepLinkResult.Success -> parseVaultSuccessResult(result.deepLink)
is CaptureDeepLinkResult.Failure -> CardFinishVaultResult.Failure(result.reason)
is CaptureDeepLinkResult.Ignore -> CardFinishVaultResult.NoResult
}
}

private fun parseVaultSuccessResult(result: BrowserSwitchFinalResult.Success): CardFinishVaultResult =
if (result.requestCode == BrowserSwitchRequestCodes.CARD_VAULT) {
val setupTokenId = result.requestMetadata?.optString(METADATA_KEY_SETUP_TOKEN_ID)
if (setupTokenId == null) {
CardFinishVaultResult.Failure(CardError.unknownError)
} else {
// TODO: see if there's a way that we can require the merchant to make their
// return and cancel urls conform to a strict schema
CardFinishVaultResult.Success(
setupTokenId,
null,
didAttemptThreeDSecureAuthentication = true
)
}
private fun parseVaultSuccessResult(deepLink: DeepLink): CardFinishVaultResult {
val originalOptions = deepLink.originalOptions
val setupTokenId = originalOptions.metadata?.optString(METADATA_KEY_SETUP_TOKEN_ID)
return if (setupTokenId == null) {
CardFinishVaultResult.Failure(CardError.unknownError)
} else {
CardFinishVaultResult.NoResult
// TODO: see if there's a way that we can require the merchant to make their
// return and cancel urls conform to a strict schema
CardFinishVaultResult.Success(
setupTokenId,
null,
didAttemptThreeDSecureAuthentication = true
)
}
}

private fun parseApproveOrderSuccessResult(
finalResult: BrowserSwitchFinalResult.Success
): CardFinishApproveOrderResult =
if (finalResult.requestCode == BrowserSwitchRequestCodes.CARD_APPROVE_ORDER) {
val orderId = finalResult.requestMetadata?.optString(METADATA_KEY_ORDER_ID)
if (orderId == null) {
CardFinishApproveOrderResult.Failure(CardError.unknownError)
} else {
CardFinishApproveOrderResult.Success(
orderId = orderId,
didAttemptThreeDSecureAuthentication = true
)
}
private fun parseApproveOrderSuccessResult(deepLink: DeepLink): CardFinishApproveOrderResult {
val originalOptions = deepLink.originalOptions
val orderId = originalOptions.metadata?.optString(METADATA_KEY_ORDER_ID)
return if (orderId == null) {
CardFinishApproveOrderResult.Failure(CardError.unknownError)
} else {
CardFinishApproveOrderResult.NoResult
CardFinishApproveOrderResult.Success(
orderId = orderId,
didAttemptThreeDSecureAuthentication = true
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package com.paypal.android.cardpayments

import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import com.braintreepayments.api.BrowserSwitchClient
import com.braintreepayments.api.BrowserSwitchFinalResult
import com.braintreepayments.api.BrowserSwitchOptions
import com.braintreepayments.api.BrowserSwitchStartResult
import com.paypal.android.corepayments.BrowserSwitchRequestCodes
import com.paypal.android.corepayments.browserswitch.BrowserSwitchClient
import com.paypal.android.corepayments.browserswitch.BrowserSwitchOptions
import com.paypal.android.corepayments.browserswitch.BrowserSwitchPendingState
import com.paypal.android.corepayments.browserswitch.BrowserSwitchStartResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
Expand Down Expand Up @@ -64,7 +65,7 @@ class CardAuthLauncherUnitTest {
@Test
fun `presentAuthChallenge() browser switches to approve order auth challenge url`() {
val slot = slot<BrowserSwitchOptions>()
val browserSwitchResult = BrowserSwitchStartResult.Started("pending request")
val browserSwitchResult = BrowserSwitchStartResult.Success
every { browserSwitchClient.start(activity, capture(slot)) } returns browserSwitchResult

val returnUrl = "merchant.app://return.com/deep-link"
Expand All @@ -79,14 +80,14 @@ class CardAuthLauncherUnitTest {
val metadata = browserSwitchOptions.metadata
assertEquals("fake-order-id", metadata?.getString("order_id"))
assertEquals("merchant.app", browserSwitchOptions.returnUrlScheme)
assertEquals(Uri.parse("https://fake.com/destination"), browserSwitchOptions.url)
assertEquals(Uri.parse("https://fake.com/destination"), browserSwitchOptions.targetUri)
assertEquals(BrowserSwitchRequestCodes.CARD_APPROVE_ORDER, browserSwitchOptions.requestCode)
}

@Test
fun `presentAuthChallenge() browser switches to vault auth challenge url`() {
val slot = slot<BrowserSwitchOptions>()
val browserSwitchResult = BrowserSwitchStartResult.Started("pending request")
val browserSwitchResult = BrowserSwitchStartResult.Success
every { browserSwitchClient.start(activity, capture(slot)) } returns browserSwitchResult

val returnUrl = "merchant.app://return.com/deep-link"
Expand All @@ -101,7 +102,7 @@ class CardAuthLauncherUnitTest {
val metadata = browserSwitchOptions.metadata
assertEquals("fake-setup-token-id", metadata?.getString("setup_token_id"))
assertEquals("merchant.app", browserSwitchOptions.returnUrlScheme)
assertEquals(Uri.parse("https://fake.com/destination"), browserSwitchOptions.url)
assertEquals(Uri.parse("https://fake.com/destination"), browserSwitchOptions.targetUri)
assertEquals(BrowserSwitchRequestCodes.CARD_VAULT, browserSwitchOptions.requestCode)
}

Expand All @@ -113,17 +114,17 @@ class CardAuthLauncherUnitTest {
val domain = "example.com"
val successDeepLink =
"$scheme://$domain/return_url?state=undefined&code=undefined&liability_shift=NO"

val finalResult = createBrowserSwitchSuccessFinalResult(
BrowserSwitchRequestCodes.CARD_APPROVE_ORDER,
approveOrderMetadata,
Uri.parse(successDeepLink)
val options = BrowserSwitchOptions(
targetUri = "https://fake.com/destination".toUri(),
requestCode = BrowserSwitchRequestCodes.CARD_APPROVE_ORDER,
returnUrlScheme = scheme,
appLinkUrl = null,
metadata = approveOrderMetadata
)
every {
browserSwitchClient.completeRequest(intent, "pending request")
} returns finalResult
val authState = BrowserSwitchPendingState(options).toBase64EncodedJSON()
intent.data = successDeepLink.toUri()

val result = sut.completeApproveOrderAuthRequest(intent, "pending request")
val result = sut.completeApproveOrderAuthRequest(intent, authState)
as CardFinishApproveOrderResult.Success

assertEquals("fake-order-id", result.orderId)
Expand All @@ -138,32 +139,19 @@ class CardAuthLauncherUnitTest {
val scheme = "com.paypal.android.demo"
val domain = "example.com"
val successDeepLink = "$scheme://$domain/success"

val finalResult = createBrowserSwitchSuccessFinalResult(
BrowserSwitchRequestCodes.CARD_VAULT,
vaultMetadata,
Uri.parse(successDeepLink)
val options = BrowserSwitchOptions(
targetUri = "https://fake.com/destination".toUri(),
requestCode = BrowserSwitchRequestCodes.CARD_VAULT,
returnUrlScheme = scheme,
appLinkUrl = null,
metadata = vaultMetadata
)
every {
browserSwitchClient.completeRequest(intent, "pending request")
} returns finalResult
val authState = BrowserSwitchPendingState(options).toBase64EncodedJSON()
intent.data = successDeepLink.toUri()

val result =
sut.completeVaultAuthRequest(intent, "pending request") as CardFinishVaultResult.Success
sut.completeVaultAuthRequest(intent, authState) as CardFinishVaultResult.Success
assertEquals("fake-setup-token-id", result.setupTokenId)
assertNull(result.status)
}

private fun createBrowserSwitchSuccessFinalResult(
requestCode: Int,
metadata: JSONObject,
deepLinkUrl: Uri
): BrowserSwitchFinalResult.Success {
val finalResult = mockk<BrowserSwitchFinalResult.Success>(relaxed = true)
every { finalResult.returnUrl } returns deepLinkUrl
every { finalResult.requestMetadata } returns metadata
every { finalResult.requestCode } returns requestCode
every { finalResult.requestUrl } returns Uri.parse("https://example.com/url")
return finalResult
}
}
1 change: 1 addition & 0 deletions CorePayments/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ android {
dependencies {
implementation libs.androidx.coreKtx
implementation libs.androidx.appcompat
implementation libs.androidx.browser
implementation libs.kotlin.stdLib
implementation libs.kotlinx.coroutinesAndroid
implementation libs.kotlinx.serializationJson
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.paypal.android.corepayments

// TODO: consider breaking change to migrate away from a centralized set of request codes
// to module-specific request keys to prevent each module from having to depend on :Core
// for its request codes; there will be risk of collision, but proper namespacing can alleviate this
// concern e.g. "PayPal.Checkout", "Venmo.Vault" etc.
object BrowserSwitchRequestCodes {
const val CARD_APPROVE_ORDER = 1
const val CARD_VAULT = 2
Expand Down
Loading