Skip to content
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

[V3] Encode Pending Browser Switch Request as Base64 String #90

4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
* Change `BrowserSwitchClient#start` parameters and return type
* Change `BrowserSwitchClient#parseResult` parameters
* Remove `deliverResult`, `getResult`, `captureResult`, `clearActiveRequests`, `getResultFromCache`, and `deliverResultFromCache` from `BrowserSwitchClient`
* Add `BrowserSwitchRequest` and `BrowserSwitchPendingRequest`
* Convert `BrowserSwitchResult` to sealed class and add `BrowserSwitchResultInfo`
* Remove `BrowserSwitchStatus`
* Add `BrowserSwitchRequest` and `BrowserSwitchStartResult`
* Rename `BrowserSwitchResult` to `BrowserSwitchParseResult` and convert it to a sealed class

## 2.6.1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import androidx.activity.ComponentActivity;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import com.braintreepayments.api.browserswitch.R;
Expand Down Expand Up @@ -41,18 +40,19 @@ public BrowserSwitchClient() {
* 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.
*
* @param activity the activity used to start browser switch
* @param activity the activity used to start browser switch
* @param browserSwitchOptions {@link BrowserSwitchOptions} the options used to configure the browser switch
* @return a {@link BrowserSwitchPendingRequest.Started} that should be stored and passed to
* {@link BrowserSwitchClient#parseResult(BrowserSwitchPendingRequest.Started, Intent)} upon return to the app,
* or {@link BrowserSwitchPendingRequest.Failure} if browser could not be launched.
* @return a {@link BrowserSwitchStartResult.Success} that should be stored and passed to
* {@link BrowserSwitchClient#parseResult(Intent, String)} upon return to the app,
* or {@link BrowserSwitchStartResult.Failure} if browser could not be launched.
*/
@NonNull
public BrowserSwitchPendingRequest start(@NonNull ComponentActivity activity, @NonNull BrowserSwitchOptions browserSwitchOptions) {
public BrowserSwitchStartResult start(@NonNull ComponentActivity activity, @NonNull BrowserSwitchOptions browserSwitchOptions) {
// TODO: allow browser switching with application context
try {
assertCanPerformBrowserSwitch(activity, browserSwitchOptions);
} catch (BrowserSwitchException e) {
return new BrowserSwitchPendingRequest.Failure(e);
return new BrowserSwitchStartResult.Failure(e);
}

Uri browserSwitchUrl = browserSwitchOptions.getUrl();
Expand All @@ -64,18 +64,17 @@ public BrowserSwitchPendingRequest start(@NonNull ComponentActivity activity, @N
if (activity.isFinishing()) {
String activityFinishingMessage =
"Unable to start browser switch while host Activity is finishing.";
return new BrowserSwitchPendingRequest.Failure(new BrowserSwitchException(activityFinishingMessage));
} else {
return new BrowserSwitchStartResult.Failure(new BrowserSwitchException(activityFinishingMessage));
} else {
boolean launchAsNewTask = browserSwitchOptions.isLaunchAsNewTask();
BrowserSwitchRequest request;
try {
request =
new BrowserSwitchRequest(requestCode, browserSwitchUrl, metadata, returnUrlScheme, true);
BrowserSwitchRequest request =
new BrowserSwitchRequest(requestCode, browserSwitchUrl, metadata, returnUrlScheme);
customTabsInternalClient.launchUrl(activity, browserSwitchUrl, launchAsNewTask);
} catch (ActivityNotFoundException e) {
return new BrowserSwitchPendingRequest.Failure(new BrowserSwitchException("Unable to start browser switch without a web browser."));
return new BrowserSwitchStartResult.Success(request.toBase64EncodedJSON());
} catch (ActivityNotFoundException | BrowserSwitchException e) {
return new BrowserSwitchStartResult.Failure(new BrowserSwitchException("Unable to start browser switch without a web browser.", e));
}
return new BrowserSwitchPendingRequest.Started(request);
}
}

Expand Down Expand Up @@ -106,23 +105,30 @@ private boolean isValidRequestCode(int requestCode) {

/**
* Parses and returns a browser switch result if a match is found for the given {@link BrowserSwitchRequest}
* @param pendingRequest the {@link BrowserSwitchPendingRequest.Started} returned from
* {@link BrowserSwitchClient#start(ComponentActivity, BrowserSwitchOptions)}
* @param intent the intent to return to your application containing a deep link result from the
* browser flow
* @return a {@link BrowserSwitchResult.Success} if the browser switch was successfully
* completed, or {@link BrowserSwitchResult.NoResult} if no result can be found for the given
* {@link BrowserSwitchPendingRequest.Started}. A {@link BrowserSwitchResult.NoResult} will be
*
* @param intent the intent to return to your application containing a deep link result from the
* browser flow
* @param pendingRequestState the {@link BrowserSwitchStartResult.Success} token returned from
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about the state/token naming here, those don't align in my mind. Maybe startResultDetails or startResultInfo or storedStartResult and remove token from the description?

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 agree the naming can be improved. Choosing the right names for Browser Switch will maximize the Developer Experience and influence the DX in core.

For me the only thing with "details" and "info" that's conflicting is an implication that the string we return can provide information at runtime. Technically it's an opaque string that we don't want merchants to inspect.

We should definitely remove the word token, that's a holdover from what I called it originally. But in a way this "state" is similar to a BT nonce–we don't expect merchants to inspect the value for details because the structure of the data is undefined i.e. doesn't follow semantic versioning.

Could we maybe shorten it to requestState? Or continue to ideate as a group this might be something to get some 👀's on. I'd be interested in seeing how this bubbles up to the features in the Core SDK too that could help us pick the right names.

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 storedStartResult makes sense. "result" seems generic enough for a merchant to not want to inspect its value.

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 like storedStartResult for this method. The only thing where the naming doesn't scale as nicely is when the result is unpacked, we'd end up with result.storedStartResult.

I'm wondering now if we should just go with something like originalRequest to keep it simple? It'll still be opaque because the type is String, and it would work nicely in both contexts (start() and parse()).

Also reiterating the main reason we make this value a String is because strings work nicely with all Android persistence mechanisms e.g. SharedPrefs, DataStore, etc. We only need the merchant to hold on to this value for us to remove the responsibility of state restoration out of the SDK. We've gotten a good number of inbounds on that in the past, which makes us think that state restoration logic may be unique to each individual merchant app.

Copy link
Contributor

@tdchow tdchow May 21, 2024

Choose a reason for hiding this comment

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

What if we had separate naming for the start() and parse() steps? It might give a better description for each for both steps.

Start - startResultToStore

        when (val result = browserSwitchClient.start(this, browserSwitchOptions)) {
            is BrowserSwitchStartResult.Success ->
                PendingRequestStore.put(this, result.startResultToStore)
            ...
        }

Parse - storedStartResult

       PendingRequestStore.get(this)?.let { storedStartRequest ->
            when (val result = browserSwitchClient.parseResult(intent, storedStartResult)) {
                ...
            }
        }

Copy link
Contributor

Choose a reason for hiding this comment

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

Honestly I still kind of think the pendingRequest naming is a little more clear for what it is - a request that hasn't been completed yet. Maybe we could change the types to Started and Failure to better indicate that the request hasn't completed successfully yet?

   when (val pendingRequest = browserSwitchClient.start(this, browserSwitchOptions)) {
            is BrowserSwitchPendingRequest.Started ->
                PendingRequestStore.put(this, pendingRequest.requestToStore)
            ...
        }

Also maybe something like completeRequest would make more sense than parse to indicate what is required by the merchant?

       PendingRequestStore.get(this)?.let { storedPendingRequest ->
            when (val result = browserSwitchClient.completeRequest(intent, storedPendingRequest)) {
                ...
            }
        }

Can't decide what would be most clear for merchants. Tagging in @scannillo @jaxdesmarais @saperi22 for more opinions

Copy link
Contributor

Choose a reason for hiding this comment

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

I like that suggestion. I think the consumer of Browser Switch could name the return value of browserSwitchClient.start(this, browserSwitchOptions) anything they wanted. But I agree with pendingRequest.

I also think completeRequest() is more clear than parse().

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree on pendingRequest and completeRequest, those both sound like the most clear options for both us and merchants

Copy link
Contributor

Choose a reason for hiding this comment

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

PR for renaming parseResult() to completeRequest(): #101

* {@link BrowserSwitchClient#start(ComponentActivity, BrowserSwitchOptions)}
* @return a {@link BrowserSwitchParseResult.Success} if the browser switch was successfully
* completed, or {@link BrowserSwitchParseResult.NoResult} if no result can be found for the given
* {@link BrowserSwitchStartResult.Success}. A {@link BrowserSwitchParseResult.NoResult} will be
* returned if the user returns to the app without completing the browser switch flow.
*/
public BrowserSwitchResult parseResult(@NonNull BrowserSwitchPendingRequest.Started pendingRequest, @Nullable Intent intent) {
@NonNull
public BrowserSwitchParseResult parseResult(@NonNull Intent intent, @NonNull String pendingRequestState) {
if (intent != null && intent.getData() != null) {
Uri deepLinkUrl = intent.getData();
if (pendingRequest.getBrowserSwitchRequest().matchesDeepLinkUrlScheme(deepLinkUrl)) {
BrowserSwitchResultInfo resultInfo = new BrowserSwitchResultInfo(pendingRequest.getBrowserSwitchRequest(), deepLinkUrl);
return new BrowserSwitchResult.Success(resultInfo);
try {
BrowserSwitchRequest pendingRequest =
BrowserSwitchRequest.fromBase64EncodedJSON(pendingRequestState);
if (pendingRequest.matchesDeepLinkUrlScheme(deepLinkUrl)) {
return new BrowserSwitchParseResult.Success(deepLinkUrl, pendingRequest);
}
} catch (BrowserSwitchException e) {
return new BrowserSwitchParseResult.Failure(e);
}
}
return BrowserSwitchResult.NoResult.INSTANCE;
return BrowserSwitchParseResult.NoResult.INSTANCE;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ public class BrowserSwitchException extends Exception {
BrowserSwitchException(String message) {
super(message);
}

BrowserSwitchException(String message, Exception reason) {
super(message, reason);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

/**
* Object that contains a set of browser switch parameters for use with
* {@link BrowserSwitchClient#start(FragmentActivity, BrowserSwitchOptions)}.
* {@link BrowserSwitchClient#start(androidx.activity.ComponentActivity, BrowserSwitchOptions)}.
*/
public class BrowserSwitchOptions {

Expand All @@ -24,7 +24,7 @@ public class BrowserSwitchOptions {
* Set browser switch metadata.
*
* @param metadata JSONObject containing metadata that will be persisted and returned in a
* {@link BrowserSwitchResultInfo} when the app has re-entered the foreground
* {@link BrowserSwitchParseResult} when the app has re-entered the foreground
* @return {@link BrowserSwitchOptions} reference to instance to allow setter invocations to be chained
*/
public BrowserSwitchOptions metadata(@Nullable JSONObject metadata) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.braintreepayments.api

import android.net.Uri
import org.json.JSONObject

/**
* The result of a browser switch obtained from [BrowserSwitchClient.parseResult]
*/
sealed class BrowserSwitchParseResult {

/**
* The browser switch was successfully completed. See [resultInfo] for details.
*/
class Success internal constructor(
val deepLinkUrl: Uri,
val requestCode: Int,
val requestUrl: Uri,
val requestMetadata: JSONObject?,
) : BrowserSwitchParseResult() {
internal constructor(deepLinkUrl: Uri, originalRequest: BrowserSwitchRequest) : this(
deepLinkUrl,
originalRequest.requestCode,
originalRequest.url,
originalRequest.metadata
)
}

/**
* The browser switch failed.
* @property [error] Error detailing the reason for the browser switch failure.
*/
class Failure internal constructor(val error: BrowserSwitchException) :
BrowserSwitchParseResult()

/**
* No browser switch result was found. This is the expected result when a user cancels the
* browser switch flow without completing by closing the browser, or navigates back to the app
* without completing the browser switch flow.
*/
object NoResult : BrowserSwitchParseResult()
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,39 +1,58 @@
package com.braintreepayments.api;

import android.net.Uri;
import android.util.Base64;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import org.json.JSONException;
import org.json.JSONObject;

import java.nio.charset.StandardCharsets;

// Links
// Base64 Encode a String in Android: https://stackoverflow.com/a/7360440

// TODO: consider encryption
// Ref: https://medium.com/fw-engineering/sharedpreferences-and-android-keystore-c4eac3373ac7

// TODO: Rename to `BrowserSwitchStartRequest` and remove `BrowserSwitchOptions` in favor this class
Comment on lines +14 to +20
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we remove these TODOs before merging the PR in?

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 agree, we may be able to convert these to JIRA tickets.

public class BrowserSwitchRequest {

private static final String KEY_REQUEST_CODE = "requestCode";
private static final String KEY_URL = "url";
private static final String KEY_RETURN_URL_SCHEME = "returnUrlScheme";
private static final String KEY_METADATA = "metadata";

private final Uri url;
private final int requestCode;
private final JSONObject metadata;
@VisibleForTesting
final String returnUrlScheme;
private boolean shouldNotifyCancellation;

static BrowserSwitchRequest fromJson(String json) throws JSONException {
JSONObject jsonObject = new JSONObject(json);
int requestCode = jsonObject.getInt("requestCode");
String url = jsonObject.getString("url");
String returnUrlScheme = jsonObject.getString("returnUrlScheme");
JSONObject metadata = jsonObject.optJSONObject("metadata");
boolean shouldNotify = jsonObject.optBoolean("shouldNotify", true);
return new BrowserSwitchRequest(requestCode, Uri.parse(url), metadata, returnUrlScheme, shouldNotify);

@NonNull
static BrowserSwitchRequest fromBase64EncodedJSON(@NonNull String base64EncodedRequest) throws BrowserSwitchException {
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess we should weigh the benefits of base64encoding compared to the extra merchant integration lift of having to handle the Failure type for all browser based flows. I could go either way. What do others think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yeah def. We can't really enforce JSON tampering though. We may still need the failure scenario for the case where we're unable to reconstruct the original BrowserSwitchRequest.

byte[] data = Base64.decode(base64EncodedRequest, Base64.DEFAULT);
String requestJSONString = new String(data, StandardCharsets.UTF_8);
try {
JSONObject requestJSON = new JSONObject(requestJSONString);
return new BrowserSwitchRequest(
requestJSON.getInt(KEY_REQUEST_CODE),
Uri.parse(requestJSON.getString(KEY_URL)),
requestJSON.optJSONObject(KEY_METADATA),
requestJSON.getString(KEY_RETURN_URL_SCHEME)
);
} catch (JSONException e) {
throw new BrowserSwitchException("Unable to deserialize browser switch state.", e);
}
}

BrowserSwitchRequest(int requestCode, Uri url, JSONObject metadata, String returnUrlScheme, boolean shouldNotifyCancellation) {
BrowserSwitchRequest(int requestCode, Uri url, JSONObject metadata, String returnUrlScheme) {
this.url = url;
this.requestCode = requestCode;
this.metadata = metadata;
this.returnUrlScheme = returnUrlScheme;
this.shouldNotifyCancellation = shouldNotifyCancellation;
}

Uri getUrl() {
Expand All @@ -48,24 +67,20 @@ JSONObject getMetadata() {
return metadata;
}

boolean getShouldNotifyCancellation() {
return shouldNotifyCancellation;
}

void setShouldNotifyCancellation(boolean shouldNotifyCancellation) {
this.shouldNotifyCancellation = shouldNotifyCancellation;
}
@NonNull
String toBase64EncodedJSON() throws BrowserSwitchException {
try {
JSONObject requestJSON = new JSONObject()
.put(KEY_REQUEST_CODE, requestCode)
.put(KEY_URL, url.toString())
.put(KEY_RETURN_URL_SCHEME, returnUrlScheme)
.putOpt(KEY_METADATA, metadata);

String toJson() throws JSONException {
JSONObject result = new JSONObject();
result.put("requestCode", requestCode);
result.put("url", url.toString());
result.put("returnUrlScheme", returnUrlScheme);
result.put("shouldNotify", shouldNotifyCancellation);
if (metadata != null) {
result.put("metadata", metadata);
byte[] requestJSONBytes = requestJSON.toString().getBytes(StandardCharsets.UTF_8);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Base 64 encoding is technically optional, though it does make the state string appear to the merchant as an opaque data type to discourage tampering. Base64 encoding also has an added benefit of masking the data to prevent casual observation of browser switch metadata e.g. intercepting an ec-token over a screen share.

return Base64.encodeToString(requestJSONBytes, Base64.DEFAULT);
} catch (JSONException e) {
throw new BrowserSwitchException("Unable to serialize browser switch state.", e);
}
return result.toString();
}

boolean matchesDeepLinkUrlScheme(@NonNull Uri url) {
Expand Down

This file was deleted.

Loading
Loading