Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DO NOT REVIEW] Merge PayPal App Switch feature branch #1167

Merged
merged 18 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/firebase_deploy_demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Deploy demo app to Firebase app distribution
on:
workflow_dispatch:
push:
branches:
- paypal-app-switch-feature
jobs:
build_and_preview:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Java
uses: ./.github/actions/setup
- name: Assemble
run: ./gradlew --stacktrace :demo:assembleDebug
- name: Upload artifact to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_CREDENTIAL_FILE_CONTENT }}
groups: testers
file: Demo/build/outputs/apk/debug/Demo-debug.apk
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ internal class AnalyticsClient(
@Throws(JSONException::class)
private fun mapDeviceMetadataToFPTIBatchParamsJSON(metadata: DeviceMetadata): JSONObject {
val isVenmoInstalled = deviceInspector.isVenmoInstalled(applicationContext)
val isPayPalInstalled = deviceInspector.isPayPalInstalled(applicationContext)
return metadata.run {
JSONObject()
.put(FPTI_BATCH_KEY_APP_ID, appId)
Expand All @@ -245,6 +246,7 @@ internal class AnalyticsClient(
.put(FPTI_BATCH_KEY_PLATFORM, platform)
.put(FPTI_BATCH_KEY_SESSION_ID, sessionId)
.put(FPTI_BATCH_KEY_VENMO_INSTALLED, isVenmoInstalled)
.put(FPTI_BATCH_KEY_PAYPAL_INSTALLED, isPayPalInstalled)
}
}

Expand All @@ -267,6 +269,7 @@ internal class AnalyticsClient(
private const val FPTI_KEY_ENDPOINT = "endpoint"

private const val FPTI_BATCH_KEY_VENMO_INSTALLED = "venmo_installed"
private const val FPTI_BATCH_KEY_PAYPAL_INSTALLED = "paypal_installed"
private const val FPTI_BATCH_KEY_APP_ID = "app_id"
private const val FPTI_BATCH_KEY_APP_NAME = "app_name"
private const val FPTI_BATCH_KEY_CLIENT_SDK_VERSION = "c_sdk_ver"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.RestrictTo
import com.braintreepayments.api.sharedutils.AppHelper
import com.braintreepayments.api.sharedutils.SignatureVerifier

internal class DeviceInspector(
/**
* @suppress
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class DeviceInspector(
private val appHelper: AppHelper = AppHelper(),
private val signatureVerifier: SignatureVerifier = SignatureVerifier(),
) {

fun getDeviceMetadata(
internal fun getDeviceMetadata(
context: Context?,
configuration: Configuration?,
sessionId: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.braintreepayments.api.core

import androidx.annotation.RestrictTo

/**
* Used to describe the link type for analytics
* Note: This enum is exposed for internal Braintree use only. Do not use.
* It is not covered by Semantic Versioning and may change or be removed at any time.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
enum class LinkType(val stringValue: String) {
APP_SWITCH("universal"),
APP_LINK("deeplink")
}
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ class AnalyticsClientUnitTest {
val metadata = createSampleDeviceMetadata()

every { deviceInspector.isVenmoInstalled(context) } returns true
every { deviceInspector.isPayPalInstalled(context) } returns true
every {
deviceInspector.getDeviceMetadata(context, any(), sessionId, integration)
} returns metadata
Expand Down Expand Up @@ -263,6 +264,7 @@ class AnalyticsClientUnitTest {
"api_integration_type": "custom",
"is_simulator": false,
"venmo_installed": true,
"paypal_installed": true,
"mapv": "fake-merchant-app-version",
"merchant_id": "fake-merchant-id",
"platform": "fake-platform",
Expand Down Expand Up @@ -443,6 +445,7 @@ class AnalyticsClientUnitTest {
fun reportCrash_sendsCrashAnalyticsEvent() {
every { analyticsParamRepository.sessionId } returns sessionId
every { deviceInspector.isVenmoInstalled(context) } returns false
every { deviceInspector.isPayPalInstalled(context) } returns false
every {
deviceInspector.getDeviceMetadata(context, configuration, sessionId, integration)
} returns createSampleDeviceMetadata()
Expand Down Expand Up @@ -478,6 +481,7 @@ class AnalyticsClientUnitTest {
"api_integration_type": "custom",
"is_simulator": false,
"venmo_installed": false,
"paypal_installed": false,
"mapv": "fake-merchant-app-version",
"merchant_id": "fake-merchant-id",
"platform": "fake-platform",
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# Braintree Android SDK Release Notes

## unreleased

* PayPal
* Add PayPal App Switch vault flow (BETA)
* Add `enablePayPalAppSwitch` property to `PayPalVaultRequest` for App Switch support
* Require `PayPalVaultRequest.userAuthenticationEmail` for App Switch support
* Require `PayPalClient.appLinkReturnUrl` for App Switch support
* Send `link_type` and `paypal_installed` in `event_params` when available to PayPal's analytics service (FPTI)
* **Note:** This feature is currently in beta and may change or be removed in future releases.

* GooglePay
* Upgrade `play-services-wallet` to `19.4.0`
* Breaking Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
Expand Down Expand Up @@ -51,13 +52,20 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
launchPayPal(false, buyerEmailEditText.getText().toString());
});
billingAgreementButton.setOnClickListener(v -> {
FragmentActivity activity = getActivity();

if (Settings.isPayPalAppSwithEnabled(activity) && buyerEmailEditText.getText().toString().isEmpty()) {
Toast.makeText(activity, "Email is required for the App Switch flow", Toast.LENGTH_SHORT).show();
return;
}

launchPayPal(true, buyerEmailEditText.getText().toString());
});

payPalClient = new PayPalClient(
requireContext(),
super.getAuthStringArg(),
Uri.parse("https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/")
Uri.parse("https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")
);
payPalLauncher = new PayPalLauncher();

Expand Down Expand Up @@ -128,6 +136,13 @@ private void launchPayPal(
} else if (paymentAuthRequest instanceof PayPalPaymentAuthRequest.ReadyToLaunch){
PayPalPendingRequest request = payPalLauncher.launch(requireActivity(),
((PayPalPaymentAuthRequest.ReadyToLaunch) paymentAuthRequest));

String pairingId = ((PayPalPaymentAuthRequest.ReadyToLaunch) paymentAuthRequest).getRequestParams().getPairingId();

if (pairingId != null && !pairingId.isEmpty()) {
Toast.makeText(getActivity(), "Pairing ID: " + pairingId, Toast.LENGTH_LONG).show();
}

if (request instanceof PayPalPendingRequest.Started) {
storePendingRequest((PayPalPendingRequest.Started) request);
} else if (request instanceof PayPalPendingRequest.Failure) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public static PayPalVaultRequest createPayPalVaultRequest(
request.setUserAuthenticationEmail(buyerEmailAddress);
}

if (Settings.isPayPalAppSwithEnabled(context)) {
request.setEnablePayPalAppSwitch(true);
}

request.setDisplayName(Settings.getPayPalDisplayName(context));

String landingPageType = Settings.getPayPalLandingPageType(context);
Expand Down
4 changes: 4 additions & 0 deletions Demo/src/main/java/com/braintreepayments/demo/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ public static boolean isPayPalCreditOffered(Context context) {
return getPreferences(context).getBoolean("paypal_credit_offered", false);
}

public static boolean isPayPalAppSwithEnabled(Context context) {
return getPreferences(context).getBoolean("paypal_app_switch", false);
}

public static boolean isPayPalSignatureVerificationDisabled(Context context) {
return getPreferences(context).getBoolean("paypal_disable_signature_verification", true);
}
Expand Down
3 changes: 2 additions & 1 deletion Demo/src/main/res/layout/fragment_paypal.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/buyer_email_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:inputType="textEmailAddress"/>

</com.google.android.material.textfield.TextInputLayout>

Expand Down
6 changes: 6 additions & 0 deletions Demo/src/main/res/xml/settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@
android:summary="@string/paypal_credit_offered_summary"
android:defaultValue="false" />

<CheckBoxPreference
android:key="paypal_app_switch"
android:title="PayPal App Switch"
android:summary="Enable PayPal App Switch"
android:defaultValue="false" />

<ListPreference
android:key="paypal_landing_page_type"
android:title="@string/paypal_landing_page_type"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ internal object PayPalAnalytics {
const val TOKENIZATION_FAILED = "paypal:tokenize:failed"
const val TOKENIZATION_SUCCEEDED = "paypal:tokenize:succeeded"
const val BROWSER_LOGIN_CANCELED = "paypal:tokenize:browser-login:canceled"

// Additional Conversion events
const val HANDLE_RETURN_STARTED = "paypal:tokenize:handle-return:started"

// App Switch events
const val APP_SWITCH_STARTED = "paypal:tokenize:app-switch:started"
const val APP_SWITCH_SUCCEEDED = "paypal:tokenize:app-switch:succeeded"
const val APP_SWITCH_FAILED = "paypal:tokenize:app-switch:failed"
const val APP_SWITCH_CANCELED = "paypal:tokenize:app-switch:canceled"
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ class PayPalCheckoutRequest @JvmOverloads constructor(
configuration: Configuration?,
authorization: Authorization?,
successUrl: String?,
cancelUrl: String?
cancelUrl: String?,
appLink: String?
): String {
val parameters = JSONObject()
.put(RETURN_URL_KEY, successUrl)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.braintreepayments.api.core.BraintreeClient
import com.braintreepayments.api.core.BraintreeException
import com.braintreepayments.api.core.BraintreeRequestCodes
import com.braintreepayments.api.core.Configuration
import com.braintreepayments.api.core.LinkType
import com.braintreepayments.api.core.UserCanceledException
import com.braintreepayments.api.paypal.PayPalPaymentIntent.Companion.fromString
import com.braintreepayments.api.sharedutils.Json
Expand All @@ -29,6 +30,11 @@ class PayPalClient internal constructor(
*/
private var payPalContextId: String? = null

/**
* Used for sending the type of flow, universal vs deeplink to FPTI
*/
private var linkType: LinkType? = null

/**
* True if `tokenize()` was called with a Vault request object type
*/
Expand Down Expand Up @@ -93,8 +99,17 @@ class PayPalClient internal constructor(
error: Exception? ->
if (payPalResponse != null) {
payPalContextId = payPalResponse.pairingId
val isAppSwitchFlow = internalPayPalClient.isAppSwitchEnabled(payPalRequest) &&
internalPayPalClient.isPayPalInstalled(context)
linkType = if (isAppSwitchFlow) LinkType.APP_SWITCH else LinkType.APP_LINK

try {
payPalResponse.browserSwitchOptions = buildBrowserSwitchOptions(payPalResponse)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_STARTED, analyticsParams)
}

callback.onPayPalPaymentAuthRequest(
PayPalPaymentAuthRequest.ReadyToLaunch(payPalResponse)
)
Expand Down Expand Up @@ -164,9 +179,17 @@ class PayPalClient internal constructor(
val approvalUrl = Json.optString(metadata, "approval-url", null)
val successUrl = Json.optString(metadata, "success-url", null)
val paymentType = Json.optString(metadata, "payment-type", "unknown")

val isBillingAgreement = paymentType.equals("billing-agreement", ignoreCase = true)
val tokenKey = if (isBillingAgreement) "ba_token" else "token"
val switchInitiatedTime = Uri.parse(approvalUrl).getQueryParameter("switch_initiated_time")
val isAppSwitchFlow = !switchInitiatedTime.isNullOrEmpty()

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(
PayPalAnalytics.HANDLE_RETURN_STARTED,
analyticsParams
)
}

approvalUrl?.let {
val pairingId = Uri.parse(approvalUrl).getQueryParameter(tokenKey)
Expand Down Expand Up @@ -195,18 +218,19 @@ class PayPalClient internal constructor(
if (payPalAccountNonce != null) {
callbackTokenizeSuccess(
callback,
PayPalResult.Success(payPalAccountNonce)
PayPalResult.Success(payPalAccountNonce),
isAppSwitchFlow
)
} else if (error != null) {
callbackTokenizeFailure(callback, PayPalResult.Failure(error))
callbackTokenizeFailure(callback, PayPalResult.Failure(error), isAppSwitchFlow)
}
}
} catch (e: UserCanceledException) {
callbackBrowserSwitchCancel(callback, PayPalResult.Cancel)
callbackBrowserSwitchCancel(callback, PayPalResult.Cancel, isAppSwitchFlow)
} catch (e: JSONException) {
callbackTokenizeFailure(callback, PayPalResult.Failure(e))
callbackTokenizeFailure(callback, PayPalResult.Failure(e), isAppSwitchFlow)
} catch (e: PayPalBrowserSwitchException) {
callbackTokenizeFailure(callback, PayPalResult.Failure(e))
callbackTokenizeFailure(callback, PayPalResult.Failure(e), isAppSwitchFlow)
}
}

Expand Down Expand Up @@ -258,32 +282,51 @@ class PayPalClient internal constructor(

private fun callbackBrowserSwitchCancel(
callback: PayPalTokenizeCallback,
cancel: PayPalResult.Cancel
cancel: PayPalResult.Cancel,
isAppSwitchFlow: Boolean
) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.BROWSER_LOGIN_CANCELED, analyticsParams)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_CANCELED, analyticsParams)
}

callback.onPayPalResult(cancel)
}

private fun callbackTokenizeFailure(
callback: PayPalTokenizeCallback,
failure: PayPalResult.Failure
failure: PayPalResult.Failure,
isAppSwitchFlow: Boolean
) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_FAILED, analyticsParams)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_FAILED, analyticsParams)
}

callback.onPayPalResult(failure)
}

private fun callbackTokenizeSuccess(
callback: PayPalTokenizeCallback,
success: PayPalResult.Success
success: PayPalResult.Success,
isAppSwitchFlow: Boolean
) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.TOKENIZATION_SUCCEEDED, analyticsParams)

if (isAppSwitchFlow) {
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_SUCCEEDED, analyticsParams)
}

callback.onPayPalResult(success)
}

private val analyticsParams: AnalyticsEventParams
get() {
return AnalyticsEventParams(
payPalContextId = payPalContextId,
linkType = linkType?.stringValue,
isVaultRequest = isVaultRequest
)
}
Expand Down
Loading
Loading