66import android .content .Intent ;
77import android .net .Uri ;
88
9+ import androidx .activity .result .ActivityResultCaller ;
10+ import androidx .activity .result .ActivityResultLauncher ;
911import androidx .annotation .NonNull ;
12+ import androidx .annotation .Nullable ;
1013import androidx .annotation .VisibleForTesting ;
14+ import androidx .browser .auth .AuthTabIntent ;
1115
1216import com .braintreepayments .api .browserswitch .R ;
1317
1923public class BrowserSwitchClient {
2024
2125 private final BrowserSwitchInspector browserSwitchInspector ;
26+ private final AuthTabInternalClient authTabInternalClient ;
27+ private ActivityResultLauncher <Intent > authTabLauncher ;
28+ private BrowserSwitchRequest pendingAuthTabRequest ;
2229
23- private final ChromeCustomTabsInternalClient customTabsInternalClient ;
30+ @ Nullable
31+ private BrowserSwitchFinalResult authTabCallbackResult ;
2432
2533 /**
26- * Construct a client that manages the logic for browser switching.
34+ * Construct a client that manages browser switching with Chrome Custom Tabs fallback only.
35+ * This constructor does not initialize Auth Tab support. For Auth Tab functionality,
36+ * use {@link #BrowserSwitchClient(ActivityResultCaller)} instead.
2737 */
2838 public BrowserSwitchClient () {
29- this (new BrowserSwitchInspector (), new ChromeCustomTabsInternalClient ());
39+ this (new BrowserSwitchInspector (), new AuthTabInternalClient ());
40+ }
41+
42+ /**
43+ * Construct a client that manages the logic for browser switching and automatically
44+ * initializes the Auth Tab launcher.
45+ *
46+ * <p>IMPORTANT: This constructor enables the AuthTab functionality, which has several caveats:
47+ *
48+ * <ul>
49+ * <li><strong>This constructor must be called in the activity/fragment's {@code onCreate()} method</strong>
50+ * to properly register the activity result launcher before the activity/fragment is started.
51+ * <li>The caller must be an {@link ActivityResultCaller} to register for activity results.
52+ * <li>{@link LaunchType#ACTIVITY_NEW_TASK} is not supported when using AuthTab and will be ignored.
53+ * Only {@link LaunchType#ACTIVITY_CLEAR_TOP} is supported with AuthTab.
54+ * <li>When using SingleTop activities, you must check for launcher results in {@code onResume()} as well
55+ * as in {@code onNewIntent()}, since the AuthTab activity result might be delivered during the
56+ * resuming phase.
57+ * <li>Care must be taken to avoid calling {@link #completeRequest(Intent, String)} multiple times
58+ * for the same result. Merchants should properly track their pending request state to ensure
59+ * the completeRequest method is only called once per browser switch session.
60+ * <li>AuthTab support is <strong>browser version dependent</strong>. It requires Chrome version 137
61+ * or higher on the user's device. On devices with older browser versions, the library will
62+ * automatically fall back to Custom Tabs. This means that enabling AuthTab is not guaranteed
63+ * to use the AuthTab flow if the user's browser version is too old.
64+ * </ul>
65+ *
66+ * <p>Consider using the default constructor {@link #BrowserSwitchClient()} if these limitations
67+ * are incompatible with your implementation.
68+ *
69+ * @param caller The ActivityResultCaller used to initialize the Auth Tab launcher.
70+ */
71+ public BrowserSwitchClient (@ NonNull ActivityResultCaller caller ) {
72+ this (new BrowserSwitchInspector (), new AuthTabInternalClient ());
73+ initializeAuthTabLauncher (caller );
3074 }
3175
3276 @ VisibleForTesting
3377 BrowserSwitchClient (BrowserSwitchInspector browserSwitchInspector ,
34- ChromeCustomTabsInternalClient customTabsInternalClient ) {
78+ AuthTabInternalClient authTabInternalClient ) {
3579 this .browserSwitchInspector = browserSwitchInspector ;
36- this .customTabsInternalClient = customTabsInternalClient ;
80+ this .authTabInternalClient = authTabInternalClient ;
81+ }
82+
83+ @ VisibleForTesting
84+ BrowserSwitchClient (@ NonNull ActivityResultCaller caller ,
85+ BrowserSwitchInspector browserSwitchInspector ,
86+ AuthTabInternalClient authTabInternalClient ) {
87+ this (browserSwitchInspector , authTabInternalClient );
88+ initializeAuthTabLauncher (caller );
89+ }
90+
91+ /**
92+ * Initialize the Auth Tab launcher. This should be called in the activity/fragment's onCreate()
93+ * before it is started.
94+ *
95+ * @param caller The ActivityResultCaller (Activity or Fragment) used to initialize the Auth Tab launcher
96+ */
97+ private void initializeAuthTabLauncher (@ NonNull ActivityResultCaller caller ) {
98+
99+ this .authTabLauncher = AuthTabIntent .registerActivityResultLauncher (
100+ caller ,
101+ result -> {
102+ BrowserSwitchFinalResult finalResult ;
103+ switch (result .resultCode ) {
104+ case AuthTabIntent .RESULT_OK :
105+ if (result .resultUri != null && pendingAuthTabRequest != null ) {
106+ finalResult = new BrowserSwitchFinalResult .Success (
107+ result .resultUri ,
108+ pendingAuthTabRequest
109+ );
110+ } else {
111+ finalResult = BrowserSwitchFinalResult .NoResult .INSTANCE ;
112+ }
113+ break ;
114+ default :
115+ finalResult = BrowserSwitchFinalResult .NoResult .INSTANCE ;
116+ }
117+ this .authTabCallbackResult = finalResult ;
118+ pendingAuthTabRequest = null ;
119+ }
120+ );
37121 }
38122
123+
39124 /**
40- * Open a browser or <a href="https://developer.chrome.com/multidevice/android/customtabs">Chrome Custom Tab</a>
41- * with a given set of {@link BrowserSwitchOptions} from an Android activity.
125+ * Restores a pending request after process kill or app restart.
126+ *
127+ * <p>Use this method to restore the browser switch state when the app process is killed while the
128+ * browser is open. This should be called in the Activity's {@code onCreate()} method and before calling
129+ * {@link #completeRequest(Intent, String)} to ensure the pending request is properly restored.
130+ *
131+ * <p>The {@code pendingRequest} parameter is the string returned by
132+ * {@link BrowserSwitchStartResult.Started#getPendingRequest()} that was stored in persistent storage
133+ * before the process was killed.
134+ *
135+ * @param pendingRequest The Base64-encoded JSON string representing the pending request to restore
136+ * @throws BrowserSwitchException if the pending request cannot be parsed
137+ */
138+ public void restorePendingRequest (@ NonNull String pendingRequest ) throws BrowserSwitchException {
139+ if (pendingRequest == null ) {
140+ throw new BrowserSwitchException ("Pending request is null" );
141+ }
142+ this .pendingAuthTabRequest = BrowserSwitchRequest .fromBase64EncodedJSON (pendingRequest );
143+ }
144+
145+ /**
146+ * Open a browser or Auth Tab with a given set of {@link BrowserSwitchOptions} from an Android activity.
42147 *
43148 * @param activity the activity used to start browser switch
44149 * @param browserSwitchOptions {@link BrowserSwitchOptions} the options used to configure the browser switch
45150 * @return a {@link BrowserSwitchStartResult.Started} that should be stored and passed to
46- * {@link BrowserSwitchClient#completeRequest(Intent, String)} upon return to the app,
151+ * {@link BrowserSwitchClient#completeRequest(Intent, String)} upon return to the app (for Custom Tabs fallback) ,
47152 * or {@link BrowserSwitchStartResult.Failure} if browser could not be launched.
48153 */
49154 @ NonNull
50- public BrowserSwitchStartResult start (@ NonNull Activity activity , @ NonNull BrowserSwitchOptions browserSwitchOptions ) {
155+ public BrowserSwitchStartResult start (@ NonNull Activity activity ,
156+ @ NonNull BrowserSwitchOptions browserSwitchOptions ) {
157+
158+ this .authTabCallbackResult = null ;
159+
51160 try {
52161 assertCanPerformBrowserSwitch (activity , browserSwitchOptions );
53162 } catch (BrowserSwitchException e ) {
@@ -58,29 +167,55 @@ public BrowserSwitchStartResult start(@NonNull Activity activity, @NonNull Brows
58167 int requestCode = browserSwitchOptions .getRequestCode ();
59168 String returnUrlScheme = browserSwitchOptions .getReturnUrlScheme ();
60169 Uri appLinkUri = browserSwitchOptions .getAppLinkUri ();
61-
62170 JSONObject metadata = browserSwitchOptions .getMetadata ();
63171
64172 if (activity .isFinishing ()) {
65173 String activityFinishingMessage =
66174 "Unable to start browser switch while host Activity is finishing." ;
67175 return new BrowserSwitchStartResult .Failure (new BrowserSwitchException (activityFinishingMessage ));
68- } else {
69- LaunchType launchType = browserSwitchOptions .getLaunchType ();
70- BrowserSwitchRequest request ;
71- try {
72- request = new BrowserSwitchRequest (
73- requestCode ,
74- browserSwitchUrl ,
75- metadata ,
76- returnUrlScheme ,
77- appLinkUri
78- );
79- customTabsInternalClient .launchUrl (activity , browserSwitchUrl , launchType );
80- return new BrowserSwitchStartResult .Started (request .toBase64EncodedJSON ());
81- } catch (ActivityNotFoundException | BrowserSwitchException e ) {
82- return new BrowserSwitchStartResult .Failure (new BrowserSwitchException ("Unable to start browser switch without a web browser." , e ));
176+ }
177+
178+ LaunchType launchType = browserSwitchOptions .getLaunchType ();
179+ BrowserSwitchRequest request ;
180+
181+ try {
182+ request = new BrowserSwitchRequest (
183+ requestCode ,
184+ browserSwitchUrl ,
185+ metadata ,
186+ returnUrlScheme ,
187+ appLinkUri
188+ );
189+
190+ boolean useAuthTab = isAuthTabSupported (activity );
191+
192+ if (useAuthTab ) {
193+ this .pendingAuthTabRequest = request ;
83194 }
195+
196+ authTabInternalClient .launchUrl (
197+ activity ,
198+ browserSwitchUrl ,
199+ returnUrlScheme ,
200+ appLinkUri ,
201+ authTabLauncher ,
202+ launchType
203+ );
204+
205+ return new BrowserSwitchStartResult .Started (request .toBase64EncodedJSON ());
206+
207+ } catch (ActivityNotFoundException e ) {
208+ this .pendingAuthTabRequest = null ;
209+ BrowserSwitchException exception = new BrowserSwitchException (
210+ "Unable to start browser switch without a web browser." , e
211+ );
212+ return new BrowserSwitchStartResult .Failure (exception );
213+ } catch (Exception e ) {
214+ this .pendingAuthTabRequest = null ;
215+ BrowserSwitchException exception = new BrowserSwitchException (
216+ "Unable to start browser switch: " + e .getMessage (), e
217+ );
218+ return new BrowserSwitchStartResult .Failure (exception );
84219 }
85220 }
86221
@@ -121,20 +256,27 @@ private boolean isValidRequestCode(int requestCode) {
121256 }
122257
123258 /**
124- * Completes the browser switch flow and returns a browser switch result if a match is found for
125- * the given {@link BrowserSwitchRequest}
259+ * Completes the browser switch flow for both Auth Tab and Custom Tabs fallback scenarios.
260+ * This method first checks if we have a result from the Auth Tab callback,
261+ * and returns it if available. Otherwise, it follows the Custom Tabs flow.
262+ *
263+ * <p>See <a href="https://developer.chrome.com/docs/android/custom-tabs/guide-auth-tab#fallback_to_custom_tabs">
264+ * Auth Tab Fallback Documentation</a> for details on when Custom Tabs fallback is required
265+ *
266+ * <p><strong>IMPORTANT:</strong> When using Auth Tab with SingleTop activities, you must call this method
267+ * in both {@code onNewIntent()} <em>and</em> {@code onResume()} to ensure the result is properly processed
268+ * regardless of which launch mode is used.
126269 *
127- * @param intent the intent to return to your application containing a deep link result from the
128- * browser flow
129- * @param pendingRequest the pending request string returned from {@link BrowserSwitchStartResult.Started} via
130- * {@link BrowserSwitchClient#start(Activity, BrowserSwitchOptions)}
131- * @return a {@link BrowserSwitchFinalResult.Success} if the browser switch was successfully
132- * completed, or {@link BrowserSwitchFinalResult.NoResult} if no result can be found for the given
133- * pending request String. A {@link BrowserSwitchFinalResult.NoResult} will be
134- * returned if the user returns to the app without completing the browser switch flow.
270+ * @param intent the intent to return to your application containing a deep link result
271+ * @param pendingRequest the pending request string returned from {@link BrowserSwitchStartResult.Started}
272+ * @return a {@link BrowserSwitchFinalResult}
135273 */
136274 public BrowserSwitchFinalResult completeRequest (@ NonNull Intent intent , @ NonNull String pendingRequest ) {
137- if (intent != null && intent .getData () != null ) {
275+ if (authTabCallbackResult != null ) {
276+ BrowserSwitchFinalResult result = authTabCallbackResult ;
277+ authTabCallbackResult = null ;
278+ return result ;
279+ } else if (intent .getData () != null ) {
138280 Uri returnUrl = intent .getData ();
139281
140282 try {
@@ -149,4 +291,15 @@ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull
149291 }
150292 return BrowserSwitchFinalResult .NoResult .INSTANCE ;
151293 }
152- }
294+
295+ /**
296+ * Checks if Auth Tab is supported on this device and if the launcher has been initialized.
297+ * @param context The application context
298+ * @return true if Auth Tab is supported by the browser AND the launcher has been initialized,
299+ * false otherwise
300+ */
301+ @ VisibleForTesting
302+ boolean isAuthTabSupported (Context context ) {
303+ return authTabLauncher != null && authTabInternalClient .isAuthTabSupported (context );
304+ }
305+ }
0 commit comments