Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -69,14 +74,20 @@ internal class PayPalInternalClient(
null
}

val requestBody = payPalRequest.createRequestBody(
val baseRequestBody = payPalRequest.createRequestBody(
configuration = configuration,
authorization = merchantRepository.authorization,
successUrl = successUrl,
cancelUrl = cancelUrl,
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,
Expand Down Expand Up @@ -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")
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -74,6 +76,13 @@ class PayPalInternalClientUnitTest {
@Throws(JSONException::class)
fun beforeEach() {
context = mockk(relaxed = true)
activityManager = mockk<ActivityManager>(relaxed = true)
every { context.getSystemService(Context.ACTIVITY_SERVICE) } returns activityManager
every { activityManager.getMemoryInfo(any()) } answers {
val memInfo = firstArg<ActivityManager.MemoryInfo>()
memInfo.availMem = 1024L * 1024L * 1024L
memInfo.totalMem = 4096L * 1024L * 1024L
}

every { resolvePayPalUseCase() } returns false

Expand Down Expand Up @@ -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<ActivityManager.MemoryInfo>()
memInfo.availMem = 350L * 1024L * 1024L
memInfo.totalMem = 4096L * 1024L * 1024L
}

val slot = slot<String>()
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<String>()
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<ActivityManager.MemoryInfo>()
memInfo.availMem = 512L * 1024L * 1024L
memInfo.totalMem = 8192L * 1024L * 1024L
}

val slot = slot<String>()
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<String>()
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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down