Skip to content

Commit 6beaa58

Browse files
noguiersaperi22
andauthored
AuthTab Support Feature (#124)
Co-authored-by: saperi22 <[email protected]> Co-authored-by: saperi <[email protected]>
1 parent 2976d73 commit 6beaa58

File tree

12 files changed

+1005
-210
lines changed

12 files changed

+1005
-210
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# browser-switch-android Release Notes
22

3+
## unreleased
4+
5+
* Add AuthTab Support
6+
* Upgrade `androidx.browser:browser` dependency version to 1.9.0
7+
* Upgrade `compileSdkVersion` and `targetSdkVersion` to API 36
8+
* Replace `ChromeCustomTabsInternalClient.java` with `AuthTabInternalClient.kt`
9+
* Add parameterized constructor `BrowserSwitchClient(ActivityResultCaller)` to initialize AuthTab support
10+
* Maintain default constructor `BrowserSwitchClient()` for backward compatibility
11+
* Add `restorePendingRequest()` method to handle process kill recovery
12+
13+
314
## 3.2.0
415

516
* Widen BraintreeClient.start() activity reference from `ComponentActivity` to plain `Activity`
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.braintreepayments.api
2+
3+
import android.content.ActivityNotFoundException
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.net.Uri
7+
import androidx.activity.result.ActivityResultLauncher
8+
import androidx.browser.auth.AuthTabIntent
9+
import androidx.browser.customtabs.CustomTabsClient
10+
import androidx.browser.customtabs.CustomTabsIntent
11+
12+
internal class AuthTabInternalClient (
13+
private val authTabIntentBuilder: AuthTabIntent.Builder = AuthTabIntent.Builder(),
14+
private val customTabsIntentBuilder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
15+
) {
16+
17+
fun isAuthTabSupported(context: Context): Boolean {
18+
val packageName = CustomTabsClient.getPackageName(context, null)
19+
return when (packageName) {
20+
null -> false
21+
else -> CustomTabsClient.isAuthTabSupported(context, packageName)
22+
}
23+
}
24+
25+
/**
26+
* Launch URL using Auth Tab if supported, otherwise fall back to Custom Tabs
27+
*/
28+
@Throws(ActivityNotFoundException::class)
29+
fun launchUrl(
30+
context: Context,
31+
url: Uri,
32+
returnUrlScheme: String?,
33+
appLinkUri: Uri?,
34+
launcher: ActivityResultLauncher<Intent>?,
35+
launchType: LaunchType?
36+
) {
37+
val useAuthTab = isAuthTabSupported(context)
38+
39+
if (useAuthTab && launcher != null) {
40+
val authTabIntent = authTabIntentBuilder.build()
41+
42+
if (launchType == LaunchType.ACTIVITY_CLEAR_TOP) {
43+
authTabIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
44+
}
45+
appLinkUri?.host?.let { host ->
46+
val path = appLinkUri.path ?: "/"
47+
authTabIntent.launch(launcher, url, host, path)
48+
} ?: returnUrlScheme?.let {
49+
authTabIntent.launch(launcher, url, returnUrlScheme)
50+
} ?: throw IllegalArgumentException("Either returnUrlScheme or appLinkUri must be provided")
51+
} else {
52+
// fall back to Custom Tabs
53+
launchCustomTabs(context, url, launchType)
54+
}
55+
}
56+
private fun launchCustomTabs(context: Context, url: Uri, launchType: LaunchType?) {
57+
val customTabsIntent = customTabsIntentBuilder.build()
58+
when (launchType) {
59+
LaunchType.ACTIVITY_NEW_TASK -> {
60+
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
61+
}
62+
LaunchType.ACTIVITY_CLEAR_TOP -> {
63+
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
64+
}
65+
null -> { }
66+
}
67+
customTabsIntent.launchUrl(context, url)
68+
}
69+
}

browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java

Lines changed: 190 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@
66
import android.content.Intent;
77
import android.net.Uri;
88

9+
import androidx.activity.result.ActivityResultCaller;
10+
import androidx.activity.result.ActivityResultLauncher;
911
import androidx.annotation.NonNull;
12+
import androidx.annotation.Nullable;
1013
import androidx.annotation.VisibleForTesting;
14+
import androidx.browser.auth.AuthTabIntent;
1115

1216
import com.braintreepayments.api.browserswitch.R;
1317

@@ -19,35 +23,140 @@
1923
public 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

Comments
 (0)