Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# browser-switch-android Release Notes

## unreleased

* Add AuthTab Support
* Upgrade `androidx.browser:browser` dependency version to 1.9.0
* Upgrade `compileSdkVersion` and `targetSdkVersion` to API 36
* Replace `ChromeCustomTabsInternalClient.java` with `AuthTabInternalClient.kt`
* Add parameterized constructor `BrowserSwitchClient(ActivityResultCaller)` to initialize AuthTab support
* Maintain default constructor `BrowserSwitchClient()` for backward compatibility
* Add `restorePendingRequest()` method to handle process kill recovery


## 3.2.0

* Widen BraintreeClient.start() activity reference from `ComponentActivity` to plain `Activity`
Expand Down
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 -> { }

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 null case necessary?

Copy link
Contributor

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.

Copy link
Contributor Author

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...

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!

}
customTabsIntent.launchUrl(context, url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,35 +23,140 @@
public class BrowserSwitchClient {

private final BrowserSwitchInspector browserSwitchInspector;
private final AuthTabInternalClient authTabInternalClient;
private ActivityResultLauncher<Intent> authTabLauncher;
private BrowserSwitchRequest pendingAuthTabRequest;
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 ActivityResultLauncher<Intent> is used by the Auth Tab API, in the event of a process kill, the merchant app would pass in a new instance of ActivityResultLauncher<Intent> which would not get invoked properly when the merchant app Fragment/Activity is recreated.

But we're open to ideas if there's a good way to handle process kill!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we encode the request and send it to the merchant: return new BrowserSwitchStartResult.Started(request.toBase64EncodedJSON());.
In case of a process kill, maybe we can have a constructor that takes the pending request?! In that case, we'd expect them to call completeRequest() next? Would that work in your opinion?

Copy link
Contributor

Choose a reason for hiding this comment

The 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 start<FEATURE>() and finish<FEATURE>() to allow merchants to pass pending state back into the SDK. The idea for having pending state be a Base64 encoded string is to:

  1. discourage merchants from tampering with the string
  2. strings are supported by virtually all Android persistence mechanisms (Parcelable, etc.) which allows merchants to use their preferred persistence mechanism out-of-the-box for browser switch pending state

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abstracted process kill recovery into a method restorePendingRequest at 387e96c


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,
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a cold start, this value will be null. A false .NoResult may occur here when the host application is recovering from a process kill.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe the ActivityResultCallback<AuthResult> will get invoked when the host app is recovering from a process kill since the authTabLauncher is not used until the host app/sdk calls start().

Copy link
Contributor

Choose a reason for hiding this comment

The 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:

Screenshot 2025-10-21 at 4 17 14 PM

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried testing with Don't keep activities and the callback itself is called, but as you mentioned, pendingAuthTabRequest is null.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the newest change with Don't keep activities, and it seems to work as expected 742b29c

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abstracted process kill recovery into a method restorePendingRequest at 387e96c

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did we want to validate that the pendingRequest sent is non-null?
@nonnull annotation on java is just for compiler, it doesn't give any guarantees, AFAIK.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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) {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}
Loading