-
Notifications
You must be signed in to change notification settings - Fork 19
AuthTab Support Feature #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ee1cce3
cc39a8a
6ddac0d
4673301
2a792f7
5a137c7
7ddb477
742b29c
387e96c
befcdd1
11248d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| package com.braintreepayments.api | ||
|
|
||
| import android.content.ActivityNotFoundException | ||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.net.Uri | ||
| import androidx.activity.result.ActivityResultLauncher | ||
| import androidx.browser.auth.AuthTabIntent | ||
| import androidx.browser.customtabs.CustomTabsClient | ||
| import androidx.browser.customtabs.CustomTabsIntent | ||
|
|
||
| internal class AuthTabInternalClient ( | ||
| private val authTabIntentBuilder: AuthTabIntent.Builder = AuthTabIntent.Builder(), | ||
| private val customTabsIntentBuilder: CustomTabsIntent.Builder = CustomTabsIntent.Builder() | ||
| ) { | ||
|
|
||
| fun isAuthTabSupported(context: Context): Boolean { | ||
| val packageName = CustomTabsClient.getPackageName(context, null) | ||
| return when (packageName) { | ||
| null -> false | ||
| else -> CustomTabsClient.isAuthTabSupported(context, packageName) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Launch URL using Auth Tab if supported, otherwise fall back to Custom Tabs | ||
| */ | ||
| @Throws(ActivityNotFoundException::class) | ||
| fun launchUrl( | ||
| context: Context, | ||
| url: Uri, | ||
| returnUrlScheme: String?, | ||
| appLinkUri: Uri?, | ||
| launcher: ActivityResultLauncher<Intent>?, | ||
| launchType: LaunchType? | ||
| ) { | ||
| val useAuthTab = isAuthTabSupported(context) | ||
|
|
||
| if (useAuthTab && launcher != null) { | ||
| val authTabIntent = authTabIntentBuilder.build() | ||
|
|
||
| if (launchType == LaunchType.ACTIVITY_CLEAR_TOP) { | ||
| authTabIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||
| } | ||
| appLinkUri?.host?.let { host -> | ||
| val path = appLinkUri.path ?: "/" | ||
| authTabIntent.launch(launcher, url, host, path) | ||
| } ?: returnUrlScheme?.let { | ||
| authTabIntent.launch(launcher, url, returnUrlScheme) | ||
| } ?: throw IllegalArgumentException("Either returnUrlScheme or appLinkUri must be provided") | ||
| } else { | ||
| // fall back to Custom Tabs | ||
| launchCustomTabs(context, url, launchType) | ||
| } | ||
| } | ||
| private fun launchCustomTabs(context: Context, url: Uri, launchType: LaunchType?) { | ||
| val customTabsIntent = customTabsIntentBuilder.build() | ||
| when (launchType) { | ||
| LaunchType.ACTIVITY_NEW_TASK -> { | ||
| customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||
| } | ||
| LaunchType.ACTIVITY_CLEAR_TOP -> { | ||
| customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||
| } | ||
| null -> { } | ||
| } | ||
| customTabsIntent.launchUrl(context, url) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,8 +6,12 @@ | |
| import android.content.Intent; | ||
| import android.net.Uri; | ||
|
|
||
| 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; | ||
|
|
||
| import com.braintreepayments.api.browserswitch.R; | ||
|
|
||
|
|
@@ -19,35 +23,140 @@ | |
| public class BrowserSwitchClient { | ||
|
|
||
| private final BrowserSwitchInspector browserSwitchInspector; | ||
| private final AuthTabInternalClient authTabInternalClient; | ||
| private ActivityResultLauncher<Intent> authTabLauncher; | ||
| private BrowserSwitchRequest pendingAuthTabRequest; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only part I can see that may not survive a process kill. I wish there was an easy way to access the pending request state that we ask the merchant to hold on to.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep. During development, there didn't seem to be a possible way to handle process kill. Since an But we're open to ideas if there's a good way to handle process kill!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like we encode the request and send it to the merchant:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it could work. On PPCP we have balancing call pattern for
It's worth getting creative to come up with a complete solution. Ideally it's good for us to provide a way for merchants to recover from the rare event of a process kill.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi all, Sai's suggestion seems to work, I pushed the change and it is visible now at 742b29c
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. abstracted process kill recovery into a method |
||
|
|
||
| private final ChromeCustomTabsInternalClient customTabsInternalClient; | ||
| @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(ActivityResultCaller)} instead. | ||
| */ | ||
| public BrowserSwitchClient() { | ||
| this(new BrowserSwitchInspector(), new ChromeCustomTabsInternalClient()); | ||
| this(new BrowserSwitchInspector(), new AuthTabInternalClient()); | ||
| } | ||
|
|
||
| /** | ||
| * Construct a client that manages the logic for browser switching and automatically | ||
| * initializes the Auth Tab launcher. | ||
| * | ||
| * <p>IMPORTANT: This constructor enables the AuthTab functionality, which has several caveats: | ||
| * | ||
| * <ul> | ||
| * <li><strong>This constructor must be called in the activity/fragment's {@code onCreate()} method</strong> | ||
| * to properly register the activity result launcher before the activity/fragment is started. | ||
| * <li>The caller must be an {@link ActivityResultCaller} to register for activity results. | ||
| * <li>{@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. | ||
| * <li>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. | ||
| * <li>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. | ||
| * <li>AuthTab support is <strong>browser version dependent</strong>. 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. | ||
| * </ul> | ||
| * | ||
| * <p>Consider using the default constructor {@link #BrowserSwitchClient()} if these limitations | ||
| * are incompatible with your implementation. | ||
| * | ||
| * @param caller The ActivityResultCaller used to initialize the Auth Tab launcher. | ||
| */ | ||
| public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { | ||
| this(new BrowserSwitchInspector(), new AuthTabInternalClient()); | ||
| initializeAuthTabLauncher(caller); | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| BrowserSwitchClient(BrowserSwitchInspector browserSwitchInspector, | ||
saperi22 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ChromeCustomTabsInternalClient customTabsInternalClient) { | ||
| AuthTabInternalClient authTabInternalClient) { | ||
| this.browserSwitchInspector = browserSwitchInspector; | ||
| this.customTabsInternalClient = customTabsInternalClient; | ||
| this.authTabInternalClient = authTabInternalClient; | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| BrowserSwitchClient(@NonNull ActivityResultCaller caller, | ||
| BrowserSwitchInspector browserSwitchInspector, | ||
| AuthTabInternalClient authTabInternalClient) { | ||
| this(browserSwitchInspector, authTabInternalClient); | ||
| initializeAuthTabLauncher(caller); | ||
| } | ||
|
|
||
| /** | ||
| * Initialize the Auth Tab launcher. This should be called in the activity/fragment's onCreate() | ||
| * before it is started. | ||
| * | ||
| * @param caller The ActivityResultCaller (Activity or Fragment) used to initialize the Auth Tab launcher | ||
| */ | ||
| private void initializeAuthTabLauncher(@NonNull ActivityResultCaller caller) { | ||
|
|
||
| this.authTabLauncher = AuthTabIntent.registerActivityResultLauncher( | ||
| caller, | ||
| result -> { | ||
| BrowserSwitchFinalResult finalResult; | ||
| switch (result.resultCode) { | ||
| case AuthTabIntent.RESULT_OK: | ||
| if (result.resultUri != null && pendingAuthTabRequest != null) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On a cold start, this value will be
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't believe the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it might. It's definitely worth trying with developer settings to see if the callback is invoked. I'm looking at the ActivityResult API docs that has this somewhat cryptic message:
It's a tough use case to handle, because the ActivityResult API also places the responsibility on the host application for state restoration, and since we wrap their API that responsibility gets forwarded to the merchant.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried testing with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tested the newest change with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. abstracted process kill recovery into a method |
||
| finalResult = new BrowserSwitchFinalResult.Success( | ||
| result.resultUri, | ||
| pendingAuthTabRequest | ||
| ); | ||
| } else { | ||
| finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; | ||
| } | ||
| break; | ||
| default: | ||
| finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; | ||
| } | ||
| this.authTabCallbackResult = finalResult; | ||
| pendingAuthTabRequest = null; | ||
| } | ||
| ); | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * Open a browser or <a href="https://developer.chrome.com/multidevice/android/customtabs">Chrome Custom Tab</a> | ||
| * with a given set of {@link BrowserSwitchOptions} from an Android activity. | ||
| * Restores a pending request after process kill or app restart. | ||
| * | ||
| * <p>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. | ||
| * | ||
| * <p>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 { | ||
| if (pendingRequest == null) { | ||
| throw new BrowserSwitchException("Pending request is null"); | ||
| } | ||
| this.pendingAuthTabRequest = BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequest); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did we want to validate that the pendingRequest sent is non-null?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hey, that's a great suggestion, I added it to befcdd1 |
||
| } | ||
|
|
||
| /** | ||
| * 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 Activity activity, @NonNull BrowserSwitchOptions browserSwitchOptions) { | ||
| public BrowserSwitchStartResult start(@NonNull Activity activity, | ||
| @NonNull BrowserSwitchOptions browserSwitchOptions) { | ||
|
|
||
| this.authTabCallbackResult = null; | ||
|
|
||
| try { | ||
| assertCanPerformBrowserSwitch(activity, browserSwitchOptions); | ||
| } catch (BrowserSwitchException e) { | ||
|
|
@@ -58,29 +167,55 @@ public BrowserSwitchStartResult start(@NonNull Activity activity, @NonNull Brows | |
| 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 = 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; | ||
| 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; | ||
| BrowserSwitchException exception = new BrowserSwitchException( | ||
| "Unable to start browser switch: " + e.getMessage(), e | ||
| ); | ||
| return new BrowserSwitchStartResult.Failure(exception); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -121,20 +256,27 @@ 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 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. | ||
| * | ||
| * <p>See <a href="https://developer.chrome.com/docs/android/custom-tabs/guide-auth-tab#fallback_to_custom_tabs"> | ||
| * Auth Tab Fallback Documentation</a> for details on when Custom Tabs fallback is required | ||
| * | ||
| * <p><strong>IMPORTANT:</strong> When using Auth Tab with SingleTop activities, you must call this method | ||
| * in both {@code onNewIntent()} <em>and</em> {@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 from the | ||
| * browser flow | ||
| * @param pendingRequest the pending request string returned from {@link BrowserSwitchStartResult.Started} via | ||
| * {@link BrowserSwitchClient#start(Activity, 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 (authTabCallbackResult != null) { | ||
| BrowserSwitchFinalResult result = authTabCallbackResult; | ||
| authTabCallbackResult = null; | ||
| return result; | ||
| } else if (intent.getData() != null) { | ||
| Uri returnUrl = intent.getData(); | ||
|
|
||
| try { | ||
|
|
@@ -149,4 +291,15 @@ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull | |
| } | ||
| return BrowserSwitchFinalResult.NoResult.INSTANCE; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| } | ||
| } | ||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'm missing some kotlin knowledge. Why is the
nullcase necessary?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See this. It is likely that the compiler complained about a non-exhaustive when statement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, this was exactly the case, i had to add it for a compiler. I suppose there must be a more elegant way to deal with it though...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah makes so much sense! Thanks for the resource Sai!