From ee1cce3120f0ff93f0278de8fd763ffc9f301a4b Mon Sep 17 00:00:00 2001 From: saperi22 <104481964+saperi22@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:31:54 -0700 Subject: [PATCH 01/10] Auth tab initial setup (#119) * remove duplicate intent-filter * - Update androidx.browser to 1.9.0 - Update AGP version to 8.9.1 -- requires update to gradle wrapper 8.11.1 - Update compile and target sdk to 36 --- build.gradle | 9 ++++----- demo/src/main/AndroidManifest.xml | 9 --------- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/build.gradle b/build.gradle index efcc5c28..30204818 100644 --- a/build.gradle +++ b/build.gradle @@ -13,11 +13,10 @@ buildscript { "javaTargetCompatibility": sdkTargetJavaVersion, ] - ext.deps = [ 'annotation' : 'androidx.annotation:annotation:1.7.0', 'appcompat' : 'androidx.appcompat:appcompat:1.6.0', - 'browser' : 'androidx.browser:browser:1.7.0', + 'browser' : 'androidx.browser:browser:1.9.0', 'kotlin' : 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20', // test dependencies @@ -29,7 +28,7 @@ buildscript { ] dependencies { - classpath 'com.android.tools.build:gradle:8.5.2' + classpath 'com.android.tools.build:gradle:8.9.1' classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.9.10' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' } @@ -44,9 +43,9 @@ plugins { version = '3.1.1-SNAPSHOT' group = "com.braintreepayments" ext { - compileSdkVersion = 35 + compileSdkVersion = 36 minSdkVersion = 23 - targetSdkVersion = 35 + targetSdkVersion = 36 versionCode = 73 versionName = version } diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index c0d6dc5d..9ca4e7d5 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -47,15 +47,6 @@ - - - - - - - - - diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cdfd13ff..a2d456c0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Jul 17 09:55:05 CDT 2023 +#Thu Sep 25 12:23:31 PDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From cc39a8a20b4de742a1aa9a297f7901bd81819afb Mon Sep 17 00:00:00 2001 From: Anastasiia Noguier Date: Fri, 10 Oct 2025 09:01:28 -0500 Subject: [PATCH 02/10] Completed AuthTab Implementation (#121) * remove duplicate intent-filter * - Update androidx.browser to 1.9.0 - Update AGP version to 8.9.1 -- requires update to gradle wrapper 8.11.1 - Update compile and target sdk to 36 * first iteration on AuthTab Setup: added AuthTabInternalClient, removed ChromeCustomTabsInternalClient, modified ComposeActivity & DemoActivitySingleTop to accept the above changes, added toast visuals to see whether or not the AuthTab is supported * Rename .java to .kt * second iteration on AuthTab Setup: rewrote AuthTabInternalClient to Kotlin and added a ClearTop Flag, wrote new AuthTabInternalClientUnitTest, removed ChromeCustomTabsInternalClientUnitTest, modified BrowserSwitchClientUnitTest to accept the AuthTab changes * third iteration on AuthTab Setup: removed CustomTabIntentBuilder, removed tests associated with CustomTabs from AuthTabInternalClientUnitTest,, modified BrowserSwitchClientUnitTest to accept the AuthTab launchURL signature * EOD commit: -Testing the authtab flow on older devices without intents * Revert "EOD commit:" This reverts commit e27cfa12d6f28f52dd54397a9152afac071b0d8b. * Revert "third iteration on AuthTab Setup:" This reverts commit 9803e438516744b2ed00bf8d9afdaad125637815. Branching still needed to provide support for older devices. * Completed implementation of AuthTab Support: - added AuthTabInternalClient, - removed ChromeCustomTabsInternalClient, - modified ComposeActivity & DemoActivitySingleTop to accept the above changes, - added toast visuals to see whether or not the AuthTab is supported * Address PR suggestions * Address PR suggestions --------- Co-authored-by: saperi --- .../braintreepayments/api/AuthTabCallback.kt | 11 + .../api/AuthTabInternalClient.kt | 69 ++++ .../api/BrowserSwitchClient.java | 143 +++++--- .../api/ChromeCustomTabsInternalClient.java | 38 --- .../api/AuthTabInternalClientUnitTest.kt | 219 ++++++++++++ .../api/BrowserSwitchClientUnitTest.java | 313 ++++++++++++++++-- .../ChromeCustomTabsInternalClientUnitTest.kt | 64 ---- .../api/browserswitch/demo/ComposeActivity.kt | 54 ++- .../demo/DemoActivitySingleTop.java | 67 +++- .../api/browserswitch/demo/MainActivity.java | 10 + 10 files changed, 794 insertions(+), 194 deletions(-) create mode 100644 browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt create mode 100644 browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt delete mode 100644 browser-switch/src/main/java/com/braintreepayments/api/ChromeCustomTabsInternalClient.java create mode 100644 browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt delete mode 100644 browser-switch/src/test/java/com/braintreepayments/api/ChromeCustomTabsInternalClientUnitTest.kt diff --git a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt new file mode 100644 index 00000000..52d20435 --- /dev/null +++ b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt @@ -0,0 +1,11 @@ +package com.braintreepayments.api + +/** + * Callback interface for Auth Tab results + */ +fun interface AuthTabCallback { + /** + * @param result The final result of the browser switch operation + */ + fun onResult(result: BrowserSwitchFinalResult) +} \ No newline at end of file diff --git a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt new file mode 100644 index 00000000..c7e93a6b --- /dev/null +++ b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt @@ -0,0 +1,69 @@ +package com.braintreepayments.api + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent + +internal class AuthTabInternalClient ( + private val authTabIntentBuilder: AuthTabIntent.Builder = AuthTabIntent.Builder(), + private val customTabsIntentBuilder: CustomTabsIntent.Builder = CustomTabsIntent.Builder() +) { + + fun isAuthTabSupported(context: Context): Boolean { + val packageName = CustomTabsClient.getPackageName(context, null) + return when (packageName) { + null -> false + else -> CustomTabsClient.isAuthTabSupported(context, packageName) + } + } + + /** + * Launch URL using Auth Tab if supported, otherwise fall back to Custom Tabs + */ + @Throws(ActivityNotFoundException::class) + fun launchUrl( + context: Context, + url: Uri, + returnUrlScheme: String?, + appLinkUri: Uri?, + launcher: ActivityResultLauncher, + launchType: LaunchType? + ) { + val useAuthTab = isAuthTabSupported(context) + + if (useAuthTab) { + val authTabIntent = authTabIntentBuilder.build() + + if (launchType == LaunchType.ACTIVITY_CLEAR_TOP) { + authTabIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + appLinkUri?.host?.let { host -> + val path = appLinkUri.path ?: "/" + authTabIntent.launch(launcher, url, host, path) + } ?: returnUrlScheme?.let { + authTabIntent.launch(launcher, url, returnUrlScheme) + } ?: throw IllegalArgumentException("Either returnUrlScheme or appLinkUri must be provided") + } else { + // fall back to Custom Tabs + launchCustomTabs(context, url, launchType) + } + } + private fun launchCustomTabs(context: Context, url: Uri, launchType: LaunchType?) { + val customTabsIntent = customTabsIntentBuilder.build() + when (launchType) { + LaunchType.ACTIVITY_NEW_TASK -> { + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + LaunchType.ACTIVITY_CLEAR_TOP -> { + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + null -> { } + } + customTabsIntent.launchUrl(context, url) + } +} \ No newline at end of file diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 65ab89e3..b275d88a 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -6,8 +6,10 @@ import android.net.Uri; import androidx.activity.ComponentActivity; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import androidx.browser.auth.AuthTabIntent; import com.braintreepayments.api.browserswitch.R; @@ -19,35 +21,76 @@ public class BrowserSwitchClient { private final BrowserSwitchInspector browserSwitchInspector; - - private final ChromeCustomTabsInternalClient customTabsInternalClient; + private final AuthTabInternalClient authTabInternalClient; + private ActivityResultLauncher authTabLauncher; + private BrowserSwitchRequest pendingAuthTabRequest; /** * Construct a client that manages the logic for browser switching. */ public BrowserSwitchClient() { - this(new BrowserSwitchInspector(), new ChromeCustomTabsInternalClient()); + this(new BrowserSwitchInspector(), new AuthTabInternalClient()); } @VisibleForTesting BrowserSwitchClient(BrowserSwitchInspector browserSwitchInspector, - ChromeCustomTabsInternalClient customTabsInternalClient) { + AuthTabInternalClient authTabInternalClient) { this.browserSwitchInspector = browserSwitchInspector; - this.customTabsInternalClient = customTabsInternalClient; + this.authTabInternalClient = authTabInternalClient; + } + + /** + * Initialize the Auth Tab launcher. This should be called in the activity's onCreate() + * before the activity is started. + */ + public void initializeAuthTabLauncher(@NonNull ComponentActivity activity, + @NonNull AuthTabCallback callback) { + this.authTabLauncher = AuthTabIntent.registerActivityResultLauncher( + activity, + result -> { + BrowserSwitchFinalResult finalResult; + + switch (result.resultCode) { + case AuthTabIntent.RESULT_OK: + if (result.resultUri != null && pendingAuthTabRequest != null) { + finalResult = new BrowserSwitchFinalResult.Success( + result.resultUri, + pendingAuthTabRequest + ); + } else { + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + } + break; + case AuthTabIntent.RESULT_CANCELED: + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + break; + case AuthTabIntent.RESULT_VERIFICATION_FAILED: + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + break; + case AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT: + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + break; + default: + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + } + callback.onResult(finalResult); + pendingAuthTabRequest = null; + } + ); } /** - * Open a browser or Chrome Custom Tab - * with a given set of {@link BrowserSwitchOptions} from an Android activity. + * Open a browser or Auth Tab with a given set of {@link BrowserSwitchOptions} from an Android activity. * * @param activity the activity used to start browser switch * @param browserSwitchOptions {@link BrowserSwitchOptions} the options used to configure the browser switch * @return a {@link BrowserSwitchStartResult.Started} that should be stored and passed to - * {@link BrowserSwitchClient#completeRequest(Intent, String)} upon return to the app, + * {@link BrowserSwitchClient#completeRequest(Intent, String)} upon return to the app (for Custom Tabs fallback), * or {@link BrowserSwitchStartResult.Failure} if browser could not be launched. */ @NonNull - public BrowserSwitchStartResult start(@NonNull ComponentActivity activity, @NonNull BrowserSwitchOptions browserSwitchOptions) { + public BrowserSwitchStartResult start(@NonNull ComponentActivity activity, + @NonNull BrowserSwitchOptions browserSwitchOptions) { try { assertCanPerformBrowserSwitch(activity, browserSwitchOptions); } catch (BrowserSwitchException e) { @@ -58,29 +101,53 @@ public BrowserSwitchStartResult start(@NonNull ComponentActivity activity, @NonN int requestCode = browserSwitchOptions.getRequestCode(); String returnUrlScheme = browserSwitchOptions.getReturnUrlScheme(); Uri appLinkUri = browserSwitchOptions.getAppLinkUri(); - JSONObject metadata = browserSwitchOptions.getMetadata(); if (activity.isFinishing()) { String activityFinishingMessage = "Unable to start browser switch while host Activity is finishing."; return new BrowserSwitchStartResult.Failure(new BrowserSwitchException(activityFinishingMessage)); - } else { - LaunchType launchType = browserSwitchOptions.getLaunchType(); - BrowserSwitchRequest request; - try { - request = new BrowserSwitchRequest( - requestCode, - browserSwitchUrl, - metadata, - returnUrlScheme, - appLinkUri - ); - customTabsInternalClient.launchUrl(activity, browserSwitchUrl, launchType); - return new BrowserSwitchStartResult.Started(request.toBase64EncodedJSON()); - } catch (ActivityNotFoundException | BrowserSwitchException e) { - return new BrowserSwitchStartResult.Failure(new BrowserSwitchException("Unable to start browser switch without a web browser.", e)); + } + + LaunchType launchType = browserSwitchOptions.getLaunchType(); + BrowserSwitchRequest request; + + try { + request = new BrowserSwitchRequest( + requestCode, + browserSwitchUrl, + metadata, + returnUrlScheme, + appLinkUri + ); + + boolean useAuthTab = authTabInternalClient.isAuthTabSupported(activity); + + if (useAuthTab) { + this.pendingAuthTabRequest = request; } + + authTabInternalClient.launchUrl( + activity, + browserSwitchUrl, + returnUrlScheme, + appLinkUri, + authTabLauncher, + launchType + ); + + return new BrowserSwitchStartResult.Started(request.toBase64EncodedJSON()); + + } catch (ActivityNotFoundException e) { + this.pendingAuthTabRequest = null; + return new BrowserSwitchStartResult.Failure( + new BrowserSwitchException("Unable to start browser switch without a web browser.", e) + ); + } catch (Exception e) { + this.pendingAuthTabRequest = null; + return new BrowserSwitchStartResult.Failure( + new BrowserSwitchException("Unable to start browser switch: " + e.getMessage(), e) + ); } } @@ -121,20 +188,18 @@ private boolean isValidRequestCode(int requestCode) { } /** - * Completes the browser switch flow and returns a browser switch result if a match is found for - * the given {@link BrowserSwitchRequest} + * Completes the browser switch flow for Custom Tabs fallback scenarios. + * This method is still needed for devices that don't support Auth Tab. + * + *

See + * Auth Tab Fallback Documentation for details on when Custom Tabs fallback is required * - * @param intent the intent to return to your application containing a deep link result from the - * browser flow - * @param pendingRequest the pending request string returned from {@link BrowserSwitchStartResult.Started} via - * {@link BrowserSwitchClient#start(ComponentActivity, BrowserSwitchOptions)} - * @return a {@link BrowserSwitchFinalResult.Success} if the browser switch was successfully - * completed, or {@link BrowserSwitchFinalResult.NoResult} if no result can be found for the given - * pending request String. A {@link BrowserSwitchFinalResult.NoResult} will be - * returned if the user returns to the app without completing the browser switch flow. + * @param intent the intent to return to your application containing a deep link result + * @param pendingRequest the pending request string returned from {@link BrowserSwitchStartResult.Started} + * @return a {@link BrowserSwitchFinalResult} */ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull String pendingRequest) { - if (intent != null && intent.getData() != null) { + if (intent.getData() != null) { Uri returnUrl = intent.getData(); try { @@ -149,4 +214,8 @@ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull } return BrowserSwitchFinalResult.NoResult.INSTANCE; } -} + + public boolean isAuthTabSupported(Context context) { + return authTabInternalClient.isAuthTabSupported(context); + } +} \ No newline at end of file diff --git a/browser-switch/src/main/java/com/braintreepayments/api/ChromeCustomTabsInternalClient.java b/browser-switch/src/main/java/com/braintreepayments/api/ChromeCustomTabsInternalClient.java deleted file mode 100644 index aedc17a2..00000000 --- a/browser-switch/src/main/java/com/braintreepayments/api/ChromeCustomTabsInternalClient.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.braintreepayments.api; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import androidx.annotation.VisibleForTesting; -import androidx.browser.customtabs.CustomTabsIntent; - -class ChromeCustomTabsInternalClient { - - private final CustomTabsIntent.Builder customTabsIntentBuilder; - - ChromeCustomTabsInternalClient() { - this(new CustomTabsIntent.Builder()); - } - - @VisibleForTesting - ChromeCustomTabsInternalClient(CustomTabsIntent.Builder builder) { - this.customTabsIntentBuilder = builder; - } - - void launchUrl(Context context, Uri url, LaunchType launchType) throws ActivityNotFoundException { - CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build(); - if (launchType != null) { - switch (launchType) { - case ACTIVITY_NEW_TASK: - customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - break; - case ACTIVITY_CLEAR_TOP: - customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - break; - } - } - customTabsIntent.launchUrl(context, url); - } -} diff --git a/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt new file mode 100644 index 00000000..b1635664 --- /dev/null +++ b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt @@ -0,0 +1,219 @@ +package com.braintreepayments.api + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import io.mockk.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AuthTabInternalClientUnitTest { + + private lateinit var authTabBuilder: AuthTabIntent.Builder + private lateinit var customTabsBuilder: CustomTabsIntent.Builder + private lateinit var authTabIntent: AuthTabIntent + private lateinit var customTabsIntent: CustomTabsIntent + private lateinit var context: Context + private lateinit var url: Uri + private lateinit var launcher: ActivityResultLauncher + + @Before + fun setUp() { + clearAllMocks() + authTabBuilder = mockk(relaxed = true) + customTabsBuilder = mockk(relaxed = true) + context = mockk(relaxed = true) + url = mockk(relaxed = true) + launcher = mockk(relaxed = true) + + authTabIntent = AuthTabIntent.Builder().build() + customTabsIntent = CustomTabsIntent.Builder().build() + + every { authTabBuilder.build() } returns authTabIntent + every { customTabsBuilder.build() } returns customTabsIntent + + mockkStatic(CustomTabsClient::class) + } + + @Test + fun `isAuthTabSupported returns false when no browser package available`() { + every { CustomTabsClient.getPackageName(context, null) } returns null + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + assertFalse(client.isAuthTabSupported(context)) + } + + @Test + fun `isAuthTabSupported returns true when browser supports Auth Tab`() { + val packageName = "com.android.chrome" + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + assertTrue(client.isAuthTabSupported(context)) + } + + @Test + fun `isAuthTabSupported returns false when browser does not support Auth Tab`() { + val packageName = "com.android.chrome" + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns false + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + assertFalse(client.isAuthTabSupported(context)) + } + + @Test + fun `launchUrl uses Auth Tab with app link when supported`() { + val appLinkUri = Uri.parse("https://example.com/auth") + val packageName = "com.android.chrome" + customTabsIntent = mockk(relaxed = true) + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + client.launchUrl(context, url, null, appLinkUri, launcher, null) + + verify { + authTabIntent.launch(launcher, url, "example.com", "/auth") + } + verify(exactly = 0) { + customTabsIntent.launchUrl(any(), any()) + } + } + + @Test + fun `launchUrl uses Auth Tab with return URL scheme when supported`() { + val returnUrlScheme = "testcustomscheme" + val packageName = "com.android.chrome" + customTabsIntent = mockk(relaxed = true) + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + client.launchUrl(context, url, returnUrlScheme, null, launcher, null) + + verify { + authTabIntent.launch(launcher, url, returnUrlScheme) + } + verify(exactly = 0) { + customTabsIntent.launchUrl(any(), any()) + } + } + + @Test + fun `launchUrl adds CLEAR_TOP flag to Auth Tab when LaunchType is ACTIVITY_CLEAR_TOP`() { + val returnUrlScheme = "example" + val packageName = "com.android.chrome" + + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val intent = authTabIntent.intent + + client.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_CLEAR_TOP) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) + + verify { + authTabIntent.launch(launcher, url, returnUrlScheme) + } + } + + @Test + fun `launchUrl falls back to Custom Tabs when Auth Tab not supported`() { + authTabIntent = mockk(relaxed = true) + every { authTabBuilder.build() } returns authTabIntent + + val returnUrlScheme = "example" + val packageName = "com.android.chrome" + + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns false + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + client.launchUrl(context, url, returnUrlScheme, null, launcher, null) + + verify { + customTabsIntent.launchUrl(context, url) + } + verify(exactly = 0) { + authTabIntent.launch(any(), any(), any()) + } + } + + + @Test + fun `launchUrl handles app link with no path`() { + val appLinkUri = Uri.parse("https://example.com") + val packageName = "com.android.chrome" + + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + client.launchUrl(context, url, null, appLinkUri, launcher, null) + + verify { + authTabIntent.launch(launcher, url, "example.com", "/") + } + } + + @Test + fun `launchUrl with null LaunchType does not add flags to Custom Tabs`() { + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val intent = customTabsIntent.intent + val returnUrlScheme = "example" + + // Force AuthTab not to be supported to fall back to Custom Tabs + every { CustomTabsClient.getPackageName(context, null) } returns null + + client.launchUrl(context, url, returnUrlScheme, null, launcher, null) + + assertEquals(0, intent.flags) + } + + @Test + fun `launchUrl with ACTIVITY_NEW_TASK adds new task flag to Custom Tabs`() { + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val intent = customTabsIntent.intent + val returnUrlScheme = "example" + + // Force AuthTab not to be supported to fall back to Custom Tabs + every { CustomTabsClient.getPackageName(context, null) } returns null + + client.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_NEW_TASK) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun `launchUrl with ACTIVITY_CLEAR_TOP adds clear top flag to Custom Tabs`() { + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val intent = customTabsIntent.intent + val returnUrlScheme = "example" + + // Force AuthTab not to be supported to fall back to Custom Tabs + every { CustomTabsClient.getPackageName(context, null) } returns null + + client.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_CLEAR_TOP) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) + } +} \ No newline at end of file diff --git a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java index 6d8453fa..dceadfd0 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java +++ b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java @@ -2,15 +2,18 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; import android.content.ActivityNotFoundException; import android.content.Context; @@ -18,12 +21,17 @@ import android.net.Uri; import androidx.activity.ComponentActivity; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.browser.auth.AuthTabIntent; import org.json.JSONException; import org.json.JSONObject; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.android.controller.ActivityController; @@ -33,8 +41,8 @@ public class BrowserSwitchClientUnitTest { private BrowserSwitchInspector browserSwitchInspector; - - private ChromeCustomTabsInternalClient customTabsInternalClient; + private AuthTabInternalClient authTabInternalClient; + private ActivityResultLauncher mockLauncher; private Uri browserSwitchDestinationUrl; private Context applicationContext; @@ -44,7 +52,8 @@ public class BrowserSwitchClientUnitTest { @Before public void beforeEach() { browserSwitchInspector = mock(BrowserSwitchInspector.class); - customTabsInternalClient = mock(ChromeCustomTabsInternalClient.class); + authTabInternalClient = mock(AuthTabInternalClient.class); + mockLauncher = mock(ActivityResultLauncher.class); browserSwitchDestinationUrl = Uri.parse("https://example.com/browser_switch_destination"); @@ -62,7 +71,7 @@ public void start_whenActivityIsFinishing_throwsException() { when(componentActivity.isFinishing()).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -73,7 +82,8 @@ public void start_whenActivityIsFinishing_throwsException() { BrowserSwitchStartResult request = sut.start(componentActivity, options); assertTrue(request instanceof BrowserSwitchStartResult.Failure); - assertEquals(((BrowserSwitchStartResult.Failure) request).getError().getMessage(), "Unable to start browser switch while host Activity is finishing."); + assertEquals("Unable to start browser switch while host Activity is finishing.", + ((BrowserSwitchStartResult.Failure) request).getError().getMessage()); } @Test @@ -81,7 +91,7 @@ public void start_whenSuccessful_returnsBrowserSwitchRequest() throws BrowserSwi when(browserSwitchInspector.isDeviceConfiguredForDeepLinking(applicationContext, "return-url-scheme")).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -92,7 +102,14 @@ public void start_whenSuccessful_returnsBrowserSwitchRequest() throws BrowserSwi .metadata(metadata); BrowserSwitchStartResult browserSwitchPendingRequest = sut.start(componentActivity, options); - verify(customTabsInternalClient).launchUrl(componentActivity, browserSwitchDestinationUrl, LaunchType.ACTIVITY_CLEAR_TOP); + verify(authTabInternalClient).launchUrl( + eq(componentActivity), + eq(browserSwitchDestinationUrl), + eq("return-url-scheme"), + isNull(), + isNull(), + eq(LaunchType.ACTIVITY_CLEAR_TOP) + ); assertNotNull(browserSwitchPendingRequest); assertTrue(browserSwitchPendingRequest instanceof BrowserSwitchStartResult.Started); @@ -102,18 +119,54 @@ public void start_whenSuccessful_returnsBrowserSwitchRequest() throws BrowserSwi BrowserSwitchRequest browserSwitchRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequest); - assertEquals(browserSwitchRequest.getRequestCode(), 123); - assertEquals(browserSwitchRequest.getUrl(), browserSwitchDestinationUrl); + assertEquals(123, browserSwitchRequest.getRequestCode()); + assertEquals(browserSwitchDestinationUrl, browserSwitchRequest.getUrl()); JSONAssert.assertEquals(browserSwitchRequest.getMetadata(), metadata, false); } + @Test + public void start_withAppLinkUri_passesItToAuthTab() { + Uri appLinkUri = Uri.parse("https://example.com/auth"); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, + authTabInternalClient); + + JSONObject metadata = new JSONObject(); + BrowserSwitchOptions options = new BrowserSwitchOptions() + .requestCode(123) + .url(browserSwitchDestinationUrl) + .appLinkUri(appLinkUri) + .metadata(metadata); + + BrowserSwitchStartResult browserSwitchPendingRequest = sut.start(componentActivity, options); + + verify(authTabInternalClient).launchUrl( + eq(componentActivity), + eq(browserSwitchDestinationUrl), + isNull(), + eq(appLinkUri), + isNull(), + isNull() + ); + + assertTrue(browserSwitchPendingRequest instanceof BrowserSwitchStartResult.Started); + } + @Test public void start_whenNoBrowserAvailable_returnsFailure() { when(browserSwitchInspector.isDeviceConfiguredForDeepLinking(applicationContext, "return-url-scheme")).thenReturn(true); - doThrow(new ActivityNotFoundException()).when(customTabsInternalClient).launchUrl(any(Context.class), any(Uri.class), eq(null)); + when(authTabInternalClient.isAuthTabSupported(any(Context.class))).thenReturn(false); + doThrow(new ActivityNotFoundException()).when(authTabInternalClient).launchUrl( + eq(componentActivity), + eq(browserSwitchDestinationUrl), + eq("return-url-scheme"), + isNull(), + isNull(), + isNull() + ); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -121,9 +174,11 @@ public void start_whenNoBrowserAvailable_returnsFailure() { .url(browserSwitchDestinationUrl) .returnUrlScheme("return-url-scheme") .metadata(metadata); + BrowserSwitchStartResult request = sut.start(componentActivity, options); assertTrue(request instanceof BrowserSwitchStartResult.Failure); - assertEquals(((BrowserSwitchStartResult.Failure) request).getError().getMessage(), "Unable to start browser switch without a web browser."); + assertEquals("Unable to start browser switch without a web browser.", + ((BrowserSwitchStartResult.Failure) request).getError().getMessage()); } @Test @@ -131,7 +186,7 @@ public void start_whenRequestCodeIsIntegerMinValue_returnsFailure() { when(browserSwitchInspector.isDeviceConfiguredForDeepLinking(applicationContext, "return-url-scheme")).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -141,7 +196,8 @@ public void start_whenRequestCodeIsIntegerMinValue_returnsFailure() { .metadata(metadata); BrowserSwitchStartResult request = sut.start(componentActivity, options); assertTrue(request instanceof BrowserSwitchStartResult.Failure); - assertEquals(((BrowserSwitchStartResult.Failure) request).getError().getMessage(), "Request code cannot be Integer.MIN_VALUE"); + assertEquals("Request code cannot be Integer.MIN_VALUE", + ((BrowserSwitchStartResult.Failure) request).getError().getMessage()); } @Test @@ -149,7 +205,7 @@ public void start_whenDeviceIsNotConfiguredForDeepLinking_returnsFailure() { when(browserSwitchInspector.isDeviceConfiguredForDeepLinking(applicationContext, "return-url-scheme")).thenReturn(false); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -171,7 +227,7 @@ public void start_whenNoAppLinkUriOrReturnUrlSchemeSet_throwsError() { when(browserSwitchInspector.isDeviceConfiguredForDeepLinking(applicationContext, "return-url-scheme")).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -185,11 +241,219 @@ public void start_whenNoAppLinkUriOrReturnUrlSchemeSet_throwsError() { assertEquals("An appLinkUri or returnUrlScheme is required.", ((BrowserSwitchStartResult.Failure) request).getError().getMessage()); } + @Test + public void initializeAuthTabLauncher_registersLauncherWithActivity() { + + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + any(ComponentActivity.class), + any(ActivityResultCallback.class) + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); + AuthTabCallback callback = mock(AuthTabCallback.class); + + sut.initializeAuthTabLauncher(componentActivity, callback); + + mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + any(ActivityResultCallback.class) + )); + + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(ActivityResultCallback.class); + mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + callbackCaptor.capture() + )); + + assertNotNull(callbackCaptor.getValue()); + } + } + + @Test + public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() throws BrowserSwitchException { + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( + componentActivity.getApplicationContext(), + "return-url-scheme" + )).thenReturn(true); + when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); + + ArgumentCaptor callbackCaptor = + ArgumentCaptor.forClass(ActivityResultCallback.class); + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + callbackCaptor.capture() + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); + + AuthTabCallback callback = mock(AuthTabCallback.class); + sut.initializeAuthTabLauncher(componentActivity, callback); + + JSONObject metadata = new JSONObject(); + BrowserSwitchOptions options = new BrowserSwitchOptions() + .requestCode(123) + .url(browserSwitchDestinationUrl) + .returnUrlScheme("return-url-scheme") + .metadata(metadata); + + BrowserSwitchStartResult result = sut.start(componentActivity, options); + + assertTrue(result instanceof BrowserSwitchStartResult.Started); + + verify(authTabInternalClient).launchUrl( + eq(componentActivity), + eq(browserSwitchDestinationUrl), + eq("return-url-scheme"), + isNull(), + eq(mockLauncher), + isNull() + ); + + String pendingRequestString = ((BrowserSwitchStartResult.Started) result).getPendingRequest(); + assertNotNull(pendingRequestString); + + BrowserSwitchRequest decodedRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequestString); + assertEquals(123, decodedRequest.getRequestCode()); + assertEquals(browserSwitchDestinationUrl, decodedRequest.getUrl()); + } + } + + @Test + public void authTabCallback_withResultOK_callsCallbackWithSuccess() { + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(ActivityResultCallback.class); + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + callbackCaptor.capture() + )).thenReturn(mockLauncher); + + + when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( + componentActivity.getApplicationContext(), + "return-url-scheme" + )).thenReturn(true); + when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); + AuthTabCallback mockCallback = mock(AuthTabCallback.class); + + sut.initializeAuthTabLauncher(componentActivity, mockCallback); + + JSONObject metadata = new JSONObject(); + BrowserSwitchOptions options = new BrowserSwitchOptions() + .requestCode(123) + .url(browserSwitchDestinationUrl) + .returnUrlScheme("return-url-scheme") + .metadata(metadata); + sut.start(componentActivity, options); + + Uri resultUri = Uri.parse("return-url-scheme://success"); + AuthTabIntent.AuthResult mockAuthResult = mock(AuthTabIntent.AuthResult.class, withSettings() + .useConstructor(AuthTabIntent.RESULT_OK, resultUri) + .defaultAnswer(CALLS_REAL_METHODS)); + + callbackCaptor.getValue().onActivityResult(mockAuthResult); + + ArgumentCaptor resultCaptor = + ArgumentCaptor.forClass(BrowserSwitchFinalResult.class); + verify(mockCallback).onResult(resultCaptor.capture()); + + BrowserSwitchFinalResult capturedResult = resultCaptor.getValue(); + assertTrue(capturedResult instanceof BrowserSwitchFinalResult.Success); + + BrowserSwitchFinalResult.Success successResult = + (BrowserSwitchFinalResult.Success) capturedResult; + assertEquals(resultUri, successResult.getReturnUrl()); + assertEquals(123, successResult.getRequestCode()); + } + } + + @Test + public void authTabCallback_withResultCanceled_callsCallbackWithNoResult() { + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(ActivityResultCallback.class); + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + callbackCaptor.capture() + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); + AuthTabCallback mockCallback = mock(AuthTabCallback.class); + + sut.initializeAuthTabLauncher(componentActivity, mockCallback); + + AuthTabIntent.AuthResult mockAuthResult = mock(AuthTabIntent.AuthResult.class, withSettings() + .useConstructor(AuthTabIntent.RESULT_CANCELED, null) + .defaultAnswer(CALLS_REAL_METHODS)); + + callbackCaptor.getValue().onActivityResult(mockAuthResult); + + ArgumentCaptor resultCaptor = + ArgumentCaptor.forClass(BrowserSwitchFinalResult.class); + verify(mockCallback).onResult(resultCaptor.capture()); + + BrowserSwitchFinalResult capturedResult = resultCaptor.getValue(); + assertTrue(capturedResult instanceof BrowserSwitchFinalResult.NoResult); + } + } + + @Test + public void start_withoutAuthTabLauncher_fallsBackToCustomTabs() { + + when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( + componentActivity.getApplicationContext(), + "return-url-scheme" + )).thenReturn(true); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); + + JSONObject metadata = new JSONObject(); + BrowserSwitchOptions options = new BrowserSwitchOptions() + .requestCode(123) + .url(browserSwitchDestinationUrl) + .returnUrlScheme("return-url-scheme") + .metadata(metadata); + + BrowserSwitchStartResult result = sut.start(componentActivity, options); + + assertTrue(result instanceof BrowserSwitchStartResult.Started); + + verify(authTabInternalClient).launchUrl( + eq(componentActivity), + eq(browserSwitchDestinationUrl), + eq("return-url-scheme"), + isNull(), + isNull(), + isNull() + ); + } + + @Test + public void isAuthTabSupported_delegatesToInternalClient() { + when(authTabInternalClient.isAuthTabSupported(applicationContext)).thenReturn(true); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, + authTabInternalClient); + + boolean result = sut.isAuthTabSupported(applicationContext); + + assertTrue(result); + verify(authTabInternalClient).isAuthTabSupported(applicationContext); + } + + @Test public void completeRequest_whenAppLinkMatches_successReturnedWithAppLink() throws BrowserSwitchException, JSONException { Uri appLinkUri = Uri.parse("https://example.com"); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject requestMetadata = new JSONObject(); BrowserSwitchRequest request = new BrowserSwitchRequest( @@ -217,7 +481,7 @@ public void completeRequest_whenAppLinkMatches_successReturnedWithAppLink() thro @Test public void completeRequest_whenActiveRequestMatchesDeepLinkResultURLScheme_returnsBrowserSwitchSuccessResult() throws BrowserSwitchException, JSONException { BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject requestMetadata = new JSONObject(); BrowserSwitchRequest request = new BrowserSwitchRequest( @@ -246,7 +510,7 @@ public void completeRequest_whenActiveRequestMatchesDeepLinkResultURLScheme_retu @Test public void completeRequest_whenDeepLinkResultURLSchemeDoesntMatch_returnsNoResult() throws BrowserSwitchException { BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject requestMetadata = new JSONObject(); BrowserSwitchRequest request = new BrowserSwitchRequest( @@ -268,14 +532,17 @@ public void completeRequest_whenDeepLinkResultURLSchemeDoesntMatch_returnsNoResu @Test public void completeRequest_whenIntentIsNull_returnsNoResult() throws BrowserSwitchException { BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - customTabsInternalClient); + authTabInternalClient); JSONObject requestMetadata = new JSONObject(); BrowserSwitchRequest request = new BrowserSwitchRequest(123, browserSwitchDestinationUrl, requestMetadata, "fake-url-scheme", null); + Intent mockIntent = mock(Intent.class); + when(mockIntent.getData()).thenReturn(null); + BrowserSwitchFinalResult result = - sut.completeRequest(null, request.toBase64EncodedJSON()); + sut.completeRequest(mockIntent, request.toBase64EncodedJSON()); assertTrue(result instanceof BrowserSwitchFinalResult.NoResult); } -} +} \ No newline at end of file diff --git a/browser-switch/src/test/java/com/braintreepayments/api/ChromeCustomTabsInternalClientUnitTest.kt b/browser-switch/src/test/java/com/braintreepayments/api/ChromeCustomTabsInternalClientUnitTest.kt deleted file mode 100644 index 0b140b47..00000000 --- a/browser-switch/src/test/java/com/braintreepayments/api/ChromeCustomTabsInternalClientUnitTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.braintreepayments.api - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.browser.customtabs.CustomTabsIntent -import io.mockk.clearAllMocks -import io.mockk.every -import io.mockk.mockk -import org.junit.Assert.assertEquals -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 ChromeCustomTabsInternalClientUnitTest { - - private lateinit var builder: CustomTabsIntent.Builder - private lateinit var customTabsIntent: CustomTabsIntent - private lateinit var context: Context - private lateinit var url: Uri - - @Before - fun setUp() { - clearAllMocks() - builder = mockk(relaxed = true) - context = mockk(relaxed = true) - url = mockk(relaxed = true) - customTabsIntent = CustomTabsIntent.Builder().build() - every { builder.build() } returns customTabsIntent - } - - @Test - fun `launchUrl with null LaunchType does not add flags`() { - val client = ChromeCustomTabsInternalClient(builder) - val intent = customTabsIntent.intent - - client.launchUrl(context, url, null) - - assertEquals(0, intent.flags) - } - - @Test - fun `launchUrl with ACTIVITY_NEW_TASK adds new task flag`() { - val client = ChromeCustomTabsInternalClient(builder) - val intent = customTabsIntent.intent - - client.launchUrl(context, url, LaunchType.ACTIVITY_NEW_TASK) - - assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) - } - - @Test - fun `launchUrl with ACTIVITY_CLEAR_TOP adds clear top flag`() { - val client = ChromeCustomTabsInternalClient(builder) - val intent = customTabsIntent.intent - - client.launchUrl(context, url, LaunchType.ACTIVITY_CLEAR_TOP) - - assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) - } -} diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index a14c0c55..886efd89 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -8,7 +8,6 @@ import androidx.activity.viewModels import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeGesturesPadding import androidx.compose.material3.Button import androidx.compose.material3.Text @@ -30,11 +29,19 @@ import org.json.JSONObject class ComposeActivity : ComponentActivity() { private val viewModel by viewModels() - private lateinit var browserSwitchClient: BrowserSwitchClient + private var useAuthTab = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) browserSwitchClient = BrowserSwitchClient() + browserSwitchClient.initializeAuthTabLauncher(this) { result -> + handleBrowserSwitchResult(result) + } + + if (browserSwitchClient.isAuthTabSupported(this)) { + useAuthTab = true + } setContent { Column(modifier = Modifier.safeGesturesPadding()) { @@ -48,19 +55,27 @@ class ComposeActivity : ComponentActivity() { override fun onResume() { super.onResume() - PendingRequestStore.get(this)?.let { startedRequest -> - when (val completeRequestResult = - browserSwitchClient.completeRequest(intent, startedRequest)) { - is BrowserSwitchFinalResult.Success -> - viewModel.browserSwitchFinalResult = completeRequestResult + // Only handle Custom Tabs fall back case + if (!useAuthTab) { + PendingRequestStore.get(this)?.let { startedRequest -> + val completeRequestResult = browserSwitchClient.completeRequest(intent, startedRequest) + handleBrowserSwitchResult(completeRequestResult) + PendingRequestStore.clear(this) + intent.data = null + } + } + } - is BrowserSwitchFinalResult.NoResult -> viewModel.browserSwitchError = - Exception("User did not complete browser switch") + private fun handleBrowserSwitchResult(result: BrowserSwitchFinalResult) { + when (result) { + is BrowserSwitchFinalResult.Success -> + viewModel.browserSwitchFinalResult = result - is BrowserSwitchFinalResult.Failure -> viewModel.browserSwitchError = - completeRequestResult.error - } - PendingRequestStore.clear(this) + is BrowserSwitchFinalResult.NoResult -> + viewModel.browserSwitchError = Exception("User did not complete browser switch") + + is BrowserSwitchFinalResult.Failure -> + viewModel.browserSwitchError = result.error } } @@ -72,11 +87,16 @@ class ComposeActivity : ComponentActivity() { .url(url) .launchAsNewTask(false) .returnUrlScheme(RETURN_URL_SCHEME) - when (val startResult = browserSwitchClient.start(this, browserSwitchOptions)) { - is BrowserSwitchStartResult.Started -> - PendingRequestStore.put(this, startResult.pendingRequest) - is BrowserSwitchStartResult.Failure -> viewModel.browserSwitchError = startResult.error + when (val startResult = browserSwitchClient.start(this, browserSwitchOptions)) { + is BrowserSwitchStartResult.Started -> { + // Only store for Custom Tabs fall back + if (!useAuthTab) { + PendingRequestStore.put(this, startResult.pendingRequest) + } + } + is BrowserSwitchStartResult.Failure -> + viewModel.browserSwitchError = startResult.error } } diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java index 8ac4888e..734931e3 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java @@ -3,6 +3,7 @@ import android.content.Intent; import android.os.Bundle; import android.view.View; +import android.widget.Toast; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -19,7 +20,6 @@ import com.braintreepayments.api.BrowserSwitchOptions; import com.braintreepayments.api.BrowserSwitchStartResult; import com.braintreepayments.api.browserswitch.demo.utils.PendingRequestStore; -import com.braintreepayments.api.demo.R; import java.util.Objects; @@ -31,10 +31,22 @@ public class DemoActivitySingleTop extends AppCompatActivity { @VisibleForTesting BrowserSwitchClient browserSwitchClient = null; + private boolean useAuthTab = false; + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); browserSwitchClient = new BrowserSwitchClient(); + browserSwitchClient.initializeAuthTabLauncher(this, this::handleBrowserSwitchResult); + + if (browserSwitchClient.isAuthTabSupported(this)) { + useAuthTab = true; + // Show a toast to indicate Auth Tab is being used + Toast.makeText(this, "Using Auth Tab", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Using Custom Tabs (Auth Tab not supported)", + Toast.LENGTH_SHORT).show(); + } FragmentManager fm = getSupportFragmentManager(); if (getDemoFragment() == null) { @@ -44,7 +56,6 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } // Support Edge-to-Edge layout in Android 15 - // Ref: https://developer.android.com/develop/ui/views/layout/edge-to-edge#cutout-insets View navHostView = findViewById(android.R.id.content); ViewCompat.setOnApplyWindowInsetsListener(navHostView, (v, insets) -> { @WindowInsetsCompat.Type.InsetsType int insetTypeMask = @@ -61,13 +72,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onNewIntent(Intent intent) { super.onNewIntent(intent); - String pendingRequest = PendingRequestStore.get(this); - if (pendingRequest != null) { - BrowserSwitchFinalResult result = browserSwitchClient.completeRequest(intent, pendingRequest); - if (result instanceof BrowserSwitchFinalResult.Success) { - Objects.requireNonNull(getDemoFragment()).onBrowserSwitchResult((BrowserSwitchFinalResult.Success) result); + // Only handle Custom Tabs fallback case + if (!useAuthTab) { + String pendingRequest = PendingRequestStore.get(this); + if (pendingRequest != null) { + BrowserSwitchFinalResult result = + browserSwitchClient.completeRequest(intent, pendingRequest); + handleBrowserSwitchResult(result); + PendingRequestStore.clear(this); + intent.setData(null); } - PendingRequestStore.clear(this); } } @@ -75,19 +89,42 @@ protected void onNewIntent(Intent intent) { protected void onResume() { super.onResume(); - String pendingRequest = PendingRequestStore.get(this); - if (pendingRequest != null) { - Objects.requireNonNull(getDemoFragment()).onBrowserSwitchError(new Exception("User did not complete browser switch")); - PendingRequestStore.clear(this); + // Only check for incomplete browser switch in Custom Tabs mode + if (!useAuthTab) { + String pendingRequest = PendingRequestStore.get(this); + if (pendingRequest != null) { + Objects.requireNonNull(getDemoFragment()) + .onBrowserSwitchError(new Exception("User did not complete browser switch")); + PendingRequestStore.clear(this); + getIntent().setData(null); + } + } + } + + private void handleBrowserSwitchResult(BrowserSwitchFinalResult result) { + if (result instanceof BrowserSwitchFinalResult.Success) { + Objects.requireNonNull(getDemoFragment()) + .onBrowserSwitchResult((BrowserSwitchFinalResult.Success) result); + } else if (result instanceof BrowserSwitchFinalResult.NoResult) { + Objects.requireNonNull(getDemoFragment()) + .onBrowserSwitchError(new Exception("User did not complete browser switch")); + } else if (result instanceof BrowserSwitchFinalResult.Failure) { + Objects.requireNonNull(getDemoFragment()) + .onBrowserSwitchError(((BrowserSwitchFinalResult.Failure) result).getError()); } } public void startBrowserSwitch(BrowserSwitchOptions options) throws BrowserSwitchException { BrowserSwitchStartResult result = browserSwitchClient.start(this, options); if (result instanceof BrowserSwitchStartResult.Started) { - PendingRequestStore.put(this, ((BrowserSwitchStartResult.Started) result).getPendingRequest()); + // Only store pending request for Custom Tabs fallback + if (!useAuthTab) { + PendingRequestStore.put(this, + ((BrowserSwitchStartResult.Started) result).getPendingRequest()); + } } else if (result instanceof BrowserSwitchStartResult.Failure) { - Objects.requireNonNull(getDemoFragment()).onBrowserSwitchError(((BrowserSwitchStartResult.Failure) result).getError()); + Objects.requireNonNull(getDemoFragment()) + .onBrowserSwitchError(((BrowserSwitchStartResult.Failure) result).getError()); } } @@ -103,4 +140,4 @@ private DemoFragment getDemoFragment() { public String getReturnUrlScheme() { return RETURN_URL_SCHEME; } -} +} \ No newline at end of file diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java index d2cf6528..a5713960 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java @@ -9,7 +9,9 @@ import android.os.Bundle; import android.view.View; import android.widget.Button; +import android.widget.Toast; +import com.braintreepayments.api.BrowserSwitchClient; import com.braintreepayments.api.demo.R; public class MainActivity extends AppCompatActivity { @@ -25,6 +27,14 @@ protected void onCreate(Bundle savedInstanceState) { Button singleTopButton = findViewById(R.id.single_top_button); singleTopButton.setOnClickListener(this::launchSingleTopBrowserSwitch); + // Show Auth Tab support status via Toast + BrowserSwitchClient client = new BrowserSwitchClient(); + if (client.isAuthTabSupported(this)) { + Toast.makeText(this, "Auth Tab is supported", Toast.LENGTH_LONG).show(); + } else { + Toast.makeText(this, "Using Custom Tabs fallback", Toast.LENGTH_LONG).show(); + } + // Support Edge-to-Edge layout in Android 15 // Ref: https://developer.android.com/develop/ui/views/layout/edge-to-edge#cutout-insets View navHostView = findViewById(R.id.content); From 6ddac0dbaa7eddf572a5e2dbf88b8b645bed8da9 Mon Sep 17 00:00:00 2001 From: Anastasiia Noguier Date: Wed, 15 Oct 2025 11:43:35 -0500 Subject: [PATCH 03/10] Add support for parametrized constructor * remove duplicate intent-filter * - Update androidx.browser to 1.9.0 - Update AGP version to 8.9.1 -- requires update to gradle wrapper 8.11.1 - Update compile and target sdk to 36 * first iteration on AuthTab Setup: added AuthTabInternalClient, removed ChromeCustomTabsInternalClient, modified ComposeActivity & DemoActivitySingleTop to accept the above changes, added toast visuals to see whether or not the AuthTab is supported * Rename .java to .kt * second iteration on AuthTab Setup: rewrote AuthTabInternalClient to Kotlin and added a ClearTop Flag, wrote new AuthTabInternalClientUnitTest, removed ChromeCustomTabsInternalClientUnitTest, modified BrowserSwitchClientUnitTest to accept the AuthTab changes * third iteration on AuthTab Setup: removed CustomTabIntentBuilder, removed tests associated with CustomTabs from AuthTabInternalClientUnitTest,, modified BrowserSwitchClientUnitTest to accept the AuthTab launchURL signature * EOD commit: -Testing the authtab flow on older devices without intents * Revert "EOD commit:" This reverts commit e27cfa12d6f28f52dd54397a9152afac071b0d8b. * Revert "third iteration on AuthTab Setup:" This reverts commit 9803e438516744b2ed00bf8d9afdaad125637815. Branching still needed to provide support for older devices. * Completed implementation of AuthTab Support: - added AuthTabInternalClient, - removed ChromeCustomTabsInternalClient, - modified ComposeActivity & DemoActivitySingleTop to accept the above changes, - added toast visuals to see whether or not the AuthTab is supported * Address PR suggestions * Address PR suggestions * Add support for parametrized constructor to give a merchant a choice to use AuthTab or not * -added the ability to store a callback internally without exposing it -modified BrowserSwitchClient's constructor to only accept Activity as a parameter -rerouted the flow to always call completeRequest and check for the callback result withing the function -added a call to completeRequest in DemoActivitySingleTop.java onResume -added the tests to support the new flow * -addressed PR suggestions -removed toast pop-ups * updated javadoc for parametrized BrowserSwitchClient constructor and completeRequest() function --------- Co-authored-by: saperi --- .../api/AuthTabInternalClient.kt | 4 +- .../api/BrowserSwitchClient.java | 102 +++++++++++--- .../api/AuthTabInternalClientUnitTest.kt | 19 ++- .../api/BrowserSwitchClientUnitTest.java | 129 ++++++++++++++---- .../api/browserswitch/demo/ComposeActivity.kt | 29 ++-- .../demo/DemoActivitySingleTop.java | 55 +++----- .../api/browserswitch/demo/MainActivity.java | 9 -- 7 files changed, 235 insertions(+), 112 deletions(-) diff --git a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt index c7e93a6b..ea4bcafd 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt +++ b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt @@ -31,12 +31,12 @@ internal class AuthTabInternalClient ( url: Uri, returnUrlScheme: String?, appLinkUri: Uri?, - launcher: ActivityResultLauncher, + launcher: ActivityResultLauncher?, launchType: LaunchType? ) { val useAuthTab = isAuthTabSupported(context) - if (useAuthTab) { + if (useAuthTab && launcher != null) { val authTabIntent = authTabIntentBuilder.build() if (launchType == LaunchType.ACTIVITY_CLEAR_TOP) { diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index b275d88a..d7e33763 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -1,13 +1,16 @@ package com.braintreepayments.api; +import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import androidx.activity.ComponentActivity; +import androidx.activity.result.ActivityResultCaller; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.browser.auth.AuthTabIntent; @@ -25,31 +28,78 @@ public class BrowserSwitchClient { private ActivityResultLauncher authTabLauncher; private BrowserSwitchRequest pendingAuthTabRequest; + @Nullable + private BrowserSwitchFinalResult authTabCallbackResult; + /** - * Construct a client that manages the logic for browser switching. + * Construct a client that manages browser switching with Chrome Custom Tabs fallback only. + * This constructor does not initialize Auth Tab support. For Auth Tab functionality, + * use {@link #BrowserSwitchClient(Activity)} instead. */ public BrowserSwitchClient() { this(new BrowserSwitchInspector(), new AuthTabInternalClient()); } + /** + * Construct a client that manages the logic for browser switching and automatically + * initializes the Auth Tab launcher. + * + *

IMPORTANT: This constructor enables the AuthTab functionality, which has several caveats: + * + *

    + *
  • The provided activity MUST implement {@link ActivityResultCaller}, which is true for all + * instances of {@link androidx.activity.ComponentActivity}. + *
  • {@link LaunchType#ACTIVITY_NEW_TASK} is not supported when using AuthTab and will be ignored. + * Only {@link LaunchType#ACTIVITY_CLEAR_TOP} is supported with AuthTab. + *
  • When using SingleTop activities, you must check for launcher results in {@code onResume()} as well + * as in {@code onNewIntent()}, since the AuthTab activity result might be delivered during the + * resuming phase. + *
  • Care must be taken to avoid calling {@link #completeRequest(Intent, String)} multiple times + * for the same result. Merchants should properly track their pending request state to ensure + * the completeRequest method is only called once per browser switch session. + *
  • AuthTab support is browser version dependent. It requires Chrome version 137 + * or higher on the user's device. On devices with older browser versions, the library will + * automatically fall back to Custom Tabs. This means that enabling AuthTab is not guaranteed + * to use the AuthTab flow if the user's browser version is too old. + *
+ * + *

Consider using the default constructor {@link #BrowserSwitchClient()} if these limitations + * are incompatible with your implementation. + * + * @param activity The activity used to initialize the Auth Tab launcher. Must implement + * {@link ActivityResultCaller}. + */ + public BrowserSwitchClient(@NonNull Activity activity) { + this(new BrowserSwitchInspector(), new AuthTabInternalClient()); + initializeAuthTabLauncher(activity); + } + @VisibleForTesting BrowserSwitchClient(BrowserSwitchInspector browserSwitchInspector, AuthTabInternalClient authTabInternalClient) { this.browserSwitchInspector = browserSwitchInspector; this.authTabInternalClient = authTabInternalClient; + this.authTabCallbackResult = null; } /** * Initialize the Auth Tab launcher. This should be called in the activity's onCreate() * before the activity is started. + * + * @param activity The activity used to initialize the Auth Tab launcher */ - public void initializeAuthTabLauncher(@NonNull ComponentActivity activity, - @NonNull AuthTabCallback callback) { + public void initializeAuthTabLauncher(@NonNull Activity activity) { + + if (!(activity instanceof ActivityResultCaller)) { + return; + } + + ComponentActivity componentActivity = (ComponentActivity) activity; + this.authTabLauncher = AuthTabIntent.registerActivityResultLauncher( - activity, + componentActivity, result -> { BrowserSwitchFinalResult finalResult; - switch (result.resultCode) { case AuthTabIntent.RESULT_OK: if (result.resultUri != null && pendingAuthTabRequest != null) { @@ -61,19 +111,10 @@ public void initializeAuthTabLauncher(@NonNull ComponentActivity activity, finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; } break; - case AuthTabIntent.RESULT_CANCELED: - finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; - break; - case AuthTabIntent.RESULT_VERIFICATION_FAILED: - finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; - break; - case AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT: - finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; - break; default: finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; } - callback.onResult(finalResult); + this.authTabCallbackResult = finalResult; pendingAuthTabRequest = null; } ); @@ -91,6 +132,9 @@ public void initializeAuthTabLauncher(@NonNull ComponentActivity activity, @NonNull public BrowserSwitchStartResult start(@NonNull ComponentActivity activity, @NonNull BrowserSwitchOptions browserSwitchOptions) { + + this.authTabCallbackResult = null; + try { assertCanPerformBrowserSwitch(activity, browserSwitchOptions); } catch (BrowserSwitchException e) { @@ -121,7 +165,7 @@ public BrowserSwitchStartResult start(@NonNull ComponentActivity activity, appLinkUri ); - boolean useAuthTab = authTabInternalClient.isAuthTabSupported(activity); + boolean useAuthTab = isAuthTabSupported(activity); if (useAuthTab) { this.pendingAuthTabRequest = request; @@ -188,18 +232,27 @@ private boolean isValidRequestCode(int requestCode) { } /** - * Completes the browser switch flow for Custom Tabs fallback scenarios. - * This method is still needed for devices that don't support Auth Tab. + * Completes the browser switch flow for both Auth Tab and Custom Tabs fallback scenarios. + * This method first checks if we have a result from the Auth Tab callback, + * and returns it if available. Otherwise, it follows the Custom Tabs flow. * *

See * Auth Tab Fallback Documentation for details on when Custom Tabs fallback is required * + *

IMPORTANT: When using Auth Tab with SingleTop activities, you must call this method + * in both {@code onNewIntent()} and {@code onResume()} to ensure the result is properly processed + * regardless of which launch mode is used. + * * @param intent the intent to return to your application containing a deep link result * @param pendingRequest the pending request string returned from {@link BrowserSwitchStartResult.Started} * @return a {@link BrowserSwitchFinalResult} */ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull String pendingRequest) { - if (intent.getData() != null) { + if (authTabCallbackResult != null) { + BrowserSwitchFinalResult result = authTabCallbackResult; + authTabCallbackResult = null; + return result; + } else if (intent.getData() != null) { Uri returnUrl = intent.getData(); try { @@ -215,7 +268,14 @@ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull return BrowserSwitchFinalResult.NoResult.INSTANCE; } - public boolean isAuthTabSupported(Context context) { - return authTabInternalClient.isAuthTabSupported(context); + /** + * Checks if Auth Tab is supported on this device and if the launcher has been initialized. + * @param context The application context + * @return true if Auth Tab is supported by the browser AND the launcher has been initialized, + * false otherwise + */ + @VisibleForTesting + boolean isAuthTabSupported(Context context) { + return authTabLauncher != null && authTabInternalClient.isAuthTabSupported(context); } } \ No newline at end of file diff --git a/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt index b1635664..59a7ef86 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt +++ b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt @@ -157,7 +157,6 @@ class AuthTabInternalClientUnitTest { } } - @Test fun `launchUrl handles app link with no path`() { val appLinkUri = Uri.parse("https://example.com") @@ -216,4 +215,20 @@ class AuthTabInternalClientUnitTest { assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) } -} \ No newline at end of file + + @Test + fun `launchUrl with null launcher falls back to Custom Tabs even when Auth Tab is supported`() { + val packageName = "com.android.chrome" + every { CustomTabsClient.getPackageName(context, null) } returns packageName + every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val returnUrlScheme = "example" + + client.launchUrl(context, url, returnUrlScheme, null, null, null) + + verify { + customTabsIntent.launchUrl(context, url) + } + } +} diff --git a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java index dceadfd0..8d5eacf5 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java +++ b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java @@ -1,6 +1,7 @@ package com.braintreepayments.api; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -252,9 +253,8 @@ public void initializeAuthTabLauncher_registersLauncherWithActivity() { )).thenReturn(mockLauncher); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - AuthTabCallback callback = mock(AuthTabCallback.class); - sut.initializeAuthTabLauncher(componentActivity, callback); + sut.initializeAuthTabLauncher(componentActivity); mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( eq(componentActivity), @@ -290,8 +290,7 @@ public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() thr BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - AuthTabCallback callback = mock(AuthTabCallback.class); - sut.initializeAuthTabLauncher(componentActivity, callback); + sut.initializeAuthTabLauncher(componentActivity); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -323,7 +322,7 @@ public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() thr } @Test - public void authTabCallback_withResultOK_callsCallbackWithSuccess() { + public void authTabCallback_withResultOK_setsInternalCallbackResult() { try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { ArgumentCaptor> callbackCaptor = @@ -333,7 +332,6 @@ public void authTabCallback_withResultOK_callsCallbackWithSuccess() { callbackCaptor.capture() )).thenReturn(mockLauncher); - when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( componentActivity.getApplicationContext(), "return-url-scheme" @@ -341,9 +339,7 @@ public void authTabCallback_withResultOK_callsCallbackWithSuccess() { when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - AuthTabCallback mockCallback = mock(AuthTabCallback.class); - - sut.initializeAuthTabLauncher(componentActivity, mockCallback); + sut.initializeAuthTabLauncher(componentActivity); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -360,11 +356,8 @@ public void authTabCallback_withResultOK_callsCallbackWithSuccess() { callbackCaptor.getValue().onActivityResult(mockAuthResult); - ArgumentCaptor resultCaptor = - ArgumentCaptor.forClass(BrowserSwitchFinalResult.class); - verify(mockCallback).onResult(resultCaptor.capture()); - - BrowserSwitchFinalResult capturedResult = resultCaptor.getValue(); + Intent dummyIntent = new Intent(); + BrowserSwitchFinalResult capturedResult = sut.completeRequest(dummyIntent, "dummyPendingRequest"); assertTrue(capturedResult instanceof BrowserSwitchFinalResult.Success); BrowserSwitchFinalResult.Success successResult = @@ -385,9 +378,8 @@ public void authTabCallback_withResultCanceled_callsCallbackWithNoResult() { )).thenReturn(mockLauncher); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - AuthTabCallback mockCallback = mock(AuthTabCallback.class); - sut.initializeAuthTabLauncher(componentActivity, mockCallback); + sut.initializeAuthTabLauncher(componentActivity); AuthTabIntent.AuthResult mockAuthResult = mock(AuthTabIntent.AuthResult.class, withSettings() .useConstructor(AuthTabIntent.RESULT_CANCELED, null) @@ -395,11 +387,8 @@ public void authTabCallback_withResultCanceled_callsCallbackWithNoResult() { callbackCaptor.getValue().onActivityResult(mockAuthResult); - ArgumentCaptor resultCaptor = - ArgumentCaptor.forClass(BrowserSwitchFinalResult.class); - verify(mockCallback).onResult(resultCaptor.capture()); - - BrowserSwitchFinalResult capturedResult = resultCaptor.getValue(); + Intent dummyIntent = new Intent(); + BrowserSwitchFinalResult capturedResult = sut.completeRequest(dummyIntent, "dummyPendingRequest"); assertTrue(capturedResult instanceof BrowserSwitchFinalResult.NoResult); } } @@ -436,7 +425,40 @@ public void start_withoutAuthTabLauncher_fallsBackToCustomTabs() { } @Test - public void isAuthTabSupported_delegatesToInternalClient() { + public void start_whenAuthTabLauncherIsNull_fallsBackToCustomTabs() { + when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( + componentActivity.getApplicationContext(), + "return-url-scheme" + )).thenReturn(true); + + // Explicitly ensure AuthTab is supported but we still fallback due to null launcher + when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); + + JSONObject metadata = new JSONObject(); + BrowserSwitchOptions options = new BrowserSwitchOptions() + .requestCode(123) + .url(browserSwitchDestinationUrl) + .returnUrlScheme("return-url-scheme") + .metadata(metadata); + + BrowserSwitchStartResult result = sut.start(componentActivity, options); + + assertTrue(result instanceof BrowserSwitchStartResult.Started); + + verify(authTabInternalClient).launchUrl( + eq(componentActivity), + eq(browserSwitchDestinationUrl), + eq("return-url-scheme"), + isNull(), + isNull(), + isNull() + ); + } + + @Test + public void isAuthTabSupported_returnsFalseWhenLauncherNotInitialized() { when(authTabInternalClient.isAuthTabSupported(applicationContext)).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, @@ -444,10 +466,69 @@ public void isAuthTabSupported_delegatesToInternalClient() { boolean result = sut.isAuthTabSupported(applicationContext); - assertTrue(result); - verify(authTabInternalClient).isAuthTabSupported(applicationContext); + assertFalse(result); + } + + @Test + public void isAuthTabSupported_returnsTrueWhenLauncherInitialized() { + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + when(authTabInternalClient.isAuthTabSupported(applicationContext)).thenReturn(true); + + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + any(ComponentActivity.class), + any(ActivityResultCallback.class) + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, + authTabInternalClient); + + sut.initializeAuthTabLauncher(componentActivity); + + boolean result = sut.isAuthTabSupported(applicationContext); + + assertTrue(result); + verify(authTabInternalClient).isAuthTabSupported(applicationContext); + } + } + + @Test + public void isAuthTabSupported_returnsFalseWhenBrowserDoesNotSupportAuthTab() { + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + when(authTabInternalClient.isAuthTabSupported(applicationContext)).thenReturn(false); + + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + any(ComponentActivity.class), + any(ActivityResultCallback.class) + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, + authTabInternalClient); + + sut.initializeAuthTabLauncher(componentActivity); + + boolean result = sut.isAuthTabSupported(applicationContext); + + assertFalse(result); + verify(authTabInternalClient).isAuthTabSupported(applicationContext); + } } + @Test + public void parameterizedConstructor_initializesAuthTabLauncher() { + try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + any(ComponentActivity.class), + any(ActivityResultCallback.class) + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient(componentActivity); + + mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + any(ActivityResultCallback.class) + )); + } + } @Test public void completeRequest_whenAppLinkMatches_successReturnedWithAppLink() throws BrowserSwitchException, JSONException { diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index 886efd89..300194f8 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -2,6 +2,7 @@ package com.braintreepayments.api.browserswitch.demo import android.net.Uri import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -30,19 +31,11 @@ class ComposeActivity : ComponentActivity() { private val viewModel by viewModels() private lateinit var browserSwitchClient: BrowserSwitchClient - private var useAuthTab = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - browserSwitchClient = BrowserSwitchClient() - browserSwitchClient.initializeAuthTabLauncher(this) { result -> - handleBrowserSwitchResult(result) - } - - if (browserSwitchClient.isAuthTabSupported(this)) { - useAuthTab = true - } - + // Initialize BrowserSwitchClient with the parameterized constructor + browserSwitchClient = BrowserSwitchClient(this) setContent { Column(modifier = Modifier.safeGesturesPadding()) { BrowserSwitchButton { @@ -55,14 +48,11 @@ class ComposeActivity : ComponentActivity() { override fun onResume() { super.onResume() - // Only handle Custom Tabs fall back case - if (!useAuthTab) { - PendingRequestStore.get(this)?.let { startedRequest -> - val completeRequestResult = browserSwitchClient.completeRequest(intent, startedRequest) - handleBrowserSwitchResult(completeRequestResult) - PendingRequestStore.clear(this) - intent.data = null - } + PendingRequestStore.get(this)?.let { startedRequest -> + val completeRequestResult = browserSwitchClient.completeRequest(intent, startedRequest) + handleBrowserSwitchResult(completeRequestResult) + PendingRequestStore.clear(this) + intent.data = null } } @@ -90,10 +80,7 @@ class ComposeActivity : ComponentActivity() { when (val startResult = browserSwitchClient.start(this, browserSwitchOptions)) { is BrowserSwitchStartResult.Started -> { - // Only store for Custom Tabs fall back - if (!useAuthTab) { PendingRequestStore.put(this, startResult.pendingRequest) - } } is BrowserSwitchStartResult.Failure -> viewModel.browserSwitchError = startResult.error diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java index 734931e3..1589697e 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java @@ -31,22 +31,11 @@ public class DemoActivitySingleTop extends AppCompatActivity { @VisibleForTesting BrowserSwitchClient browserSwitchClient = null; - private boolean useAuthTab = false; - @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - browserSwitchClient = new BrowserSwitchClient(); - browserSwitchClient.initializeAuthTabLauncher(this, this::handleBrowserSwitchResult); - - if (browserSwitchClient.isAuthTabSupported(this)) { - useAuthTab = true; - // Show a toast to indicate Auth Tab is being used - Toast.makeText(this, "Using Auth Tab", Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, "Using Custom Tabs (Auth Tab not supported)", - Toast.LENGTH_SHORT).show(); - } + // Initialize BrowserSwitchClient with the parameterized constructor + browserSwitchClient = new BrowserSwitchClient(this); FragmentManager fm = getSupportFragmentManager(); if (getDemoFragment() == null) { @@ -71,28 +60,31 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); - - // Only handle Custom Tabs fallback case - if (!useAuthTab) { - String pendingRequest = PendingRequestStore.get(this); - if (pendingRequest != null) { - BrowserSwitchFinalResult result = - browserSwitchClient.completeRequest(intent, pendingRequest); - handleBrowserSwitchResult(result); - PendingRequestStore.clear(this); - intent.setData(null); - } + String pendingRequest = PendingRequestStore.get(this); + if (pendingRequest != null) { + BrowserSwitchFinalResult result = + browserSwitchClient.completeRequest(intent, pendingRequest); + handleBrowserSwitchResult(result); + PendingRequestStore.clear(this); + intent.setData(null); } } @Override protected void onResume() { super.onResume(); + String pendingRequest = PendingRequestStore.get(this); + if (pendingRequest != null) { + // When using AuthTab, results come via the ActivityResultLauncher callback, + // so we need to check for results in onResume too, not just onNewIntent + BrowserSwitchFinalResult result = browserSwitchClient.completeRequest(getIntent(), pendingRequest); - // Only check for incomplete browser switch in Custom Tabs mode - if (!useAuthTab) { - String pendingRequest = PendingRequestStore.get(this); - if (pendingRequest != null) { + if (result instanceof BrowserSwitchFinalResult.Success) { + handleBrowserSwitchResult(result); + PendingRequestStore.clear(this); + getIntent().setData(null); + } + else { Objects.requireNonNull(getDemoFragment()) .onBrowserSwitchError(new Exception("User did not complete browser switch")); PendingRequestStore.clear(this); @@ -117,11 +109,8 @@ private void handleBrowserSwitchResult(BrowserSwitchFinalResult result) { public void startBrowserSwitch(BrowserSwitchOptions options) throws BrowserSwitchException { BrowserSwitchStartResult result = browserSwitchClient.start(this, options); if (result instanceof BrowserSwitchStartResult.Started) { - // Only store pending request for Custom Tabs fallback - if (!useAuthTab) { - PendingRequestStore.put(this, - ((BrowserSwitchStartResult.Started) result).getPendingRequest()); - } + PendingRequestStore.put(this, + ((BrowserSwitchStartResult.Started) result).getPendingRequest()); } else if (result instanceof BrowserSwitchStartResult.Failure) { Objects.requireNonNull(getDemoFragment()) .onBrowserSwitchError(((BrowserSwitchStartResult.Failure) result).getError()); diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java index a5713960..56de514e 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java @@ -26,15 +26,6 @@ protected void onCreate(Bundle savedInstanceState) { Button singleTopButton = findViewById(R.id.single_top_button); singleTopButton.setOnClickListener(this::launchSingleTopBrowserSwitch); - - // Show Auth Tab support status via Toast - BrowserSwitchClient client = new BrowserSwitchClient(); - if (client.isAuthTabSupported(this)) { - Toast.makeText(this, "Auth Tab is supported", Toast.LENGTH_LONG).show(); - } else { - Toast.makeText(this, "Using Custom Tabs fallback", Toast.LENGTH_LONG).show(); - } - // Support Edge-to-Edge layout in Android 15 // Ref: https://developer.android.com/develop/ui/views/layout/edge-to-edge#cutout-insets View navHostView = findViewById(R.id.content); From 4673301bef9153a0f948208e6fe904e0ac3c92b2 Mon Sep 17 00:00:00 2001 From: Anastasiia Noguier Date: Mon, 20 Oct 2025 16:41:12 -0500 Subject: [PATCH 04/10] Update CHANGELOG.md and an argument for a parametrized constructor (#123) * updated default constructor to accept `ActivityResultCaller` as a parameter updated `CHANGELOG.md` * addressed PR suggestions * addressed PR suggestions: switch the version to `unreleased` --- CHANGELOG.md | 9 ++ .../braintreepayments/api/AuthTabCallback.kt | 11 -- .../api/BrowserSwitchClient.java | 40 +++---- .../api/BrowserSwitchClientUnitTest.java | 103 +++++++++--------- 4 files changed, 83 insertions(+), 80 deletions(-) delete mode 100644 browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b4c038..6600b34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # browser-switch-android Release Notes +## unreleased + +* Add AuthTab Support + * Upgrade `androidx.browser:browser` dependency version to 1.9.0 + * Upgrade `compileSdkVersion` and `targetSdkVersion` to API 36 + * Replace `ChromeCustomTabsInternalClient.java` with `AuthTabInternalClient.kt` + * Add parameterized constructor `BrowserSwitchClient(ActivityResultCaller)` to initialize AuthTab support + * Maintain default constructor `BrowserSwitchClient()` (without AuthTab support) for backward compatibility + ## 3.1.0 * Add `LaunchType` to `BrowserSwitchOptions` to specify how the browser switch should be launched diff --git a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt deleted file mode 100644 index 52d20435..00000000 --- a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabCallback.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.braintreepayments.api - -/** - * Callback interface for Auth Tab results - */ -fun interface AuthTabCallback { - /** - * @param result The final result of the browser switch operation - */ - fun onResult(result: BrowserSwitchFinalResult) -} \ No newline at end of file diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index d7e33763..b45697b9 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -1,6 +1,5 @@ package com.braintreepayments.api; -import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; @@ -34,7 +33,7 @@ public class BrowserSwitchClient { /** * Construct a client that manages browser switching with Chrome Custom Tabs fallback only. * This constructor does not initialize Auth Tab support. For Auth Tab functionality, - * use {@link #BrowserSwitchClient(Activity)} instead. + * use {@link #BrowserSwitchClient(ActivityResultCaller)} instead. */ public BrowserSwitchClient() { this(new BrowserSwitchInspector(), new AuthTabInternalClient()); @@ -47,8 +46,9 @@ public BrowserSwitchClient() { *

IMPORTANT: This constructor enables the AuthTab functionality, which has several caveats: * *

    - *
  • The provided activity MUST implement {@link ActivityResultCaller}, which is true for all - * instances of {@link androidx.activity.ComponentActivity}. + *
  • This constructor must be called in the activity/fragment's {@code onCreate()} method + * to properly register the activity result launcher before the activity/fragment is started. + *
  • The caller must be an {@link ActivityResultCaller} to register for activity results. *
  • {@link LaunchType#ACTIVITY_NEW_TASK} is not supported when using AuthTab and will be ignored. * Only {@link LaunchType#ACTIVITY_CLEAR_TOP} is supported with AuthTab. *
  • When using SingleTop activities, you must check for launcher results in {@code onResume()} as well @@ -66,12 +66,11 @@ public BrowserSwitchClient() { *

    Consider using the default constructor {@link #BrowserSwitchClient()} if these limitations * are incompatible with your implementation. * - * @param activity The activity used to initialize the Auth Tab launcher. Must implement - * {@link ActivityResultCaller}. + * @param caller The ActivityResultCaller used to initialize the Auth Tab launcher. */ - public BrowserSwitchClient(@NonNull Activity activity) { + public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { this(new BrowserSwitchInspector(), new AuthTabInternalClient()); - initializeAuthTabLauncher(activity); + initializeAuthTabLauncher(caller); } @VisibleForTesting @@ -79,25 +78,26 @@ public BrowserSwitchClient(@NonNull Activity activity) { AuthTabInternalClient authTabInternalClient) { this.browserSwitchInspector = browserSwitchInspector; this.authTabInternalClient = authTabInternalClient; - this.authTabCallbackResult = null; + } + + @VisibleForTesting + BrowserSwitchClient(@NonNull ActivityResultCaller caller, + BrowserSwitchInspector inspector, + AuthTabInternalClient internal) { + this(inspector, internal); + initializeAuthTabLauncher(caller); } /** - * Initialize the Auth Tab launcher. This should be called in the activity's onCreate() - * before the activity is started. + * Initialize the Auth Tab launcher. This should be called in the activity/fragment's onCreate() + * before it is started. * - * @param activity The activity used to initialize the Auth Tab launcher + * @param caller The ActivityResultCaller (Activity or Fragment) used to initialize the Auth Tab launcher */ - public void initializeAuthTabLauncher(@NonNull Activity activity) { - - if (!(activity instanceof ActivityResultCaller)) { - return; - } - - ComponentActivity componentActivity = (ComponentActivity) activity; + private void initializeAuthTabLauncher(@NonNull ActivityResultCaller caller) { this.authTabLauncher = AuthTabIntent.registerActivityResultLauncher( - componentActivity, + caller, result -> { BrowserSwitchFinalResult finalResult; switch (result.resultCode) { diff --git a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java index 8d5eacf5..8e7e2699 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java +++ b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java @@ -23,6 +23,7 @@ import androidx.activity.ComponentActivity; import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultCaller; import androidx.activity.result.ActivityResultLauncher; import androidx.browser.auth.AuthTabIntent; @@ -252,9 +253,11 @@ public void initializeAuthTabLauncher_registersLauncherWithActivity() { any(ActivityResultCallback.class) )).thenReturn(mockLauncher); - BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - - sut.initializeAuthTabLauncher(componentActivity); + BrowserSwitchClient sut = new BrowserSwitchClient( + componentActivity, + browserSwitchInspector, + authTabInternalClient + ); mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( eq(componentActivity), @@ -280,17 +283,22 @@ public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() thr "return-url-scheme" )).thenReturn(true); when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); - - ArgumentCaptor callbackCaptor = + ArgumentCaptor> callbackCaptor = ArgumentCaptor.forClass(ActivityResultCallback.class); mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( eq(componentActivity), callbackCaptor.capture() )).thenReturn(mockLauncher); + BrowserSwitchClient sut = new BrowserSwitchClient( + componentActivity, + browserSwitchInspector, + authTabInternalClient + ); - BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - - sut.initializeAuthTabLauncher(componentActivity); + mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + any(ActivityResultCallback.class) + )); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -298,9 +306,7 @@ public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() thr .url(browserSwitchDestinationUrl) .returnUrlScheme("return-url-scheme") .metadata(metadata); - BrowserSwitchStartResult result = sut.start(componentActivity, options); - assertTrue(result instanceof BrowserSwitchStartResult.Started); verify(authTabInternalClient).launchUrl( @@ -314,10 +320,6 @@ public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() thr String pendingRequestString = ((BrowserSwitchStartResult.Started) result).getPendingRequest(); assertNotNull(pendingRequestString); - - BrowserSwitchRequest decodedRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequestString); - assertEquals(123, decodedRequest.getRequestCode()); - assertEquals(browserSwitchDestinationUrl, decodedRequest.getUrl()); } } @@ -327,10 +329,6 @@ public void authTabCallback_withResultOK_setsInternalCallbackResult() { ArgumentCaptor> callbackCaptor = ArgumentCaptor.forClass(ActivityResultCallback.class); - mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( - eq(componentActivity), - callbackCaptor.capture() - )).thenReturn(mockLauncher); when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( componentActivity.getApplicationContext(), @@ -338,8 +336,16 @@ public void authTabCallback_withResultOK_setsInternalCallbackResult() { )).thenReturn(true); when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); - BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - sut.initializeAuthTabLauncher(componentActivity); + mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( + eq(componentActivity), + callbackCaptor.capture() + )).thenReturn(mockLauncher); + + BrowserSwitchClient sut = new BrowserSwitchClient( + componentActivity, + browserSwitchInspector, + authTabInternalClient + ); JSONObject metadata = new JSONObject(); BrowserSwitchOptions options = new BrowserSwitchOptions() @@ -347,7 +353,9 @@ public void authTabCallback_withResultOK_setsInternalCallbackResult() { .url(browserSwitchDestinationUrl) .returnUrlScheme("return-url-scheme") .metadata(metadata); - sut.start(componentActivity, options); + + BrowserSwitchStartResult startResult = sut.start(componentActivity, options); + String pendingRequest = ((BrowserSwitchStartResult.Started) startResult).getPendingRequest(); Uri resultUri = Uri.parse("return-url-scheme://success"); AuthTabIntent.AuthResult mockAuthResult = mock(AuthTabIntent.AuthResult.class, withSettings() @@ -357,7 +365,7 @@ public void authTabCallback_withResultOK_setsInternalCallbackResult() { callbackCaptor.getValue().onActivityResult(mockAuthResult); Intent dummyIntent = new Intent(); - BrowserSwitchFinalResult capturedResult = sut.completeRequest(dummyIntent, "dummyPendingRequest"); + BrowserSwitchFinalResult capturedResult = sut.completeRequest(dummyIntent, pendingRequest); assertTrue(capturedResult instanceof BrowserSwitchFinalResult.Success); BrowserSwitchFinalResult.Success successResult = @@ -377,9 +385,11 @@ public void authTabCallback_withResultCanceled_callsCallbackWithNoResult() { callbackCaptor.capture() )).thenReturn(mockLauncher); - BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); - - sut.initializeAuthTabLauncher(componentActivity); + BrowserSwitchClient sut = new BrowserSwitchClient( + componentActivity, + browserSwitchInspector, + authTabInternalClient + ); AuthTabIntent.AuthResult mockAuthResult = mock(AuthTabIntent.AuthResult.class, withSettings() .useConstructor(AuthTabIntent.RESULT_CANCELED, null) @@ -395,7 +405,6 @@ public void authTabCallback_withResultCanceled_callsCallbackWithNoResult() { @Test public void start_withoutAuthTabLauncher_fallsBackToCustomTabs() { - when(browserSwitchInspector.isDeviceConfiguredForDeepLinking( componentActivity.getApplicationContext(), "return-url-scheme" @@ -431,7 +440,6 @@ public void start_whenAuthTabLauncherIsNull_fallsBackToCustomTabs() { "return-url-scheme" )).thenReturn(true); - // Explicitly ensure AuthTab is supported but we still fallback due to null launcher when(authTabInternalClient.isAuthTabSupported(componentActivity)).thenReturn(true); BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, authTabInternalClient); @@ -479,10 +487,11 @@ public void isAuthTabSupported_returnsTrueWhenLauncherInitialized() { any(ActivityResultCallback.class) )).thenReturn(mockLauncher); - BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - authTabInternalClient); - - sut.initializeAuthTabLauncher(componentActivity); + BrowserSwitchClient sut = new BrowserSwitchClient( + componentActivity, + browserSwitchInspector, + authTabInternalClient + ); boolean result = sut.isAuthTabSupported(applicationContext); @@ -494,39 +503,35 @@ public void isAuthTabSupported_returnsTrueWhenLauncherInitialized() { @Test public void isAuthTabSupported_returnsFalseWhenBrowserDoesNotSupportAuthTab() { try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { - when(authTabInternalClient.isAuthTabSupported(applicationContext)).thenReturn(false); - mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( - any(ComponentActivity.class), + any(ActivityResultCaller.class), any(ActivityResultCallback.class) )).thenReturn(mockLauncher); - BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, - authTabInternalClient); + when(authTabInternalClient.isAuthTabSupported(any())).thenReturn(false); - sut.initializeAuthTabLauncher(componentActivity); + BrowserSwitchClient sut = new BrowserSwitchClient( + componentActivity, + browserSwitchInspector, + authTabInternalClient + ); - boolean result = sut.isAuthTabSupported(applicationContext); + boolean result = sut.isAuthTabSupported(componentActivity); assertFalse(result); - verify(authTabInternalClient).isAuthTabSupported(applicationContext); } } @Test - public void parameterizedConstructor_initializesAuthTabLauncher() { + public void defaultConstructor_doesNotInitializeAuthTabLauncher() { try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { - mockedAuthTab.when(() -> AuthTabIntent.registerActivityResultLauncher( - any(ComponentActivity.class), - any(ActivityResultCallback.class) - )).thenReturn(mockLauncher); + BrowserSwitchClient sut = new BrowserSwitchClient(); - BrowserSwitchClient sut = new BrowserSwitchClient(componentActivity); + mockedAuthTab.verifyNoInteractions(); - mockedAuthTab.verify(() -> AuthTabIntent.registerActivityResultLauncher( - eq(componentActivity), - any(ActivityResultCallback.class) - )); + when(authTabInternalClient.isAuthTabSupported(applicationContext)).thenReturn(true); + boolean result = sut.isAuthTabSupported(applicationContext); + assertFalse(result); } } From 5a137c7455975decea82e8a489ed4f3eef7b5caa Mon Sep 17 00:00:00 2001 From: noguier Date: Tue, 21 Oct 2025 10:51:06 -0500 Subject: [PATCH 05/10] address merge conflicts --- .../main/java/com/braintreepayments/api/BrowserSwitchClient.java | 1 + .../braintreepayments/api/browserswitch/demo/ComposeActivity.kt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 5c7a6a5e..51f39fef 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -1,5 +1,6 @@ package com.braintreepayments.api; +import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index 300194f8..9dad00e3 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -2,7 +2,6 @@ package com.braintreepayments.api.browserswitch.demo import android.net.Uri import android.os.Bundle -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels From 7ddb477e55e6d817a1cd4141f4f04501f20757b8 Mon Sep 17 00:00:00 2001 From: noguier Date: Tue, 21 Oct 2025 15:27:28 -0500 Subject: [PATCH 06/10] address PR suggestions --- .../java/com/braintreepayments/api/BrowserSwitchClient.java | 6 +++--- .../api/browserswitch/demo/DemoActivitySingleTop.java | 1 - .../api/browserswitch/demo/MainActivity.java | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 51f39fef..137dee15 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -82,9 +82,9 @@ public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { @VisibleForTesting BrowserSwitchClient(@NonNull ActivityResultCaller caller, - BrowserSwitchInspector inspector, - AuthTabInternalClient internal) { - this(inspector, internal); + BrowserSwitchInspector browserSwitchInspector, + AuthTabInternalClient authTabInternalClient) { + this(browserSwitchInspector, authTabInternalClient); initializeAuthTabLauncher(caller); } diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java index 1589697e..17fe9b9f 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java @@ -3,7 +3,6 @@ import android.content.Intent; import android.os.Bundle; import android.view.View; -import android.widget.Toast; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java index 56de514e..b6a46dbe 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java @@ -9,9 +9,6 @@ import android.os.Bundle; import android.view.View; import android.widget.Button; -import android.widget.Toast; - -import com.braintreepayments.api.BrowserSwitchClient; import com.braintreepayments.api.demo.R; public class MainActivity extends AppCompatActivity { From 742b29c583ef6186c7a0cee7d965f6a183ee4b7f Mon Sep 17 00:00:00 2001 From: noguier Date: Tue, 28 Oct 2025 10:38:40 -0500 Subject: [PATCH 07/10] address process kill as per Sai's suggestion --- CHANGELOG.md | 1 + .../api/BrowserSwitchClient.java | 21 +++++++++++++++++++ .../api/browserswitch/demo/ComposeActivity.kt | 11 ++++++++-- .../demo/DemoActivitySingleTop.java | 17 +++++++++++++-- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06aebca7..459c0912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Upgrade `compileSdkVersion` and `targetSdkVersion` to API 36 * Replace `ChromeCustomTabsInternalClient.java` with `AuthTabInternalClient.kt` * Add parameterized constructor `BrowserSwitchClient(ActivityResultCaller)` to initialize AuthTab support + * Add constructor `BrowserSwitchClient(ActivityResultCaller, String)` for process kill recovery * Maintain default constructor `BrowserSwitchClient()` (without AuthTab support) for backward compatibility diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 137dee15..2805ce66 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -73,6 +73,27 @@ public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { initializeAuthTabLauncher(caller); } + + /** + * Constructor to initialize BrowserSwitchClient with a pending request to handle process kill scenarios. + *

    + * When an app is killed during a browser switch, the pending request is lost. To properly handle this: + *

      + *
    1. Store the pendingRequest string from {@link BrowserSwitchStartResult.Started} in persistent storage
    2. + *
    3. In {@code onCreate()}, check if there's a stored pending request
    4. + *
    5. If present, initialize {@code BrowserSwitchClient} with this constructor
    6. + *
    + *

    + * + * @param caller The ActivityResultCaller used to initialize the Auth Tab launcher + * @param pendingRequest The base64 encoded JSON string of the pending browser switch request retrieved from persistent storage + * @throws BrowserSwitchException if the pendingRequest cannot be parsed + */ + public BrowserSwitchClient(@NonNull ActivityResultCaller caller, @NonNull String pendingRequest) throws BrowserSwitchException { + this(new BrowserSwitchInspector(), new AuthTabInternalClient()); + initializeAuthTabLauncher(caller); + this.pendingAuthTabRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequest); + } @VisibleForTesting BrowserSwitchClient(BrowserSwitchInspector browserSwitchInspector, AuthTabInternalClient authTabInternalClient) { diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index 9dad00e3..e52b5c86 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -33,8 +33,15 @@ class ComposeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Initialize BrowserSwitchClient with the parameterized constructor - browserSwitchClient = BrowserSwitchClient(this) + // Check if there is a preserved pending request after the process kill + val pendingRequest = PendingRequestStore.get(this) + browserSwitchClient = if (pendingRequest != null) { + // Restore state after process kill + BrowserSwitchClient(this, pendingRequest) + } else { + // Normal initialization + BrowserSwitchClient(this) + } setContent { Column(modifier = Modifier.safeGesturesPadding()) { BrowserSwitchButton { diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java index 17fe9b9f..b20edec9 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java @@ -33,8 +33,21 @@ public class DemoActivitySingleTop extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // Initialize BrowserSwitchClient with the parameterized constructor - browserSwitchClient = new BrowserSwitchClient(this); + // Check if there is a preserved pending request after the process kill + String pendingRequest = PendingRequestStore.get(this); + + if (pendingRequest != null) { + // Restore state after process kill + try { + browserSwitchClient = new BrowserSwitchClient(this, pendingRequest); + } catch (BrowserSwitchException e) { + PendingRequestStore.clear(this); + browserSwitchClient = new BrowserSwitchClient(this); + } + } else { + // Normal initialization + browserSwitchClient = new BrowserSwitchClient(this); + } FragmentManager fm = getSupportFragmentManager(); if (getDemoFragment() == null) { From 387e96c810f131554ffffa7892284ee7d3d94027 Mon Sep 17 00:00:00 2001 From: noguier Date: Wed, 29 Oct 2025 16:06:19 -0500 Subject: [PATCH 08/10] Add restorePendingRequest() method to handle process kill recovery --- CHANGELOG.md | 2 +- .../api/BrowserSwitchClient.java | 40 +++++++++---------- .../api/BrowserSwitchClientUnitTest.java | 28 +++++++++++++ .../api/browserswitch/demo/ComposeActivity.kt | 16 ++++---- .../demo/DemoActivitySingleTop.java | 11 ++--- 5 files changed, 61 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459c0912..134b1540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ * Upgrade `compileSdkVersion` and `targetSdkVersion` to API 36 * Replace `ChromeCustomTabsInternalClient.java` with `AuthTabInternalClient.kt` * Add parameterized constructor `BrowserSwitchClient(ActivityResultCaller)` to initialize AuthTab support - * Add constructor `BrowserSwitchClient(ActivityResultCaller, String)` for process kill recovery * Maintain default constructor `BrowserSwitchClient()` (without AuthTab support) for backward compatibility + * Add `restorePendingRequest()` method to handle process kill recovery ## 3.2.0 diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 2805ce66..23f340f7 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -73,27 +73,6 @@ public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { initializeAuthTabLauncher(caller); } - - /** - * Constructor to initialize BrowserSwitchClient with a pending request to handle process kill scenarios. - *

    - * When an app is killed during a browser switch, the pending request is lost. To properly handle this: - *

      - *
    1. Store the pendingRequest string from {@link BrowserSwitchStartResult.Started} in persistent storage
    2. - *
    3. In {@code onCreate()}, check if there's a stored pending request
    4. - *
    5. If present, initialize {@code BrowserSwitchClient} with this constructor
    6. - *
    - *

    - * - * @param caller The ActivityResultCaller used to initialize the Auth Tab launcher - * @param pendingRequest The base64 encoded JSON string of the pending browser switch request retrieved from persistent storage - * @throws BrowserSwitchException if the pendingRequest cannot be parsed - */ - public BrowserSwitchClient(@NonNull ActivityResultCaller caller, @NonNull String pendingRequest) throws BrowserSwitchException { - this(new BrowserSwitchInspector(), new AuthTabInternalClient()); - initializeAuthTabLauncher(caller); - this.pendingAuthTabRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequest); - } @VisibleForTesting BrowserSwitchClient(BrowserSwitchInspector browserSwitchInspector, AuthTabInternalClient authTabInternalClient) { @@ -141,6 +120,25 @@ private void initializeAuthTabLauncher(@NonNull ActivityResultCaller caller) { ); } + + /** + * Restores a pending request after process kill or app restart. + * + *

    Use this method to restore the browser switch state when the app process is killed while the + * browser is open. This should be called in the Activity's {@code onCreate()} method and before calling + * {@link #completeRequest(Intent, String)} to ensure the pending request is properly restored. + * + *

    The {@code pendingRequest} parameter is the string returned by + * {@link BrowserSwitchStartResult.Started#getPendingRequest()} that was stored in persistent storage + * before the process was killed. + * + * @param pendingRequest The Base64-encoded JSON string representing the pending request to restore + * @throws BrowserSwitchException if the pending request cannot be parsed + */ + public void restorePendingRequest(@NonNull String pendingRequest) throws BrowserSwitchException { + this.pendingAuthTabRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequest); + } + /** * Open a browser or Auth Tab with a given set of {@link BrowserSwitchOptions} from an Android activity. * diff --git a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java index 8e7e2699..a97b36cb 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java +++ b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java @@ -535,6 +535,34 @@ public void defaultConstructor_doesNotInitializeAuthTabLauncher() { } } + @Test + public void restorePendingRequest_setsInternalPendingAuthTabRequest() throws BrowserSwitchException { + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, + authTabInternalClient); + + JSONObject metadata = new JSONObject(); + BrowserSwitchRequest originalRequest = new BrowserSwitchRequest( + 123, + browserSwitchDestinationUrl, + metadata, + "return-url-scheme", + null + ); + String pendingRequestString = originalRequest.toBase64EncodedJSON(); + + sut.restorePendingRequest(pendingRequestString); + + Uri deepLinkUrl = Uri.parse("return-url-scheme://success"); + Intent intent = new Intent(Intent.ACTION_VIEW, deepLinkUrl); + BrowserSwitchFinalResult result = sut.completeRequest(intent, pendingRequestString); + + assertTrue(result instanceof BrowserSwitchFinalResult.Success); + BrowserSwitchFinalResult.Success successResult = (BrowserSwitchFinalResult.Success) result; + assertEquals(deepLinkUrl, successResult.getReturnUrl()); + assertEquals(123, successResult.getRequestCode()); + assertEquals(browserSwitchDestinationUrl, successResult.getRequestUrl()); + } + @Test public void completeRequest_whenAppLinkMatches_successReturnedWithAppLink() throws BrowserSwitchException, JSONException { Uri appLinkUri = Uri.parse("https://example.com"); diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index e52b5c86..791da733 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.braintreepayments.api.BrowserSwitchClient +import com.braintreepayments.api.BrowserSwitchException import com.braintreepayments.api.BrowserSwitchFinalResult import com.braintreepayments.api.BrowserSwitchOptions import com.braintreepayments.api.BrowserSwitchStartResult @@ -33,14 +34,15 @@ class ComposeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + browserSwitchClient = BrowserSwitchClient(this) // Check if there is a preserved pending request after the process kill - val pendingRequest = PendingRequestStore.get(this) - browserSwitchClient = if (pendingRequest != null) { - // Restore state after process kill - BrowserSwitchClient(this, pendingRequest) - } else { - // Normal initialization - BrowserSwitchClient(this) + PendingRequestStore.get(this)?.let { pendingRequest -> + try { + // Restore pending request after process kill + browserSwitchClient.restorePendingRequest(pendingRequest) + } catch (e: BrowserSwitchException) { + PendingRequestStore.clear(this) + } } setContent { Column(modifier = Modifier.safeGesturesPadding()) { diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java index b20edec9..00e62b7d 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java @@ -33,20 +33,17 @@ public class DemoActivitySingleTop extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + + browserSwitchClient = new BrowserSwitchClient(this); // Check if there is a preserved pending request after the process kill String pendingRequest = PendingRequestStore.get(this); - if (pendingRequest != null) { - // Restore state after process kill + // Restore pending request after process kill try { - browserSwitchClient = new BrowserSwitchClient(this, pendingRequest); + browserSwitchClient.restorePendingRequest(pendingRequest); } catch (BrowserSwitchException e) { PendingRequestStore.clear(this); - browserSwitchClient = new BrowserSwitchClient(this); } - } else { - // Normal initialization - browserSwitchClient = new BrowserSwitchClient(this); } FragmentManager fm = getSupportFragmentManager(); From befcdd1be9b33f14debaf5966a0d82d828b666af Mon Sep 17 00:00:00 2001 From: noguier Date: Thu, 30 Oct 2025 13:42:03 -0500 Subject: [PATCH 09/10] add a null check to restorePendingRequest --- .../braintreepayments/api/BrowserSwitchClient.java | 3 +++ .../api/BrowserSwitchClientUnitTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 23f340f7..0d3bb8b0 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -136,6 +136,9 @@ private void initializeAuthTabLauncher(@NonNull ActivityResultCaller caller) { * @throws BrowserSwitchException if the pending request cannot be parsed */ public void restorePendingRequest(@NonNull String pendingRequest) throws BrowserSwitchException { + if (pendingRequest == null) { + throw new BrowserSwitchException("Pending request is null"); + } this.pendingAuthTabRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequest); } diff --git a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java index a97b36cb..1009bad5 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java +++ b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java @@ -3,6 +3,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -563,6 +564,18 @@ public void restorePendingRequest_setsInternalPendingAuthTabRequest() throws Bro assertEquals(browserSwitchDestinationUrl, successResult.getRequestUrl()); } + @Test + public void restorePendingRequest_whenPendingRequestIsNull_throwsException() { + BrowserSwitchClient sut = new BrowserSwitchClient(browserSwitchInspector, + authTabInternalClient); + + BrowserSwitchException exception = assertThrows(BrowserSwitchException.class, () -> { + sut.restorePendingRequest(null); + }); + + assertEquals("Pending request is null", exception.getMessage()); + } + @Test public void completeRequest_whenAppLinkMatches_successReturnedWithAppLink() throws BrowserSwitchException, JSONException { Uri appLinkUri = Uri.parse("https://example.com"); From 11248d15bed9641393214c407e59e443d02d90f7 Mon Sep 17 00:00:00 2001 From: noguier Date: Thu, 30 Oct 2025 16:01:18 -0500 Subject: [PATCH 10/10] add PR suggestions --- CHANGELOG.md | 2 +- .../api/BrowserSwitchClient.java | 10 ++-- .../api/AuthTabInternalClientUnitTest.kt | 48 +++++++++---------- .../api/browserswitch/demo/ComposeActivity.kt | 2 - .../demo/DemoActivitySingleTop.java | 2 - .../api/browserswitch/demo/MainActivity.java | 2 + 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 134b1540..405c1dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ * Upgrade `compileSdkVersion` and `targetSdkVersion` to API 36 * Replace `ChromeCustomTabsInternalClient.java` with `AuthTabInternalClient.kt` * Add parameterized constructor `BrowserSwitchClient(ActivityResultCaller)` to initialize AuthTab support - * Maintain default constructor `BrowserSwitchClient()` (without AuthTab support) for backward compatibility + * Maintain default constructor `BrowserSwitchClient()` for backward compatibility * Add `restorePendingRequest()` method to handle process kill recovery diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 0d3bb8b0..a70a180e 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -206,14 +206,16 @@ public BrowserSwitchStartResult start(@NonNull Activity activity, } catch (ActivityNotFoundException e) { this.pendingAuthTabRequest = null; - return new BrowserSwitchStartResult.Failure( - new BrowserSwitchException("Unable to start browser switch without a web browser.", e) + BrowserSwitchException exception = new BrowserSwitchException( + "Unable to start browser switch without a web browser.", e ); + return new BrowserSwitchStartResult.Failure(exception); } catch (Exception e) { this.pendingAuthTabRequest = null; - return new BrowserSwitchStartResult.Failure( - new BrowserSwitchException("Unable to start browser switch: " + e.getMessage(), e) + BrowserSwitchException exception = new BrowserSwitchException( + "Unable to start browser switch: " + e.getMessage(), e ); + return new BrowserSwitchStartResult.Failure(exception); } } diff --git a/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt index 59a7ef86..a00dd187 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt +++ b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt @@ -47,9 +47,9 @@ class AuthTabInternalClientUnitTest { fun `isAuthTabSupported returns false when no browser package available`() { every { CustomTabsClient.getPackageName(context, null) } returns null - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - assertFalse(client.isAuthTabSupported(context)) + assertFalse(sut.isAuthTabSupported(context)) } @Test @@ -58,9 +58,9 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - assertTrue(client.isAuthTabSupported(context)) + assertTrue(sut.isAuthTabSupported(context)) } @Test @@ -69,9 +69,9 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns false - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - assertFalse(client.isAuthTabSupported(context)) + assertFalse(sut.isAuthTabSupported(context)) } @Test @@ -82,9 +82,9 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - client.launchUrl(context, url, null, appLinkUri, launcher, null) + sut.launchUrl(context, url, null, appLinkUri, launcher, null) verify { authTabIntent.launch(launcher, url, "example.com", "/auth") @@ -102,9 +102,9 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - client.launchUrl(context, url, returnUrlScheme, null, launcher, null) + sut.launchUrl(context, url, returnUrlScheme, null, launcher, null) verify { authTabIntent.launch(launcher, url, returnUrlScheme) @@ -122,10 +122,10 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) val intent = authTabIntent.intent - client.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_CLEAR_TOP) + sut.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_CLEAR_TOP) assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) @@ -145,9 +145,9 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns false - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - client.launchUrl(context, url, returnUrlScheme, null, launcher, null) + sut.launchUrl(context, url, returnUrlScheme, null, launcher, null) verify { customTabsIntent.launchUrl(context, url) @@ -165,9 +165,9 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) - client.launchUrl(context, url, null, appLinkUri, launcher, null) + sut.launchUrl(context, url, null, appLinkUri, launcher, null) verify { authTabIntent.launch(launcher, url, "example.com", "/") @@ -176,42 +176,42 @@ class AuthTabInternalClientUnitTest { @Test fun `launchUrl with null LaunchType does not add flags to Custom Tabs`() { - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) val intent = customTabsIntent.intent val returnUrlScheme = "example" // Force AuthTab not to be supported to fall back to Custom Tabs every { CustomTabsClient.getPackageName(context, null) } returns null - client.launchUrl(context, url, returnUrlScheme, null, launcher, null) + sut.launchUrl(context, url, returnUrlScheme, null, launcher, null) assertEquals(0, intent.flags) } @Test fun `launchUrl with ACTIVITY_NEW_TASK adds new task flag to Custom Tabs`() { - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) val intent = customTabsIntent.intent val returnUrlScheme = "example" // Force AuthTab not to be supported to fall back to Custom Tabs every { CustomTabsClient.getPackageName(context, null) } returns null - client.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_NEW_TASK) + sut.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_NEW_TASK) assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) } @Test fun `launchUrl with ACTIVITY_CLEAR_TOP adds clear top flag to Custom Tabs`() { - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) val intent = customTabsIntent.intent val returnUrlScheme = "example" // Force AuthTab not to be supported to fall back to Custom Tabs every { CustomTabsClient.getPackageName(context, null) } returns null - client.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_CLEAR_TOP) + sut.launchUrl(context, url, returnUrlScheme, null, launcher, LaunchType.ACTIVITY_CLEAR_TOP) assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TOP != 0) } @@ -222,10 +222,10 @@ class AuthTabInternalClientUnitTest { every { CustomTabsClient.getPackageName(context, null) } returns packageName every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns true - val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + val sut = AuthTabInternalClient(authTabBuilder, customTabsBuilder) val returnUrlScheme = "example" - client.launchUrl(context, url, returnUrlScheme, null, null, null) + sut.launchUrl(context, url, returnUrlScheme, null, null, null) verify { customTabsIntent.launchUrl(context, url) diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index 791da733..571d7388 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -35,10 +35,8 @@ class ComposeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) browserSwitchClient = BrowserSwitchClient(this) - // Check if there is a preserved pending request after the process kill PendingRequestStore.get(this)?.let { pendingRequest -> try { - // Restore pending request after process kill browserSwitchClient.restorePendingRequest(pendingRequest) } catch (e: BrowserSwitchException) { PendingRequestStore.clear(this) diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java index 00e62b7d..09dfac4e 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java @@ -35,10 +35,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); browserSwitchClient = new BrowserSwitchClient(this); - // Check if there is a preserved pending request after the process kill String pendingRequest = PendingRequestStore.get(this); if (pendingRequest != null) { - // Restore pending request after process kill try { browserSwitchClient.restorePendingRequest(pendingRequest); } catch (BrowserSwitchException e) { diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java index b6a46dbe..d2cf6528 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java @@ -9,6 +9,7 @@ import android.os.Bundle; import android.view.View; import android.widget.Button; + import com.braintreepayments.api.demo.R; public class MainActivity extends AppCompatActivity { @@ -23,6 +24,7 @@ protected void onCreate(Bundle savedInstanceState) { Button singleTopButton = findViewById(R.id.single_top_button); singleTopButton.setOnClickListener(this::launchSingleTopBrowserSwitch); + // Support Edge-to-Edge layout in Android 15 // Ref: https://developer.android.com/develop/ui/views/layout/edge-to-edge#cutout-insets View navHostView = findViewById(R.id.content);