// Copyright 2017 The Chromium Authors
// 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.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.content.pm.Signature;
import android.os.Bundle;
import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.SmallTest;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentMatcher;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.BaseJUnit4ClassRunner;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorSupplier;
import org.chromium.chrome.test.ChromeBrowserTestRule;
import org.chromium.components.payments.AndroidPaymentAppFinder;
import org.chromium.components.payments.AppCreationFailureReason;
import org.chromium.components.payments.CSPChecker;
import org.chromium.components.payments.PackageManagerDelegate;
import org.chromium.components.payments.PaymentApp;
import org.chromium.components.payments.PaymentAppFactoryDelegate;
import org.chromium.components.payments.PaymentAppFactoryParams;
import org.chromium.components.payments.PaymentManifestDownloader;
import org.chromium.components.payments.PaymentManifestParser;
import org.chromium.components.payments.PaymentManifestWebDataService;
import org.chromium.components.payments.WebAppManifestSection;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.NativeLibraryTestUtils;
import org.chromium.payments.mojom.PaymentDetailsModifier;
import org.chromium.payments.mojom.PaymentMethodData;
import org.chromium.ui.base.ActivityWindowAndroid;
import org.chromium.ui.base.IntentRequestTracker;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.test.util.BlankUiTestActivityTestCase;
import org.chromium.url.GURL;
import org.chromium.url.Origin;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Tests for the native Android payment app finder. */
@RunWith(BaseJUnit4ClassRunner.class)
@Batch(AndroidPaymentAppFinderUnitTest.PAYMENTS_BROWSER_UNIT_TESTS)
public class AndroidPaymentAppFinderUnitTest extends BlankUiTestActivityTestCase {
// Collection of payments unit tests that require the browser process to be initialized.
static final String PAYMENTS_BROWSER_UNIT_TESTS = "PaymentsBrowserUnitTests";
private static final IntentArgumentMatcher sPayIntentArgumentMatcher =
new IntentArgumentMatcher(new Intent("org.chromium.intent.action.PAY"));
@Rule public ChromeBrowserTestRule mTestRule = new ChromeBrowserTestRule();
@Mock private PaymentManifestWebDataService mPaymentManifestWebDataService;
@Mock private PaymentManifestDownloader mPaymentManifestDownloader;
@Mock private PaymentManifestParser mPaymentManifestParser;
@Mock private PackageManagerDelegate mPackageManagerDelegate;
private WindowAndroid mWindowAndroid;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mWindowAndroid =
ThreadUtils.runOnUiThreadBlocking(
() -> {
return new ActivityWindowAndroid(
getActivity(),
/* listenToActivityState= */ true,
IntentRequestTracker.createFromActivity(getActivity()));
});
NativeLibraryTestUtils.loadNativeLibraryAndInitBrowserProcess();
}
@After
public void tearDown() throws Exception {
ThreadUtils.runOnUiThreadBlocking(
() -> {
mWindowAndroid.destroy();
});
}
/** Argument matcher that matches Intents using |filterEquals| method. */
private static class IntentArgumentMatcher implements ArgumentMatcher<Intent> {
private final Intent mIntent;
public IntentArgumentMatcher(Intent intent) {
mIntent = intent;
}
@Override
public boolean matches(Intent other) {
return mIntent.filterEquals(other);
}
@Override
public String toString() {
return mIntent.toString();
}
}
private PaymentAppFactoryDelegate findApps(
String[] methodNames,
PaymentManifestDownloader downloader,
PaymentManifestParser parser,
PackageManagerDelegate packageManagerDelegate) {
Map<String, PaymentMethodData> methodData = new HashMap<>();
for (String methodName : methodNames) {
PaymentMethodData data = new PaymentMethodData();
data.supportedMethod = methodName;
data.stringifiedData = "{\"key\":\"value\"}";
methodData.put(methodName, data);
}
PaymentAppFactoryParams params = Mockito.mock(PaymentAppFactoryParams.class);
WebContents webContents = Mockito.mock(WebContents.class);
TabModelSelector tabModelSelector = Mockito.mock(TabModelSelector.class);
TabModelSelectorSupplier.setInstanceForTesting(tabModelSelector);
Mockito.when(tabModelSelector.isIncognitoSelected()).thenReturn(false);
Mockito.when(webContents.getTopLevelNativeWindow()).thenReturn(mWindowAndroid);
Mockito.when(params.getWebContents()).thenReturn(webContents);
Mockito.when(params.getId()).thenReturn("id");
Mockito.when(params.getMethodData()).thenReturn(methodData);
Mockito.when(params.getTopLevelOrigin()).thenReturn("https://chromium.org");
Mockito.when(params.getPaymentRequestOrigin()).thenReturn("https://chromium.org");
Mockito.when(params.getCertificateChain()).thenReturn(null);
Mockito.when(params.getUnmodifiableModifiers())
.thenReturn(new HashMap<String, PaymentDetailsModifier>());
Mockito.when(params.getMayCrawl()).thenReturn(false);
PaymentAppFactoryDelegate delegate = Mockito.mock(PaymentAppFactoryDelegate.class);
Mockito.when(delegate.getParams()).thenReturn(params);
AndroidPaymentAppFinder finder =
new AndroidPaymentAppFinder(
mPaymentManifestWebDataService,
downloader,
parser,
packageManagerDelegate,
delegate,
/* factory= */ null);
finder.bypassIsReadyToPayServiceInTest();
finder.findAndroidPaymentApps();
return delegate;
}
private void verifyNoAppsFound(PaymentAppFactoryDelegate delegate) {
Mockito.verify(delegate, Mockito.never())
.onPaymentAppCreated(Mockito.any(PaymentApp.class));
Mockito.verify(delegate, Mockito.never())
.onPaymentAppCreationError(
Mockito.any(String.class), Mockito.eq(AppCreationFailureReason.UNKNOWN));
Mockito.verify(delegate).onCanMakePaymentCalculated(false);
Mockito.verify(delegate).onDoneCreatingPaymentApps(/* factory= */ null);
}
@SmallTest
@Test
@UiThreadTest
public void testNoValidPaymentMethodNames() {
var histograms =
HistogramWatcher.newBuilder()
.expectNoRecords("PaymentRequest.NumberOfSupportedMethods.AndroidApp")
.build();
verifyNoAppsFound(
findApps(
new String[] {
"unknown-payment-method-name",
"http://not.secure.payment.method.name.com",
"https://"
},
mPaymentManifestDownloader,
mPaymentManifestParser,
mPackageManagerDelegate));
histograms.assertExpected("No apps, so 0 records are expected");
}
@SmallTest
@Test
@UiThreadTest
public void testQueryWithoutApps() {
var histograms =
HistogramWatcher.newBuilder()
.expectNoRecords("PaymentRequest.NumberOfSupportedMethods.AndroidApp")
.build();
Mockito.when(
mPackageManagerDelegate.getActivitiesThatCanRespondToIntentWithMetaData(
ArgumentMatchers.argThat(sPayIntentArgumentMatcher)))
.thenReturn(new ArrayList<ResolveInfo>());
verifyNoAppsFound(
findApps(
new String[] {"basic-card"},
mPaymentManifestDownloader,
mPaymentManifestParser,
mPackageManagerDelegate));
Mockito.verify(mPackageManagerDelegate, Mockito.never())
.getStringArrayResourceForApplication(
ArgumentMatchers.any(ApplicationInfo.class), ArgumentMatchers.anyInt());
histograms.assertExpected("No apps, so 0 records are expected");
}
@SmallTest
@Test
@UiThreadTest
public void testQueryWithoutMetaData() {
var histograms =
HistogramWatcher.newSingleRecordWatcher(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp", /* value= */ 0);
List<ResolveInfo> activities = new ArrayList<>();
ResolveInfo alicePay = new ResolveInfo();
alicePay.activityInfo = new ActivityInfo();
alicePay.activityInfo.packageName = "com.alicepay.app";
alicePay.activityInfo.name = "com.alicepay.app.WebPaymentActivity";
activities.add(alicePay);
Mockito.when(mPackageManagerDelegate.getAppLabel(Mockito.any(ResolveInfo.class)))
.thenReturn("A non-empty label");
Mockito.when(
mPackageManagerDelegate.getActivitiesThatCanRespondToIntentWithMetaData(
ArgumentMatchers.argThat(sPayIntentArgumentMatcher)))
.thenReturn(activities);
verifyNoAppsFound(
findApps(
new String[] {"basic-card"},
mPaymentManifestDownloader,
mPaymentManifestParser,
mPackageManagerDelegate));
Mockito.verify(mPackageManagerDelegate, Mockito.never())
.getStringArrayResourceForApplication(
ArgumentMatchers.any(ApplicationInfo.class), ArgumentMatchers.anyInt());
histograms.assertExpected(
"The installed app should have declared support for 0 payment methods");
}
@SmallTest
@Test
@UiThreadTest
public void testQueryWithoutLabel() {
var histograms =
HistogramWatcher.newSingleRecordWatcher(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp", /* value= */ 1);
List<ResolveInfo> activities = new ArrayList<>();
ResolveInfo alicePay = new ResolveInfo();
alicePay.activityInfo = new ActivityInfo();
alicePay.activityInfo.packageName = "com.alicepay.app";
alicePay.activityInfo.name = "com.alicepay.app.WebPaymentActivity";
Bundle activityMetaData = new Bundle();
activityMetaData.putString(
AndroidPaymentAppFinder.META_DATA_NAME_OF_DEFAULT_PAYMENT_METHOD_NAME,
"basic-card");
alicePay.activityInfo.metaData = activityMetaData;
activities.add(alicePay);
Mockito.when(
mPackageManagerDelegate.getActivitiesThatCanRespondToIntentWithMetaData(
ArgumentMatchers.argThat(sPayIntentArgumentMatcher)))
.thenReturn(activities);
verifyNoAppsFound(
findApps(
new String[] {"basic-card"},
mPaymentManifestDownloader,
mPaymentManifestParser,
mPackageManagerDelegate));
Mockito.verify(mPackageManagerDelegate, Mockito.never())
.getStringArrayResourceForApplication(
ArgumentMatchers.any(ApplicationInfo.class), ArgumentMatchers.anyInt());
histograms.assertExpected("The installed app should support only \"basic-card\" method");
}
@SmallTest
@Test
@UiThreadTest
public void testQueryUnsupportedPaymentMethod() {
var histograms =
HistogramWatcher.newSingleRecordWatcher(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp", /* value= */ 1);
PackageManagerDelegate packageManagerDelegate =
installPaymentApps(
new String[] {"com.alicepay.app"},
new String[] {"unsupported-payment-method"});
verifyNoAppsFound(
findApps(
new String[] {"unsupported-payment-method"},
mPaymentManifestDownloader,
mPaymentManifestParser,
packageManagerDelegate));
Mockito.verify(packageManagerDelegate, Mockito.never())
.getStringArrayResourceForApplication(
ArgumentMatchers.any(ApplicationInfo.class), ArgumentMatchers.anyInt());
histograms.assertExpected(
"The installed app should support only \"unsupported-payment-method\" method");
}
private PackageManagerDelegate installPaymentApps(String[] packageNames, String[] methodNames) {
assert packageNames.length == methodNames.length;
List<ResolveInfo> activities = new ArrayList<>();
for (int i = 0; i < packageNames.length; i++) {
ResolveInfo alicePay = new ResolveInfo();
alicePay.activityInfo = new ActivityInfo();
alicePay.activityInfo.packageName = packageNames[i];
alicePay.activityInfo.name = packageNames[i] + ".WebPaymentActivity";
Bundle activityMetaData = new Bundle();
activityMetaData.putString(
AndroidPaymentAppFinder.META_DATA_NAME_OF_DEFAULT_PAYMENT_METHOD_NAME,
methodNames[i]);
alicePay.activityInfo.metaData = activityMetaData;
activities.add(alicePay);
}
Mockito.when(mPackageManagerDelegate.getAppLabel(Mockito.any(ResolveInfo.class)))
.thenReturn("A non-empty label");
Mockito.when(
mPackageManagerDelegate.getActivitiesThatCanRespondToIntentWithMetaData(
ArgumentMatchers.argThat(sPayIntentArgumentMatcher)))
.thenReturn(activities);
return mPackageManagerDelegate;
}
@SmallTest
@Test
@UiThreadTest
public void testQueryDifferentPaymentMethod() {
var histograms =
HistogramWatcher.newSingleRecordWatcher(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp", /* value= */ 1);
PackageManagerDelegate packageManagerDelegate =
installPaymentApps(new String[] {"com.alicepay.app"}, new String[] {"basic-card"});
verifyNoAppsFound(
findApps(
new String[] {"interledger"},
mPaymentManifestDownloader,
mPaymentManifestParser,
packageManagerDelegate));
Mockito.verify(packageManagerDelegate, Mockito.never())
.getStringArrayResourceForApplication(
ArgumentMatchers.any(ApplicationInfo.class), ArgumentMatchers.anyInt());
histograms.assertExpected("The installed app should support only \"basic-card\" method");
}
@SmallTest
@Test
@UiThreadTest
public void testQueryNoPaymentMethod() {
var histograms =
HistogramWatcher.newSingleRecordWatcher(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp", /* value= */ 1);
PackageManagerDelegate packageManagerDelegate =
installPaymentApps(new String[] {"com.alicepay.app"}, new String[] {"basic-card"});
verifyNoAppsFound(
findApps(
new String[0],
mPaymentManifestDownloader,
mPaymentManifestParser,
packageManagerDelegate));
Mockito.verify(packageManagerDelegate, Mockito.never())
.getStringArrayResourceForApplication(
ArgumentMatchers.any(ApplicationInfo.class), ArgumentMatchers.anyInt());
histograms.assertExpected("The installed app should support only \"basic-card\" method");
}
@SmallTest
@Test
@UiThreadTest
public void testHistogramForMutlipleApps() {
var histograms =
HistogramWatcher.newBuilder()
.expectIntRecordTimes(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp",
/* value= */ 1,
/* times= */ 2)
.build();
PackageManagerDelegate packageManagerDelegate =
installPaymentApps(
new String[] {"com.alicepay.app", "com.bobpay.app"},
new String[] {"https://alicepay.test", "https://bobpay.test"});
// Trigger app lookup.
findApps(
new String[] {"https://charliepay.test"},
mPaymentManifestDownloader,
mPaymentManifestParser,
packageManagerDelegate);
histograms.assertExpected(
"Two apps are installed with one method each, expected two records with value 1.");
}
@SmallTest
@Test
@UiThreadTest
public void testHistogramForMutlipleMethods() {
var histograms =
HistogramWatcher.newSingleRecordWatcher(
"PaymentRequest.NumberOfSupportedMethods.AndroidApp", /* value= */ 2);
List<ResolveInfo> activities = new ArrayList<>();
ResolveInfo bobPay = new ResolveInfo();
bobPay.activityInfo = new ActivityInfo();
bobPay.activityInfo.packageName = "com.bobpay.app";
bobPay.activityInfo.name = "com.bobpay.app.WebPaymentActivity";
bobPay.activityInfo.applicationInfo = new ApplicationInfo();
Bundle bobPayMetaData = new Bundle();
bobPayMetaData.putString(
AndroidPaymentAppFinder.META_DATA_NAME_OF_DEFAULT_PAYMENT_METHOD_NAME,
"https://bobpay.test");
bobPayMetaData.putInt(AndroidPaymentAppFinder.META_DATA_NAME_OF_PAYMENT_METHOD_NAMES, 1);
bobPay.activityInfo.metaData = bobPayMetaData;
activities.add(bobPay);
Mockito.when(mPackageManagerDelegate.getAppLabel(Mockito.any(ResolveInfo.class)))
.thenReturn("A non-empty label");
Mockito.when(
mPackageManagerDelegate.getActivitiesThatCanRespondToIntentWithMetaData(
ArgumentMatchers.argThat(sPayIntentArgumentMatcher)))
.thenReturn(activities);
Mockito.when(
mPackageManagerDelegate.getStringArrayResourceForApplication(
ArgumentMatchers.eq(bobPay.activityInfo.applicationInfo),
ArgumentMatchers.eq(1)))
.thenReturn(new String[] {"https://bobpay.test", "https://alicepay.test"});
// Trigger app lookup.
findApps(
new String[] {"https://charliepay.test"},
mPaymentManifestDownloader,
mPaymentManifestParser,
mPackageManagerDelegate);
histograms.assertExpected(
"One app is installed with two payment methods, expected one record with value 2.");
}
@SmallTest
@Test
@UiThreadTest
public void testQueryBobPayWithOneAppThatHasIsReadyToPayService() {
List<ResolveInfo> activities = new ArrayList<>();
ResolveInfo bobPay = new ResolveInfo();
bobPay.activityInfo = new ActivityInfo();
bobPay.activityInfo.packageName = "com.bobpay.app";
bobPay.activityInfo.name = "com.bobpay.app.WebPaymentActivity";
bobPay.activityInfo.applicationInfo = new ApplicationInfo();
Bundle bobPayMetaData = new Bundle();
bobPayMetaData.putString(
AndroidPaymentAppFinder.META_DATA_NAME_OF_DEFAULT_PAYMENT_METHOD_NAME,
"https://bobpay.test");
bobPayMetaData.putInt(AndroidPaymentAppFinder.META_DATA_NAME_OF_PAYMENT_METHOD_NAMES, 1);
bobPay.activityInfo.metaData = bobPayMetaData;
activities.add(bobPay);
Mockito.when(mPackageManagerDelegate.getAppLabel(Mockito.any(ResolveInfo.class)))
.thenReturn("A non-empty label");
Mockito.when(
mPackageManagerDelegate.getActivitiesThatCanRespondToIntentWithMetaData(
ArgumentMatchers.argThat(sPayIntentArgumentMatcher)))
.thenReturn(activities);
Mockito.when(
mPackageManagerDelegate.getStringArrayResourceForApplication(
ArgumentMatchers.eq(bobPay.activityInfo.applicationInfo),
ArgumentMatchers.eq(1)))
.thenReturn(new String[] {"https://bobpay.test", "basic-card"});
List<ResolveInfo> services = new ArrayList<>();
ResolveInfo isBobPayReadyToPay = new ResolveInfo();
isBobPayReadyToPay.serviceInfo = new ServiceInfo();
isBobPayReadyToPay.serviceInfo.packageName = "com.bobpay.app";
isBobPayReadyToPay.serviceInfo.name = "com.bobpay.app.IsReadyToWebPay";
services.add(isBobPayReadyToPay);
Intent isReadyToPayIntent = new Intent(AndroidPaymentAppFinder.ACTION_IS_READY_TO_PAY);
Mockito.when(
mPackageManagerDelegate.getServicesThatCanRespondToIntent(
ArgumentMatchers.argThat(
new IntentArgumentMatcher(isReadyToPayIntent))))
.thenReturn(services);
PackageInfo bobPayPackageInfo = new PackageInfo();
bobPayPackageInfo.versionCode = 10;
bobPayPackageInfo.signatures = new Signature[1];
bobPayPackageInfo.signatures[0] = PaymentManifestVerifierTest.BOB_PAY_SIGNATURE;
Mockito.when(mPackageManagerDelegate.getPackageInfoWithSignatures("com.bobpay.app"))
.thenReturn(bobPayPackageInfo);
PaymentManifestDownloader downloader =
new PaymentManifestDownloader() {
@Override
public void initialize(WebContents webContents, CSPChecker cspChecker) {}
@Override
public void downloadPaymentMethodManifest(
Origin merchantOrigin, GURL url, ManifestDownloadCallback callback) {
callback.onPaymentMethodManifestDownloadSuccess(
url,
PaymentManifestDownloader.createOpaqueOriginForTest(),
"some content here");
}
@Override
public void downloadWebAppManifest(
Origin paynentMethodManifestOrigin,
GURL url,
ManifestDownloadCallback callback) {
callback.onWebAppManifestDownloadSuccess("some content here");
}
@Override
public void destroy() {}
};
PaymentManifestParser parser =
new PaymentManifestParser() {
@Override
public void parsePaymentMethodManifest(
GURL paymentMethodManifestUrl,
String content,
ManifestParseCallback callback) {
callback.onPaymentMethodManifestParseSuccess(
new GURL[] {new GURL("https://bobpay.test/app.json")}, new GURL[0]);
}
@Override
public void parseWebAppManifest(
String content, ManifestParseCallback callback) {
WebAppManifestSection[] manifest = new WebAppManifestSection[1];
int minVersion = 10;
manifest[0] =
new WebAppManifestSection(
"com.bobpay.app",
minVersion,
PaymentManifestVerifierTest.BOB_PAY_SIGNATURE_FINGERPRINTS);
callback.onWebAppManifestParseSuccess(manifest);
}
@Override
public void createNative(WebContents webContents) {}
@Override
public void destroyNative() {}
};
PaymentAppFactoryDelegate delegate =
findApps(
new String[] {"https://bobpay.test"},
downloader,
parser,
mPackageManagerDelegate);
Mockito.verify(delegate)
.onPaymentAppCreated(
ArgumentMatchers.argThat(Matches.paymentAppIdentifier("com.bobpay.app")));
Mockito.verify(delegate).onDoneCreatingPaymentApps(/* factory= */ null);
}
private static final class Matches implements ArgumentMatcher<PaymentApp> {
private final String mExpectedAppIdentifier;
private Matches(String expectedAppIdentifier) {
mExpectedAppIdentifier = expectedAppIdentifier;
}
/**
* Builds a matcher based on payment app identifier.
*
* @param expectedAppIdentifier The expected app identifier to match.
* @return A matcher to use in a mock expectation.
*/
public static ArgumentMatcher<PaymentApp> paymentAppIdentifier(
String expectedAppIdentifier) {
return new Matches(expectedAppIdentifier);
}
@Override
public boolean matches(PaymentApp app) {
return app.getIdentifier().equals(mExpectedAppIdentifier);
}
}
}