diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 7f5a85c02..1a8885e89 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -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 diff --git a/CardPayments/build.gradle b/CardPayments/build.gradle index 6265bc3e7..d0588f2c9 100644 --- a/CardPayments/build.gradle +++ b/CardPayments/build.gradle @@ -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 diff --git a/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthChallenge.kt b/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthChallenge.kt index 815bdedcb..a60a1865d 100644 --- a/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthChallenge.kt +++ b/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthChallenge.kt @@ -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 /** @@ -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 } diff --git a/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthLauncher.kt b/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthLauncher.kt index 303bd812e..130e7fd54 100644 --- a/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthLauncher.kt +++ b/CardPayments/src/main/java/com/paypal/android/cardpayments/CardAuthLauncher.kt @@ -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( @@ -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 -> { @@ -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 + ) } + } } diff --git a/CardPayments/src/test/java/com/paypal/android/cardpayments/CardAuthLauncherUnitTest.kt b/CardPayments/src/test/java/com/paypal/android/cardpayments/CardAuthLauncherUnitTest.kt index 5c83bf965..9c54b4d0f 100644 --- a/CardPayments/src/test/java/com/paypal/android/cardpayments/CardAuthLauncherUnitTest.kt +++ b/CardPayments/src/test/java/com/paypal/android/cardpayments/CardAuthLauncherUnitTest.kt @@ -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 @@ -64,7 +65,7 @@ class CardAuthLauncherUnitTest { @Test fun `presentAuthChallenge() browser switches to approve order auth challenge url`() { val slot = slot() - 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" @@ -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() - 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" @@ -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) } @@ -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) @@ -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(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 - } } diff --git a/CorePayments/build.gradle b/CorePayments/build.gradle index 84f860943..a4083322d 100644 --- a/CorePayments/build.gradle +++ b/CorePayments/build.gradle @@ -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 diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/BrowserSwitchRequestCodes.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/BrowserSwitchRequestCodes.kt index 7d52b9ee2..e294939f6 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/BrowserSwitchRequestCodes.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/BrowserSwitchRequestCodes.kt @@ -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 diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/DeepLinkUtils.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/DeepLinkUtils.kt new file mode 100644 index 000000000..1908d0b94 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/DeepLinkUtils.kt @@ -0,0 +1,74 @@ +package com.paypal.android.corepayments + +import android.content.Intent +import android.net.Uri +import androidx.annotation.RestrictTo +import androidx.core.net.toUri +import com.paypal.android.corepayments.browserswitch.BrowserSwitchOptions +import com.paypal.android.corepayments.browserswitch.BrowserSwitchPendingState + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +data class DeepLink(val uri: Uri, val originalOptions: BrowserSwitchOptions) + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +sealed class CaptureDeepLinkResult { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + data class Success(val deepLink: DeepLink) : CaptureDeepLinkResult() + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + data class Failure(val reason: PayPalSDKError) : CaptureDeepLinkResult() + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + data class Ignore(val debugMessage: String) : CaptureDeepLinkResult() +} + +// TODO: see if we can resolve ReturnCount lint error instead of suppressing it +@Suppress("ReturnCount") +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +fun captureDeepLink( + requestCode: Int, + intent: Intent, + authState: String +): CaptureDeepLinkResult { + val pendingState = BrowserSwitchPendingState.fromBase64(authState) + if (pendingState == null) { + // 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 reason = PayPalSDKError(0, "Auth state invalid.") + return CaptureDeepLinkResult.Failure(reason) + } + + val options = pendingState.originalOptions + if (requestCode != options.requestCode) { + return CaptureDeepLinkResult.Ignore("Request code does not match.") + } + + val deepLinkUri = intent.data + if (deepLinkUri == null) { + return CaptureDeepLinkResult.Ignore("Intent data is null.") + } + + val isMatchingDeepLink = + isCustomSchemeMatch(deepLinkUri, options) || isAppLinkMatch(deepLinkUri, options) + return if (isMatchingDeepLink) { + val deepLink = DeepLink(deepLinkUri, pendingState.originalOptions) + CaptureDeepLinkResult.Success(deepLink) + } else { + val message = "Deep link custom scheme or host is not associated with the original request." + CaptureDeepLinkResult.Ignore(message) + } +} + +private fun isCustomSchemeMatch(uri: Uri, options: BrowserSwitchOptions) = + uri.scheme.orEmpty().equals(options.returnUrlScheme, ignoreCase = true) + +private fun isAppLinkMatch(uri: Uri, options: BrowserSwitchOptions): Boolean { + val appLinkUrl = options.appLinkUrl?.toUri() + if (appLinkUrl != null) { + val hasMatchingScheme = uri.scheme?.equals(appLinkUrl.scheme) ?: false + val hasMatchingHost = uri.host?.equals(appLinkUrl.host) ?: false + return hasMatchingScheme && hasMatchingHost + } + return false +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchClient.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchClient.kt new file mode 100644 index 000000000..9cdc22b17 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchClient.kt @@ -0,0 +1,18 @@ +package com.paypal.android.corepayments.browserswitch + +import android.content.Context +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class BrowserSwitchClient( + private val chromeCustomTabsClient: ChromeCustomTabsClient = ChromeCustomTabsClient() +) { + fun start( + context: Context, + options: BrowserSwitchOptions + ): BrowserSwitchStartResult { + val cctOptions = ChromeCustomTabOptions(launchUri = options.targetUri) + chromeCustomTabsClient.launch(context, cctOptions) + return BrowserSwitchStartResult.Success + } +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchOptions.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchOptions.kt new file mode 100644 index 000000000..6bf33e0b9 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchOptions.kt @@ -0,0 +1,14 @@ +package com.paypal.android.corepayments.browserswitch + +import android.net.Uri +import androidx.annotation.RestrictTo +import org.json.JSONObject + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +data class BrowserSwitchOptions( + val targetUri: Uri, + val requestCode: Int, + val returnUrlScheme: String?, + val appLinkUrl: String?, + val metadata: JSONObject? = null +) diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchPendingState.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchPendingState.kt new file mode 100644 index 000000000..9f3ffc8fa --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchPendingState.kt @@ -0,0 +1,47 @@ +package com.paypal.android.corepayments.browserswitch + +import android.util.Base64 +import androidx.annotation.RestrictTo +import androidx.core.net.toUri +import org.json.JSONObject +import java.nio.charset.StandardCharsets + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +data class BrowserSwitchPendingState(val originalOptions: BrowserSwitchOptions) { + + fun toBase64EncodedJSON(): String { + val json = JSONObject() + .put(KEY_TARGET_URI, originalOptions.targetUri) + .put(KEY_REQUEST_CODE, originalOptions.requestCode) + .putOpt(KEY_RETURN_URL_SCHEME, originalOptions.returnUrlScheme) + .putOpt(KEY_APP_LINK_URL, originalOptions.appLinkUrl) + .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) + } + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + companion object { + + const val KEY_TARGET_URI = "targetUri" + const val KEY_REQUEST_CODE = "requestCode" + const val KEY_RETURN_URL_SCHEME = "returnUrlScheme" + const val KEY_APP_LINK_URL = "appLinkUrl" + const val KEY_METADATA = "metadata" + + 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.optString(KEY_RETURN_URL_SCHEME), + appLinkUrl = json.optString(KEY_APP_LINK_URL), + metadata = json.optJSONObject(KEY_METADATA) + ) + return BrowserSwitchPendingState(options) + } + } +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchStartResult.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchStartResult.kt new file mode 100644 index 000000000..caadceb62 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchStartResult.kt @@ -0,0 +1,11 @@ +package com.paypal.android.corepayments.browserswitch + +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +sealed class BrowserSwitchStartResult { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + object Success : BrowserSwitchStartResult() + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + class Failure(val error: Exception) : BrowserSwitchStartResult() +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/ChromeCustomTabsClient.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/ChromeCustomTabsClient.kt new file mode 100644 index 000000000..dca3b6370 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/browserswitch/ChromeCustomTabsClient.kt @@ -0,0 +1,19 @@ +package com.paypal.android.corepayments.browserswitch + +import android.content.Context +import android.net.Uri +import androidx.annotation.RestrictTo +import androidx.browser.customtabs.CustomTabsIntent + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +data class ChromeCustomTabOptions( + val launchUri: Uri +) + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class ChromeCustomTabsClient { + fun launch(context: Context, options: ChromeCustomTabOptions) { + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.launchUrl(context, options.launchUri) + } +} diff --git a/CorePayments/src/test/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchClientUnitTest.kt b/CorePayments/src/test/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchClientUnitTest.kt new file mode 100644 index 000000000..f021cb35a --- /dev/null +++ b/CorePayments/src/test/java/com/paypal/android/corepayments/browserswitch/BrowserSwitchClientUnitTest.kt @@ -0,0 +1,46 @@ +package com.paypal.android.corepayments.browserswitch + +import android.content.Context +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import io.mockk.mockk +import io.mockk.verify +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BrowserSwitchClientUnitTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val browserSwitchOptions = BrowserSwitchOptions( + targetUri = "https://example.com/uri".toUri(), + requestCode = 123, + returnUrlScheme = "example.return.url.scheme", + metadata = JSONObject().put("example_prop", "example_value"), + appLinkUrl = null + ) + + private lateinit var chromeCustomTabsClient: ChromeCustomTabsClient + private lateinit var sut: BrowserSwitchClient + + @Before + fun beforeEach() { + chromeCustomTabsClient = mockk(relaxed = true) + sut = BrowserSwitchClient(chromeCustomTabsClient) + } + + @Test + fun `it should launch a chrome custom tab on success`() { + val result = sut.start(context, browserSwitchOptions) + val expectedCCTOptions = + ChromeCustomTabOptions(launchUri = "https://example.com/uri".toUri()) + + assertTrue(result is BrowserSwitchStartResult.Success) + verify { chromeCustomTabsClient.launch(context, expectedCCTOptions) } + } +} diff --git a/PayPalWebPayments/build.gradle b/PayPalWebPayments/build.gradle index a114f9430..588bb4e4f 100644 --- a/PayPalWebPayments/build.gradle +++ b/PayPalWebPayments/build.gradle @@ -46,7 +46,6 @@ android { dependencies { api project(':CorePayments') - implementation libs.braintree.browserSwitch implementation libs.kotlin.stdLib implementation libs.androidx.coreKtx diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt index 9862e989e..abda3a7f0 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt @@ -3,13 +3,14 @@ package com.paypal.android.paypalwebpayments import android.app.Activity import android.content.Intent import android.net.Uri -import androidx.core.net.toUri -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 com.paypal.android.corepayments.model.TokenType import com.paypal.android.paypalwebpayments.errors.PayPalWebCheckoutError import org.json.JSONObject @@ -35,12 +36,13 @@ internal class PayPalWebLauncher( appLinkUrl: String? = null ): PayPalPresentAuthChallengeResult { val metadata = getMetadata(token, tokenType) - val options = BrowserSwitchOptions() - .url(uri) - .requestCode(getRequestCode(tokenType)) - .returnUrlScheme(returnUrlScheme) - .appLinkUri(appLinkUrl?.toUri()) - .metadata(metadata) + val options = BrowserSwitchOptions( + targetUri = uri, + requestCode = getRequestCode(tokenType), + returnUrlScheme = returnUrlScheme, + appLinkUrl = appLinkUrl, + metadata = metadata + ) return launchBrowserSwitch(activity, options) } @@ -68,8 +70,9 @@ internal class PayPalWebLauncher( options: BrowserSwitchOptions ): PayPalPresentAuthChallengeResult = when (val startResult = browserSwitchClient.start(activity, options)) { - is BrowserSwitchStartResult.Started -> { - PayPalPresentAuthChallengeResult.Success(startResult.pendingRequest) + is BrowserSwitchStartResult.Success -> { + val pendingState = BrowserSwitchPendingState(options) + PayPalPresentAuthChallengeResult.Success(pendingState.toBase64EncodedJSON()) } is BrowserSwitchStartResult.Failure -> { @@ -82,18 +85,13 @@ internal class PayPalWebLauncher( intent: Intent, authState: String ): PayPalWebCheckoutFinishStartResult { - return when (val finalResult = browserSwitchClient.completeRequest(intent, authState)) { - is BrowserSwitchFinalResult.Success -> parseWebCheckoutSuccessResult(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) - PayPalWebCheckoutFinishStartResult.Failure(browserSwitchError, null) - } + val requestCode = BrowserSwitchRequestCodes.PAYPAL_CHECKOUT + return when (val result = captureDeepLink(requestCode, intent, authState)) { + is CaptureDeepLinkResult.Success -> parseWebCheckoutSuccessResult(result.deepLink) + is CaptureDeepLinkResult.Failure -> + PayPalWebCheckoutFinishStartResult.Failure(result.reason, orderId = null) - BrowserSwitchFinalResult.NoResult -> PayPalWebCheckoutFinishStartResult.NoResult + is CaptureDeepLinkResult.Ignore -> PayPalWebCheckoutFinishStartResult.NoResult } } @@ -101,40 +99,30 @@ internal class PayPalWebLauncher( intent: Intent, authState: String ): PayPalWebCheckoutFinishVaultResult { - return 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) - PayPalWebCheckoutFinishVaultResult.Failure(browserSwitchError) - } + val requestCode = BrowserSwitchRequestCodes.PAYPAL_VAULT + return when (val result = captureDeepLink(requestCode, intent, authState)) { + is CaptureDeepLinkResult.Success -> parseVaultSuccessResult(result.deepLink) + is CaptureDeepLinkResult.Failure -> + PayPalWebCheckoutFinishVaultResult.Failure(result.reason) - BrowserSwitchFinalResult.NoResult -> PayPalWebCheckoutFinishVaultResult.NoResult + is CaptureDeepLinkResult.Ignore -> PayPalWebCheckoutFinishVaultResult.NoResult } } private fun parseWebCheckoutSuccessResult( - finalResult: BrowserSwitchFinalResult.Success + deepLink: DeepLink ): PayPalWebCheckoutFinishStartResult { - if (finalResult.requestCode != BrowserSwitchRequestCodes.PAYPAL_CHECKOUT) { - return PayPalWebCheckoutFinishStartResult.NoResult - } - - val deepLinkUrl = finalResult.returnUrl - val metadata = finalResult.requestMetadata + val metadata = deepLink.originalOptions.metadata return if (metadata == null) { val unknownError = PayPalWebCheckoutError.unknownError PayPalWebCheckoutFinishStartResult.Failure(unknownError, null) } else { val orderId = metadata.optString(METADATA_KEY_ORDER_ID) - val opType = deepLinkUrl.getQueryParameter("opType") + val opType = deepLink.uri.getQueryParameter("opType") if (opType == "cancel") { PayPalWebCheckoutFinishStartResult.Canceled(orderId) } else { - val payerId = deepLinkUrl.getQueryParameter("PayerID") + val payerId = deepLink.uri.getQueryParameter("PayerID") if (orderId.isNullOrBlank() || payerId.isNullOrBlank()) { val malformedResultError = PayPalWebCheckoutError.malformedResultError PayPalWebCheckoutFinishStartResult.Failure(malformedResultError, orderId) @@ -146,23 +134,18 @@ internal class PayPalWebLauncher( } private fun parseVaultSuccessResult( - finalResult: BrowserSwitchFinalResult.Success + deepLink: DeepLink ): PayPalWebCheckoutFinishVaultResult { - if (finalResult.requestCode != BrowserSwitchRequestCodes.PAYPAL_VAULT) { - return PayPalWebCheckoutFinishVaultResult.NoResult - } - - val deepLinkUrl = finalResult.returnUrl - val requestMetadata = finalResult.requestMetadata + val requestMetadata = deepLink.originalOptions.metadata return if (requestMetadata == null) { PayPalWebCheckoutFinishVaultResult.Failure(PayPalWebCheckoutError.unknownError) } else { - val isCancelUrl = deepLinkUrl.path?.contains("cancel") ?: false + val isCancelUrl = deepLink.uri.path?.contains("cancel") ?: false if (isCancelUrl) { PayPalWebCheckoutFinishVaultResult.Canceled } else { val approvalSessionId = - deepLinkUrl.getQueryParameter(URL_PARAM_APPROVAL_SESSION_ID) + deepLink.uri.getQueryParameter(URL_PARAM_APPROVAL_SESSION_ID) if (approvalSessionId.isNullOrEmpty()) { PayPalWebCheckoutFinishVaultResult.Failure(PayPalWebCheckoutError.malformedResultError) } else { diff --git a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt index 7226e1a5c..86b30e2cc 100644 --- a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt +++ b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt @@ -4,12 +4,12 @@ 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.PAYPAL_CHECKOUT import com.paypal.android.corepayments.BrowserSwitchRequestCodes.PAYPAL_VAULT +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.model.TokenType import io.mockk.every import io.mockk.mockk @@ -47,7 +47,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -61,7 +61,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("fake-order-id") get { returnUrlScheme }.isEqualTo("com.example.app") - get { url }.isEqualTo(Uri.parse("https://www.sandbox.paypal.com/checkoutnow")) + get { targetUri }.isEqualTo(Uri.parse("https://www.sandbox.paypal.com/checkoutnow")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) } } @@ -73,7 +73,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -87,7 +87,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("fake-order-id") get { returnUrlScheme }.isEqualTo("com.example.app") - get { url }.isEqualTo(Uri.parse("https://www.paypal.com/checkoutnow")) + get { targetUri }.isEqualTo(Uri.parse("https://www.paypal.com/checkoutnow")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) } } @@ -99,7 +99,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -113,7 +113,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("fake-order-id") get { returnUrlScheme }.isEqualTo("com.example.app") - get { url }.isEqualTo(Uri.parse("https://www.paypal.com/checkoutnow")) + get { targetUri }.isEqualTo(Uri.parse("https://www.paypal.com/checkoutnow")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) } } @@ -125,7 +125,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -139,7 +139,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("fake-order-id") get { returnUrlScheme }.isEqualTo("com.example.app") - get { url }.isEqualTo(Uri.parse("https://www.paypal.com/checkoutnow")) + get { targetUri }.isEqualTo(Uri.parse("https://www.paypal.com/checkoutnow")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) } } @@ -171,7 +171,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -185,7 +185,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("setup_token_id") }.isEqualTo("fake-setup-token") get { returnUrlScheme }.isEqualTo("com.example.app") - get { url }.isEqualTo(Uri.parse("https://sandbox.paypal.com/agreements/approve")) + get { targetUri }.isEqualTo(Uri.parse("https://sandbox.paypal.com/agreements/approve")) get { requestCode }.isEqualTo(PAYPAL_VAULT) } } @@ -197,7 +197,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -211,7 +211,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("setup_token_id") }.isEqualTo("fake-setup-token") get { returnUrlScheme }.isEqualTo("com.example.app") - get { url }.isEqualTo(Uri.parse("https://paypal.com/agreements/approve")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/agreements/approve")) get { requestCode }.isEqualTo(PAYPAL_VAULT) } } @@ -238,19 +238,18 @@ class PayPalWebLauncherUnitTest { @Test fun `completeCheckoutAuthRequest() parses successful checkout result`() { - val browserSwitchResult = createCheckoutSuccessBrowserSwitchResult( + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), requestCode = PAYPAL_CHECKOUT, - orderId = "fake-order-id", - payerId = "fake-payer-id" + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createCheckoutMetadata("fake-order-id") ) - - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = createCheckoutDeepLinkUrl("fake-payer-id") sut = PayPalWebLauncher(browserSwitchClient) - - val result = sut.completeCheckoutAuthRequest(intent, "pending request") + val result = sut.completeCheckoutAuthRequest(intent, authState) as PayPalWebCheckoutFinishStartResult.Success assertEquals("fake-order-id", result.orderId) assertEquals("fake-payer-id", result.payerId) @@ -258,17 +257,18 @@ class PayPalWebLauncherUnitTest { @Test fun `completeCheckoutAuthRequest() parses checkout failure when Payer Id is blank`() { - val browserSwitchResult = createCheckoutSuccessBrowserSwitchResult( + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), requestCode = PAYPAL_CHECKOUT, - orderId = "fake-order-id", - payerId = "" + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createCheckoutMetadata("fake-order-id") ) - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = createCheckoutDeepLinkUrl("") sut = PayPalWebLauncher(browserSwitchClient) - val result = sut.completeCheckoutAuthRequest(intent, "pending request") + val result = sut.completeCheckoutAuthRequest(intent, authState) as PayPalWebCheckoutFinishStartResult.Failure val expectedDescription = "Result did not contain the expected data. Payer ID or Order ID is null." @@ -277,17 +277,18 @@ class PayPalWebLauncherUnitTest { @Test fun `completeCheckoutAuthRequest() parses checkout failure when Order Id is blank`() { - val browserSwitchResult = createCheckoutSuccessBrowserSwitchResult( + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), requestCode = PAYPAL_CHECKOUT, - orderId = "", - payerId = "fake-payer-id" + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createCheckoutMetadata("") ) - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = createCheckoutDeepLinkUrl("fake-payer-id") sut = PayPalWebLauncher(browserSwitchClient) - val result = sut.completeCheckoutAuthRequest(intent, "pending request") + val result = sut.completeCheckoutAuthRequest(intent, authState) as PayPalWebCheckoutFinishStartResult.Failure val expectedDescription = "Result did not contain the expected data. Payer ID or Order ID is null." @@ -296,17 +297,18 @@ class PayPalWebLauncherUnitTest { @Test fun `completeCheckoutAuthRequest() parses checkout failure when metadata is null`() { - val browserSwitchResult = createCheckoutSuccessBrowserSwitchResult( + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), requestCode = PAYPAL_CHECKOUT, - payerId = "fake-payer-id", + returnUrlScheme = "com.example.app", + appLinkUrl = null, metadata = null ) - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = createCheckoutDeepLinkUrl("fake-payer-id") sut = PayPalWebLauncher(browserSwitchClient) - val result = sut.completeCheckoutAuthRequest(intent, "pending request") + val result = sut.completeCheckoutAuthRequest(intent, authState) as PayPalWebCheckoutFinishStartResult.Failure val expectedDescription = "An unknown error occurred. Contact developer.paypal.com/support." @@ -315,60 +317,71 @@ class PayPalWebLauncherUnitTest { @Test fun `completeCheckoutAuthRequest() parses checkout cancellation deep link url indicates failure`() { - val browserSwitchResult = createCheckoutCancellationBrowserSwitchResult() - - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), + requestCode = PAYPAL_CHECKOUT, + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createCheckoutMetadata("fake-order-id") + ) + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = "com.example.app://testurl.com/checkout?opType=cancel".toUri() sut = PayPalWebLauncher(browserSwitchClient) - - val result = sut.completeCheckoutAuthRequest(intent, "pending request") + val result = sut.completeCheckoutAuthRequest(intent, authState) assertTrue(result is PayPalWebCheckoutFinishStartResult.Canceled) } @Test fun `completeVaultAuthRequest() parses successful vault result`() { - val browserSwitchResult = createVaultSuccessBrowserSwitchResult( + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), requestCode = PAYPAL_VAULT, - setupTokenId = "fake-setup-token-id", - approvalSessionId = "fake-approval-session-id", + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createVaultMetadata("fake-setup-token-id") ) - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = createVaultDeepLinkUrl("fake-approval-session-id") sut = PayPalWebLauncher(browserSwitchClient) - val result = sut.completeVaultAuthRequest(intent, "pending request") + val result = sut.completeVaultAuthRequest(intent, authState) as PayPalWebCheckoutFinishVaultResult.Success assertEquals("fake-approval-session-id", result.approvalSessionId) } @Test fun `completeVaultAuthRequest() parses cancellation vault result when deep link path contains the word cancel`() { - val browserSwitchResult = createVaultCancellationBrowserSwitchResult() - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), + requestCode = PAYPAL_VAULT, + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createVaultMetadata("fake-setup-token-id") + ) + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = + "com.example.app://testurl.com/checkout/cancel?approval_session_id=fake-approval-session-id".toUri() sut = PayPalWebLauncher(browserSwitchClient) - val result = sut.completeVaultAuthRequest(intent, "pending request") + val result = sut.completeVaultAuthRequest(intent, authState) assertTrue(result is PayPalWebCheckoutFinishVaultResult.Canceled) } @Test fun `completeVaultAuthRequest() parses vault failure when approval session id is blank`() { - val browserSwitchResult = createVaultSuccessBrowserSwitchResult( + val originalOptions = BrowserSwitchOptions( + targetUri = "https://www.sandbox.paypal.com/checkoutnow".toUri(), requestCode = PAYPAL_VAULT, - setupTokenId = "fake-setup-token-id", - approvalSessionId = "", + returnUrlScheme = "com.example.app", + appLinkUrl = null, + metadata = createVaultMetadata("fake-setup-token-id") ) - every { - browserSwitchClient.completeRequest(intent, "pending request") - } returns browserSwitchResult + val authState = BrowserSwitchPendingState(originalOptions).toBase64EncodedJSON() + intent.data = createVaultDeepLinkUrl("") sut = PayPalWebLauncher(browserSwitchClient) - val result = sut.completeVaultAuthRequest(intent, "pending request") + val result = sut.completeVaultAuthRequest(intent, authState) as PayPalWebCheckoutFinishVaultResult.Failure val expectedDescription = "Result did not contain the expected data. Payer ID or Order ID is null." @@ -379,55 +392,13 @@ class PayPalWebLauncherUnitTest { .put("order_id", orderId) private fun createCheckoutDeepLinkUrl(payerId: String) = - Uri.parse("http://testurl.com/checkout?PayerID=$payerId") - - private fun createCheckoutSuccessBrowserSwitchResult( - requestCode: Int, - orderId: String? = null, - payerId: String? = null, - metadata: JSONObject? = createCheckoutMetadata(orderId!!), - deepLinkUrl: Uri = createCheckoutDeepLinkUrl(payerId!!) - ) = createBrowserSwitchSuccessFinalResult(requestCode, metadata, deepLinkUrl) - - private fun createCheckoutCancellationBrowserSwitchResult(): BrowserSwitchFinalResult.Success { - val deepLinkUrl = "http://testurl.com/checkout?opType=cancel".toUri() - val metadata = createCheckoutMetadata("fake-order-id") - return createBrowserSwitchSuccessFinalResult(PAYPAL_CHECKOUT, metadata, deepLinkUrl) - } + Uri.parse("com.example.app://testurl.com/checkout?PayerID=$payerId") private fun createVaultMetadata(setupTokenId: String) = JSONObject() .put("setup_token_id", setupTokenId) private fun createVaultDeepLinkUrl(approvalSessionId: String) = - Uri.parse("http://testurl.com/checkout?approval_session_id=$approvalSessionId") - - private fun createVaultSuccessBrowserSwitchResult( - requestCode: Int, - setupTokenId: String? = null, - approvalSessionId: String? = null, - metadata: JSONObject? = createVaultMetadata(setupTokenId!!), - deepLinkUrl: Uri = createVaultDeepLinkUrl(approvalSessionId!!) - ) = createBrowserSwitchSuccessFinalResult(requestCode, metadata, deepLinkUrl) - - private fun createVaultCancellationBrowserSwitchResult(): BrowserSwitchFinalResult.Success { - val metadata = createVaultMetadata("fake-setup-token-id") - val deepLinkUrl = - "http://testurl.com/checkout/cancel?approval_session_id=fake-approval-session-id".toUri() - return createBrowserSwitchSuccessFinalResult(PAYPAL_VAULT, metadata, deepLinkUrl) - } - - private fun createBrowserSwitchSuccessFinalResult( - requestCode: Int, - metadata: JSONObject?, - deepLinkUrl: Uri - ): BrowserSwitchFinalResult.Success { - val finalResult = mockk(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 - } + Uri.parse("com.example.app://testurl.com/checkout?approval_session_id=$approvalSessionId") // LAUNCH WITH URL TESTS @@ -438,7 +409,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -452,7 +423,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("order-123") get { returnUrlScheme }.isEqualTo("custom_url_scheme") - get { url }.isEqualTo(Uri.parse("https://paypal.com/app-switch")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/app-switch")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) } } @@ -464,7 +435,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -478,7 +449,7 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("setup_token_id") }.isEqualTo("setup-456") get { returnUrlScheme }.isEqualTo("custom_url_scheme") - get { url }.isEqualTo(Uri.parse("https://paypal.com/vault-switch")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/vault-switch")) get { requestCode }.isEqualTo(PAYPAL_VAULT) } } @@ -511,7 +482,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success val appLinkUrl = "https://example.com/return" sut.launchWithUrl( @@ -527,9 +498,9 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("order-123") get { returnUrlScheme }.isEqualTo("custom_url_scheme") // Should use provided returnUrlScheme as fallback - get { url }.isEqualTo(Uri.parse("https://paypal.com/checkout")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/checkout")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) - get { appLinkUri }.isEqualTo(appLinkUrl.toUri()) + get { this.appLinkUrl }.isEqualTo(appLinkUrl) } } @@ -540,7 +511,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success sut.launchWithUrl( activity, @@ -555,9 +526,9 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("order-123") get { returnUrlScheme }.isEqualTo("custom_url_scheme") - get { url }.isEqualTo(Uri.parse("https://paypal.com/checkout")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/checkout")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) - get { appLinkUri }.isEqualTo(null) + get { appLinkUrl }.isEqualTo(null) } } @@ -568,7 +539,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success val appLinkUrl = "https://example.com/vault/return" sut.launchWithUrl( @@ -584,9 +555,9 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("setup_token_id") }.isEqualTo("setup-456") get { returnUrlScheme }.isEqualTo("custom_url_scheme") // Should use provided returnUrlScheme as fallback - get { url }.isEqualTo(Uri.parse("https://paypal.com/vault")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/vault")) get { requestCode }.isEqualTo(PAYPAL_VAULT) - get { appLinkUri }.isEqualTo(appLinkUrl.toUri()) + get { this.appLinkUrl }.isEqualTo(appLinkUrl) } } @@ -597,7 +568,7 @@ class PayPalWebLauncherUnitTest { val slot = slot() every { browserSwitchClient.start(activity, capture(slot)) - } returns BrowserSwitchStartResult.Started("pending request") + } returns BrowserSwitchStartResult.Success // Call with old signature (should default appLinkUrl to null) sut.launchWithUrl( @@ -612,9 +583,9 @@ class PayPalWebLauncherUnitTest { expectThat(browserSwitchOptions) { get { metadata?.get("order_id") }.isEqualTo("order-123") get { returnUrlScheme }.isEqualTo("custom_url_scheme") - get { url }.isEqualTo(Uri.parse("https://paypal.com/checkout")) + get { targetUri }.isEqualTo(Uri.parse("https://paypal.com/checkout")) get { requestCode }.isEqualTo(PAYPAL_CHECKOUT) - get { appLinkUri }.isEqualTo(null) + get { appLinkUrl }.isEqualTo(null) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a79e551d9..0268f0096 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] androidGradlePlugin = "8.7.1" androidxAppcompat = "1.3.1" +androidxBrowser="1.7.0" androidxComposeBom = "2024.10.01" androidxCoreKtx = "1.6.0" androidxEspressoCore = "3.4.0" @@ -13,7 +14,6 @@ androidxTestCore = "1.5.0" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxTestUiAutomator = "2.2.0" -browserSwitch = "3.2.0" constraintLayout = "2.1.0" daggerHilt = "2.51.1" detektVersion = "1.22.0" @@ -44,6 +44,7 @@ striktMockk = "0.30.1" [libraries] android-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser"} androidx-constraintLayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintLayout" } androidx-coreKtx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCoreKtx" } androidx-fragmentKtx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragmentKtx" } @@ -54,7 +55,6 @@ androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-uiAutomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxTestUiAutomator" } -braintree-browserSwitch = { group = "com.braintreepayments.api", name = "browser-switch", version.ref = "browserSwitch" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-uiTestJunit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }