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:
+ *
+ * - Store the pendingRequest string from {@link BrowserSwitchStartResult.Started} in persistent storage
+ * - In {@code onCreate()}, check if there's a stored pending request
+ * - If present, initialize {@code BrowserSwitchClient} with this constructor
+ *
+ *
+ *
+ * @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:
- *
- * - Store the pendingRequest string from {@link BrowserSwitchStartResult.Started} in persistent storage
- * - In {@code onCreate()}, check if there's a stored pending request
- * - If present, initialize {@code BrowserSwitchClient} with this constructor
- *
- *
- *
- * @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);