diff --git a/CHANGELOG.md b/CHANGELOG.md index e8956a9531..e0487a48d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Braintree Android SDK Release Notes +## unreleased + +* PayPal + * Collect and send device information (`model`, `memory_available_mb`, `memory_total_mb`) in PayPal Hermes requests when PayPal app switch is enabled, to improve app switch eligibility determination + ## 5.28.1 (2026-06-01) * PayPal diff --git a/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestTest.kt b/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestTest.kt index b758821d19..c19cba90a5 100644 --- a/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestTest.kt +++ b/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestTest.kt @@ -330,9 +330,10 @@ class PayPalCheckoutRequestTest { val json = JSONObject(result) assertTrue(json.getBoolean("launch_paypal_app")) - assertEquals("Android", json.getString("os_type")) - assertNotNull(json.getString("os_version")) - assertEquals("https://merchant.example.com/applink", json.getString("merchant_app_return_url")) + val nativeApp = json.getJSONObject("app_switch_context").getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertNotNull(nativeApp.getString("os_version")) + assertEquals("https://merchant.example.com/applink", nativeApp.getString("app_url")) } @Test @@ -353,8 +354,7 @@ class PayPalCheckoutRequestTest { val json = JSONObject(result) assertFalse(json.has("launch_paypal_app")) - assertFalse(json.has("os_type")) - assertFalse(json.has("merchant_app_return_url")) + assertFalse(json.has("app_switch_context")) } @Suppress("LongMethod") diff --git a/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalVaultRequestTest.kt b/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalVaultRequestTest.kt index 8936156b6a..6d9dbddcc7 100644 --- a/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalVaultRequestTest.kt +++ b/PayPal/src/androidTest/java/com/braintreepayments/api/paypal/PayPalVaultRequestTest.kt @@ -140,6 +140,28 @@ class PayPalVaultRequestTest { assertTrue(json.getBoolean("offer_paypal_credit")) } + @Test + fun createRequestBody_withAppSwitchEnabled_returnsNestedNativeAppStructure() { + val request = PayPalVaultRequest(hasUserLocationConsent = true).apply { + enablePayPalAppSwitch = true + } + + val result = request.createRequestBody( + configuration, + authorization, + "https://example.com/success", + "https://example.com/cancel", + "https://merchant.example.com/applink" + ) + + val json = JSONObject(result) + assertTrue(json.getBoolean("launch_paypal_app")) + val nativeApp = json.getJSONObject("app_switch_context").getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertNotNull(nativeApp.getString("os_version")) + assertEquals("https://merchant.example.com/applink", nativeApp.getString("app_url")) + } + @Test fun parcels_correctly() { val original = PayPalVaultRequest( diff --git a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalCheckoutRequest.kt b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalCheckoutRequest.kt index c4fb250f59..6bcbd9f7c2 100644 --- a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalCheckoutRequest.kt +++ b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalCheckoutRequest.kt @@ -172,9 +172,13 @@ class PayPalCheckoutRequest @JvmOverloads constructor( if (enablePayPalAppSwitch && !appLink.isNullOrEmpty()) { parameters.put(ENABLE_APP_SWITCH_KEY, enablePayPalAppSwitch) - parameters.put(OS_VERSION_KEY, Build.VERSION.SDK_INT.toString()) - parameters.put(OS_TYPE_KEY, "Android") - parameters.put(MERCHANT_APP_RETURN_URL_KEY, appLink) + parameters.put(APP_SWITCH_CONTEXT_KEY, JSONObject().apply { + put(NATIVE_APP_KEY, JSONObject().apply { + put(APP_URL_KEY, appLink) + put(OS_TYPE_KEY, OS_TYPE_VALUE) + put(OS_VERSION_KEY, Build.VERSION.SDK_INT.toString()) + }) + }) } parameters.putOpt(SHOPPER_SESSION_ID_KEY, shopperSessionId) diff --git a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalInternalClient.kt b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalInternalClient.kt index 8e7994a14a..bd1fafedcc 100644 --- a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalInternalClient.kt +++ b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalInternalClient.kt @@ -1,7 +1,9 @@ package com.braintreepayments.api.paypal +import android.app.ActivityManager import android.content.Context import android.net.Uri +import android.os.Build import com.braintreepayments.api.core.AnalyticsParamRepository import com.braintreepayments.api.core.ApiClient import com.braintreepayments.api.core.AppSwitchRepository @@ -16,7 +18,10 @@ import com.braintreepayments.api.core.usecase.GetAppSwitchUseCase import com.braintreepayments.api.core.usecase.GetReturnLinkUseCase import com.braintreepayments.api.datacollector.DataCollector import com.braintreepayments.api.datacollector.DataCollectorInternalRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONException +import org.json.JSONObject internal class PayPalInternalClient( private val braintreeClient: BraintreeClient, @@ -69,7 +74,7 @@ internal class PayPalInternalClient( null } - val requestBody = payPalRequest.createRequestBody( + val baseRequestBody = payPalRequest.createRequestBody( configuration = configuration, authorization = merchantRepository.authorization, successUrl = successUrl, @@ -77,6 +82,12 @@ internal class PayPalInternalClient( appLink = appLinkParam ) ?: throw JSONException("Error creating requestBody") + val requestBody = if (payPalRequest.enablePayPalAppSwitch && appLinkParam != null) { + withContext(Dispatchers.IO) { injectDeviceInfo(context, baseRequestBody) } + } else { + baseRequestBody + } + return sendPost( url = url, requestBody = requestBody, @@ -187,6 +198,19 @@ internal class PayPalInternalClient( .build() } + private fun injectDeviceInfo(context: Context, requestBody: String): String { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memInfo = ActivityManager.MemoryInfo().also { activityManager.getMemoryInfo(it) } + return JSONObject(requestBody).apply { + val appSwitchContext = optJSONObject(PayPalRequest.APP_SWITCH_CONTEXT_KEY) ?: return@apply + appSwitchContext.put(PayPalRequest.DEVICE_INFO_KEY, JSONObject().apply { + put(PayPalRequest.DEVICE_MODEL_KEY, Build.MODEL) + put(PayPalRequest.MEMORY_AVAILABLE_MB_KEY, (memInfo.availMem / BYTES_PER_MB).toInt()) + put(PayPalRequest.MEMORY_TOTAL_MB_KEY, (memInfo.totalMem / BYTES_PER_MB).toInt()) + }) + }.toString() + } + private fun extractContextId(redirectUri: Uri): String? { return redirectUri.getQueryParameter("ba_token") ?: redirectUri.getQueryParameter("token") @@ -197,5 +221,6 @@ internal class PayPalInternalClient( companion object { private const val CREATE_SINGLE_PAYMENT_ENDPOINT = "paypal_hermes/create_payment_resource" private const val SETUP_BILLING_AGREEMENT_ENDPOINT = "paypal_hermes/setup_billing_agreement" + private const val BYTES_PER_MB = 1024L * 1024L } } diff --git a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalRequest.kt b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalRequest.kt index bf95660ea7..d717d90bbc 100644 --- a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalRequest.kt +++ b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalRequest.kt @@ -143,9 +143,12 @@ abstract class PayPalRequest internal constructor( internal const val LINE_ITEMS_KEY: String = "line_items" internal const val USER_ACTION_KEY: String = "user_action" internal const val ENABLE_APP_SWITCH_KEY: String = "launch_paypal_app" + internal const val APP_SWITCH_CONTEXT_KEY: String = "app_switch_context" + internal const val NATIVE_APP_KEY: String = "native_app" + internal const val APP_URL_KEY: String = "app_url" internal const val OS_VERSION_KEY: String = "os_version" internal const val OS_TYPE_KEY: String = "os_type" - internal const val MERCHANT_APP_RETURN_URL_KEY: String = "merchant_app_return_url" + internal const val OS_TYPE_VALUE: String = "ANDROID" internal const val PLAN_TYPE_KEY: String = "plan_type" internal const val PLAN_METADATA_KEY: String = "plan_metadata" internal const val PAYER_PHONE_KEY: String = "payer_phone" @@ -155,5 +158,9 @@ abstract class PayPalRequest internal constructor( internal const val CONTACT_PREFERENCE_KEY: String = "contact_preference" internal const val SHOPPER_SESSION_ID_KEY: String = "shopper_session_id" internal const val AMOUNT_BREAKDOWN_KEY: String = "amount_breakdown" + internal const val DEVICE_INFO_KEY: String = "device_info" + internal const val DEVICE_MODEL_KEY: String = "model" + internal const val MEMORY_AVAILABLE_MB_KEY: String = "memory_available_mb" + internal const val MEMORY_TOTAL_MB_KEY: String = "memory_total_mb" } } diff --git a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalVaultRequest.kt b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalVaultRequest.kt index d674f39dd1..28567c429b 100644 --- a/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalVaultRequest.kt +++ b/PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalVaultRequest.kt @@ -100,9 +100,13 @@ class PayPalVaultRequest if (enablePayPalAppSwitch && !appLink.isNullOrEmpty()) { parameters.put(ENABLE_APP_SWITCH_KEY, enablePayPalAppSwitch) - parameters.put(OS_VERSION_KEY, Build.VERSION.SDK_INT.toString()) - parameters.put(OS_TYPE_KEY, "Android") - parameters.put(MERCHANT_APP_RETURN_URL_KEY, appLink) + parameters.put(APP_SWITCH_CONTEXT_KEY, JSONObject().apply { + put(NATIVE_APP_KEY, JSONObject().apply { + put(APP_URL_KEY, appLink) + put(OS_TYPE_KEY, OS_TYPE_VALUE) + put(OS_VERSION_KEY, Build.VERSION.SDK_INT.toString()) + }) + }) } val experienceProfile = JSONObject() diff --git a/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestUnitTest.kt b/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestUnitTest.kt index d866521cc0..ca1080b81d 100644 --- a/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestUnitTest.kt +++ b/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalCheckoutRequestUnitTest.kt @@ -225,9 +225,10 @@ class PayPalCheckoutRequestUnitTest { val jsonObject = JSONObject(requestBody) assertTrue(jsonObject.getBoolean("launch_paypal_app")) - assertEquals("Android", jsonObject.getString("os_type")) - assertEquals(appLink, jsonObject.getString("merchant_app_return_url")) - assertNotNull(jsonObject.getString("os_version")) + val nativeApp = jsonObject.getJSONObject("app_switch_context").getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertEquals(appLink, nativeApp.getString("app_url")) + assertNotNull(nativeApp.getString("os_version")) } @OptIn(ExperimentalBetaApi::class) diff --git a/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalInternalClientUnitTest.kt b/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalInternalClientUnitTest.kt index 86853e4f53..aa7ea60607 100644 --- a/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalInternalClientUnitTest.kt +++ b/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalInternalClientUnitTest.kt @@ -1,5 +1,6 @@ package com.braintreepayments.api.paypal +import android.app.ActivityManager import android.content.Context import android.net.Uri import com.braintreepayments.api.core.AnalyticsParamRepository @@ -55,6 +56,7 @@ class PayPalInternalClientUnitTest { private lateinit var context: Context private lateinit var configuration: Configuration + private lateinit var activityManager: ActivityManager private lateinit var clientToken: ClientToken private lateinit var tokenizationKey: TokenizationKey @@ -74,6 +76,13 @@ class PayPalInternalClientUnitTest { @Throws(JSONException::class) fun beforeEach() { context = mockk(relaxed = true) + activityManager = mockk(relaxed = true) + every { context.getSystemService(Context.ACTIVITY_SERVICE) } returns activityManager + every { activityManager.getMemoryInfo(any()) } answers { + val memInfo = firstArg() + memInfo.availMem = 1024L * 1024L * 1024L + memInfo.totalMem = 4096L * 1024L * 1024L + } every { resolvePayPalUseCase() } returns false @@ -1076,4 +1085,118 @@ class PayPalInternalClientUnitTest { ) } } + + @Test + fun `sendRequest with app switch enabled injects device_info in app_switch_context`() = + runTest(testDispatcher) { + every { clientToken.bearer } returns "client-token-bearer" + every { merchantRepository.authorization } returns clientToken + every { merchantRepository.appLinkReturnUri } returns Uri.parse("https://example.com") + every { deviceInspector.isPayPalInstalled() } returns true + every { resolvePayPalUseCase() } returns true + every { activityManager.getMemoryInfo(any()) } answers { + val memInfo = firstArg() + memInfo.availMem = 350L * 1024L * 1024L + memInfo.totalMem = 4096L * 1024L * 1024L + } + + val slot = slot() + val sut = createSutWithMocks(captureRequestBody = slot) + + val payPalRequest = PayPalCheckoutRequest("1.00", true).apply { + enablePayPalAppSwitch = true + } + + sut.sendRequest(context, payPalRequest, configuration) + + val actual = JSONObject(slot.captured) + val appSwitchContext = actual.getJSONObject("app_switch_context") + val deviceInfo = appSwitchContext.getJSONObject("device_info") + assertTrue(deviceInfo.has("model")) + assertEquals(350, deviceInfo.getInt("memory_available_mb")) + assertEquals(4096, deviceInfo.getInt("memory_total_mb")) + + val nativeApp = appSwitchContext.getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertNotNull(nativeApp.getString("os_version")) + assertNotNull(nativeApp.getString("app_url")) + } + + @Test + fun `sendRequest with app switch disabled does not inject app_switch_context`() = + runTest(testDispatcher) { + every { clientToken.bearer } returns "client-token-bearer" + every { merchantRepository.authorization } returns clientToken + every { merchantRepository.appLinkReturnUri } returns Uri.parse("https://example.com") + + val slot = slot() + val sut = createSutWithMocks(captureRequestBody = slot) + + val payPalRequest = PayPalCheckoutRequest("1.00", true).apply { + enablePayPalAppSwitch = false + } + + sut.sendRequest(context, payPalRequest, configuration) + + val actual = JSONObject(slot.captured) + assertFalse(actual.has("app_switch_context")) + } + + @Test + fun `sendRequest vault with app switch enabled injects device_info in app_switch_context`() = + runTest(testDispatcher) { + every { clientToken.bearer } returns "client-token-bearer" + every { merchantRepository.authorization } returns clientToken + every { merchantRepository.appLinkReturnUri } returns Uri.parse("https://example.com") + every { deviceInspector.isPayPalInstalled() } returns true + every { resolvePayPalUseCase() } returns true + every { activityManager.getMemoryInfo(any()) } answers { + val memInfo = firstArg() + memInfo.availMem = 512L * 1024L * 1024L + memInfo.totalMem = 8192L * 1024L * 1024L + } + + val slot = slot() + val sut = createSutWithMocks(captureRequestBody = slot) + + val payPalRequest = PayPalVaultRequest(true).apply { + enablePayPalAppSwitch = true + } + + sut.sendRequest(context, payPalRequest, configuration) + + val actual = JSONObject(slot.captured) + val appSwitchContext = actual.getJSONObject("app_switch_context") + val deviceInfo = appSwitchContext.getJSONObject("device_info") + assertTrue(deviceInfo.has("model")) + assertEquals(512, deviceInfo.getInt("memory_available_mb")) + assertEquals(8192, deviceInfo.getInt("memory_total_mb")) + + val nativeApp = appSwitchContext.getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertNotNull(nativeApp.getString("os_version")) + assertNotNull(nativeApp.getString("app_url")) + } + + @Test + fun `sendRequest with app switch enabled but no app link does not inject app_switch_context`() = + runTest(testDispatcher) { + every { clientToken.bearer } returns "client-token-bearer" + every { merchantRepository.authorization } returns clientToken + every { deviceInspector.isPayPalInstalled() } returns true + every { resolvePayPalUseCase() } returns true + every { getReturnLinkUseCase.invoke(any()) } returns DeepLink("com.example.app") + + val slot = slot() + val sut = createSutWithMocks(captureRequestBody = slot) + + val payPalRequest = PayPalCheckoutRequest("1.00", true).apply { + enablePayPalAppSwitch = true + } + + sut.sendRequest(context, payPalRequest, configuration) + + val actual = JSONObject(slot.captured) + assertFalse(actual.has("app_switch_context")) + } } diff --git a/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalVaultRequestUnitTest.kt b/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalVaultRequestUnitTest.kt index d436b487d4..1139b6baa2 100644 --- a/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalVaultRequestUnitTest.kt +++ b/PayPal/src/test/java/com/braintreepayments/api/paypal/PayPalVaultRequestUnitTest.kt @@ -263,10 +263,12 @@ class PayPalVaultRequestUnitTest { appLink = "universal_url" ) - assertTrue(requestBody.contains("\"launch_paypal_app\":true")) - assertTrue(requestBody.contains("\"os_type\":" + "\"Android\"")) - assertTrue(requestBody.contains("\"os_version\":\"$versionSDK\"")) - assertTrue(requestBody.contains("\"merchant_app_return_url\":" + "\"universal_url\"")) + val jsonObject = JSONObject(requestBody) + assertTrue(jsonObject.getBoolean("launch_paypal_app")) + val nativeApp = jsonObject.getJSONObject("app_switch_context").getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertEquals(versionSDK.toString(), nativeApp.getString("os_version")) + assertEquals("universal_url", nativeApp.getString("app_url")) } @OptIn(ExperimentalBetaApi::class) @@ -375,9 +377,10 @@ class PayPalVaultRequestUnitTest { val jsonObject = JSONObject(requestBody) assertTrue(jsonObject.getBoolean("launch_paypal_app")) - assertEquals("Android", jsonObject.getString("os_type")) - assertEquals(appLink, jsonObject.getString("merchant_app_return_url")) - assertNotNull(jsonObject.getString("os_version")) + val nativeApp = jsonObject.getJSONObject("app_switch_context").getJSONObject("native_app") + assertEquals("ANDROID", nativeApp.getString("os_type")) + assertEquals(appLink, nativeApp.getString("app_url")) + assertNotNull(nativeApp.getString("os_version")) } @Test