diff --git a/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_null_venmo.json b/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_null_venmo.json deleted file mode 100644 index 12cdc89e38..0000000000 --- a/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_null_venmo.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "clientApiUrl": "client_api_url", - "environment": "test", - "merchantId": "integration_merchant_id", - "merchantAccountId": "integration_merchant_account_id" -} \ No newline at end of file diff --git a/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_offline_venmo.json b/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_offline_venmo.json deleted file mode 100644 index f842c4c30b..0000000000 --- a/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_offline_venmo.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "clientApiUrl": "client_api_url", - "environment": "test", - "authorizationFingerprint": "authorization_fingerprint", - "challenges": ["cvv"], - "merchantId": "integration_merchant_id", - "merchantAccountId": "integration_merchant_account_id", - "venmo": "offline" -} \ No newline at end of file diff --git a/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_pay_with_venmo.json b/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_pay_with_venmo.json new file mode 100644 index 0000000000..4602f0de97 --- /dev/null +++ b/BraintreeApi/src/androidTest/assets/fixtures/configuration_with_pay_with_venmo.json @@ -0,0 +1,9 @@ +{ + "clientApiUrl": "client_api_url", + "environment": "test", + "merchantId": "integration_merchant_id", + "merchantAccountId": "integration_merchant_account_id", + "payWithVenmo": { + "accessToken" : "access-token" + } +} \ No newline at end of file diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/BraintreeFragmentTest.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/BraintreeFragmentTest.java index 25e37e59ec..387aff2a45 100644 --- a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/BraintreeFragmentTest.java +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/BraintreeFragmentTest.java @@ -94,7 +94,8 @@ public void newInstance_returnsABraintreeFragmentFromAClientToken() @Test(timeout = 1000, expected = InvalidArgumentException.class) @SmallTest - public void newInstance_throwsAnExceptionForABadTokenizationKey() throws InvalidArgumentException { + public void newInstance_throwsAnExceptionForABadTokenizationKey() + throws InvalidArgumentException { BraintreeFragment.newInstance(mActivity, "test_key_merchant"); } @@ -125,7 +126,7 @@ public void newInstance_setsIntegrationTypeToCustomForAllActivities() @Test(timeout = 1000) @SmallTest - public void sendsAnalyticsEventForTokenizationKey() throws InterruptedException{ + public void sendsAnalyticsEventForTokenizationKey() throws InterruptedException { BraintreeFragment fragment = getFragment(mActivity, TOKENIZATION_KEY); fragment.waitForConfiguration(new ConfigurationListener() { @Override @@ -333,7 +334,8 @@ public void onPaymentMethodNonceCreated(PaymentMethodNonce paymentMethodNonce) { @Test(timeout = 1000) @SmallTest - public void addListener_flushesPaymentMethodNoncesUpdatedCallback() throws InterruptedException { + public void addListener_flushesPaymentMethodNoncesUpdatedCallback() + throws InterruptedException { BraintreeFragment fragment = getFragment(mActivity, mClientToken); fragment.postCallback(new ArrayList()); diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/VenmoTest.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/VenmoTest.java new file mode 100644 index 0000000000..342dbfe63e --- /dev/null +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/VenmoTest.java @@ -0,0 +1,379 @@ +package com.braintreepayments.api; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.test.runner.AndroidJUnit4; +import android.test.mock.MockContext; +import android.test.suitebuilder.annotation.SmallTest; + +import com.braintreepayments.api.exceptions.AppSwitchNotAvailableException; +import com.braintreepayments.api.exceptions.InvalidArgumentException; +import com.braintreepayments.api.interfaces.BraintreeErrorListener; +import com.braintreepayments.api.interfaces.PaymentMethodNonceCreatedListener; +import com.braintreepayments.api.internal.BraintreeHttpClient; +import com.braintreepayments.api.models.AnalyticsConfiguration; +import com.braintreepayments.api.models.Configuration; +import com.braintreepayments.api.models.PaymentMethodNonce; +import com.braintreepayments.api.models.VenmoAccountNonce; +import com.braintreepayments.api.models.VenmoConfiguration; +import com.braintreepayments.api.test.TestActivity; +import com.braintreepayments.testutils.BraintreeActivityTestRule; +import com.braintreepayments.testutils.MockContextForVenmo; + +import org.json.JSONException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; + +import java.util.concurrent.CountDownLatch; + +import static com.braintreepayments.api.BraintreeFragmentTestUtils.getMockFragment; +import static com.braintreepayments.api.internal.SignatureVerificationTestUtils.disableSignatureVerification; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class VenmoTest { + + @Rule + public final BraintreeActivityTestRule mActivityTestRule = + new BraintreeActivityTestRule<>(TestActivity.class); + + private Activity mActivity; + + @Before + public void setUp() { + mActivity = mActivityTestRule.getActivity(); + } + + @Test(timeout = 1000) + public void packageIsCorrect() { + assertEquals("com.venmo", Venmo.PACKAGE_NAME); + } + + @Test(timeout = 1000) + public void appSwitchActivityIsCorrect() { + assertEquals("controller.SetupMerchantActivity", Venmo.APP_SWITCH_ACTIVITY); + } + + @Test(timeout = 1000) + public void certificateSubjectIsCorrect() { + assertEquals("CN=Andrew Kortina,OU=Engineering,O=Venmo,L=Philadelphia,ST=PA,C=US", + Venmo.CERTIFICATE_SUBJECT); + } + + @Test(timeout = 1000) + public void certificateIssuerIsCorrect() { + assertEquals("CN=Andrew Kortina,OU=Engineering,O=Venmo,L=Philadelphia,ST=PA,C=US", + Venmo.CERTIFICATE_ISSUER); + } + + @Test(timeout = 1000) + public void publicKeyHashCodeIsCorrect() { + assertEquals(-129711843, Venmo.PUBLIC_KEY_HASH_CODE); + } + + @Test(timeout = 1000) + public void containsCorrectVenmoExtras() throws JSONException { + Configuration configuration = mock(Configuration.class); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + when(configuration.getMerchantId()).thenReturn("merchant_id"); + when(venmoConfiguration.getAccessToken()).thenReturn("access-token"); + when(configuration.getEnvironment()).thenReturn("environment"); + + Intent intent = Venmo.getLaunchIntent(configuration); + + assertEquals(new ComponentName("com.venmo", "com.venmo.controller.SetupMerchantActivity"), + intent.getComponent()); + assertEquals("merchant_id", intent.getStringExtra(Venmo.EXTRA_MERCHANT_ID)); + assertEquals("access-token", intent.getStringExtra(Venmo.EXTRA_ACCESS_TOKEN)); + assertEquals(BuildConfig.VERSION_NAME, intent.getStringExtra(Venmo.EXTRA_SDK_VERSION)); + assertEquals("environment", intent.getStringExtra(Venmo.EXTRA_ENVIRONMENT)); + } + + @Test(timeout = 1000) + public void authorizeAccount_failsAndSendsExceptionWhenControlPanelNotEnabled() + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + fragment.addListener(new BraintreeErrorListener() { + @Override + public void onError(Exception error) { + assertEquals("Venmo is not enabled on the control panel.", error.getMessage()); + latch.countDown(); + } + }); + Context mockContextForVenmo = new MockContextForVenmo() + .venmoInstalled() + .whitelistValue("true") + .build(); + when(fragment.getApplicationContext()).thenReturn(mockContextForVenmo); + when(venmoConfiguration.isVenmoWhitelisted(any(ContentResolver.class))).thenReturn(true); + when(configuration.getMerchantId()).thenReturn("merchant_id"); + when(configuration.getEnvironment()).thenReturn("environment"); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + doNothing().when(fragment).sendAnalyticsEvent(anyString()); + doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + disableSignatureVerification(); + + Venmo.authorizeAccount(fragment, configuration); + + latch.await(); + } + + @Test(timeout = 1000) + public void authorizeAccount_failsAndSendsExceptionWhenNotInstalled() + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + fragment.addListener(new BraintreeErrorListener() { + @Override + public void onError(Exception error) { + assertEquals("Venmo is not installed.", error.getMessage()); + latch.countDown(); + } + }); + Context mockContextForVenmo = new MockContextForVenmo() + .whitelistValue("true") + .build(); + when(fragment.getApplicationContext()).thenReturn(mockContextForVenmo); + when(venmoConfiguration.getAccessToken()).thenReturn("access-token"); + when(venmoConfiguration.isAccessTokenValid()).thenReturn(true); + when(venmoConfiguration.isVenmoWhitelisted(any(ContentResolver.class))).thenReturn(true); + when(configuration.getMerchantId()).thenReturn("merchant_id"); + when(configuration.getEnvironment()).thenReturn("environment"); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + doNothing().when(fragment).sendAnalyticsEvent(anyString()); + doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + disableSignatureVerification(); + + Venmo.authorizeAccount(fragment, configuration); + + latch.await(); + } + + @Test(timeout = 1000) + public void authorizeAccount_failsAndSendsExceptionWhenNotWhitelisted() + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + fragment.addListener(new BraintreeErrorListener() { + @Override + public void onError(Exception error) { + assertEquals("Venmo is not whitelisted.", error.getMessage()); + latch.countDown(); + } + }); + Context mockContextForVenmo = new MockContextForVenmo() + .venmoInstalled() + .build(); + when(fragment.getApplicationContext()).thenReturn(mockContextForVenmo); + when(venmoConfiguration.getAccessToken()).thenReturn("access-token"); + when(venmoConfiguration.isAccessTokenValid()).thenReturn(true); + when(configuration.getMerchantId()).thenReturn("merchant_id"); + when(configuration.getEnvironment()).thenReturn("environment"); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + doNothing().when(fragment).sendAnalyticsEvent(anyString()); + doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + disableSignatureVerification(); + + Venmo.authorizeAccount(fragment, configuration); + + latch.await(); + } + + @Test(timeout = 1000) + public void performAppSwitch_appSwitchesWithVenmoLaunchIntent() { + ArgumentCaptor launchIntentCaptor = ArgumentCaptor.forClass(Intent.class); + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + Context mockContextForVenmo = new MockContextForVenmo() + .venmoInstalled() + .whitelistValue("true") + .build(); + when(fragment.getApplicationContext()).thenReturn(mockContextForVenmo); + when(venmoConfiguration.getAccessToken()).thenReturn("access-token"); + when(venmoConfiguration.isAccessTokenValid()).thenReturn(true); + when(venmoConfiguration.isVenmoWhitelisted(any(ContentResolver.class))).thenReturn(true); + when(configuration.getMerchantId()).thenReturn("merchant_id"); + when(configuration.getEnvironment()).thenReturn("environment"); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + doNothing().when(fragment).sendAnalyticsEvent(anyString()); + doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + disableSignatureVerification(); + + Venmo.authorizeAccount(fragment, configuration); + + verify(fragment).startActivityForResult(launchIntentCaptor.capture(), + eq(Venmo.VENMO_REQUEST_CODE)); + Intent launchIntent = launchIntentCaptor.getValue(); + assertEquals("com.venmo/com.venmo.controller.SetupMerchantActivity", + launchIntent.getComponent().flattenToString()); + Bundle extras = launchIntent.getExtras(); + assertEquals("merchant_id", extras.getString(Venmo.EXTRA_MERCHANT_ID)); + assertEquals("access-token", extras.getString(Venmo.EXTRA_ACCESS_TOKEN)); + assertEquals(BuildConfig.VERSION_NAME, extras.getString(Venmo.EXTRA_SDK_VERSION)); + assertEquals("environment", extras.getString(Venmo.EXTRA_ENVIRONMENT)); + } + + @Test(timeout = 1000) + public void performAppSwitch_sendsAnalyticsEvent() { + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + + when(venmoConfiguration.getAccessToken()).thenReturn("access-token"); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + when(fragment.getHttpClient()).thenReturn(mock(BraintreeHttpClient.class)); + doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + + Venmo.authorizeAccount(fragment); + + verify(fragment).sendAnalyticsEvent("pay-with-venmo.selected"); + } + + @Test(timeout = 1000) + public void performAppSwitch_sendsAnalyticsEventWhenStarted() { + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("true") + .venmoInstalled() + .build(); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + + when(venmoConfiguration.isAccessTokenValid()).thenReturn(true); + when(venmoConfiguration.isVenmoWhitelisted(any(ContentResolver.class))).thenReturn(true); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + doNothing().when(fragment).sendAnalyticsEvent(anyString()); + doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + when(fragment.getApplicationContext()).thenReturn(mockContext); + disableSignatureVerification(); + + Venmo.authorizeAccount(fragment, configuration); + + verify(fragment).sendAnalyticsEvent("pay-with-venmo.selected"); + verify(fragment).sendAnalyticsEvent("pay-with-venmo.app-switch.started"); + } + + @Test(timeout = 1000) + public void performAppSwitch_sendsAnalyticsEventWhenUnavailableAndPostException() { + ArgumentCaptor argumentCaptor = + ArgumentCaptor.forClass(AppSwitchNotAvailableException.class); + Configuration configuration = getConfiguration(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + BraintreeFragment fragment = getMockFragment(mActivity, configuration); + + when(venmoConfiguration.isAccessTokenValid()).thenReturn(true); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + + Venmo.authorizeAccount(fragment, configuration); + + InOrder order = inOrder(fragment); + order.verify(fragment).sendAnalyticsEvent("pay-with-venmo.selected"); + order.verify(fragment).sendAnalyticsEvent("pay-with-venmo.app-switch.failed"); + verify(fragment).postCallback(argumentCaptor.capture()); + assertEquals("Venmo is not installed.", + argumentCaptor.getValue().getMessage()); + } + + @Test(timeout = 1000) + public void onActivityResult_postsPaymentMethodNonceAndUsernameOnSuccess() + throws InterruptedException, InvalidArgumentException { + BraintreeFragment fragment = getMockFragment(mActivity, getConfiguration()); + when(fragment.getHttpClient()).thenReturn(mock(BraintreeHttpClient.class)); + final CountDownLatch latch = new CountDownLatch(1); + fragment.addListener(new PaymentMethodNonceCreatedListener() { + @Override + public void onPaymentMethodNonceCreated(PaymentMethodNonce paymentMethodNonce) { + assertTrue(paymentMethodNonce instanceof VenmoAccountNonce); + VenmoAccountNonce venmoAccountNonce = (VenmoAccountNonce) paymentMethodNonce; + assertEquals("123456-12345-12345-a-adfa", venmoAccountNonce.getNonce()); + assertEquals("username", venmoAccountNonce.getDescription()); + assertEquals("username", venmoAccountNonce.getUsername()); + latch.countDown(); + } + }); + Intent intent = new Intent() + .putExtra(Venmo.EXTRA_PAYMENT_METHOD_NONCE, "123456-12345-12345-a-adfa") + .putExtra(Venmo.EXTRA_USERNAME, "username"); + + Venmo.onActivityResult(fragment, Activity.RESULT_OK, intent); + + latch.await(); + } + + @Test(timeout = 1000) + public void onActivityResult_sendsAnalyticsEventOnSuccess() throws InvalidArgumentException { + BraintreeFragment fragment = getMockFragment(mActivity, getConfiguration()); + when(fragment.getHttpClient()).thenReturn(mock(BraintreeHttpClient.class)); + Intent intent = new Intent().putExtra(Venmo.EXTRA_PAYMENT_METHOD_NONCE, + "123456-12345-12345-a-adfa").putExtra(Venmo.EXTRA_USERNAME, "username"); + + Venmo.onActivityResult(fragment, Activity.RESULT_OK, intent); + + verify(fragment).sendAnalyticsEvent("pay-with-venmo.app-switch.success"); + } + + @Test(timeout = 1000) + public void onActivityResult_sendsAnalyticsEventOnCancel() { + BraintreeFragment fragment = getMockFragment(mActivity, getConfiguration()); + when(fragment.getHttpClient()).thenReturn(mock(BraintreeHttpClient.class)); + + Venmo.onActivityResult(fragment, Activity.RESULT_CANCELED, new Intent()); + + verify(fragment).sendAnalyticsEvent("pay-with-venmo.app-switch.canceled"); + } + + @Test(timeout = 2000) + public void isVenmoInstalled_returnsTrueWhenInstalled() { + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("true") + .venmoInstalled() + .build(); + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + when(venmoConfiguration.getAccessToken()).thenReturn("access-token"); + Configuration configuration = getConfiguration(); + when(configuration.getPayWithVenmo()).thenReturn(venmoConfiguration); + BraintreeFragment braintreeFragment = getMockFragment(mActivity, configuration); + when(braintreeFragment.getApplicationContext()).thenReturn(mockContext); + + disableSignatureVerification(); + + assertTrue(Venmo.isVenmoInstalled(mockContext)); + } + + private Configuration getConfiguration() { + AnalyticsConfiguration analyticsConfiguration = mock(AnalyticsConfiguration.class); + when(analyticsConfiguration.isEnabled()).thenReturn(true); + Configuration configuration = mock(Configuration.class); + when(configuration.getAnalytics()).thenReturn(analyticsConfiguration); + + return configuration; + } +} diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTest.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTest.java new file mode 100644 index 0000000000..3fe9d0e7af --- /dev/null +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTest.java @@ -0,0 +1,50 @@ +package com.braintreepayments.api.internal; + +import android.os.SystemClock; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.LargeTest; +import android.util.Log; + +import com.braintreepayments.api.BuildConfig; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static android.support.test.InstrumentationRegistry.getTargetContext; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class SignatureVerificationTest { + + @Test(timeout = 15000) + @LargeTest + public void isSignatureValid_returnsFalseWhenAppNotInstalled() { + if (!BuildConfig.RUN_ALL_TESTS) { + return; + } + + Log.d("request_command", "uninstall fakewallet"); + SystemClock.sleep(10000); + + assertFalse(checkSignature()); + } + + @Test(timeout = 65000) + @LargeTest + public void isSignatureValid_returnsTrueWhenAppIsInstalled() { + if (!BuildConfig.RUN_ALL_TESTS) { + return; + } + + Log.d("request_command", "install fakewallet"); + SystemClock.sleep(60000); + + assertTrue(checkSignature()); + } + + private boolean checkSignature() { + return SignatureVerification.isSignatureValid(getTargetContext(), "com.braintreepayments.fake.wallet", + "CN=Android Debug,O=Android,C=US", "CN=Android Debug,O=Android,C=US", 496242318); + } +} diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTestUtils.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTestUtils.java new file mode 100644 index 0000000000..7cd7cd8ea4 --- /dev/null +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTestUtils.java @@ -0,0 +1,8 @@ +package com.braintreepayments.api.internal; + +public class SignatureVerificationTestUtils { + + public static void disableSignatureVerification() { + SignatureVerification.sEnableSignatureVerification = false; + } +} \ No newline at end of file diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/ConfigurationTest.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/ConfigurationTest.java index 214d0da18a..1e400ec65c 100644 --- a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/ConfigurationTest.java +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/ConfigurationTest.java @@ -2,6 +2,7 @@ import android.support.test.runner.AndroidJUnit4; import android.test.suitebuilder.annotation.SmallTest; +import android.text.TextUtils; import org.json.JSONException; import org.junit.Test; @@ -114,6 +115,26 @@ public void fromJson_parsesMerchantAccountId() throws JSONException { assertEquals("integration_merchant_account_id", configuration.getMerchantAccountId()); } + @Test(timeout = 1000) + @SmallTest + public void returnsEmptyVenmoConfigurationWhenNotDefined() throws JSONException { + Configuration configuration = Configuration.fromJson( + stringFromFixture("configuration.json")); + + assertNotNull(configuration.getPayWithVenmo()); + assertTrue(TextUtils.isEmpty(configuration.getPayWithVenmo().getAccessToken())); + } + + @Test(timeout = 1000) + @SmallTest + public void payWithVenmoIsEnabledWhenConfigurationExists() throws JSONException { + Configuration configuration = Configuration.fromJson( + stringFromFixture("configuration_with_pay_with_venmo.json")); + + assertNotNull(configuration.getPayWithVenmo()); + assertFalse(TextUtils.isEmpty(configuration.getPayWithVenmo().getAccessToken())); + } + @Test(timeout = 1000) @SmallTest public void reportsThreeDSecureEnabledWhenEnabled() throws JSONException { diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/VenmoAccountNonceTest.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/VenmoAccountNonceTest.java new file mode 100644 index 0000000000..827ed0d7c7 --- /dev/null +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/VenmoAccountNonceTest.java @@ -0,0 +1,53 @@ +package com.braintreepayments.api.models; + +import android.os.Parcel; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static junit.framework.Assert.assertEquals; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class VenmoAccountNonceTest { + + private static final String NONCE = "nonce"; + private static final String DESCRIPTION = "description"; + private static final String USERNAME = "username"; + private static final VenmoAccountNonce + VENMO_NONCE = new VenmoAccountNonce(NONCE, DESCRIPTION, USERNAME); + + @Test(timeout = 1000) + public void getTypeLabel_returnsPayWithVenmo() { + assertEquals("Venmo", VENMO_NONCE.getTypeLabel()); + } + + @Test(timeout = 1000) + public void getNonce_returnsNonce() { + assertEquals(NONCE, VENMO_NONCE.getNonce()); + } + + @Test(timeout = 1000) + public void getDescription_returnsDescription() { + assertEquals(DESCRIPTION, VENMO_NONCE.getDescription()); + } + + @Test(timeout = 1000) + public void getUsername_returnsUsername() { + assertEquals(USERNAME, VENMO_NONCE.getUsername()); + } + + @Test(timeout = 1000) + public void writeToParcel_parcelsVenmoAccountNonce() { + Parcel parcel = Parcel.obtain(); + VENMO_NONCE.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + + VenmoAccountNonce venmoAccountNonce = new VenmoAccountNonce(parcel); + assertEquals(NONCE, venmoAccountNonce.getNonce()); + assertEquals(DESCRIPTION, venmoAccountNonce.getDescription()); + assertEquals(USERNAME, venmoAccountNonce.getUsername()); + } +} diff --git a/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/VenmoConfigurationTest.java b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/VenmoConfigurationTest.java new file mode 100644 index 0000000000..ebf69009d1 --- /dev/null +++ b/BraintreeApi/src/androidTest/java/com/braintreepayments/api/models/VenmoConfigurationTest.java @@ -0,0 +1,107 @@ +package com.braintreepayments.api.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.mock.MockContext; +import android.test.suitebuilder.annotation.SmallTest; +import android.text.TextUtils; + +import com.braintreepayments.api.internal.SignatureVerificationTestUtils; +import com.braintreepayments.testutils.MockContextForVenmo; + +import org.json.JSONException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static com.braintreepayments.testutils.FixturesHelper.stringFromFixture; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class VenmoConfigurationTest { + + private Configuration configurationWithVenmo; + + @Before + public void setup() throws JSONException { + configurationWithVenmo = Configuration.fromJson( + stringFromFixture("configuration_with_pay_with_venmo.json")); + } + + @Test(timeout = 1000) + public void fromJson_parsesPayWithVenmoConfiguration() throws JSONException { + assertEquals("access-token", configurationWithVenmo.getPayWithVenmo().getAccessToken()); + } + + @Test(timeout = 1000) + public void fromJson_parsesEmptyVenmoConfigurationWhenConfigurationDoesntHavePayWithVenmo() + throws JSONException { + Configuration configuration = Configuration.fromJson( + stringFromFixture("configuration.json")); + + VenmoConfiguration venmoConfiguration = configuration.getPayWithVenmo(); + assertEquals("", venmoConfiguration.getAccessToken()); + assertTrue(TextUtils.isEmpty(venmoConfiguration.getAccessToken())); + } + + @Test(timeout = 1000) + public void isEnabled_returnsTrueWhenEnabled() throws JSONException { + VenmoConfiguration venmoConfiguration = configurationWithVenmo.getPayWithVenmo(); + assertFalse(TextUtils.isEmpty(venmoConfiguration.getAccessToken())); + } + + @Test(timeout = 1000) + public void isVenmoWhitelisted_returnsTrueForWhitelist() throws JSONException { + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("true") + .build(); + + assertTrue(configurationWithVenmo.getPayWithVenmo() + .isVenmoWhitelisted(mockContext.getContentResolver())); + } + + @Test(timeout = 1000) + public void isVenmoWhitelisted_returnsFalseForInvalidWhitelist() { + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("false") + .build(); + + assertFalse(configurationWithVenmo.getPayWithVenmo() + .isVenmoWhitelisted(mockContext.getContentResolver())); + } + + @Test(timeout = 1000) + public void isVenmoWhitelisted_returnsFalseForJunkContentProviderAndExceptionIsPosted() { + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("neither") + .build(); + + assertFalse(configurationWithVenmo.getPayWithVenmo() + .isVenmoWhitelisted(mockContext.getContentResolver())); + } + + @Test(timeout = 1000) + public void isEnabled_returnsTrueWhenAppIsInstalled() throws JSONException { + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("true") + .venmoInstalled() + .build(); + + SignatureVerificationTestUtils.disableSignatureVerification(); + + assertTrue(configurationWithVenmo.getPayWithVenmo().isEnabled(mockContext)); + } + + @Test(timeout = 1000) + public void isEnabled_returnsFalseForNotInstalled() { + MockContext mockContext = new MockContextForVenmo() + .whitelistValue("true") + .build(); + + SignatureVerificationTestUtils.disableSignatureVerification(); + + assertFalse(configurationWithVenmo.getPayWithVenmo().isEnabled(mockContext)); + } +} diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/BraintreeFragment.java b/BraintreeApi/src/main/java/com/braintreepayments/api/BraintreeFragment.java index c9817d8a3e..c995a609fc 100644 --- a/BraintreeApi/src/main/java/com/braintreepayments/api/BraintreeFragment.java +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/BraintreeFragment.java @@ -203,6 +203,9 @@ public void onActivityResult(final int requestCode, int resultCode, Intent data) case ThreeDSecure.THREE_D_SECURE_REQUEST_CODE: ThreeDSecure.onActivityResult(this, resultCode, data); break; + case Venmo.VENMO_REQUEST_CODE: + Venmo.onActivityResult(this, resultCode, data); + break; } if (resultCode == Activity.RESULT_CANCELED) { diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/Venmo.java b/BraintreeApi/src/main/java/com/braintreepayments/api/Venmo.java new file mode 100644 index 0000000000..93b1af7aae --- /dev/null +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/Venmo.java @@ -0,0 +1,127 @@ +package com.braintreepayments.api; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; + +import com.braintreepayments.api.exceptions.AppSwitchNotAvailableException; +import com.braintreepayments.api.interfaces.ConfigurationListener; +import com.braintreepayments.api.internal.SignatureVerification; +import com.braintreepayments.api.models.Configuration; +import com.braintreepayments.api.models.VenmoAccountNonce; +import com.braintreepayments.api.models.VenmoConfiguration; + +import java.util.List; + +/** + * Class containing Venmo specific logic. + */ +public class Venmo { + + static final String EXTRA_MERCHANT_ID = "com.braintreepayments.api.MERCHANT_ID"; + static final String EXTRA_SDK_VERSION = "com.braintreepayments.api.SDK_VERSION"; + static final String EXTRA_ACCESS_TOKEN = "com.braintreepayments.api.ACCESS_TOKEN"; + static final String EXTRA_ENVIRONMENT = "com.braintreepayments.api.ENVIRONMENT"; + static final String EXTRA_PAYMENT_METHOD_NONCE = + "com.braintreepayments.api.EXTRA_PAYMENT_METHOD_NONCE"; + static final String EXTRA_USERNAME = + "com.braintreepayments.api.EXTRA_USER_NAME"; + + static final String PACKAGE_NAME = "com.venmo"; + static final String APP_SWITCH_ACTIVITY = "controller.SetupMerchantActivity"; + static final String CERTIFICATE_SUBJECT = + "CN=Andrew Kortina,OU=Engineering,O=Venmo,L=Philadelphia,ST=PA,C=US"; + static final String CERTIFICATE_ISSUER = + "CN=Andrew Kortina,OU=Engineering,O=Venmo,L=Philadelphia,ST=PA,C=US"; + static final int PUBLIC_KEY_HASH_CODE = -129711843; + + static final int VENMO_REQUEST_CODE = 13488; + + /** + * @param context A context to access the installed packages. + * @return boolean depending on if the Venmo app is installed, and has a valid signature. + */ + public static boolean isVenmoInstalled(Context context) { + List activities = + context.getPackageManager().queryIntentActivities(getVenmoIntent(), 0); + + return activities.size() == 1 && + PACKAGE_NAME.equals(activities.get(0).activityInfo.packageName) && + SignatureVerification.isSignatureValid(context, PACKAGE_NAME, CERTIFICATE_SUBJECT, + CERTIFICATE_ISSUER, PUBLIC_KEY_HASH_CODE); + } + + private static Intent getVenmoIntent() { + return new Intent().setComponent(new ComponentName( + PACKAGE_NAME, PACKAGE_NAME + "." + APP_SWITCH_ACTIVITY)); + } + + static Intent getLaunchIntent(Configuration configuration) { + Intent intent = getVenmoIntent() + .putExtra(EXTRA_MERCHANT_ID, configuration.getMerchantId()) + .putExtra(EXTRA_SDK_VERSION, BuildConfig.VERSION_NAME) + .putExtra(EXTRA_ACCESS_TOKEN, configuration.getPayWithVenmo().getAccessToken()) + .putExtra(EXTRA_ENVIRONMENT, configuration.getEnvironment()); + + return intent; + } + + /** + * Start the Pay With Venmo flow. This will app switch to the Venmo app. + *

+ * If the Venmo app is not available, {@link AppSwitchNotAvailableException} will be sent to + * {@link com.braintreepayments.api.interfaces.BraintreeErrorListener#onError(Exception)}. + * + * @param fragment {@link BraintreeFragment} + */ + public static void authorizeAccount(final BraintreeFragment fragment) { + fragment.waitForConfiguration(new ConfigurationListener() { + @Override + public void onConfigurationFetched(Configuration configuration) { + authorizeAccount(fragment, configuration); + } + }); + } + + static void authorizeAccount(BraintreeFragment fragment, Configuration configuration) { + fragment.sendAnalyticsEvent("pay-with-venmo.selected"); + + String exceptionMessage = ""; + VenmoConfiguration venmoConfiguration = configuration.getPayWithVenmo(); + + if(!venmoConfiguration.isAccessTokenValid()) { + exceptionMessage = "Venmo is not enabled on the control panel."; + } + else if(!Venmo.isVenmoInstalled(fragment.getApplicationContext())) { + exceptionMessage = "Venmo is not installed."; + } + else if(!venmoConfiguration.isVenmoWhitelisted(fragment.getApplicationContext().getContentResolver())) { + exceptionMessage = "Venmo is not whitelisted."; + } + + if(exceptionMessage == "") { + fragment.startActivityForResult(Venmo.getLaunchIntent(configuration), + VENMO_REQUEST_CODE); + fragment.sendAnalyticsEvent("pay-with-venmo.app-switch.started"); + } else { + fragment.postCallback(new AppSwitchNotAvailableException(exceptionMessage)); + fragment.sendAnalyticsEvent("pay-with-venmo.app-switch.failed"); + } + } + + static void onActivityResult(final BraintreeFragment fragment, int resultCode, + Intent data) { + if (resultCode == Activity.RESULT_OK) { + String nonce = data.getStringExtra(EXTRA_PAYMENT_METHOD_NONCE); + String venmoUsername = data.getStringExtra(EXTRA_USERNAME); + VenmoAccountNonce venmoAccountNonce = + new VenmoAccountNonce(nonce, venmoUsername, venmoUsername); + fragment.postCallback(venmoAccountNonce); + fragment.sendAnalyticsEvent("pay-with-venmo.app-switch.success"); + } else if (resultCode == Activity.RESULT_CANCELED) { + fragment.sendAnalyticsEvent("pay-with-venmo.app-switch.canceled"); + } + } +} diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/exceptions/PaymentMethodNotAvailableException.java b/BraintreeApi/src/main/java/com/braintreepayments/api/exceptions/PaymentMethodNotAvailableException.java new file mode 100644 index 0000000000..1ef50930d7 --- /dev/null +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/exceptions/PaymentMethodNotAvailableException.java @@ -0,0 +1,16 @@ +package com.braintreepayments.api.exceptions; + +import com.braintreepayments.api.models.PaymentMethodNonce; + +import java.util.List; + +/** + * Thrown when a payment method isn't available with a developer friendly explanation on why + * it isn't available. + */ +public class PaymentMethodNotAvailableException extends BraintreeException { + + public PaymentMethodNotAvailableException(String message) { + super(message); + } +} diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/internal/SignatureVerification.java b/BraintreeApi/src/main/java/com/braintreepayments/api/internal/SignatureVerification.java new file mode 100644 index 0000000000..0b152c17ed --- /dev/null +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/internal/SignatureVerification.java @@ -0,0 +1,78 @@ +package com.braintreepayments.api.internal; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.Signature; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +public class SignatureVerification { + + /** + * Used to disable signature verification for development and test. + */ + static boolean sEnableSignatureVerification = true; + + /** + * Check if an app has the correct, matching, signature. Used to prevent malicious apps from + * impersonating other apps. + * + * @param context + * @param packageName the package name of the app to verify. + * @param certificateSubject the expected certificate subject of the app. + * @param certificateIssuer the expected certificate issuer of the app. + * @param publicKeyHashCode the hash code of the app's public key. + * @return true is signature is valid or signature verification has been disabled. + */ + public static boolean isSignatureValid(Context context, String packageName, + String certificateSubject, String certificateIssuer, int publicKeyHashCode) { + if (!sEnableSignatureVerification) { + return true; + } + + PackageManager packageManager = context.getPackageManager(); + Signature[] signatures; + try { + signatures = packageManager + .getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures; + } catch (NameNotFoundException e) { + return false; + } + InputStream certStream = null; + boolean validated = signatures.length != 0; + for (Signature signature : signatures) { + try { + certStream = new ByteArrayInputStream(signature.toByteArray()); + + X509Certificate x509Cert = + (X509Certificate) CertificateFactory.getInstance("X509") + .generateCertificate(certStream); + + String subject = x509Cert.getSubjectX500Principal().getName(); + String issuer = x509Cert.getIssuerX500Principal().getName(); + int actualPublicKeyHashCode = x509Cert.getPublicKey().hashCode(); + + validated &= (certificateSubject.equals(subject) && + certificateIssuer.equals(issuer) && + publicKeyHashCode == actualPublicKeyHashCode); + + if (!validated) { + return false; + } + } catch (CertificateException e) { + return false; + } finally { + try { + certStream.close(); + } catch(Exception ignored) {} + } + } + + return validated; + } +} \ No newline at end of file diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/models/Configuration.java b/BraintreeApi/src/main/java/com/braintreepayments/api/models/Configuration.java index 97907e0112..d48d15b9a9 100644 --- a/BraintreeApi/src/main/java/com/braintreepayments/api/models/Configuration.java +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/models/Configuration.java @@ -19,6 +19,8 @@ public class Configuration { private static final String PAYPAL_KEY = "paypal"; private static final String ANDROID_PAY_KEY = "androidPay"; private static final String THREE_D_SECURE_ENABLED_KEY = "threeDSecureEnabled"; + private static final String PAY_WITH_VENMO_KEY = "payWithVenmo"; + private String mConfigurationString; private String mClientApiUrl; @@ -31,6 +33,7 @@ public class Configuration { private PayPalConfiguration mPayPalConfiguration; private AndroidPayConfiguration mAndroidPayConfiguration; private boolean mThreeDSecureEnabled; + private VenmoConfiguration mVenmoConfiguration; /** * Creates a new {@link com.braintreepayments.api.models.Configuration} instance from a json string. @@ -48,11 +51,14 @@ public static Configuration fromJson(String configurationString) throws JSONExce configuration.mEnvironment = json.getString(ENVIRONMENT_KEY); configuration.mPaypalEnabled = json.optBoolean(PAYPAL_ENABLED_KEY, false); configuration.mPayPalConfiguration = PayPalConfiguration.fromJson(json.optJSONObject(PAYPAL_KEY)); - configuration.mAndroidPayConfiguration = AndroidPayConfiguration.fromJson(json.optJSONObject(ANDROID_PAY_KEY)); + configuration.mAndroidPayConfiguration = AndroidPayConfiguration.fromJson( + json.optJSONObject(ANDROID_PAY_KEY)); configuration.mThreeDSecureEnabled = json.optBoolean(THREE_D_SECURE_ENABLED_KEY, false); configuration.mMerchantId = json.getString(MERCHANT_ID_KEY); configuration.mMerchantAccountId = json.optString(MERCHANT_ACCOUNT_ID_KEY, null); configuration.mAnalyticsConfiguration = AnalyticsConfiguration.fromJson(json.optJSONObject(ANALYTICS_KEY)); + configuration.mVenmoConfiguration = VenmoConfiguration + .fromJson(json.optJSONObject(PAY_WITH_VENMO_KEY)); return configuration; } @@ -140,6 +146,13 @@ public AnalyticsConfiguration getAnalytics() { return mAnalyticsConfiguration; } + /** + * @return instance of {@link VenmoConfiguration} + */ + public VenmoConfiguration getPayWithVenmo() { + return mVenmoConfiguration; + } + private boolean isChallengePresent(String requestedChallenge) { for (String challenge : mChallenges) { if (challenge.equals(requestedChallenge)) { diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/models/VenmoAccountNonce.java b/BraintreeApi/src/main/java/com/braintreepayments/api/models/VenmoAccountNonce.java new file mode 100644 index 0000000000..6c838bd4ff --- /dev/null +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/models/VenmoAccountNonce.java @@ -0,0 +1,63 @@ +package com.braintreepayments.api.models; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * {@link PaymentMethodNonce} representing a {@link VenmoAccountNonce} + * @see PaymentMethodNonce + */ +public class VenmoAccountNonce extends PaymentMethodNonce implements Parcelable { + + private static final String TYPE = "Venmo"; + + private String mUsername; + + public VenmoAccountNonce(String nonce, String description, String username) { + mNonce = nonce; + mDescription = description; + mUsername = username; + } + + /** + * @return the Venmo username + */ + public String getUsername() { + return mUsername; + } + + @Override + public String getTypeLabel() { + return TYPE; + } + + @Override + public int describeContents() { + return 0; + } + + protected VenmoAccountNonce(Parcel in) { + mNonce = in.readString(); + mDescription = in.readString(); + mUsername = in.readString(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public VenmoAccountNonce createFromParcel(Parcel in) { + return new VenmoAccountNonce(in); + } + + @Override + public VenmoAccountNonce[] newArray(int size) { + return new VenmoAccountNonce[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mNonce); + dest.writeString(mDescription); + dest.writeString(mUsername); + } +} diff --git a/BraintreeApi/src/main/java/com/braintreepayments/api/models/VenmoConfiguration.java b/BraintreeApi/src/main/java/com/braintreepayments/api/models/VenmoConfiguration.java new file mode 100644 index 0000000000..69f52ad67d --- /dev/null +++ b/BraintreeApi/src/main/java/com/braintreepayments/api/models/VenmoConfiguration.java @@ -0,0 +1,82 @@ +package com.braintreepayments.api.models; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; + +import com.braintreepayments.api.Venmo; + +import org.json.JSONObject; +import org.w3c.dom.Text; + +/** + * Contains the remote Pay with Venmo configuration for the Braintree SDK. + */ +public class VenmoConfiguration { + + private static final String ACCESS_TOKEN_KEY = "accessToken"; + private static final Uri VENMO_AUTHORITY_URI = + Uri.parse("content://com.venmo.whitelistprovider"); + + private String mAccessToken; + + /** + * Parses the Pay with Venmo configuration from json. + * + * @param json The json to parse. + * @return A {@link VenmoConfiguration} instance with data that was able to be parsed from the + * {@link JSONObject}. + */ + static VenmoConfiguration fromJson(JSONObject json) { + if (json == null) { + json = new JSONObject(); + } + + VenmoConfiguration venmoConfiguration = new VenmoConfiguration(); + venmoConfiguration.mAccessToken = json.optString(ACCESS_TOKEN_KEY, ""); + + return venmoConfiguration; + } + + /** + * @return The access token to use Pay with Venmo + */ + public String getAccessToken() { + return mAccessToken; + } + + /** + * Determines if the Pay with Venmo flow is available to be used. This can be used to determine + * if UI components should be shown or hidden. + * + * @param context A context to access the {@link PackageManager} + * @return boolean if Venmo is enabled, and available to be used + */ + public boolean isEnabled(Context context) { + return isAccessTokenValid() && + isVenmoWhitelisted(context.getContentResolver()) && + Venmo.isVenmoInstalled(context); + } + + public boolean isAccessTokenValid() { + return !TextUtils.isEmpty(mAccessToken); + } + + public boolean isVenmoWhitelisted(ContentResolver contentResolver) { + Cursor cursor = contentResolver + .query(VENMO_AUTHORITY_URI, null, null, null, null); + + boolean isVenmoWhiteListed = + cursor != null && cursor.moveToFirst() && "true".equals(cursor.getString(0)); + + if (cursor != null) { + cursor.close(); + } + + return isVenmoWhiteListed; + } + +} diff --git a/Drop-In/src/androidTest/assets/fixtures/configuration_with_android_pay_and_venmo_and_paypal.json b/Drop-In/src/androidTest/assets/fixtures/configuration_with_android_pay_and_venmo_and_paypal.json new file mode 100644 index 0000000000..afda42396b --- /dev/null +++ b/Drop-In/src/androidTest/assets/fixtures/configuration_with_android_pay_and_venmo_and_paypal.json @@ -0,0 +1,30 @@ +{ + "clientApiUrl": "client-api-url", + "environment": "environment", + "merchantId": "merchant-id", + "androidPay": { + "enabled": true, + "displayName": "Android Pay Merchant", + "environment": "sandbox", + "googleAuthorizationFingerprint": "google-auth-fingerprint", + "supportedNetworks": [ + "visa", + "mastercard", + "amex", + "discover" + ] + }, + "paypalEnabled": true, + "paypal": { + "displayName": "paypal_merchant", + "clientId": "paypal_client_id", + "privacyUrl": "http://www.example.com/privacy", + "userAgreementUrl": "http://www.example.com/user_agreement", + "baseUrl": "http://localhost:9000", + "directBaseUrl": "https://braintree.paypal.com", + "environment": "custom" + }, + "payWithVenmo" : { + "accessToken" : "access-token" + } +} diff --git a/Drop-In/src/androidTest/assets/fixtures/configuration_with_paypal_and_venmo.json b/Drop-In/src/androidTest/assets/fixtures/configuration_with_paypal_and_venmo.json new file mode 100644 index 0000000000..7651846d8b --- /dev/null +++ b/Drop-In/src/androidTest/assets/fixtures/configuration_with_paypal_and_venmo.json @@ -0,0 +1,18 @@ +{ + "clientApiUrl": "client-api-url", + "environment": "environment", + "merchantId": "merchant-id", + "paypalEnabled": true, + "paypal": { + "displayName": "paypal_merchant", + "clientId": "paypal_client_id", + "privacyUrl": "http://www.example.com/privacy", + "userAgreementUrl": "http://www.example.com/user_agreement", + "baseUrl": "http://localhost:9000", + "directBaseUrl": "https://braintree.paypal.com", + "environment": "custom" + }, + "payWithVenmo" : { + "accessToken" : "access-token" + } +} diff --git a/Drop-In/src/androidTest/assets/fixtures/configuration_with_venmo.json b/Drop-In/src/androidTest/assets/fixtures/configuration_with_venmo.json new file mode 100644 index 0000000000..e548660df5 --- /dev/null +++ b/Drop-In/src/androidTest/assets/fixtures/configuration_with_venmo.json @@ -0,0 +1,8 @@ +{ + "clientApiUrl": "client-api-url", + "environment": "environment", + "merchantId": "merchant-id", + "payWithVenmo" : { + "accessToken" : "access-token" + } +} diff --git a/Drop-In/src/androidTest/assets/fixtures/configuration_with_venmo_and_android_pay.json b/Drop-In/src/androidTest/assets/fixtures/configuration_with_venmo_and_android_pay.json new file mode 100644 index 0000000000..3160150faa --- /dev/null +++ b/Drop-In/src/androidTest/assets/fixtures/configuration_with_venmo_and_android_pay.json @@ -0,0 +1,20 @@ +{ + "clientApiUrl": "client-api-url", + "environment": "environment", + "merchantId": "merchant-id", + "androidPay": { + "enabled": true, + "displayName": "Android Pay Merchant", + "environment": "sandbox", + "googleAuthorizationFingerprint": "google-auth-fingerprint", + "supportedNetworks": [ + "visa", + "mastercard", + "amex", + "discover" + ] + }, + "payWithVenmo" : { + "accessToken" : "access-token" + } +} diff --git a/Drop-In/src/androidTest/java/com/braintreepayments/api/PaymentButtonTest.java b/Drop-In/src/androidTest/java/com/braintreepayments/api/PaymentButtonTest.java index 4a4b617300..a2a822763a 100644 --- a/Drop-In/src/androidTest/java/com/braintreepayments/api/PaymentButtonTest.java +++ b/Drop-In/src/androidTest/java/com/braintreepayments/api/PaymentButtonTest.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.app.Instrumentation.ActivityResult; +import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Looper; @@ -17,9 +18,14 @@ import com.braintreepayments.api.interfaces.BraintreeErrorListener; import com.braintreepayments.api.interfaces.HttpResponseCallback; import com.braintreepayments.api.internal.BraintreeHttpClient; +import com.braintreepayments.api.internal.SignatureVerificationTestUtils; +import com.braintreepayments.api.models.AndroidPayConfiguration; import com.braintreepayments.api.models.Authorization; import com.braintreepayments.testutils.BraintreeActivityTestRule; +import com.braintreepayments.api.models.Configuration; +import com.braintreepayments.api.models.VenmoConfiguration; import com.braintreepayments.api.test.TestActivity; +import com.braintreepayments.testutils.MockContextForVenmo; import com.google.android.gms.wallet.Cart; import org.hamcrest.Description; @@ -58,8 +64,10 @@ import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyInt; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @RunWith(AndroidJUnit4.class) @SmallTest @@ -77,7 +85,8 @@ public void setup() { } @Test(timeout = 1000) - public void newInstance_returnsAPaymentButtonFromATokenizationKey() throws InvalidArgumentException { + public void newInstance_returnsAPaymentButtonFromATokenizationKey() + throws InvalidArgumentException { PaymentRequest paymentRequest = new PaymentRequest() .tokenizationKey(TOKENIZATION_KEY); PaymentButton paymentButton = PaymentButton.newInstance(mActivity, 0, paymentRequest); @@ -102,7 +111,8 @@ public void newInstance_throwsAnExceptionWhenCheckoutRequestIsMissingAuthorizati } @Test(timeout = 1000, expected = InvalidArgumentException.class) - public void newInstance_throwsAnExceptionForABadTokenizationKey() throws InvalidArgumentException { + public void newInstance_throwsAnExceptionForABadTokenizationKey() + throws InvalidArgumentException { PaymentRequest paymentRequest = new PaymentRequest() .clientToken("test_key_merchant"); PaymentButton.newInstance(mActivity, 0, paymentRequest); @@ -149,7 +159,8 @@ public void get(String path, HttpResponseCallback callback) {} new PaymentRequest().tokenizationKey(TOKENIZATION_KEY)); getInstrumentation().waitForIdleSync(); - ViewSwitcher viewSwitcher = (ViewSwitcher) paymentButton.getView().findViewById(R.id.bt_payment_method_view_switcher); + ViewSwitcher viewSwitcher = (ViewSwitcher) paymentButton.getView() + .findViewById(R.id.bt_payment_method_view_switcher); assertEquals(1, viewSwitcher.getDisplayedChild()); } @@ -231,7 +242,7 @@ public void doesNotCrashWhenNoOnClickListenerIsSet() throws InvalidArgumentExcep @Test(timeout = 1000) public void notVisibleWhenNoMethodsAreEnabled() throws InvalidArgumentException, JSONException { - getFragment(false, false); + getFragment(false, false, false); PaymentButton paymentButton = PaymentButton.newInstance(mActivity, android.R.id.content, new PaymentRequest().tokenizationKey(TOKENIZATION_KEY)); getInstrumentation().waitForIdleSync(); @@ -241,7 +252,7 @@ public void notVisibleWhenNoMethodsAreEnabled() throws InvalidArgumentException, @Test(timeout = 1000) public void onlyShowsPayPal() throws InvalidArgumentException, JSONException { - getFragment(true, false); + getFragment(true, false, false); PaymentButton paymentButton = PaymentButton.newInstance(mActivity, android.R.id.content, new PaymentRequest().tokenizationKey(TOKENIZATION_KEY)); getInstrumentation().waitForIdleSync(); @@ -249,16 +260,48 @@ public void onlyShowsPayPal() throws InvalidArgumentException, JSONException { assertEquals(View.VISIBLE, paymentButton.getView().getVisibility()); assertEquals(View.VISIBLE, paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider) + .getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); + } + + @Test(timeout = 1000) + public void onlyShowsVenmo() throws InvalidArgumentException, JSONException { + BraintreeFragment fragment = getFragment(false, true, false); + PaymentRequest paymentRequest = new PaymentRequest().tokenizationKey(TOKENIZATION_KEY); + PaymentButton paymentButton = + PaymentButton.newInstance(mActivity, paymentRequest); + getInstrumentation().waitForIdleSync(); + paymentButton.mBraintreeFragment = fragment; + paymentButton.setPaymentRequest(paymentRequest); + paymentButton.setupButton(fragment.getConfiguration()); + getInstrumentation().waitForIdleSync(); + + assertEquals(View.VISIBLE, paymentButton.getView().getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); assertEquals(View.GONE, paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); assertEquals(View.GONE, paymentButton.getView().findViewById(R.id.bt_payment_button_divider) .getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); } @Test(timeout = 1000) public void onlyShowsAndroidPay() throws InvalidArgumentException, JSONException { - getFragment(false, true); + getFragment(false, false, true); PaymentRequest paymentRequest = new PaymentRequest() .tokenizationKey(TOKENIZATION_KEY) .androidPayCart(Cart.newBuilder().build()); @@ -271,36 +314,129 @@ public void onlyShowsAndroidPay() throws InvalidArgumentException, JSONException paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); assertEquals(View.GONE, paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); assertEquals(View.GONE, paymentButton.getView().findViewById(R.id.bt_payment_button_divider) .getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); + } + + @Test(timeout = 1000) + public void showsPayPalAndAndroidPay() throws InvalidArgumentException, JSONException { + getFragment(true, false, true); + PaymentRequest paymentRequest = new PaymentRequest() + .tokenizationKey(TOKENIZATION_KEY) + .androidPayCart(Cart.newBuilder().build()); + PaymentButton paymentButton = PaymentButton.newInstance(mActivity, paymentRequest); + getInstrumentation().waitForIdleSync(); + + assertEquals(View.VISIBLE, paymentButton.getView().getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider) + .getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); + } + + @Test(timeout = 1000) + public void showsPayPalAndVenmo() throws InvalidArgumentException, JSONException { + BraintreeFragment fragment = getFragment(true, true, false); + PaymentRequest paymentRequest = new PaymentRequest() + .tokenizationKey(TOKENIZATION_KEY) + .androidPayCart(Cart.newBuilder().build()); + PaymentButton paymentButton = PaymentButton.newInstance(mActivity, paymentRequest); + getInstrumentation().waitForIdleSync(); + paymentButton.mBraintreeFragment = fragment; + paymentButton.setPaymentRequest(paymentRequest); + paymentButton.setupButton(fragment.getConfiguration()); + getInstrumentation().waitForIdleSync(); + + assertEquals(View.VISIBLE, paymentButton.getView().getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider) + .getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); + } + + @Test(timeout = 1000) + public void showsVenmoAndAndroidPay() throws InvalidArgumentException, JSONException { + BraintreeFragment fragment = getFragment(false, true, true); + PaymentRequest paymentRequest = new PaymentRequest() + .tokenizationKey(TOKENIZATION_KEY) + .androidPayCart(Cart.newBuilder().build()); + PaymentButton paymentButton = PaymentButton.newInstance(mActivity, paymentRequest); + getInstrumentation().waitForIdleSync(); + paymentButton.mBraintreeFragment = fragment; + paymentButton.setPaymentRequest(paymentRequest); + paymentButton.setupButton(fragment.getConfiguration()); + getInstrumentation().waitForIdleSync(); + + assertEquals(View.VISIBLE, paymentButton.getView().getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); + assertEquals(View.GONE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider) + .getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); } @Test(timeout = 1000) public void showsAllMethodsAndDividers() throws InvalidArgumentException, JSONException { - getFragment(true, true); + BraintreeFragment fragment = getFragment(true, true, true); PaymentRequest paymentRequest = new PaymentRequest() .tokenizationKey(TOKENIZATION_KEY) .androidPayCart(Cart.newBuilder().build()); PaymentButton paymentButton = PaymentButton.newInstance(mActivity, android.R.id.content, paymentRequest); getInstrumentation().waitForIdleSync(); + paymentButton.mBraintreeFragment = fragment; + getInstrumentation().waitForIdleSync(); + paymentButton.setupButton(fragment.getConfiguration()); assertEquals(View.VISIBLE, paymentButton.getView().getVisibility()); assertEquals(View.VISIBLE, paymentButton.getView().findViewById(R.id.bt_android_pay_button).getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_venmo_button).getVisibility()); assertEquals(View.VISIBLE, paymentButton.getView().findViewById(R.id.bt_paypal_button).getVisibility()); assertEquals(View.VISIBLE, paymentButton.getView().findViewById(R.id.bt_payment_button_divider) .getVisibility()); + assertEquals(View.VISIBLE, + paymentButton.getView().findViewById(R.id.bt_payment_button_divider_2) + .getVisibility()); } @Test(timeout = 5000) public void startsPayWithPayPal() throws InvalidArgumentException, JSONException, InterruptedException { Looper.prepare(); - getFragment(true, true); + getFragment(true, true, true); PaymentButton paymentButton = PaymentButton.newInstance(mActivity, android.R.id.content, new PaymentRequest().tokenizationKey(TOKENIZATION_KEY)); getInstrumentation().waitForIdleSync(); @@ -328,7 +464,7 @@ public void startsPayWithPayPal() public void startsPayWithPayPalWithAddressScope() throws InvalidArgumentException, JSONException, InterruptedException { Looper.prepare(); - getFragment(true, true); + getFragment(true, true, true); PaymentRequest paymentRequest = new PaymentRequest() .tokenizationKey(TOKENIZATION_KEY) .paypalAdditionalScopes(Collections.singletonList(PayPal.SCOPE_ADDRESS)); @@ -355,11 +491,31 @@ public void startsPayWithPayPalWithAddressScope() throws InvalidArgumentExceptio true))))); } + @Test(timeout = 1000) + public void startsPayWithVenmo() throws InvalidArgumentException, JSONException { + BraintreeFragment fragment = getFragment(true, true, false); + Context mockContextForVenmo = new MockContextForVenmo() + .venmoInstalled() + .build(); + when(fragment.getApplicationContext()).thenReturn(mockContextForVenmo); + SignatureVerificationTestUtils.disableSignatureVerification(); + PaymentRequest paymentRequest = new PaymentRequest() + .tokenizationKey(TOKENIZATION_KEY); + PaymentButton paymentButton = PaymentButton.newInstance(mActivity, paymentRequest); + getInstrumentation().waitForIdleSync(); + paymentButton.mBraintreeFragment = fragment; + paymentButton.setupButton(fragment.getConfiguration()); + getInstrumentation().waitForIdleSync(); + + paymentButton.getView().findViewById(R.id.bt_venmo_button).performClick(); + + verify(fragment).sendAnalyticsEvent("pay-with-venmo.selected"); + } + @Test(timeout = 5000) - public void startsPayWithAndroidPay() - throws JSONException, InvalidArgumentException, InterruptedException { + public void startsPayWithAndroidPay() throws JSONException, InvalidArgumentException { Looper.prepare(); - BraintreeFragment fragment = getFragment(true, true); + BraintreeFragment fragment = getFragment(true, true, true); PaymentRequest paymentRequest = new PaymentRequest() .tokenizationKey(TOKENIZATION_KEY) .androidPayCart(Cart.newBuilder().build()); @@ -367,6 +523,8 @@ public void startsPayWithAndroidPay() paymentRequest); getInstrumentation().waitForIdleSync(); paymentButton.mBraintreeFragment = fragment; + paymentButton.setupButton(fragment.getConfiguration()); + getInstrumentation().waitForIdleSync(); paymentButton.getView().findViewById(R.id.bt_android_pay_button).performClick(); @@ -374,13 +532,23 @@ public void startsPayWithAndroidPay() } /** helpers */ - private BraintreeFragment getFragment(boolean paypalEnabled, boolean androidPayEnabled) + private BraintreeFragment getFragment(boolean paypalEnabled, boolean payWithVenmoEnabled, + boolean androidPayEnabled) throws InvalidArgumentException, JSONException { String configuration; - if (paypalEnabled && androidPayEnabled) { + if (paypalEnabled && payWithVenmoEnabled && androidPayEnabled) { + configuration = + stringFromFixture("configuration_with_android_pay_and_venmo_and_paypal.json"); + } else if (paypalEnabled && payWithVenmoEnabled) { + configuration = stringFromFixture("configuration_with_paypal_and_venmo.json"); + } else if (paypalEnabled && androidPayEnabled) { configuration = stringFromFixture("configuration_with_android_pay_and_paypal.json"); } else if (paypalEnabled) { configuration = stringFromFixture("configuration_with_paypal.json"); + } else if (payWithVenmoEnabled && androidPayEnabled) { + configuration = stringFromFixture("configuration_with_venmo_and_android_pay.json"); + } else if (payWithVenmoEnabled) { + configuration = stringFromFixture("configuration_with_venmo.json"); } else if (androidPayEnabled) { configuration = stringFromFixture("configuration_with_android_pay.json"); } else { @@ -393,6 +561,28 @@ private BraintreeFragment getFragment(boolean paypalEnabled, boolean androidPayE BraintreeFragment fragment = spy(BraintreeFragment.newInstance(mActivity, clientToken.toString())); doNothing().when(fragment).startActivity(any(Intent.class)); doNothing().when(fragment).startActivityForResult(any(Intent.class), anyInt()); + + if (payWithVenmoEnabled || androidPayEnabled) { + Configuration configurationObj = spy(Configuration.fromJson(configuration)); + if (payWithVenmoEnabled) { + VenmoConfiguration venmoConfiguration = mock(VenmoConfiguration.class); + when(venmoConfiguration.isEnabled(any(Context.class))) + .thenReturn(true); + when(configurationObj.getPayWithVenmo()).thenReturn(venmoConfiguration); + } + if (androidPayEnabled) { + AndroidPayConfiguration androidPayConfiguration = mock( + AndroidPayConfiguration.class); + when(androidPayConfiguration.isEnabled(any(Context.class))).thenReturn(true); + when(androidPayConfiguration.getGoogleAuthorizationFingerprint()) + .thenReturn("google-authorization-fingerprint"); + when(androidPayConfiguration.getSupportedNetworks()) + .thenReturn(new String[]{"visa"}); + when(configurationObj.getAndroidPay()).thenReturn(androidPayConfiguration); + } + when(fragment.getConfiguration()).thenReturn(configurationObj); + } + getInstrumentation().waitForIdleSync(); return fragment; @@ -404,7 +594,8 @@ private Matcher hasScope(final String scope) { return new TypeSafeMatcher() { @Override public boolean matchesSafely(Uri uri) { - String payload = new String(Base64.decode(uri.getQueryParameter("payload"), Base64.DEFAULT)); + String payload = + new String(Base64.decode(uri.getQueryParameter("payload"), Base64.DEFAULT)); return payload.contains(scope); } diff --git a/Drop-In/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTestUtils.java b/Drop-In/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTestUtils.java new file mode 100644 index 0000000000..d35fbeacb0 --- /dev/null +++ b/Drop-In/src/androidTest/java/com/braintreepayments/api/internal/SignatureVerificationTestUtils.java @@ -0,0 +1,8 @@ +package com.braintreepayments.api.internal; + +public class SignatureVerificationTestUtils { + + public static void disableSignatureVerification() { + SignatureVerification.sEnableSignatureVerification = false; + } +} diff --git a/Drop-In/src/main/java/com/braintreepayments/api/BraintreePaymentActivity.java b/Drop-In/src/main/java/com/braintreepayments/api/BraintreePaymentActivity.java index 5e4f6b2c5d..a0a183738e 100644 --- a/Drop-In/src/main/java/com/braintreepayments/api/BraintreePaymentActivity.java +++ b/Drop-In/src/main/java/com/braintreepayments/api/BraintreePaymentActivity.java @@ -31,6 +31,7 @@ import com.braintreepayments.api.models.CardNonce; import com.braintreepayments.api.models.PayPalAccountNonce; import com.braintreepayments.api.models.PaymentMethodNonce; +import com.braintreepayments.api.models.VenmoAccountNonce; import java.util.List; import java.util.concurrent.Executors; @@ -47,35 +48,40 @@ public class BraintreePaymentActivity extends Activity implements /** * {@link PaymentMethodNonce} returned by successfully exiting the flow. */ - public static final String EXTRA_PAYMENT_METHOD_NONCE = "com.braintreepayments.api.dropin.EXTRA_PAYMENT_METHOD_NONCE"; + public static final String EXTRA_PAYMENT_METHOD_NONCE = + "com.braintreepayments.api.dropin.EXTRA_PAYMENT_METHOD_NONCE"; /** - * Error messages are returned as the value of this key in the data intent in {@link android.app.Activity#onActivityResult(int, int, android.content.Intent)} - * if {@code responseCode} is not {@link android.app.Activity#RESULT_OK} or {@link android.app.Activity#RESULT_CANCELED} + * Error messages are returned as the value of this key in the data intent in {@link + * android.app.Activity#onActivityResult(int, int, android.content.Intent)} if {@code + * responseCode} is not {@link android.app.Activity#RESULT_OK} or {@link + * android.app.Activity#RESULT_CANCELED} */ - public static final String EXTRA_ERROR_MESSAGE = "com.braintreepayments.api.dropin.EXTRA_ERROR_MESSAGE"; + public static final String EXTRA_ERROR_MESSAGE = + "com.braintreepayments.api.dropin.EXTRA_ERROR_MESSAGE"; /** - * The payment method flow halted due to a resolvable error (authentication, authorization, SDK upgrade required). - * The reason for the error will be returned in a future release. + * The payment method flow halted due to a resolvable error (authentication, authorization, SDK + * upgrade required). The reason for the error will be returned in a future release. */ public static final int BRAINTREE_RESULT_DEVELOPER_ERROR = 2; /** - * The payment method flow halted due to an error from the Braintree gateway. - * The best recovery path is to try again with a new client token. + * The payment method flow halted due to an error from the Braintree gateway. The best recovery + * path is to try again with a new client token. */ public static final int BRAINTREE_RESULT_SERVER_ERROR = 3; /** - * The payment method flow halted due to the Braintree gateway going down for maintenance. - * Try again later. + * The payment method flow halted due to the Braintree gateway going down for maintenance. Try + * again later. */ public static final int BRAINTREE_RESULT_SERVER_UNAVAILABLE = 4; static final String EXTRA_CHECKOUT_REQUEST = "com.braintreepayments.api.EXTRA_CHECKOUT_REQUEST"; - private static final String ON_PAYMENT_METHOD_ADD_FORM_KEY = "com.braintreepayments.api.dropin.PAYMENT_METHOD_ADD_FORM"; + private static final String ON_PAYMENT_METHOD_ADD_FORM_KEY = + "com.braintreepayments.api.dropin.PAYMENT_METHOD_ADD_FORM"; @VisibleForTesting protected BraintreeFragment mBraintreeFragment; @@ -109,7 +115,8 @@ protected void onCreate(Bundle savedInstanceState) { waitForData(); } } catch (InvalidArgumentException e) { - setResult(BRAINTREE_RESULT_DEVELOPER_ERROR, new Intent().putExtra(EXTRA_ERROR_MESSAGE, e)); + setResult(BRAINTREE_RESULT_DEVELOPER_ERROR, + new Intent().putExtra(EXTRA_ERROR_MESSAGE, e)); finish(); } } @@ -153,6 +160,9 @@ public void run() { } else if (paymentMethodNonce instanceof AndroidPayCardNonce) { mBraintreeFragment.sendAnalyticsEvent("add-android-pay.success"); finishCreate(); + } else if (paymentMethodNonce instanceof VenmoAccountNonce) { + mBraintreeFragment.sendAnalyticsEvent("add-pay-with-venmo.success"); + finishCreate(); } } @@ -187,7 +197,8 @@ public void onError(Exception error) { mAddPaymentMethodViewController.setErrors((ErrorWithResponse) error); } else { // Falling back to add payment method if getPaymentMethodNonces fails - if (StubbedView.LOADING_VIEW.mCurrentView && !mHavePaymentMethodNoncesBeenReceived.get() && + if (StubbedView.LOADING_VIEW.mCurrentView && + !mHavePaymentMethodNoncesBeenReceived.get() && mBraintreeFragment.getConfiguration() != null) { mBraintreeFragment.sendAnalyticsEvent("appeared"); mHavePaymentMethodNoncesBeenReceived.set(true); @@ -326,7 +337,8 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override public void onBackPressed() { - if (StubbedView.CARD_FORM.mCurrentView && mBraintreeFragment.getCachedPaymentMethodNonces().size() > 0) { + if (StubbedView.CARD_FORM.mCurrentView && + mBraintreeFragment.getCachedPaymentMethodNonces().size() > 0) { initSelectPaymentMethodNonceView(); } else if (mAddPaymentMethodViewController != null && mAddPaymentMethodViewController.isSubmitting()) { diff --git a/Drop-In/src/main/java/com/braintreepayments/api/PaymentButton.java b/Drop-In/src/main/java/com/braintreepayments/api/PaymentButton.java index 791177887e..ef29ac7e24 100644 --- a/Drop-In/src/main/java/com/braintreepayments/api/PaymentButton.java +++ b/Drop-In/src/main/java/com/braintreepayments/api/PaymentButton.java @@ -34,15 +34,15 @@ /** * An intelligent button for handling non-card payment methods. This button will display payment * methods depending on their availability. - * - * Created {@link PaymentMethodNonce}s will be posted to - * {@link PaymentMethodNonceCreatedListener}. + *

+ * Created {@link PaymentMethodNonce}s will be posted to {@link PaymentMethodNonceCreatedListener}. */ public class PaymentButton extends Fragment implements ConfigurationListener, BraintreeResponseListener, OnClickListener { private static final String TAG = "com.braintreepayments.api.PaymentButton"; - private static final String EXTRA_PAYMENT_REQUEST = "com.braintreepayments.api.EXTRA_PAYMENT_REQUEST"; + private static final String EXTRA_PAYMENT_REQUEST = + "com.braintreepayments.api.EXTRA_PAYMENT_REQUEST"; @VisibleForTesting BraintreeFragment mBraintreeFragment; @@ -64,8 +64,8 @@ public PaymentButton() {} * @param containerViewId Optional identifier of the container this fragment is to be placed in. * If 0, it will not be placed in a container. * @return {@link PaymentButton} - * @throws InvalidArgumentException If the client key or client token is not valid or cannot be - * parsed. + * @throws InvalidArgumentException If the client key or tokenization key is not valid or + * cannot be parsed. */ public static PaymentButton newInstance(Activity activity, int containerViewId, PaymentRequest paymentRequest) throws InvalidArgumentException { @@ -129,7 +129,8 @@ public void onCreate(Bundle savedInstanceState) { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.bt_payment_button, container, false); - mProgressViewSwitcher = (ViewSwitcher) view.findViewById(R.id.bt_payment_method_view_switcher); + mProgressViewSwitcher = + (ViewSwitcher) view.findViewById(R.id.bt_payment_method_view_switcher); showProgress(true); if (mPaymentRequest == null) { @@ -151,8 +152,8 @@ public void onActivityCreated(Bundle savedInstanceState) { } /** - * Initialize the {@link PaymentButton}. This method *MUST* be called if the - * {@link PaymentButton} was adding using XML or {@link PaymentButton} will not be displayed. + * Initialize the {@link PaymentButton}. This method *MUST* be called if the {@link + * PaymentButton} was adding using XML or {@link PaymentButton} will not be displayed. * * @param paymentRequest {@link PaymentRequest} containing payment method options. */ @@ -190,7 +191,10 @@ public void onResponse(Exception exception) { @Override public void onClick(View v) { if (v.getId() == R.id.bt_paypal_button) { - PayPal.authorizeAccount(mBraintreeFragment, mPaymentRequest.getPayPalAdditionalScopes()); + PayPal.authorizeAccount(mBraintreeFragment, + mPaymentRequest.getPayPalAdditionalScopes()); + } else if (v.getId() == R.id.bt_venmo_button) { + Venmo.authorizeAccount(mBraintreeFragment); } else if (v.getId() == R.id.bt_android_pay_button) { AndroidPay.performMaskedWalletRequest(mBraintreeFragment, mPaymentRequest.getAndroidPayCart(), @@ -204,7 +208,7 @@ public void onClick(View v) { } } - private void setupButton(Configuration configuration) { + void setupButton(Configuration configuration) { View view = getView(); if (view == null) { setVisibility(GONE); @@ -212,14 +216,18 @@ private void setupButton(Configuration configuration) { } boolean isPayPalEnabled = configuration.isPayPalEnabled(); + boolean isVenmoEnabled = configuration.getPayWithVenmo().isEnabled(mBraintreeFragment.getApplicationContext()); boolean isAndroidPayEnabled = isAndroidPayEnabled(configuration); int buttonCount = 0; - if (!isPayPalEnabled && !isAndroidPayEnabled) { + if (!isPayPalEnabled && !isVenmoEnabled && !isAndroidPayEnabled) { setVisibility(GONE); } else { if (isPayPalEnabled) { buttonCount++; } + if (isVenmoEnabled) { + buttonCount++; + } if (isAndroidPayEnabled) { buttonCount++; } @@ -227,12 +235,20 @@ private void setupButton(Configuration configuration) { if (isPayPalEnabled) { enableButton(view.findViewById(R.id.bt_paypal_button), buttonCount); } + if (isVenmoEnabled) { + enableButton(view.findViewById(R.id.bt_venmo_button), buttonCount); + } if (isAndroidPayEnabled) { enableButton(view.findViewById(R.id.bt_android_pay_button), buttonCount); } if (isPayPalEnabled && buttonCount > 1) { view.findViewById(R.id.bt_payment_button_divider).setVisibility(VISIBLE); + } else if (isVenmoEnabled && buttonCount > 1) { + view.findViewById(R.id.bt_payment_button_divider_2).setVisibility(VISIBLE); + } + if (buttonCount > 2) { + view.findViewById(R.id.bt_payment_button_divider_2).setVisibility(VISIBLE); } setVisibility(VISIBLE); @@ -244,8 +260,9 @@ private void enableButton(View view, int buttonCount) { view.setVisibility(VISIBLE); view.setOnClickListener(this); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT, 3f / buttonCount); + LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT, 3f / buttonCount); view.setLayoutParams(params); } @@ -281,6 +298,7 @@ private void setupBraintreeFragment() throws InvalidArgumentException { } } + @VisibleForTesting private boolean isAndroidPayEnabled(Configuration configuration) { try { return (configuration.getAndroidPay().isEnabled( diff --git a/Drop-In/src/main/java/com/braintreepayments/api/dropin/utils/PaymentMethodType.java b/Drop-In/src/main/java/com/braintreepayments/api/dropin/utils/PaymentMethodType.java index 049f70baf6..4662061087 100644 --- a/Drop-In/src/main/java/com/braintreepayments/api/dropin/utils/PaymentMethodType.java +++ b/Drop-In/src/main/java/com/braintreepayments/api/dropin/utils/PaymentMethodType.java @@ -13,6 +13,7 @@ public enum PaymentMethodType { MASTERCARD(R.drawable.bt_mastercard, R.string.bt_descriptor_mastercard, "MasterCard"), PAYPAL(R.drawable.bt_paypal, R.string.bt_descriptor_paypal, "PayPal"), VISA(R.drawable.bt_visa, R.string.bt_descriptor_visa, "Visa"), + PAY_WITH_VENMO(R.drawable.bt_venmo, R.string.bt_descriptor_pay_with_venmo, "Venmo"), UNKNOWN(0, R.string.bt_descriptor_unknown, "unknown"); private final int mDrawable; @@ -26,10 +27,10 @@ public enum PaymentMethodType { } /** - * @param paymentMethodType A {@link String} representing a canonical name for a payment method. - * - * @return a {@link PaymentMethodType} for for the given {@link String}, or - * {@link PaymentMethodType#UNKNOWN} if no match could be made. + * @param paymentMethodType A {@link String} representing a canonical name for a payment + * method. + * @return a {@link PaymentMethodType} for for the given {@link String}, or {@link + * PaymentMethodType#UNKNOWN} if no match could be made. */ public static PaymentMethodType forType(String paymentMethodType) { for (PaymentMethodType type : values()) { @@ -42,15 +43,15 @@ public static PaymentMethodType forType(String paymentMethodType) { /** * @return An id representing a {@link android.graphics.drawable.Drawable} icon for the current - * {@link PaymentMethodType}. + * {@link PaymentMethodType}. */ public int getDrawable() { return mDrawable; } /** - * @return An id representing a localized {@link String} for the current - * {@link PaymentMethodType}. + * @return An id representing a localized {@link String} for the current {@link + * PaymentMethodType}. */ public int getLocalizedName() { return mLocalizedName; @@ -58,7 +59,7 @@ public int getLocalizedName() { /** * @return A {@link String} name of the {@link PaymentMethodType} as it is categorized by - * Braintree. + * Braintree. */ public String getCanonicalName() { return mCanonicalName; diff --git a/Drop-In/src/main/res/drawable-hdpi/bt_venmo.png b/Drop-In/src/main/res/drawable-hdpi/bt_venmo.png new file mode 100644 index 0000000000..30be1ac60e Binary files /dev/null and b/Drop-In/src/main/res/drawable-hdpi/bt_venmo.png differ diff --git a/Drop-In/src/main/res/drawable-mdpi/bt_venmo.png b/Drop-In/src/main/res/drawable-mdpi/bt_venmo.png new file mode 100644 index 0000000000..b9f97bdc12 Binary files /dev/null and b/Drop-In/src/main/res/drawable-mdpi/bt_venmo.png differ diff --git a/Drop-In/src/main/res/drawable-xhdpi/bt_venmo.png b/Drop-In/src/main/res/drawable-xhdpi/bt_venmo.png new file mode 100644 index 0000000000..3dcef55b02 Binary files /dev/null and b/Drop-In/src/main/res/drawable-xhdpi/bt_venmo.png differ diff --git a/Drop-In/src/main/res/drawable-xxhdpi/bt_venmo.png b/Drop-In/src/main/res/drawable-xxhdpi/bt_venmo.png new file mode 100644 index 0000000000..3a0b7d19b6 Binary files /dev/null and b/Drop-In/src/main/res/drawable-xxhdpi/bt_venmo.png differ diff --git a/Drop-In/src/main/res/drawable-xxxhdpi/bt_venmo.png b/Drop-In/src/main/res/drawable-xxxhdpi/bt_venmo.png new file mode 100644 index 0000000000..952d59202e Binary files /dev/null and b/Drop-In/src/main/res/drawable-xxxhdpi/bt_venmo.png differ diff --git a/Drop-In/src/main/res/layout/bt_payment_button.xml b/Drop-In/src/main/res/layout/bt_payment_button.xml index 2bc8215385..4ebecf3eb2 100644 --- a/Drop-In/src/main/res/layout/bt_payment_button.xml +++ b/Drop-In/src/main/res/layout/bt_payment_button.xml @@ -12,7 +12,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" - android:weightSum="3" > + android:weightSum="3"> + + + + slutter på %s Betal med PayPal + Betal med Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-de/strings.xml b/Drop-In/src/main/res/values-de/strings.xml index e80def39c1..d31fff5c6d 100644 --- a/Drop-In/src/main/res/values-de/strings.xml +++ b/Drop-In/src/main/res/values-de/strings.xml @@ -16,6 +16,7 @@ endet mit %s Mit PayPal zahlen + Zahlen Sie mit Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-en-rAU/strings.xml b/Drop-In/src/main/res/values-en-rAU/strings.xml index 0feb5cca8f..0a3718626e 100644 --- a/Drop-In/src/main/res/values-en-rAU/strings.xml +++ b/Drop-In/src/main/res/values-en-rAU/strings.xml @@ -16,6 +16,7 @@ ends in %s Pay with PayPal + Pay with Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-en-rGB/strings.xml b/Drop-In/src/main/res/values-en-rGB/strings.xml index 546992649d..e8ef2f244f 100644 --- a/Drop-In/src/main/res/values-en-rGB/strings.xml +++ b/Drop-In/src/main/res/values-en-rGB/strings.xml @@ -16,6 +16,7 @@ ends in %s Pay with PayPal + Pay with Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-es/strings.xml b/Drop-In/src/main/res/values-es/strings.xml index f0b43d5e34..03826b6cf5 100644 --- a/Drop-In/src/main/res/values-es/strings.xml +++ b/Drop-In/src/main/res/values-es/strings.xml @@ -16,6 +16,7 @@ termina en %s Pagar con PayPal + Pagar con Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-fr-rCA/strings.xml b/Drop-In/src/main/res/values-fr-rCA/strings.xml index 3263189291..1c7062476d 100644 --- a/Drop-In/src/main/res/values-fr-rCA/strings.xml +++ b/Drop-In/src/main/res/values-fr-rCA/strings.xml @@ -15,7 +15,8 @@ se termine par %s - Payer avec PayPal + Payer avec PayPal + Payer avec Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-fr/strings.xml b/Drop-In/src/main/res/values-fr/strings.xml index 29700ec029..83eb9f8302 100644 --- a/Drop-In/src/main/res/values-fr/strings.xml +++ b/Drop-In/src/main/res/values-fr/strings.xml @@ -16,6 +16,7 @@ se termine par %s Payer avec PayPal + Payer avec Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-it/strings.xml b/Drop-In/src/main/res/values-it/strings.xml index 873ba92cf1..2c63fbe3e4 100644 --- a/Drop-In/src/main/res/values-it/strings.xml +++ b/Drop-In/src/main/res/values-it/strings.xml @@ -16,6 +16,7 @@ termina in %s Paga con PayPal + Paga con Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-iw-rIL/strings.xml b/Drop-In/src/main/res/values-iw-rIL/strings.xml index bcf585e45f..c974967caa 100644 --- a/Drop-In/src/main/res/values-iw-rIL/strings.xml +++ b/Drop-In/src/main/res/values-iw-rIL/strings.xml @@ -16,6 +16,7 @@ מסתיים בספרות %s שלם באמצעות PayPal‏ + שלם באמצעות Venmo‏ ויזה מאסטרקארד diff --git a/Drop-In/src/main/res/values-nl/strings.xml b/Drop-In/src/main/res/values-nl/strings.xml index 62045fafab..3a9fbe0e79 100644 --- a/Drop-In/src/main/res/values-nl/strings.xml +++ b/Drop-In/src/main/res/values-nl/strings.xml @@ -16,6 +16,7 @@ eindigend op %s Betalen met PayPal + Betalen met Venno Visa MasterCard diff --git a/Drop-In/src/main/res/values-no/strings.xml b/Drop-In/src/main/res/values-no/strings.xml index 23dcd437b5..a25b53d8c7 100644 --- a/Drop-In/src/main/res/values-no/strings.xml +++ b/Drop-In/src/main/res/values-no/strings.xml @@ -16,6 +16,7 @@ slutter på %s Betal med PayPal + Betal med Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-pl/strings.xml b/Drop-In/src/main/res/values-pl/strings.xml index 5e0d469b21..f4e7484201 100644 --- a/Drop-In/src/main/res/values-pl/strings.xml +++ b/Drop-In/src/main/res/values-pl/strings.xml @@ -16,6 +16,7 @@ kończy się cyframi %s Zapłać w systemie PayPal + Zapłać w systemie Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-pt/strings.xml b/Drop-In/src/main/res/values-pt/strings.xml index d3fe1ad58a..fa6acc8f4e 100644 --- a/Drop-In/src/main/res/values-pt/strings.xml +++ b/Drop-In/src/main/res/values-pt/strings.xml @@ -16,6 +16,7 @@ terminado em %s Pagar com PayPal + Pagar com Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-ru/strings.xml b/Drop-In/src/main/res/values-ru/strings.xml index 99a74bf2ad..70e2068b71 100644 --- a/Drop-In/src/main/res/values-ru/strings.xml +++ b/Drop-In/src/main/res/values-ru/strings.xml @@ -16,6 +16,7 @@ заканчивается в %s Оплатить через PayPal + Оплатить с помощью Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-sv-rSE/strings.xml b/Drop-In/src/main/res/values-sv-rSE/strings.xml index 2859139032..e680bda8f2 100644 --- a/Drop-In/src/main/res/values-sv-rSE/strings.xml +++ b/Drop-In/src/main/res/values-sv-rSE/strings.xml @@ -16,6 +16,7 @@ slutar på %s Betala med PayPal + Betala med Venmo Visa MasterCard diff --git a/Drop-In/src/main/res/values-tr/strings.xml b/Drop-In/src/main/res/values-tr/strings.xml index 61efed528b..47eb2881d5 100644 --- a/Drop-In/src/main/res/values-tr/strings.xml +++ b/Drop-In/src/main/res/values-tr/strings.xml @@ -16,6 +16,7 @@ sonu şu numarayla biten %s PayPal ile ödeyin + Venmo ile ödeyin Visa MasterCard diff --git a/Drop-In/src/main/res/values-zh-rCN/strings.xml b/Drop-In/src/main/res/values-zh-rCN/strings.xml index c405c209a9..2bb3b6707f 100644 --- a/Drop-In/src/main/res/values-zh-rCN/strings.xml +++ b/Drop-In/src/main/res/values-zh-rCN/strings.xml @@ -16,6 +16,7 @@ 尾号为%s 用PayPal付款 + 用Venmo付款 Visa MasterCard diff --git a/Drop-In/src/main/res/values/strings.xml b/Drop-In/src/main/res/values/strings.xml index 110db86107..bc89790a12 100644 --- a/Drop-In/src/main/res/values/strings.xml +++ b/Drop-In/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ ends in %s Pay with PayPal + Pay with Venmo Pay with Android Pay Visa @@ -26,6 +27,7 @@ Diners Maestro PayPal + Venmo Android Pay Card diff --git a/TestUtils/src/main/java/com/braintreepayments/testutils/MockContextForVenmo.java b/TestUtils/src/main/java/com/braintreepayments/testutils/MockContextForVenmo.java new file mode 100644 index 0000000000..dc25eba474 --- /dev/null +++ b/TestUtils/src/main/java/com/braintreepayments/testutils/MockContextForVenmo.java @@ -0,0 +1,101 @@ +package com.braintreepayments.testutils; + +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.database.Cursor; +import android.net.Uri; +import android.test.mock.MockContentProvider; +import android.test.mock.MockContentResolver; +import android.test.mock.MockContext; +import android.test.mock.MockCursor; + +import java.util.Collections; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MockContextForVenmo { + + private PackageManager mPackageManager = null; + private MockCursor mMockCursor = null; + private MockContentResolver mContentResolver = null; + + public MockContextForVenmo whitelistValue(String whitelistValue) { + mMockCursor = mockCursor(whitelistValue); + mContentResolver = new MockContentResolver(); + return this; + } + + public MockContextForVenmo venmoInstalled() { + mPackageManager = mockPackageManager(); + return this; + } + + public MockContext build() { + MockContext mockContext = new MockContext() { + @Override + public ContentResolver getContentResolver() { + if (mContentResolver == null) { + return mock(ContentResolver.class); + } else { + return mContentResolver; + } + } + + @Override + public PackageManager getPackageManager() { + if (mPackageManager == null) { + return mock(PackageManager.class); + } else { + return mPackageManager; + } + } + }; + + if (mMockCursor != null) { + MockContentProvider mockContentProvider = mockContentProvider(mockContext, mMockCursor); + mContentResolver.addProvider("com.venmo.whitelistprovider", mockContentProvider); + } + + return mockContext; + } + + private MockCursor mockCursor(String whitelistValue) { + final MockCursor mockCursor = mock(MockCursor.class); + when(mockCursor.getCount()).thenReturn(1); + when(mockCursor.getString(anyInt())).thenReturn(whitelistValue); + when(mockCursor.moveToFirst()).thenReturn(true); + return mockCursor; + } + + private MockContentProvider mockContentProvider(MockContext mockContext, + final MockCursor mockCursor) { + MockContentProvider mockContentProvider = new MockContentProvider(mockContext) { + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, + String sortOrder) { + return mockCursor; + } + }; + mContentResolver.addProvider("com.venmo.whitelistprovider", mockContentProvider); + return mockContentProvider; + } + + private PackageManager mockPackageManager() { + ActivityInfo activityInfo = new ActivityInfo(); + activityInfo.packageName = "com.venmo"; + ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = activityInfo; + final PackageManager packageManager = mock(PackageManager.class); + when(packageManager.queryIntentActivities(any(Intent.class), anyInt())) + .thenReturn(Collections.singletonList(resolveInfo)); + return packageManager; + } +} +