Skip to content
Draft
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
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,12 +2,13 @@ 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.browserswitch.BrowserSwitchClient
import com.paypal.android.corepayments.browserswitch.BrowserSwitchFinishResult
import com.paypal.android.corepayments.browserswitch.BrowserSwitchOptions
import com.paypal.android.corepayments.browserswitch.BrowserSwitchPendingState
import com.paypal.android.corepayments.browserswitch.BrowserSwitchStartResult
import org.json.JSONObject

internal class CardAuthLauncher(
Expand Down Expand Up @@ -43,15 +44,17 @@ internal class CardAuthLauncher(
}

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

return when (val startResult = browserSwitchClient.start(activity, browserSwitchOptions)) {
is BrowserSwitchStartResult.Started -> {
CardPresentAuthChallengeResult.Success(startResult.pendingRequest)
val options = BrowserSwitchOptions(
targetUri = authChallenge.url,
requestCode = requestCode,
returnUrlScheme = authChallenge.returnUrlScheme!!,
metadata = metadata
)

return when (val startResult = browserSwitchClient.start(activity, options)) {
is BrowserSwitchStartResult.Success -> {
val authState = startResult.pendingState.toBase64EncodedJSON()
CardPresentAuthChallengeResult.Success(authState)
}

is BrowserSwitchStartResult.Failure -> {
Expand All @@ -64,39 +67,39 @@ 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)
): CardFinishApproveOrderResult {
val pendingState = BrowserSwitchPendingState.fromBase64(authState)
return if (pendingState == null) {
val invalidAuthStateError = PayPalSDKError(0, "Auth State Invalid.")
CardFinishApproveOrderResult.Failure(invalidAuthStateError)
} else {
val requestCode = BrowserSwitchRequestCodes.CARD_APPROVE_ORDER
when (val finalResult = browserSwitchClient.finish(intent, requestCode, pendingState)) {
is BrowserSwitchFinishResult.Success -> parseApproveOrderSuccessResult(finalResult)
is BrowserSwitchFinishResult.DeepLinkNotPresent,
is BrowserSwitchFinishResult.DeepLinkDoesNotMatch,
is BrowserSwitchFinishResult.RequestCodeDoesNotMatch -> CardFinishApproveOrderResult.NoResult
}

BrowserSwitchFinalResult.NoResult -> 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)
fun completeVaultAuthRequest(intent: Intent, authState: String): CardFinishVaultResult {
val pendingState = BrowserSwitchPendingState.fromBase64(authState)
return if (pendingState == null) {
val invalidAuthStateError = PayPalSDKError(0, "Auth State Invalid.")
CardFinishVaultResult.Failure(invalidAuthStateError)
} else {
val requestCode = BrowserSwitchRequestCodes.CARD_VAULT
when (val finalResult = browserSwitchClient.finish(intent, requestCode, pendingState)) {
is BrowserSwitchFinishResult.Success -> parseVaultSuccessResult(finalResult)
is BrowserSwitchFinishResult.DeepLinkNotPresent,
is BrowserSwitchFinishResult.DeepLinkDoesNotMatch,
is BrowserSwitchFinishResult.RequestCodeDoesNotMatch -> CardFinishVaultResult.NoResult
}

BrowserSwitchFinalResult.NoResult -> CardFinishVaultResult.NoResult
}
}

private fun parseVaultSuccessResult(result: BrowserSwitchFinalResult.Success): CardFinishVaultResult =
private fun parseVaultSuccessResult(result: BrowserSwitchFinishResult.Success): CardFinishVaultResult =
if (result.requestCode == BrowserSwitchRequestCodes.CARD_VAULT) {
val setupTokenId = result.requestMetadata?.optString(METADATA_KEY_SETUP_TOKEN_ID)
if (setupTokenId == null) {
Expand All @@ -115,7 +118,7 @@ internal class CardAuthLauncher(
}

private fun parseApproveOrderSuccessResult(
finalResult: BrowserSwitchFinalResult.Success
finalResult: BrowserSwitchFinishResult.Success
): CardFinishApproveOrderResult =
if (finalResult.requestCode == BrowserSwitchRequestCodes.CARD_APPROVE_ORDER) {
val orderId = finalResult.requestMetadata?.optString(METADATA_KEY_ORDER_ID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package com.paypal.android.cardpayments
import android.content.Intent
import android.net.Uri
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.BrowserSwitchFinishResult
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,8 +65,10 @@ class CardAuthLauncherUnitTest {
@Test
fun `presentAuthChallenge() browser switches to approve order auth challenge url`() {
val slot = slot<BrowserSwitchOptions>()
val browserSwitchResult = BrowserSwitchStartResult.Started("pending request")
every { browserSwitchClient.start(activity, capture(slot)) } returns browserSwitchResult
every { browserSwitchClient.start(activity, capture(slot)) } answers {
val originalOptions = secondArg<BrowserSwitchOptions>()
BrowserSwitchStartResult.Success(BrowserSwitchPendingState(originalOptions))
}

val returnUrl = "merchant.app://return.com/deep-link"
val cardRequest = CardRequest("fake-order-id", card, returnUrl)
Expand All @@ -79,15 +82,17 @@ 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")
every { browserSwitchClient.start(activity, capture(slot)) } returns browserSwitchResult
every { browserSwitchClient.start(activity, capture(slot)) } answers {
val originalOptions = secondArg<BrowserSwitchOptions>()
BrowserSwitchStartResult.Success(BrowserSwitchPendingState(originalOptions))
}

val returnUrl = "merchant.app://return.com/deep-link"
val vaultRequest = CardVaultRequest("fake-setup-token-id", card, returnUrl)
Expand All @@ -101,7 +106,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 @@ -120,7 +125,11 @@ class CardAuthLauncherUnitTest {
Uri.parse(successDeepLink)
)
every {
browserSwitchClient.completeRequest(intent, "pending request")
browserSwitchClient.finish(
intent,
BrowserSwitchRequestCodes.CARD_APPROVE_ORDER,
"pending request"
)
} returns finalResult

val result = sut.completeApproveOrderAuthRequest(intent, "pending request")
Expand Down Expand Up @@ -158,8 +167,8 @@ class CardAuthLauncherUnitTest {
requestCode: Int,
metadata: JSONObject,
deepLinkUrl: Uri
): BrowserSwitchFinalResult.Success {
val finalResult = mockk<BrowserSwitchFinalResult.Success>(relaxed = true)
): BrowserSwitchFinishResult.Success {
val finalResult = mockk<BrowserSwitchFinishResult.Success>(relaxed = true)
every { finalResult.returnUrl } returns deepLinkUrl
every { finalResult.requestMetadata } returns metadata
every { finalResult.requestCode } returns requestCode
Expand Down
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
@@ -0,0 +1,48 @@
package com.paypal.android.corepayments.browserswitch

import android.content.Context
import android.content.Intent

class BrowserSwitchClient(
private val chromeCustomTabsClient: ChromeCustomTabsClient = ChromeCustomTabsClient()
) {
fun start(
context: Context,
options: BrowserSwitchOptions
): BrowserSwitchStartResult {
val cctOptions = ChromeCustomTabOptions(launchUri = options.targetUri)
chromeCustomTabsClient.launch(context, cctOptions)
val pendingState = BrowserSwitchPendingState(options)
return BrowserSwitchStartResult.Success(pendingState)
}

fun finish(
intent: Intent,
requestCode: Int,
pendingState: BrowserSwitchPendingState
): BrowserSwitchFinishResult {
val originalOptions = pendingState.originalOptions
if (requestCode != originalOptions.requestCode) {
return BrowserSwitchFinishResult.RequestCodeDoesNotMatch
}

val deepLinkUri = intent.data
if (deepLinkUri == null) {
return BrowserSwitchFinishResult.DeepLinkNotPresent
}

val deepLinkScheme = deepLinkUri.scheme.orEmpty()
val isMatchingDeepLink =
deepLinkScheme.equals(originalOptions.returnUrlScheme, ignoreCase = true)
return if (isMatchingDeepLink) {
BrowserSwitchFinishResult.Success(
returnUrl = deepLinkUri,
requestCode = originalOptions.requestCode,
requestUrl = originalOptions.targetUri,
requestMetadata = originalOptions.metadata
)
} else {
BrowserSwitchFinishResult.DeepLinkDoesNotMatch
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.paypal.android.corepayments.browserswitch

import android.net.Uri
import org.json.JSONObject

sealed class BrowserSwitchFinishResult() {

data class Success(
val returnUrl: Uri,
val requestCode: Int,
val requestUrl: Uri,
val requestMetadata: JSONObject?,
) : BrowserSwitchFinishResult()

object RequestCodeDoesNotMatch : BrowserSwitchFinishResult()
object DeepLinkNotPresent : BrowserSwitchFinishResult()
object DeepLinkDoesNotMatch : BrowserSwitchFinishResult()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.paypal.android.corepayments.browserswitch

import android.net.Uri
import org.json.JSONObject

data class BrowserSwitchOptions(
val targetUri: Uri,
val requestCode: Int,
val returnUrlScheme: String,
val metadata: JSONObject? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.paypal.android.corepayments.browserswitch

import android.util.Base64
import androidx.core.net.toUri
import org.json.JSONObject
import java.nio.charset.StandardCharsets

const val KEY_TARGET_URI = "targetUri"
const val KEY_REQUEST_CODE = "requestCode"
const val KEY_RETURN_URL_SCHEME = "returnUrlScheme"
const val KEY_METADATA = "metadata"

data class BrowserSwitchPendingState(val originalOptions: BrowserSwitchOptions) {

fun toBase64EncodedJSON(): String {
val json = JSONObject()
.put(KEY_TARGET_URI, originalOptions.targetUri)
.put(KEY_REQUEST_CODE, originalOptions.requestCode)
.put(KEY_RETURN_URL_SCHEME, originalOptions.returnUrlScheme)
.putOpt(KEY_METADATA, originalOptions.metadata)
val jsonBytes: ByteArray? = json.toString().toByteArray(StandardCharsets.UTF_8)
val flags = Base64.DEFAULT or Base64.NO_WRAP
return Base64.encodeToString(jsonBytes, flags)
}

companion object {
fun fromBase64(base64EncodedJSON: String): BrowserSwitchPendingState? {
val data = Base64.decode(base64EncodedJSON, Base64.DEFAULT)
val requestJSONString = String(data, StandardCharsets.UTF_8)
val json = JSONObject(requestJSONString)
val options = BrowserSwitchOptions(
targetUri = json.getString(KEY_TARGET_URI).toUri(),
requestCode = json.getInt(KEY_REQUEST_CODE),
returnUrlScheme = json.getString(KEY_RETURN_URL_SCHEME),
metadata = json.optJSONObject(KEY_METADATA)
)
return BrowserSwitchPendingState(options)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.paypal.android.corepayments.browserswitch

sealed class BrowserSwitchStartResult() {
class Success(val pendingState: BrowserSwitchPendingState): BrowserSwitchStartResult()
class Failure(val error: Exception): BrowserSwitchStartResult()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.paypal.android.corepayments.browserswitch

import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent

data class ChromeCustomTabOptions(
val launchUri: Uri
)

class ChromeCustomTabsClient {
fun launch(context: Context, options: ChromeCustomTabOptions) {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(context, options.launchUri)
}
}
Loading
Loading