Skip to content

Conversation

@noguier
Copy link
Contributor

@noguier noguier commented Oct 20, 2025

Thank you for your contribution to Braintree.

Summary of changes

  • added support for AuthTab
  • introduced a parametrized constructor, to initialize AuthTabLauncher
  • added restorePendingRequest() method to handle process kill recovery
  • updated Unit Tests to support new flow

Checklist

  • Added a changelog entry

Authors

@noguier, @saperi22

saperi22 and others added 4 commits October 7, 2025 10:31
* remove duplicate intent-filter

* - Update androidx.browser to 1.9.0
- Update AGP version to 8.9.1 -- requires update to gradle wrapper 8.11.1
- Update compile and target sdk to 36
* remove duplicate intent-filter

* - Update androidx.browser to 1.9.0
- Update AGP version to 8.9.1 -- requires update to gradle wrapper 8.11.1
- Update compile and target sdk to 36

* first iteration on AuthTab Setup:
added AuthTabInternalClient,
removed ChromeCustomTabsInternalClient,
modified ComposeActivity & DemoActivitySingleTop to accept the above changes,
added toast visuals to see whether or not the AuthTab is supported

* Rename .java to .kt

* second iteration on AuthTab Setup:
rewrote AuthTabInternalClient to Kotlin and added a ClearTop Flag,
wrote new AuthTabInternalClientUnitTest,
removed ChromeCustomTabsInternalClientUnitTest,
modified BrowserSwitchClientUnitTest to accept the AuthTab changes

* third iteration on AuthTab Setup:
removed CustomTabIntentBuilder,
removed tests associated with CustomTabs from AuthTabInternalClientUnitTest,,
modified BrowserSwitchClientUnitTest to accept the AuthTab launchURL signature

* EOD commit:
-Testing the authtab flow on older devices without intents

* Revert "EOD commit:"

This reverts commit e27cfa1.

* Revert "third iteration on AuthTab Setup:"

This reverts commit 9803e43. Branching still needed to provide support for older devices.

* Completed implementation of AuthTab Support:
 - added AuthTabInternalClient,
 -  removed ChromeCustomTabsInternalClient,
 -  modified ComposeActivity & DemoActivitySingleTop to accept the above changes,
 - added toast visuals to see whether or not the AuthTab is supported

* Address PR suggestions

* Address PR suggestions

---------

Co-authored-by: saperi <[email protected]>
* remove duplicate intent-filter

* - Update androidx.browser to 1.9.0
- Update AGP version to 8.9.1 -- requires update to gradle wrapper 8.11.1
- Update compile and target sdk to 36

* first iteration on AuthTab Setup:
added AuthTabInternalClient,
removed ChromeCustomTabsInternalClient,
modified ComposeActivity & DemoActivitySingleTop to accept the above changes,
added toast visuals to see whether or not the AuthTab is supported

* Rename .java to .kt

* second iteration on AuthTab Setup:
rewrote AuthTabInternalClient to Kotlin and added a ClearTop Flag,
wrote new AuthTabInternalClientUnitTest,
removed ChromeCustomTabsInternalClientUnitTest,
modified BrowserSwitchClientUnitTest to accept the AuthTab changes

* third iteration on AuthTab Setup:
removed CustomTabIntentBuilder,
removed tests associated with CustomTabs from AuthTabInternalClientUnitTest,,
modified BrowserSwitchClientUnitTest to accept the AuthTab launchURL signature

* EOD commit:
-Testing the authtab flow on older devices without intents

* Revert "EOD commit:"

This reverts commit e27cfa1.

* Revert "third iteration on AuthTab Setup:"

This reverts commit 9803e43. Branching still needed to provide support for older devices.

* Completed implementation of AuthTab Support:
 - added AuthTabInternalClient,
 -  removed ChromeCustomTabsInternalClient,
 -  modified ComposeActivity & DemoActivitySingleTop to accept the above changes,
 - added toast visuals to see whether or not the AuthTab is supported

* Address PR suggestions

* Address PR suggestions

* Add support for parametrized constructor to give a merchant a choice to use AuthTab or not

* -added the ability to store a callback internally without exposing it
-modified BrowserSwitchClient's constructor to only accept Activity as a parameter
-rerouted the flow to always call completeRequest and check for the callback result withing the function
-added a call to completeRequest in DemoActivitySingleTop.java onResume
-added the tests to support the new flow

* -addressed PR suggestions
-removed toast pop-ups

* updated javadoc for parametrized BrowserSwitchClient constructor and completeRequest() function

---------

Co-authored-by: saperi <[email protected]>
)

* updated default constructor to accept `ActivityResultCaller` as a parameter
updated `CHANGELOG.md`

* addressed PR suggestions

* addressed PR suggestions: switch the version to `unreleased`
@noguier noguier requested a review from a team as a code owner October 20, 2025 21:47
# Conflicts:
#	CHANGELOG.md
#	browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java
#	build.gradle
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

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

* @throws BrowserSwitchException if the pending request cannot be parsed
*/
public void restorePendingRequest(@NonNull String pendingRequest) throws BrowserSwitchException {
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

Copy link

@saralvasquez saralvasquez left a comment

Choose a reason for hiding this comment

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

This looks great! 🎉 I had a few non-blocking comments and a question but this looks ready to go to me!

CHANGELOG.md Outdated
* 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()` (without AuthTab support) for backward compatibility

Choose a reason for hiding this comment

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

Tiny tiny nit: not sure if the (without AuthTab support) is necessary and it make the entry a bit long

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!


} catch (ActivityNotFoundException e) {
this.pendingAuthTabRequest = null;
return new BrowserSwitchStartResult.Failure(

Choose a reason for hiding this comment

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

Nit: Nested function calls and object instantiation is a bit hard to read. Especially when it's also a return. It might be easier to read if the exception creation was broken out onto a separate line. The same goes for line 214

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this was another pattern that I took from legacy code, but i agree that readability can be improved!

import android.os.Bundle;
import android.view.View;
import android.widget.Button;

Choose a reason for hiding this comment

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

Nit: Cleanup is nice, but no-ops can make commit history for a file a bit cluttered. I would recommend leaving this file alone


@Before
fun setUp() {
clearAllMocks()

Choose a reason for hiding this comment

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

This definitely feels like best practice! Love it. Out of curiosity, did you notice that this call was necessary to the tests passing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was a part of the ChromeCustomTabClientUnitTest that was removed because the new AuthTabClient was introduced. Since it was used in the old test, I decided to add it to this one as well. However, the tests DO pass without that line, because we are not reusing the mocked objects, I opted in to keeping it because it is was in the old test.

Choose a reason for hiding this comment

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

Ah makes sense! Thanks for the info!

every { CustomTabsClient.getPackageName(context, null) } returns packageName
every { CustomTabsClient.isAuthTabSupported(context, packageName) } returns false

val client = AuthTabInternalClient(authTabBuilder, customTabsBuilder)

Choose a reason for hiding this comment

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

Nit: I think in most of the unit test classes we use sut for the variable name if it's the object we testing

browserSwitchClient = new BrowserSwitchClient();

browserSwitchClient = new BrowserSwitchClient(this);
// Check if there is a preserved pending request after the process kill

Choose a reason for hiding this comment

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

Nit: The code here seems pretty self explanatory so I'm not sure if the comments here add much

@noguier noguier merged commit 6beaa58 into main Oct 30, 2025
4 checks passed
@noguier noguier deleted the auth-tab-feature branch October 30, 2025 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants