blob: 0448c4626098d219e56a2bc3c43d2f41ed2e4673 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.payments;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.content.ServiceConnection;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Parcelable;
import android.os.RemoteException;
import android.util.JsonWriter;
import androidx.annotation.Nullable;
import org.chromium.IsReadyToPayService;
import org.chromium.IsReadyToPayServiceCallback;
import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.components.payments.ErrorStrings;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.content_public.browser.WebContents;
import org.chromium.payments.mojom.PaymentCurrencyAmount;
import org.chromium.payments.mojom.PaymentDetailsModifier;
import org.chromium.payments.mojom.PaymentItem;
import org.chromium.payments.mojom.PaymentMethodData;
import org.chromium.ui.UiUtils;
import org.chromium.ui.base.WindowAndroid;
import java.io.IOException;
import java.io.StringWriter;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* The point of interaction with a locally installed 3rd party native Android payment app.
* https://docs.google.com/document/d/1izV4uC-tiRJG3JLooqY3YRLU22tYOsLTNq0P_InPJeE
*/
public class AndroidPaymentApp
extends PaymentInstrument implements PaymentApp, WindowAndroid.IntentCallback {
/** The action name for the Pay Intent. */
public static final String ACTION_PAY = "org.chromium.intent.action.PAY";
/** The maximum number of milliseconds to wait for a response from a READY_TO_PAY service. */
private static final long READY_TO_PAY_TIMEOUT_MS = 400;
/** The maximum number of milliseconds to wait for a connection to READY_TO_PAY service. */
private static final long SERVICE_CONNECTION_TIMEOUT_MS = 1000;
// Deprecated parameters sent to the payment app for backward compatibility.
private static final String EXTRA_DEPRECATED_CERTIFICATE_CHAIN = "certificateChain";
private static final String EXTRA_DEPRECATED_DATA = "data";
private static final String EXTRA_DEPRECATED_DATA_MAP = "dataMap";
private static final String EXTRA_DEPRECATED_DETAILS = "details";
private static final String EXTRA_DEPRECATED_ID = "id";
private static final String EXTRA_DEPRECATED_IFRAME_ORIGIN = "iframeOrigin";
private static final String EXTRA_DEPRECATED_METHOD_NAME = "methodName";
private static final String EXTRA_DEPRECATED_ORIGIN = "origin";
// Freshest parameters sent to the payment app.
private static final String EXTRA_CERTIFICATE = "certificate";
private static final String EXTRA_MERCHANT_NAME = "merchantName";
private static final String EXTRA_METHOD_DATA = "methodData";
private static final String EXTRA_METHOD_NAMES = "methodNames";
private static final String EXTRA_MODIFIERS = "modifiers";
private static final String EXTRA_PAYMENT_REQUEST_ID = "paymentRequestId";
private static final String EXTRA_PAYMENT_REQUEST_ORIGIN = "paymentRequestOrigin";
private static final String EXTRA_TOP_CERTIFICATE_CHAIN = "topLevelCertificateChain";
private static final String EXTRA_TOP_ORIGIN = "topLevelOrigin";
private static final String EXTRA_TOTAL = "total";
// Response from the payment app.
private static final String EXTRA_DEPRECATED_RESPONSE_INSTRUMENT_DETAILS = "instrumentDetails";
private static final String EXTRA_RESPONSE_DETAILS = "details";
private static final String EXTRA_RESPONSE_METHOD_NAME = "methodName";
private static final String EMPTY_JSON_DATA = "{}";
private final Handler mHandler;
private final WebContents mWebContents;
private final Intent mIsReadyToPayIntent;
private final Intent mPayIntent;
private final Set<String> mMethodNames;
private final boolean mIsIncognito;
private InstrumentsCallback mInstrumentsCallback;
private InstrumentDetailsCallback mInstrumentDetailsCallback;
private ServiceConnection mServiceConnection;
@Nullable
private URI mCanDedupedApplicationId;
private boolean mIsReadyToPayQueried;
private boolean mIsServiceBindingInitiated;
/**
* Builds the point of interaction with a locally installed 3rd party native Android payment
* app.
*
* @param webContents The web contents.
* @param packageName The name of the package of the payment app.
* @param activity The name of the payment activity in the payment app.
* @param label The UI label to use for the payment app.
* @param icon The icon to use in UI for the payment app.
* @param isIncognito Whether the user is in incognito mode.
* @param canDedupedApplicationId The corresponding app Id this app can deduped.
*/
public AndroidPaymentApp(WebContents webContents, String packageName, String activity,
String label, Drawable icon, boolean isIncognito,
@Nullable URI canDedupedApplicationId) {
super(packageName, label, null, icon);
ThreadUtils.assertOnUiThread();
mHandler = new Handler();
mWebContents = webContents;
mPayIntent = new Intent();
mIsReadyToPayIntent = new Intent();
mIsReadyToPayIntent.setPackage(packageName);
mPayIntent.setClassName(packageName, activity);
mPayIntent.setAction(ACTION_PAY);
mMethodNames = new HashSet<>();
mIsIncognito = isIncognito;
mCanDedupedApplicationId = canDedupedApplicationId;
}
/** @param methodName A payment method that this app supports, e.g., "https://bobpay.com". */
public void addMethodName(String methodName) {
mMethodNames.add(methodName);
}
/** @param className The class name of the "is ready to pay" service in the payment app. */
public void setIsReadyToPayAction(String className) {
mIsReadyToPayIntent.setClassName(mIsReadyToPayIntent.getPackage(), className);
}
@Override
public void getInstruments(String unusedId, Map<String, PaymentMethodData> methodDataMap,
String origin, String iframeOrigin, @Nullable byte[][] certificateChain,
Map<String, PaymentDetailsModifier> modifiers, InstrumentsCallback callback) {
assert mMethodNames.containsAll(methodDataMap.keySet());
assert mInstrumentsCallback
== null : "Have not responded to previous request for instruments yet";
mInstrumentsCallback = callback;
if (mIsReadyToPayIntent.getComponent() == null) {
respondToGetInstrumentsQuery(AndroidPaymentApp.this);
return;
}
assert !mIsIncognito;
mServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
IsReadyToPayService isReadyToPayService =
IsReadyToPayService.Stub.asInterface(service);
if (isReadyToPayService == null) {
respondToGetInstrumentsQuery(null);
} else {
sendIsReadyToPayIntentToPaymentApp(isReadyToPayService);
}
}
// "Called when a connection to the Service has been lost. This typically happens when
// the process hosting the service has crashed or been killed. This does not remove the
// ServiceConnection itself -- this binding to the service will remain active, and you
// will receive a call to onServiceConnected(ComponentName, IBinder) when the Service is
// next running."
// https://developer.android.com/reference/android/content/ServiceConnection.html#onServiceDisconnected(android.content.ComponentName)
@Override
public void onServiceDisconnected(ComponentName name) {
// Do not wait for the service to restart.
respondToGetInstrumentsQuery(null);
}
};
mIsReadyToPayIntent.putExtras(buildExtras(null /* id */, null /* merchantName */,
removeUrlScheme(origin), removeUrlScheme(iframeOrigin), certificateChain,
methodDataMap, null /* total */, null /* displayItems */, null /* modifiers */));
try {
// This method returns "true if the system is in the process of bringing up a service
// that your client has permission to bind to; false if the system couldn't find the
// service or if your client doesn't have permission to bind to it. If this value is
// true, you should later call unbindService(ServiceConnection) to release the
// connection."
// https://developer.android.com/reference/android/content/Context.html#bindService(android.content.Intent,%20android.content.ServiceConnection,%20int)
mIsServiceBindingInitiated = ContextUtils.getApplicationContext().bindService(
mIsReadyToPayIntent, mServiceConnection, Context.BIND_AUTO_CREATE);
} catch (SecurityException e) {
// Intentionally blank, so mIsServiceBindingInitiated is false.
}
if (!mIsServiceBindingInitiated) {
respondToGetInstrumentsQuery(null);
return;
}
mHandler.postDelayed(() -> {
if (!mIsReadyToPayQueried) respondToGetInstrumentsQuery(null);
}, SERVICE_CONNECTION_TIMEOUT_MS);
}
private void respondToGetInstrumentsQuery(final PaymentInstrument instrument) {
if (mServiceConnection != null) {
if (mIsServiceBindingInitiated) {
// mServiceConnection "parameter must not be null."
// https://developer.android.com/reference/android/content/Context.html#unbindService(android.content.ServiceConnection)
ContextUtils.getApplicationContext().unbindService(mServiceConnection);
mIsServiceBindingInitiated = false;
}
mServiceConnection = null;
}
if (mInstrumentsCallback == null) return;
mHandler.post(() -> {
ThreadUtils.assertOnUiThread();
if (mInstrumentsCallback == null) return;
List<PaymentInstrument> instruments = null;
if (instrument != null) {
instruments = new ArrayList<>();
instruments.add(instrument);
}
mInstrumentsCallback.onInstrumentsReady(AndroidPaymentApp.this, instruments);
mInstrumentsCallback = null;
});
}
private void sendIsReadyToPayIntentToPaymentApp(IsReadyToPayService isReadyToPayService) {
if (mInstrumentsCallback == null) return;
mIsReadyToPayQueried = true;
IsReadyToPayServiceCallback.Stub callback = new IsReadyToPayServiceCallback.Stub() {
@Override
public void handleIsReadyToPay(boolean isReadyToPay) throws RemoteException {
if (isReadyToPay) {
respondToGetInstrumentsQuery(AndroidPaymentApp.this);
} else {
respondToGetInstrumentsQuery(null);
}
}
};
try {
isReadyToPayService.isReadyToPay(callback);
} catch (Throwable e) {
// Many undocumented exceptions are not caught in the remote Service but passed on to
// the Service caller, see writeException in Parcel.java.
respondToGetInstrumentsQuery(null);
return;
}
mHandler.postDelayed(() -> respondToGetInstrumentsQuery(null), READY_TO_PAY_TIMEOUT_MS);
}
@Override
public boolean supportsMethodsAndData(Map<String, PaymentMethodData> methodsAndData) {
assert methodsAndData != null;
Set<String> methodNames = new HashSet<>(methodsAndData.keySet());
methodNames.retainAll(getAppMethodNames());
return !methodNames.isEmpty();
}
@Override
public URI getCanDedupedApplicationId() {
return mCanDedupedApplicationId;
}
@Override
public String getAppIdentifier() {
return getIdentifier();
}
@Override
public Set<String> getAppMethodNames() {
return Collections.unmodifiableSet(mMethodNames);
}
@Override
public Set<String> getInstrumentMethodNames() {
return getAppMethodNames();
}
@Override
public void invokePaymentApp(final String id, final String merchantName, String origin,
String iframeOrigin, final byte[][] certificateChain,
final Map<String, PaymentMethodData> methodDataMap, final PaymentItem total,
final List<PaymentItem> displayItems,
final Map<String, PaymentDetailsModifier> modifiers,
InstrumentDetailsCallback callback) {
mInstrumentDetailsCallback = callback;
final String schemelessOrigin = removeUrlScheme(origin);
final String schemelessIframeOrigin = removeUrlScheme(iframeOrigin);
if (!mIsIncognito) {
launchPaymentApp(id, merchantName, schemelessOrigin, schemelessIframeOrigin,
certificateChain, methodDataMap, total, displayItems, modifiers);
return;
}
ChromeActivity activity = ChromeActivity.fromWebContents(mWebContents);
if (activity == null) {
notifyErrorInvokingPaymentApp(ErrorStrings.ACTIVITY_NOT_FOUND);
return;
}
new UiUtils.CompatibleAlertDialogBuilder(activity, R.style.Theme_Chromium_AlertDialog)
.setTitle(R.string.external_app_leave_incognito_warning_title)
.setMessage(R.string.external_payment_app_leave_incognito_warning)
.setPositiveButton(R.string.ok,
(OnClickListener) (dialog, which)
-> launchPaymentApp(id, merchantName, schemelessOrigin,
schemelessIframeOrigin, certificateChain, methodDataMap,
total, displayItems, modifiers))
.setNegativeButton(R.string.cancel,
(OnClickListener) (dialog, which)
-> notifyErrorInvokingPaymentApp(ErrorStrings.USER_CANCELLED))
.setOnCancelListener(
dialog -> notifyErrorInvokingPaymentApp(ErrorStrings.USER_CANCELLED))
.show();
}
private static String removeUrlScheme(String url) {
return UrlFormatter.formatUrlForSecurityDisplayOmitScheme(url);
}
private void launchPaymentApp(String id, String merchantName, String origin,
String iframeOrigin, byte[][] certificateChain,
Map<String, PaymentMethodData> methodDataMap, PaymentItem total,
List<PaymentItem> displayItems, Map<String, PaymentDetailsModifier> modifiers) {
assert mMethodNames.containsAll(methodDataMap.keySet());
assert mInstrumentDetailsCallback != null;
if (mWebContents.isDestroyed()) {
notifyErrorInvokingPaymentApp(ErrorStrings.PAYMENT_APP_LAUNCH_FAIL);
return;
}
WindowAndroid window = mWebContents.getTopLevelNativeWindow();
if (window == null) {
notifyErrorInvokingPaymentApp(ErrorStrings.PAYMENT_APP_LAUNCH_FAIL);
return;
}
mPayIntent.putExtras(buildExtras(id, merchantName, origin, iframeOrigin, certificateChain,
methodDataMap, total, displayItems, modifiers));
try {
if (!window.showIntent(mPayIntent, this, R.string.payments_android_app_error)) {
notifyErrorInvokingPaymentApp(ErrorStrings.PAYMENT_APP_LAUNCH_FAIL);
}
} catch (SecurityException e) {
// Payment app does not have android:exported="true" on the PAY activity.
notifyErrorInvokingPaymentApp(ErrorStrings.PAYMENT_APP_PRIVATE_ACTIVITY);
}
}
private static Bundle buildExtras(@Nullable String id, @Nullable String merchantName,
String origin, String iframeOrigin, @Nullable byte[][] certificateChain,
Map<String, PaymentMethodData> methodDataMap, @Nullable PaymentItem total,
@Nullable List<PaymentItem> displayItems,
@Nullable Map<String, PaymentDetailsModifier> modifiers) {
Bundle extras = new Bundle();
if (id != null) extras.putString(EXTRA_PAYMENT_REQUEST_ID, id);
if (merchantName != null) extras.putString(EXTRA_MERCHANT_NAME, merchantName);
extras.putString(EXTRA_TOP_ORIGIN, origin);
extras.putString(EXTRA_PAYMENT_REQUEST_ORIGIN, iframeOrigin);
Parcelable[] serializedCertificateChain = null;
if (certificateChain != null && certificateChain.length > 0) {
serializedCertificateChain = buildCertificateChain(certificateChain);
extras.putParcelableArray(EXTRA_TOP_CERTIFICATE_CHAIN, serializedCertificateChain);
}
extras.putStringArrayList(EXTRA_METHOD_NAMES, new ArrayList<>(methodDataMap.keySet()));
Bundle methodDataBundle = new Bundle();
for (Map.Entry<String, PaymentMethodData> methodData : methodDataMap.entrySet()) {
methodDataBundle.putString(methodData.getKey(),
methodData.getValue() == null ? EMPTY_JSON_DATA
: methodData.getValue().stringifiedData);
}
extras.putParcelable(EXTRA_METHOD_DATA, methodDataBundle);
if (modifiers != null) {
extras.putString(EXTRA_MODIFIERS, serializeModifiers(modifiers.values()));
}
if (total != null) {
String serializedTotalAmount = serializeTotalAmount(total.amount);
extras.putString(EXTRA_TOTAL,
serializedTotalAmount == null ? EMPTY_JSON_DATA : serializedTotalAmount);
}
return addDeprecatedExtras(id, origin, iframeOrigin, serializedCertificateChain,
methodDataMap, methodDataBundle, total, displayItems, extras);
}
private static Bundle addDeprecatedExtras(@Nullable String id, String origin,
String iframeOrigin, @Nullable Parcelable[] serializedCertificateChain,
Map<String, PaymentMethodData> methodDataMap, Bundle methodDataBundle,
@Nullable PaymentItem total, @Nullable List<PaymentItem> displayItems, Bundle extras) {
if (id != null) extras.putString(EXTRA_DEPRECATED_ID, id);
extras.putString(EXTRA_DEPRECATED_ORIGIN, origin);
extras.putString(EXTRA_DEPRECATED_IFRAME_ORIGIN, iframeOrigin);
if (serializedCertificateChain != null) {
extras.putParcelableArray(
EXTRA_DEPRECATED_CERTIFICATE_CHAIN, serializedCertificateChain);
}
String methodName = methodDataMap.entrySet().iterator().next().getKey();
extras.putString(EXTRA_DEPRECATED_METHOD_NAME, methodName);
PaymentMethodData firstMethodData = methodDataMap.get(methodName);
extras.putString(EXTRA_DEPRECATED_DATA,
firstMethodData == null ? EMPTY_JSON_DATA : firstMethodData.stringifiedData);
extras.putParcelable(EXTRA_DEPRECATED_DATA_MAP, methodDataBundle);
String details = deprecatedSerializeDetails(total, displayItems);
extras.putString(EXTRA_DEPRECATED_DETAILS, details == null ? EMPTY_JSON_DATA : details);
return extras;
}
private static Parcelable[] buildCertificateChain(byte[][] certificateChain) {
Parcelable[] result = new Parcelable[certificateChain.length];
for (int i = 0; i < certificateChain.length; i++) {
Bundle bundle = new Bundle();
bundle.putByteArray(EXTRA_CERTIFICATE, certificateChain[i]);
result[i] = bundle;
}
return result;
}
private void notifyErrorInvokingPaymentApp(String errorMessage) {
mHandler.post(() -> mInstrumentDetailsCallback.onInstrumentDetailsError(errorMessage));
}
private static String deprecatedSerializeDetails(
@Nullable PaymentItem total, @Nullable List<PaymentItem> displayItems) {
StringWriter stringWriter = new StringWriter();
JsonWriter json = new JsonWriter(stringWriter);
try {
// details {{{
json.beginObject();
if (total != null) {
// total {{{
json.name("total");
serializeTotal(total, json);
// }}} total
}
// displayitems {{{
if (displayItems != null) {
json.name("displayItems").beginArray();
// Do not pass any display items to the payment app.
json.endArray();
}
// }}} displayItems
json.endObject();
// }}} details
} catch (IOException e) {
return null;
}
return stringWriter.toString();
}
private static String serializeTotalAmount(PaymentCurrencyAmount totalAmount) {
StringWriter stringWriter = new StringWriter();
JsonWriter json = new JsonWriter(stringWriter);
try {
// {{{
json.beginObject();
json.name("currency").value(totalAmount.currency);
json.name("value").value(totalAmount.value);
json.endObject();
// }}}
} catch (IOException e) {
return null;
}
return stringWriter.toString();
}
private static void serializeTotal(PaymentItem item, JsonWriter json)
throws IOException {
// item {{{
json.beginObject();
// Sanitize the total name, because the payment app does not need it to complete the
// transaction. Matches the behavior of:
// https://w3c.github.io/payment-handler/#total-attribute
json.name("label").value("");
// amount {{{
json.name("amount").beginObject();
json.name("currency").value(item.amount.currency);
json.name("value").value(item.amount.value);
json.endObject();
// }}} amount
json.endObject();
// }}} item
}
private static String serializeModifiers(Collection<PaymentDetailsModifier> modifiers) {
StringWriter stringWriter = new StringWriter();
JsonWriter json = new JsonWriter(stringWriter);
try {
json.beginArray();
for (PaymentDetailsModifier modifier : modifiers) {
serializeModifier(modifier, json);
}
json.endArray();
} catch (IOException e) {
return EMPTY_JSON_DATA;
}
return stringWriter.toString();
}
private static void serializeModifier(PaymentDetailsModifier modifier, JsonWriter json)
throws IOException {
// {{{
json.beginObject();
// total {{{
if (modifier.total != null) {
json.name("total");
serializeTotal(modifier.total, json);
} else {
json.name("total").nullValue();
}
// }}} total
// TODO(https://crbug.com/754779): The supportedMethods field was already changed from array
// to string but we should keep backward-compatibility for now.
// supportedMethods {{{
json.name("supportedMethods").beginArray();
json.value(modifier.methodData.supportedMethod);
json.endArray();
// }}} supportedMethods
// data {{{
json.name("data").value(modifier.methodData.stringifiedData);
// }}}
json.endObject();
// }}}
}
@Override
public void onIntentCompleted(WindowAndroid window, int resultCode, Intent data) {
ThreadUtils.assertOnUiThread();
window.removeIntentCallback(this);
if (data == null) {
mInstrumentDetailsCallback.onInstrumentDetailsError(ErrorStrings.MISSING_INTENT_DATA);
} else if (data.getExtras() == null) {
mInstrumentDetailsCallback.onInstrumentDetailsError(ErrorStrings.MISSING_INTENT_EXTRAS);
} else if (resultCode == Activity.RESULT_CANCELED) {
mInstrumentDetailsCallback.onInstrumentDetailsError(ErrorStrings.RESULT_CANCELED);
} else if (resultCode != Activity.RESULT_OK) {
mInstrumentDetailsCallback.onInstrumentDetailsError(String.format(
Locale.US, ErrorStrings.UNRECOGNIZED_ACTIVITY_RESULT, resultCode));
} else {
String details = data.getExtras().getString(EXTRA_RESPONSE_DETAILS);
if (details == null) {
details = data.getExtras().getString(EXTRA_DEPRECATED_RESPONSE_INSTRUMENT_DETAILS);
}
if (details == null) details = EMPTY_JSON_DATA;
String methodName = data.getExtras().getString(EXTRA_RESPONSE_METHOD_NAME);
if (methodName == null) methodName = "";
mInstrumentDetailsCallback.onInstrumentDetailsReady(methodName, details);
}
mInstrumentDetailsCallback = null;
}
@Override
public void dismissInstrument() {}
}