diff --git a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt new file mode 100644 index 00000000..1eb656b3 --- /dev/null +++ b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt @@ -0,0 +1,83 @@ +package com.braintreepayments.api + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.VisibleForTesting +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent + +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +class AuthTabInternalClient @VisibleForTesting constructor( + private val authTabIntentBuilder: AuthTabIntent.Builder, + private val customTabsIntentBuilder: CustomTabsIntent.Builder +) { + + constructor() : this(AuthTabIntent.Builder(), CustomTabsIntent.Builder()) + + /** + * Checks if Auth Tab is supported by the current browser + */ + fun isAuthTabSupported(context: Context): Boolean { + val packageName = CustomTabsClient.getPackageName(context, null) + return when (packageName) { + null -> false + else -> CustomTabsClient.isAuthTabSupported(context, packageName) + } + } + + /** + * Launch URL using Auth Tab if supported, otherwise fall back to Custom Tabs + */ + @Throws(ActivityNotFoundException::class) + fun launchUrl( + context: Context, + url: Uri, + returnUrlScheme: String?, + appLinkUri: Uri?, + launcher: ActivityResultLauncher?, + launchType: LaunchType? + ) { + val useAuthTab = launcher != null && + isAuthTabSupported(context) && + (returnUrlScheme != null || appLinkUri?.host != null) + + if (useAuthTab) { + val authTabIntent = authTabIntentBuilder.build() + + if (launchType == LaunchType.ACTIVITY_CLEAR_TOP) { + authTabIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + when { + appLinkUri?.host != null -> { + val host = appLinkUri.host!! + val path = appLinkUri.path ?: "/" + authTabIntent.launch(launcher!!, url, host, path) + } + returnUrlScheme != null -> { + authTabIntent.launch(launcher!!, url, returnUrlScheme) + } + } + } else { + //fallback to Custom Tabs + launchCustomTabs(context, url, launchType) + } + } + private fun launchCustomTabs(context: Context, url: Uri, launchType: LaunchType?) { + val customTabsIntent = customTabsIntentBuilder.build() + when (launchType) { + LaunchType.ACTIVITY_NEW_TASK -> { + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + LaunchType.ACTIVITY_CLEAR_TOP -> { + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + null -> { } + } + customTabsIntent.launchUrl(context, url) + } +} \ No newline at end of file diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index 65ab89e3..2ac85a94 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -6,8 +6,10 @@ import android.net.Uri; import androidx.activity.ComponentActivity; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import androidx.browser.auth.AuthTabIntent; import com.braintreepayments.api.browserswitch.R; @@ -19,35 +21,81 @@ public class BrowserSwitchClient { private final BrowserSwitchInspector browserSwitchInspector; - - private final ChromeCustomTabsInternalClient customTabsInternalClient; + private final AuthTabInternalClient authTabInternalClient; + private ActivityResultLauncher authTabLauncher; + private BrowserSwitchRequest pendingAuthTabRequest; + private AuthTabCallback authTabCallback; /** * 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.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; + } + + if (this.authTabCallback != null) { + this.authTabCallback.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 +106,57 @@ 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 + ); + + // Check if we should use Auth Tab + boolean useAuthTab = authTabLauncher != null && + authTabInternalClient.isAuthTabSupported(activity); + + if (useAuthTab) { + // Store the pending request for Auth Tab callback + this.pendingAuthTabRequest = request; } + + // Launch using Auth Tab or Custom Tabs + authTabInternalClient.launchUrl( + activity, + browserSwitchUrl, + returnUrlScheme, + appLinkUri, + authTabLauncher, // Will be null if not initialized or not supported + 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,17 +197,12 @@ 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. * - * @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) { @@ -149,4 +220,18 @@ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull } return BrowserSwitchFinalResult.NoResult.INSTANCE; } -} + + /** + * Check if Auth Tab is supported on the current device + */ + public boolean isAuthTabSupported(Context context) { + return authTabInternalClient.isAuthTabSupported(context); + } + + /** + * Callback interface for Auth Tab results + */ + public interface AuthTabCallback { + void onResult(BrowserSwitchFinalResult result); + } +} \ 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..a24a7547 --- /dev/null +++ b/browser-switch/src/test/java/com/braintreepayments/api/AuthTabInternalClientUnitTest.kt @@ -0,0 +1,227 @@ +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 launcher is null`() { + authTabIntent = mockk(relaxed = true) + every { authTabBuilder.build() } returns authTabIntent + + val returnUrlScheme = "example" + + val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder) + + client.launchUrl(context, url, returnUrlScheme, null, null, null) + + verify { + customTabsIntent.launchUrl(context, url) + } + verify(exactly = 0) { + authTabIntent.launch(any(), any(), any()) + } + } + + @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 + + client.launchUrl(context, url, null, null, null, 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 + + client.launchUrl(context, url, null, null, null, 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 + + client.launchUrl(context, url, null, null, null, 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..96bda772 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); + BrowserSwitchClient.AuthTabCallback callback = mock(BrowserSwitchClient.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); + + BrowserSwitchClient.AuthTabCallback callback = mock(BrowserSwitchClient.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); + BrowserSwitchClient.AuthTabCallback mockCallback = mock(BrowserSwitchClient.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); + BrowserSwitchClient.AuthTabCallback mockCallback = mock(BrowserSwitchClient.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,7 +532,7 @@ 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 = @@ -278,4 +542,4 @@ public void completeRequest_whenIntentIsNull_returnsNoResult() throws BrowserSwi sut.completeRequest(null, 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..25335311 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,12 +29,21 @@ 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() + // Initialize Auth Tab if supported + if (browserSwitchClient.isAuthTabSupported(this)) { + useAuthTab = true + browserSwitchClient.initializeAuthTabLauncher(this) { result -> + handleBrowserSwitchResult(result) + } + } + setContent { Column(modifier = Modifier.safeGesturesPadding()) { BrowserSwitchButton { @@ -48,19 +56,26 @@ 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 fallback case + if (!useAuthTab) { + PendingRequestStore.get(this)?.let { startedRequest -> + val completeRequestResult = browserSwitchClient.completeRequest(intent, startedRequest) + handleBrowserSwitchResult(completeRequestResult) + PendingRequestStore.clear(this) + } + } + } - 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 fallback + 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..2c19d920 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,11 +31,25 @@ 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(); + // Initialize Auth Tab if supported + if (browserSwitchClient.isAuthTabSupported(this)) { + useAuthTab = true; + browserSwitchClient.initializeAuthTabLauncher(this, this::handleBrowserSwitchResult); + + // 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) { fm.beginTransaction() @@ -44,7 +58,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 +74,15 @@ 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); } - PendingRequestStore.clear(this); } } @@ -75,19 +90,41 @@ 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); + } + } + } + + 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);