Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
4b1122c
Add auth tab launcher support for PayPal checkout
kgangineni Jan 15, 2026
cfc38c2
Add unit tests for auth tab launcher functionality
kgangineni Jan 15, 2026
af9f06d
Merge branch 'develop' into closeAuthTab
kgangineni Jan 16, 2026
3ae0902
fix static analysis and API check
kgangineni Jan 16, 2026
49f58a4
⏺ Fallback scenarios when auth tab is not supported
kgangineni Jan 23, 2026
68adbb9
Fix Unit tests
kgangineni Jan 23, 2026
fc2c282
check default browser when checking for auth tab support instead of j…
kgangineni Jan 27, 2026
23fe558
add info and documentation
kgangineni Jan 27, 2026
4fc4040
fix PR feedback
kgangineni Jan 27, 2026
e18f486
- updates androidTargetVersion
kgangineni Jan 27, 2026
eb68e8a
fix unit tests
kgangineni Jan 27, 2026
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
12 changes: 6 additions & 6 deletions CardPayments/api/CardPayments.api
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public final class com/paypal/android/cardpayments/Card : android/os/Parcelable
public final fun component6 ()Lcom/paypal/android/corepayments/Address;
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/paypal/android/corepayments/Address;)Lcom/paypal/android/cardpayments/Card;
public static synthetic fun copy$default (Lcom/paypal/android/cardpayments/Card;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/paypal/android/corepayments/Address;ILjava/lang/Object;)Lcom/paypal/android/cardpayments/Card;
public fun describeContents ()I
public final fun describeContents ()I
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In a strict sense, this is technically a breaking change. Is there a way to prevent this change?

public fun equals (Ljava/lang/Object;)Z
public final fun getBillingAddress ()Lcom/paypal/android/corepayments/Address;
public final fun getCardholderName ()Ljava/lang/String;
Expand All @@ -33,7 +33,7 @@ public final class com/paypal/android/cardpayments/Card : android/os/Parcelable
public final fun setNumber (Ljava/lang/String;)V
public final fun setSecurityCode (Ljava/lang/String;)V
public fun toString ()Ljava/lang/String;
public fun writeToParcel (Landroid/os/Parcel;I)V
public final fun writeToParcel (Landroid/os/Parcel;I)V
}

public final class com/paypal/android/cardpayments/Card$Creator : android/os/Parcelable$Creator {
Expand Down Expand Up @@ -245,15 +245,15 @@ public final class com/paypal/android/cardpayments/CardRequest : android/os/Parc
public final fun component4 ()Lcom/paypal/android/cardpayments/threedsecure/SCA;
public final fun copy (Ljava/lang/String;Lcom/paypal/android/cardpayments/Card;Ljava/lang/String;Lcom/paypal/android/cardpayments/threedsecure/SCA;)Lcom/paypal/android/cardpayments/CardRequest;
public static synthetic fun copy$default (Lcom/paypal/android/cardpayments/CardRequest;Ljava/lang/String;Lcom/paypal/android/cardpayments/Card;Ljava/lang/String;Lcom/paypal/android/cardpayments/threedsecure/SCA;ILjava/lang/Object;)Lcom/paypal/android/cardpayments/CardRequest;
public fun describeContents ()I
public final fun describeContents ()I
public fun equals (Ljava/lang/Object;)Z
public final fun getCard ()Lcom/paypal/android/cardpayments/Card;
public final fun getOrderId ()Ljava/lang/String;
public final fun getReturnUrl ()Ljava/lang/String;
public final fun getSca ()Lcom/paypal/android/cardpayments/threedsecure/SCA;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
public fun writeToParcel (Landroid/os/Parcel;I)V
public final fun writeToParcel (Landroid/os/Parcel;I)V
}

public final class com/paypal/android/cardpayments/CardRequest$Creator : android/os/Parcelable$Creator {
Expand All @@ -277,14 +277,14 @@ public final class com/paypal/android/cardpayments/CardVaultRequest : android/os
public final fun component3 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Lcom/paypal/android/cardpayments/Card;Ljava/lang/String;)Lcom/paypal/android/cardpayments/CardVaultRequest;
public static synthetic fun copy$default (Lcom/paypal/android/cardpayments/CardVaultRequest;Ljava/lang/String;Lcom/paypal/android/cardpayments/Card;Ljava/lang/String;ILjava/lang/Object;)Lcom/paypal/android/cardpayments/CardVaultRequest;
public fun describeContents ()I
public final fun describeContents ()I
public fun equals (Ljava/lang/Object;)Z
public final fun getCard ()Lcom/paypal/android/cardpayments/Card;
public final fun getReturnUrl ()Ljava/lang/String;
public final fun getSetupTokenId ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
public fun writeToParcel (Landroid/os/Parcel;I)V
public final fun writeToParcel (Landroid/os/Parcel;I)V
}

public final class com/paypal/android/cardpayments/CardVaultRequest$Creator : android/os/Parcelable$Creator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ class CardAuthLauncherUnitTest {
fun `presentAuthChallenge() returns an error for approve order when it cannot browser switch`() {
val browserSwitchResult =
BrowserSwitchStartResult.Failure(Exception("error message from browser switch"))
every { browserSwitchClient.start(any(), any()) } returns browserSwitchResult
every {
browserSwitchClient.start(any<FragmentActivity>(), any<BrowserSwitchOptions>())
} returns browserSwitchResult

val returnUrl = "merchant.app://return.com/deep-link"
val cardRequest = CardRequest("fake-order-id", card, returnUrl)
Expand Down
138 changes: 72 additions & 66 deletions CorePayments/api/CorePayments.api

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CorePayments/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ dependencies {
implementation libs.kotlin.stdLib
implementation libs.kotlinx.coroutinesAndroid
implementation libs.kotlinx.serializationJson
implementation libs.androidx.activity.ktx

testImplementation libs.json
testImplementation libs.jsonAssert
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.paypal.android.corepayments.browserswitch

import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.browser.auth.AuthTabIntent
import androidx.core.net.toUri

class AuthTabClient {
fun launchAuthTab(
options: ChromeCustomTabOptions,
activityResultLauncher: ActivityResultLauncher<Intent>,
appLinkUrl: String? = null,
returnUrlScheme: String? = null
) {
val appLinkUri = appLinkUrl?.toUri()
val redirectHost = appLinkUri?.host
val redirectPath = appLinkUri?.path
val authTabIntent = AuthTabIntent.Builder().build()

when {
redirectHost != null && redirectPath != null -> {
authTabIntent.launch(
activityResultLauncher,
options.launchUri,
redirectHost,
redirectPath
)
}

returnUrlScheme != null -> {
authTabIntent.launch(
activityResultLauncher,
options.launchUri,
returnUrlScheme
)
}

else -> {
throw IllegalArgumentException("Either appLinkUrl or returnUrlScheme must be provided")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@ package com.paypal.android.corepayments.browserswitch

import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RestrictTo
import com.paypal.android.corepayments.common.DeviceInspector

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class BrowserSwitchClient internal constructor(
class BrowserSwitchClient(
private val chromeCustomTabsClient: ChromeCustomTabsClient,
private val authTabClient: AuthTabClient,
private val deviceInspector: DeviceInspector
) {

constructor(context: Context) : this(ChromeCustomTabsClient(), DeviceInspector(context))
constructor(context: Context) : this(
ChromeCustomTabsClient(),
AuthTabClient(),
DeviceInspector(context)
)

fun start(
context: Context,
Expand Down Expand Up @@ -63,4 +70,25 @@ class BrowserSwitchClient internal constructor(
)
}
}

fun start(
activityResultLauncher: ActivityResultLauncher<Intent>,
options: BrowserSwitchOptions,
context: Context
): BrowserSwitchStartResult {
// Check if Chrome supports auth tabs
if (!deviceInspector.isAuthTabSupported) {
// Fallback to custom tab implementation
return start(context, options)
}

val cctOptions = ChromeCustomTabOptions(launchUri = options.targetUri)
authTabClient.launchAuthTab(
options = cctOptions,
activityResultLauncher = activityResultLauncher,
appLinkUrl = options.appLinkUrl,
returnUrlScheme = options.returnUrlScheme
)
return BrowserSwitchStartResult.Success
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package com.paypal.android.corepayments.common

import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.annotation.RestrictTo
import androidx.browser.customtabs.CustomTabsClient
import androidx.core.net.toUri

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
Expand All @@ -11,6 +13,14 @@ class DeviceInspector(private val context: Context) {
val isPayPalInstalled: Boolean
get() = isAppInstalled(PAYPAL_APP_PACKAGE)

val isAuthTabSupported: Boolean
get() {
val defaultBrowser = getDefaultBrowser(context)
return defaultBrowser?.let {
CustomTabsClient.isAuthTabSupported(context, it)
} ?: false
}

private fun isAppInstalled(packageName: String): Boolean = runCatching {
context.packageManager.getApplicationInfo(packageName, 0)
true
Expand All @@ -26,6 +36,13 @@ class DeviceInspector(private val context: Context) {
return candidateActivities.isNotEmpty()
}

fun getDefaultBrowser(context: Context): String? {
val intent = Intent(Intent.ACTION_VIEW, "http://www.example.com".toUri())
val resolveInfo =
context.packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
return resolveInfo?.activityInfo?.packageName
}

companion object {
const val PAYPAL_APP_PACKAGE = "com.paypal.android.p2pmobile"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.paypal.android.corepayments.browserswitch

import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class AuthTabClientUnitTest {

private lateinit var sut: AuthTabClient
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
private lateinit var options: ChromeCustomTabOptions

@Before
fun beforeEach() {
sut = AuthTabClient()
activityResultLauncher = mockk(relaxed = true)
options = ChromeCustomTabOptions(launchUri = "https://example.com/auth".toUri())
}

@Test
fun `launchAuthTab with appLinkUrl launches auth tab with host and path`() {
val appLinkUrl = "https://example.com/return/path"
val intentSlot = slot<Intent>()

sut.launchAuthTab(
options = options,
activityResultLauncher = activityResultLauncher,
appLinkUrl = appLinkUrl,
returnUrlScheme = null
)

verify { activityResultLauncher.launch(capture(intentSlot)) }
val intent = intentSlot.captured
assertNotNull(intent)
assertEquals("https://example.com/auth", intent.data?.toString())
}

@Test
fun `launchAuthTab with returnUrlScheme launches auth tab with scheme`() {
val returnUrlScheme = "com.example.app"
val intentSlot = slot<Intent>()

sut.launchAuthTab(
options = options,
activityResultLauncher = activityResultLauncher,
appLinkUrl = null,
returnUrlScheme = returnUrlScheme
)

verify { activityResultLauncher.launch(capture(intentSlot)) }
val intent = intentSlot.captured
assertNotNull(intent)
assertEquals("https://example.com/auth", intent.data?.toString())
}

@Test
fun `launchAuthTab throws when both appLinkUrl and returnUrlScheme are null`() {
val exception = assertThrows(IllegalArgumentException::class.java) {
sut.launchAuthTab(
options = options,
activityResultLauncher = activityResultLauncher,
appLinkUrl = null,
returnUrlScheme = null
)
}

assert(exception.message == "Either appLinkUrl or returnUrlScheme must be provided")
}

@Test
fun `launchAuthTab with appLinkUrl with root path launches auth tab successfully`() {
val appLinkUrl = "https://example.com"
val intentSlot = slot<Intent>()

sut.launchAuthTab(
options = options,
activityResultLauncher = activityResultLauncher,
appLinkUrl = appLinkUrl,
returnUrlScheme = null
)

verify { activityResultLauncher.launch(capture(intentSlot)) }
val intent = intentSlot.captured
assertNotNull(intent)
assertEquals("https://example.com/auth", intent.data?.toString())
}

@Test
fun `launchAuthTab prefers appLinkUrl when both appLinkUrl and returnUrlScheme are provided`() {
val appLinkUrl = "https://example.com/return/path"
val returnUrlScheme = "com.example.app"
val intentSlot = slot<Intent>()

sut.launchAuthTab(
options = options,
activityResultLauncher = activityResultLauncher,
appLinkUrl = appLinkUrl,
returnUrlScheme = returnUrlScheme
)

verify { activityResultLauncher.launch(capture(intentSlot)) }
val intent = intentSlot.captured
assertNotNull(intent)
assertEquals("https://example.com/auth", intent.data?.toString())
}

@Test
fun `launchAuthTab with valid appLinkUrl parses host and path correctly`() {
val appLinkUrl = "https://paypal.com/checkoutnow/return"
val intentSlot = slot<Intent>()

sut.launchAuthTab(
options = options,
activityResultLauncher = activityResultLauncher,
appLinkUrl = appLinkUrl,
returnUrlScheme = null
)

verify { activityResultLauncher.launch(capture(intentSlot)) }
val intent = intentSlot.captured
assertNotNull(intent)
assertEquals("https://example.com/auth", intent.data?.toString())
}
}
Loading