diff --git a/CorePayments/build.gradle b/CorePayments/build.gradle index 42adae532..c6b57d72e 100644 --- a/CorePayments/build.gradle +++ b/CorePayments/build.gradle @@ -68,6 +68,7 @@ dependencies { implementation libs.androidx.appcompat implementation libs.kotlin.stdLib implementation libs.kotlinx.coroutinesAndroid + implementation libs.androidx.browser testImplementation libs.json testImplementation libs.jsonAssert diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/ChromeCustomTabsResult.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/ChromeCustomTabsResult.kt new file mode 100644 index 000000000..420a13d94 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/ChromeCustomTabsResult.kt @@ -0,0 +1,5 @@ +package com.paypal.android.corepayments + +import android.content.Intent + +data class ChromeCustomTabsResult(val resultCode: Int, val intent: Intent?) diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/LaunchChromeCustomTab.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/LaunchChromeCustomTab.kt new file mode 100644 index 000000000..dbfe5df13 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/LaunchChromeCustomTab.kt @@ -0,0 +1,20 @@ +package com.paypal.android.corepayments + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import androidx.browser.customtabs.CustomTabsIntent + +// Ref: https://developer.android.com/training/basics/intents/result#custom +class LaunchChromeCustomTab : ActivityResultContract() { + override fun createIntent(context: Context, input: Uri): Intent { + val customTabsIntent = CustomTabsIntent.Builder().build() + val intent = customTabsIntent.intent + intent.data = input + return intent + } + + override fun parseResult(resultCode: Int, intent: Intent?) + = ChromeCustomTabsResult(resultCode = resultCode, intent = intent) +} \ No newline at end of file diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/MapUtils.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/MapUtils.kt new file mode 100644 index 000000000..f8eff72f6 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/MapUtils.kt @@ -0,0 +1,27 @@ +package com.paypal.android.corepayments + +import android.util.Base64 +import androidx.annotation.RestrictTo +import org.json.JSONObject +import java.nio.charset.StandardCharsets + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +fun MutableMap.restoreFromBase64EncodedJSON(base64EncodedJSON: String) { + clear() + + val data = Base64.decode(base64EncodedJSON, Base64.DEFAULT) + val requestJSONString = String(data, StandardCharsets.UTF_8) + val requestJSON = JSONObject(requestJSONString) + val properties = mutableMapOf().apply { + requestJSON.keys().forEach { put(it, requestJSON[it]) } + } + properties.putAll(properties) +} + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +fun MutableMap.toBase64EncodedJSON(): String { + val propertiesAsJSON = JSONObject(this) + val requestJSONBytes: ByteArray? = + propertiesAsJSON.toString().toByteArray(StandardCharsets.UTF_8) + return Base64.encodeToString(requestJSONBytes, Base64.DEFAULT) +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/SessionStore.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/SessionStore.kt new file mode 100644 index 000000000..09226464a --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/SessionStore.kt @@ -0,0 +1,14 @@ +package com.paypal.android.corepayments + +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +open class SessionStore { + val properties: MutableMap = mutableMapOf() + + fun clear() = properties.clear() + fun restore(base64EncodedJSON: String) = + properties.restoreFromBase64EncodedJSON(base64EncodedJSON) + + fun toBase64EncodedJSON() = properties.toBase64EncodedJSON() +} \ No newline at end of file diff --git a/Demo/build.gradle b/Demo/build.gradle index 69b71f92f..b24cd1ab0 100644 --- a/Demo/build.gradle +++ b/Demo/build.gradle @@ -113,6 +113,7 @@ dependencies { implementation libs.androidx.constraintLayout implementation libs.lifecycle.runtimeKtx implementation libs.androidx.fragmentKtx + implementation libs.androidx.activity.compose // Compose Bill of Materials (BOM) dependency manages compose dependency versions without // us having to explicitly state versions of individual compose dependencies; the BOM project diff --git a/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebView.kt b/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebView.kt index e805facfe..0f83b4fdf 100644 --- a/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebView.kt +++ b/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebView.kt @@ -1,5 +1,6 @@ package com.paypal.android.ui.paypalweb +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,11 +14,13 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.paypal.android.corepayments.LaunchChromeCustomTab import com.paypal.android.uishared.components.ActionButtonColumn import com.paypal.android.uishared.components.CreateOrderForm import com.paypal.android.uishared.components.ErrorView @@ -28,6 +31,7 @@ import com.paypal.android.utils.OnLifecycleOwnerResumeEffect import com.paypal.android.utils.OnNewIntentEffect import com.paypal.android.utils.UIConstants import com.paypal.android.utils.getActivityOrNull +import kotlinx.coroutines.launch @Composable fun PayPalWebView( @@ -50,6 +54,14 @@ fun PayPalWebView( viewModel.completeAuthChallenge(newIntent) } + // Ref: https://stackoverflow.com/a/67156998 + val coroutineScope = rememberCoroutineScope() + val chromeCustomTabsLauncher = + rememberLauncherForActivityResult(LaunchChromeCustomTab()) { result -> + // forward result from activity launcher + viewModel.completeAuthChallenge(result) + } + val contentPadding = UIConstants.paddingMedium Column( verticalArrangement = UIConstants.spacingLarge, @@ -60,7 +72,17 @@ fun PayPalWebView( ) { Step1_CreateOrder(uiState, viewModel) if (uiState.isCreateOrderSuccessful) { - Step2_StartPayPalWebCheckout(uiState, viewModel) + Step2_StartPayPalWebCheckout( + uiState = uiState, + viewModel = viewModel, + onPayPalCheckoutInitiated = { + coroutineScope.launch { + viewModel.startWebCheckout()?.let { uri -> + chromeCustomTabsLauncher.launch(uri) + } + } + } + ) } if (uiState.isPayPalWebCheckoutSuccessful) { Step3_CompleteOrder(uiState, viewModel) @@ -96,8 +118,11 @@ private fun Step1_CreateOrder(uiState: PayPalWebUiState, viewModel: PayPalWebVie } @Composable -private fun Step2_StartPayPalWebCheckout(uiState: PayPalWebUiState, viewModel: PayPalWebViewModel) { - val context = LocalContext.current +private fun Step2_StartPayPalWebCheckout( + uiState: PayPalWebUiState, + viewModel: PayPalWebViewModel, + onPayPalCheckoutInitiated: () -> Unit +) { Column( verticalArrangement = UIConstants.spacingMedium, ) { @@ -110,7 +135,7 @@ private fun Step2_StartPayPalWebCheckout(uiState: PayPalWebUiState, viewModel: P defaultTitle = "START CHECKOUT", successTitle = "CHECKOUT COMPLETE", state = uiState.payPalWebCheckoutState, - onClick = { context.getActivityOrNull()?.let { viewModel.startWebCheckout(it) } }, + onClick = onPayPalCheckoutInitiated, modifier = Modifier .fillMaxWidth() ) { state -> diff --git a/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebViewModel.kt b/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebViewModel.kt index 1d6c1d36f..9d8cbb471 100644 --- a/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebViewModel.kt +++ b/Demo/src/main/java/com/paypal/android/ui/paypalweb/PayPalWebViewModel.kt @@ -2,13 +2,16 @@ package com.paypal.android.ui.paypalweb import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.Bundle import android.util.Log -import androidx.activity.ComponentActivity +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.paypal.android.api.model.Order import com.paypal.android.api.model.OrderIntent import com.paypal.android.api.services.SDKSampleServerResult +import com.paypal.android.corepayments.ChromeCustomTabsResult import com.paypal.android.corepayments.CoreConfig import com.paypal.android.fraudprotection.PayPalDataCollector import com.paypal.android.fraudprotection.PayPalDataCollectorRequest @@ -23,6 +26,7 @@ import com.paypal.android.usecase.CompleteOrderUseCase import com.paypal.android.usecase.CreateOrderUseCase import com.paypal.android.usecase.GetClientIdUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -31,9 +35,11 @@ import javax.inject.Inject @HiltViewModel class PayPalWebViewModel @Inject constructor( + @ApplicationContext val applicationContext: Context, val getClientIdUseCase: GetClientIdUseCase, val createOrderUseCase: CreateOrderUseCase, - val completeOrderUseCase: CompleteOrderUseCase + val completeOrderUseCase: CompleteOrderUseCase, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { companion object { @@ -46,7 +52,9 @@ class PayPalWebViewModel @Inject constructor( private val _uiState = MutableStateFlow(PayPalWebUiState()) val uiState = _uiState.asStateFlow() - private var authState: String? = null + init { + registerPayPalWebCheckoutClientSaveInstanceStateHandler() + } var intentOption: OrderIntent get() = _uiState.value.intentOption @@ -81,6 +89,28 @@ class PayPalWebViewModel @Inject constructor( _uiState.update { it.copy(fundingSource = value) } } + private fun registerPayPalWebCheckoutClientSaveInstanceStateHandler() { + savedStateHandle.setSavedStateProvider("pay_pal_web_view_model") { + val bundle = Bundle() + paypalClient?.instanceState?.let { instanceState -> + bundle.putString("instance_state", instanceState) + } + bundle + } + } + + private fun restorePayPalWebCheckoutClientFromSavedInstanceState() { + // restore instance state + val savedStateBundle = savedStateHandle.get("pay_pal_web_view_model") + savedStateBundle?.let { bundle -> + bundle.getString("instance_state")?.let { instanceState -> + paypalClient?.restore(instanceState) + } + } + // make sure saved instance state is only restored once + savedStateHandle.remove("pay_pal_web_view_model") + } + fun createOrder() { viewModelScope.launch { createOrderState = ActionState.Loading @@ -91,18 +121,17 @@ class PayPalWebViewModel @Inject constructor( } } - fun startWebCheckout(activity: ComponentActivity) { + suspend fun startWebCheckout(): Uri? { val orderId = createdOrder?.id if (orderId == null) { payPalWebCheckoutState = ActionState.Failure(Exception("Create an order to continue.")) } else { - viewModelScope.launch { - startWebCheckoutWithOrderId(activity, orderId) - } + return startWebCheckoutWithOrderId(orderId) } + return null } - private suspend fun startWebCheckoutWithOrderId(activity: ComponentActivity, orderId: String) { + private suspend fun startWebCheckoutWithOrderId(orderId: String): Uri? { payPalWebCheckoutState = ActionState.Loading when (val clientIdResult = getClientIdUseCase()) { @@ -115,12 +144,14 @@ class PayPalWebViewModel @Inject constructor( payPalDataCollector = PayPalDataCollector(coreConfig) paypalClient = - PayPalWebCheckoutClient(activity, coreConfig, "com.paypal.android.demo") + PayPalWebCheckoutClient(applicationContext, coreConfig, "com.paypal.android.demo") + restorePayPalWebCheckoutClientFromSavedInstanceState() val checkoutRequest = PayPalWebCheckoutRequest(orderId, fundingSource) - when (val startResult = paypalClient?.start(activity, checkoutRequest)) { - is PayPalPresentAuthChallengeResult.Success -> - authState = startResult.authState + when (val startResult = paypalClient?.start(checkoutRequest)) { + is PayPalPresentAuthChallengeResult.Success -> { + return startResult.uri + } is PayPalPresentAuthChallengeResult.Failure -> payPalWebCheckoutState = ActionState.Failure(startResult.error) @@ -131,6 +162,7 @@ class PayPalWebViewModel @Inject constructor( } } } + return null } fun completeOrder(context: Context) { @@ -150,26 +182,23 @@ class PayPalWebViewModel @Inject constructor( } private fun checkIfPayPalAuthFinished(intent: Intent): PayPalWebCheckoutFinishStartResult? = - authState?.let { paypalClient?.finishStart(intent, it) } + paypalClient?.finishStart(intent) fun completeAuthChallenge(intent: Intent) { checkIfPayPalAuthFinished(intent)?.let { payPalAuthResult -> when (payPalAuthResult) { is PayPalWebCheckoutFinishStartResult.Success -> { payPalWebCheckoutState = ActionState.Success(payPalAuthResult) - discardAuthState() } is PayPalWebCheckoutFinishStartResult.Canceled -> { val error = Exception("USER CANCELED") payPalWebCheckoutState = ActionState.Failure(error) - discardAuthState() } is PayPalWebCheckoutFinishStartResult.Failure -> { Log.i(TAG, "Checkout Error: ${payPalAuthResult.error.errorDescription}") payPalWebCheckoutState = ActionState.Failure(payPalAuthResult.error) - discardAuthState() } PayPalWebCheckoutFinishStartResult.NoResult -> { @@ -180,7 +209,11 @@ class PayPalWebViewModel @Inject constructor( } } - private fun discardAuthState() { - authState = null + fun completeAuthChallenge(chromeCustomTabsResult: ChromeCustomTabsResult) { +// if (chromeCustomTabsResult.resultCode == Activity.RESULT_CANCELED) { +// val error = Exception("USER CANCELED") +// payPalWebCheckoutState = ActionState.Failure(error) +// discardAuthState() +// } } } diff --git a/Demo/src/main/java/com/paypal/android/ui/paypalwebvault/PayPalWebVaultViewModel.kt b/Demo/src/main/java/com/paypal/android/ui/paypalwebvault/PayPalWebVaultViewModel.kt index adadc75c2..04b94208e 100644 --- a/Demo/src/main/java/com/paypal/android/ui/paypalwebvault/PayPalWebVaultViewModel.kt +++ b/Demo/src/main/java/com/paypal/android/ui/paypalwebvault/PayPalWebVaultViewModel.kt @@ -34,7 +34,6 @@ class PayPalWebVaultViewModel @Inject constructor( const val URL_SCHEME = "com.paypal.android.demo" } - private var authState: String? = null private val _uiState = MutableStateFlow(PayPalWebVaultUiState()) val uiState = _uiState.asStateFlow() @@ -96,12 +95,10 @@ class PayPalWebVaultViewModel @Inject constructor( paypalClient = PayPalWebCheckoutClient(activity, coreConfig, URL_SCHEME) when (val result = paypalClient?.vault(activity, request)) { - is PayPalPresentAuthChallengeResult.Success -> - authState = result.authState - is PayPalPresentAuthChallengeResult.Failure -> vaultPayPalState = ActionState.Failure(result.error) + is PayPalPresentAuthChallengeResult.Success, null -> { // do nothing for now } @@ -125,24 +122,21 @@ class PayPalWebVaultViewModel @Inject constructor( } private fun checkIfPayPalAuthFinished(intent: Intent): PayPalWebCheckoutFinishVaultResult? = - authState?.let { paypalClient?.finishVault(intent, it) } + paypalClient?.finishVault(intent) fun completeAuthChallenge(intent: Intent) { checkIfPayPalAuthFinished(intent)?.let { result -> when (result) { is PayPalWebCheckoutFinishVaultResult.Success -> { vaultPayPalState = ActionState.Success(result) - discardAuthState() } is PayPalWebCheckoutFinishVaultResult.Failure -> { vaultPayPalState = ActionState.Failure(result.error) - discardAuthState() } PayPalWebCheckoutFinishVaultResult.Canceled -> { vaultPayPalState = ActionState.Failure(Exception("USER CANCELED")) - discardAuthState() } PayPalWebCheckoutFinishVaultResult.NoResult -> { @@ -152,8 +146,4 @@ class PayPalWebVaultViewModel @Inject constructor( } } } - - private fun discardAuthState() { - authState = null - } } diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalPresentAuthChallengeResult.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalPresentAuthChallengeResult.kt index bec8ca10d..d63612ac3 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalPresentAuthChallengeResult.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalPresentAuthChallengeResult.kt @@ -1,8 +1,9 @@ package com.paypal.android.paypalwebpayments +import android.net.Uri import com.paypal.android.corepayments.PayPalSDKError sealed class PayPalPresentAuthChallengeResult { - data class Success(val authState: String) : PayPalPresentAuthChallengeResult() + data class Success(val uri: Uri, internal val authState: String) : PayPalPresentAuthChallengeResult() data class Failure(val error: PayPalSDKError) : PayPalPresentAuthChallengeResult() } diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalSessionStore.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalSessionStore.kt new file mode 100644 index 000000000..5555dd64c --- /dev/null +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalSessionStore.kt @@ -0,0 +1,11 @@ +package com.paypal.android.paypalwebpayments + +import com.paypal.android.corepayments.SessionStore + +class PayPalSessionStore(): SessionStore() { + internal var authState: String? by properties + + // for analytics tracking + internal var checkoutOrderId: String? by properties + internal var vaultSetupTokenId: String? by properties +} diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt index adaa99798..a6facee3a 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt @@ -17,12 +17,16 @@ import com.paypal.android.paypalwebpayments.analytics.VaultEvent */ class PayPalWebCheckoutClient internal constructor( private val analytics: PayPalWebAnalytics, - private val payPalWebLauncher: PayPalWebLauncher + private val payPalWebLauncher: PayPalWebLauncher, + private val sessionStore: PayPalSessionStore ) { - // for analytics tracking - private var checkoutOrderId: String? = null - private var vaultSetupTokenId: String? = null + /** + * Capture instance state for later restoration. This can be useful for recovery during a + * process kill. + */ + val instanceState: String + get() = sessionStore.toBase64EncodedJSON() /** * Create a new instance of [PayPalWebCheckoutClient]. @@ -34,29 +38,39 @@ class PayPalWebCheckoutClient internal constructor( constructor(context: Context, configuration: CoreConfig, urlScheme: String) : this( PayPalWebAnalytics(AnalyticsService(context.applicationContext, configuration)), PayPalWebLauncher(urlScheme, configuration), + PayPalSessionStore() ) + /** + * Restore a feature client using instance state. @see [instanceState] + */ + fun restore(instanceState: String) { + sessionStore.restore(instanceState) + } + /** * Confirm PayPal payment source for an order. * * @param request [PayPalWebCheckoutRequest] for requesting an order approval */ - fun start( - activity: ComponentActivity, - request: PayPalWebCheckoutRequest - ): PayPalPresentAuthChallengeResult { - checkoutOrderId = request.orderId - analytics.notify(CheckoutEvent.STARTED, checkoutOrderId) + fun start(request: PayPalWebCheckoutRequest): PayPalPresentAuthChallengeResult { + sessionStore.clear() + sessionStore.checkoutOrderId = request.orderId + + analytics.notify(CheckoutEvent.STARTED, sessionStore.checkoutOrderId) - val result = payPalWebLauncher.launchPayPalWebCheckout(activity, request) + val result = payPalWebLauncher.launchPayPalWebCheckout(request) when (result) { - is PayPalPresentAuthChallengeResult.Success -> analytics.notify( - CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, - checkoutOrderId - ) + is PayPalPresentAuthChallengeResult.Success -> { + analytics.notify( + CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, + sessionStore.checkoutOrderId + ) + sessionStore.authState = result.authState + } is PayPalPresentAuthChallengeResult.Failure -> - analytics.notify(CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, checkoutOrderId) + analytics.notify(CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, sessionStore.checkoutOrderId) } return result } @@ -70,18 +84,23 @@ class PayPalWebCheckoutClient internal constructor( activity: ComponentActivity, request: PayPalWebVaultRequest ): PayPalPresentAuthChallengeResult { - vaultSetupTokenId = request.setupTokenId - analytics.notify(VaultEvent.STARTED, vaultSetupTokenId) + sessionStore.clear() + + sessionStore.vaultSetupTokenId = request.setupTokenId + analytics.notify(VaultEvent.STARTED, sessionStore.vaultSetupTokenId) val result = payPalWebLauncher.launchPayPalWebVault(activity, request) when (result) { - is PayPalPresentAuthChallengeResult.Success -> analytics.notify( - VaultEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, - vaultSetupTokenId - ) + is PayPalPresentAuthChallengeResult.Success -> { + analytics.notify( + VaultEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, + sessionStore.vaultSetupTokenId + ) + sessionStore.authState = result.authState + } is PayPalPresentAuthChallengeResult.Failure -> - analytics.notify(VaultEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, vaultSetupTokenId) + analytics.notify(VaultEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, sessionStore.vaultSetupTokenId) } return result } @@ -93,28 +112,32 @@ class PayPalWebCheckoutClient internal constructor( * * @param [intent] An Android intent that holds the deep link put the merchant app * back into the foreground after an auth challenge. - * @param [authState] A continuation state received from [PayPalPresentAuthChallengeResult.Success] - * when calling [PayPalWebCheckoutClient.start]. This is needed to properly verify that an - * authorization completed successfully. */ - fun finishStart(intent: Intent, authState: String): PayPalWebCheckoutFinishStartResult { - val result = payPalWebLauncher.completeCheckoutAuthRequest(intent, authState) - when (result) { - is PayPalWebCheckoutFinishStartResult.Success -> - analytics.notify(CheckoutEvent.SUCCEEDED, checkoutOrderId) - - is PayPalWebCheckoutFinishStartResult.Canceled -> - analytics.notify(CheckoutEvent.CANCELED, checkoutOrderId) - - is PayPalWebCheckoutFinishStartResult.Failure -> - analytics.notify(CheckoutEvent.FAILED, checkoutOrderId) - - PayPalWebCheckoutFinishStartResult.NoResult -> { - // no analytics tracking required at the moment + fun finishStart(intent: Intent): PayPalWebCheckoutFinishStartResult? = + sessionStore.authState?.let { authState -> + val result = payPalWebLauncher.completeCheckoutAuthRequest(intent, authState) + when (result) { + is PayPalWebCheckoutFinishStartResult.Success -> { + analytics.notify(CheckoutEvent.SUCCEEDED, sessionStore.checkoutOrderId) + sessionStore.clear() + } + + is PayPalWebCheckoutFinishStartResult.Canceled -> { + analytics.notify(CheckoutEvent.CANCELED, sessionStore.checkoutOrderId) + sessionStore.clear() + } + + is PayPalWebCheckoutFinishStartResult.Failure -> { + analytics.notify(CheckoutEvent.FAILED, sessionStore.checkoutOrderId) + sessionStore.clear() + } + + PayPalWebCheckoutFinishStartResult.NoResult -> { + // no analytics tracking required at the moment + } } + result } - return result - } /** * After a merchant app has re-entered the foreground following an auth challenge @@ -123,27 +146,31 @@ class PayPalWebCheckoutClient internal constructor( * * @param [intent] An Android intent that holds the deep link put the merchant app * back into the foreground after an auth challenge. - * @param [authState] A continuation state received from [PayPalPresentAuthChallengeResult.Success] - * when calling [PayPalWebCheckoutClient.vault]. This is needed to properly verify that an - * authorization completed successfully. */ - fun finishVault(intent: Intent, authState: String): PayPalWebCheckoutFinishVaultResult { - val result = payPalWebLauncher.completeVaultAuthRequest(intent, authState) - // TODO: see if we can get setup token id from somewhere for tracking - when (result) { - is PayPalWebCheckoutFinishVaultResult.Success -> - analytics.notify(VaultEvent.SUCCEEDED, vaultSetupTokenId) - - is PayPalWebCheckoutFinishVaultResult.Failure -> - analytics.notify(VaultEvent.FAILED, vaultSetupTokenId) - - PayPalWebCheckoutFinishVaultResult.Canceled -> - analytics.notify(VaultEvent.CANCELED, vaultSetupTokenId) - - PayPalWebCheckoutFinishVaultResult.NoResult -> { - // no analytics tracking required at the moment + fun finishVault(intent: Intent): PayPalWebCheckoutFinishVaultResult? = + sessionStore.authState?.let { authState -> + val result = payPalWebLauncher.completeVaultAuthRequest(intent, authState) + // TODO: see if we can get setup token id from somewhere for tracking + when (result) { + is PayPalWebCheckoutFinishVaultResult.Success -> { + analytics.notify(VaultEvent.SUCCEEDED, sessionStore.vaultSetupTokenId) + sessionStore.clear() + } + + is PayPalWebCheckoutFinishVaultResult.Failure -> { + analytics.notify(VaultEvent.FAILED, sessionStore.vaultSetupTokenId) + sessionStore.clear() + } + + PayPalWebCheckoutFinishVaultResult.Canceled -> { + analytics.notify(VaultEvent.CANCELED, sessionStore.vaultSetupTokenId) + sessionStore.clear() + } + + PayPalWebCheckoutFinishVaultResult.NoResult -> { + // no analytics tracking required at the moment + } } + result } - return result - } } diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt index ebe12e91e..bb4bd62f9 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebLauncher.kt @@ -2,17 +2,24 @@ package com.paypal.android.paypalwebpayments import android.content.Intent import android.net.Uri +import android.util.Base64 import androidx.activity.ComponentActivity import com.braintreepayments.api.BrowserSwitchClient import com.braintreepayments.api.BrowserSwitchFinalResult import com.braintreepayments.api.BrowserSwitchOptions -import com.braintreepayments.api.BrowserSwitchStartResult import com.paypal.android.corepayments.BrowserSwitchRequestCodes import com.paypal.android.corepayments.CoreConfig import com.paypal.android.corepayments.Environment import com.paypal.android.corepayments.PayPalSDKError import com.paypal.android.paypalwebpayments.errors.PayPalWebCheckoutError import org.json.JSONObject +import java.nio.charset.StandardCharsets + +private const val KEY_REQUEST_CODE = "requestCode" +private const val KEY_URL = "url" +private const val KEY_RETURN_URL_SCHEME = "returnUrlScheme" +private const val KEY_METADATA = "metadata" +private const val KEY_APP_LINK_URI = "appLinkUri" // TODO: consider renaming PayPalWebLauncher to PayPalAuthChallengeLauncher internal class PayPalWebLauncher( @@ -30,7 +37,6 @@ internal class PayPalWebLauncher( } fun launchPayPalWebCheckout( - activity: ComponentActivity, request: PayPalWebCheckoutRequest, ): PayPalPresentAuthChallengeResult { val metadata = JSONObject() @@ -41,7 +47,7 @@ internal class PayPalWebLauncher( .requestCode(BrowserSwitchRequestCodes.PAYPAL_CHECKOUT) .returnUrlScheme(urlScheme) .metadata(metadata) - return launchBrowserSwitch(activity, options) + return launchBrowserSwitch(options) } fun launchPayPalWebVault( @@ -56,24 +62,28 @@ internal class PayPalWebLauncher( .requestCode(BrowserSwitchRequestCodes.PAYPAL_VAULT) .returnUrlScheme(urlScheme) .metadata(metadata) - return launchBrowserSwitch(activity, options) + return launchBrowserSwitch(options) } private fun launchBrowserSwitch( - activity: ComponentActivity, options: BrowserSwitchOptions - ): PayPalPresentAuthChallengeResult = - when (val startResult = browserSwitchClient.start(activity, options)) { - is BrowserSwitchStartResult.Started -> { - PayPalPresentAuthChallengeResult.Success(startResult.pendingRequest) - } - - is BrowserSwitchStartResult.Failure -> { - val error = PayPalWebCheckoutError.browserSwitchError(startResult.error) - PayPalPresentAuthChallengeResult.Failure(error) - } + ): PayPalPresentAuthChallengeResult { + // HACK: create auth state object for now + val requestJSON = options.run { + JSONObject() + .put(KEY_REQUEST_CODE, requestCode) + .put(KEY_URL, url.toString()) + .putOpt(KEY_RETURN_URL_SCHEME, returnUrlScheme) + .putOpt(KEY_METADATA, metadata) + .putOpt(KEY_APP_LINK_URI, appLinkUri) } + val requestJSONBytes: ByteArray? = + requestJSON.toString().toByteArray(StandardCharsets.UTF_8) + val authState = Base64.encodeToString(requestJSONBytes, Base64.DEFAULT) + return PayPalPresentAuthChallengeResult.Success(uri = options.url!!, authState = authState) + } + private fun buildPayPalCheckoutUri( orderId: String?, config: CoreConfig, diff --git a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt index 6157f79a9..d1196e2ec 100644 --- a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt +++ b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt @@ -36,21 +36,21 @@ class PayPalWebCheckoutClientUnitTest { @Test fun `start() launches PayPal web checkout`() { val launchResult = PayPalPresentAuthChallengeResult.Success("auth state") - every { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } returns launchResult + every { payPalWebLauncher.launchPayPalWebCheckout(any()) } returns launchResult val request = PayPalWebCheckoutRequest("fake-order-id") - sut.start(activity, request) - verify(exactly = 1) { payPalWebLauncher.launchPayPalWebCheckout(activity, request) } + sut.start(request) + verify(exactly = 1) { payPalWebLauncher.launchPayPalWebCheckout(request) } } @Test fun `start() notifies merchant of browser switch failure`() { val sdkError = PayPalSDKError(123, "fake error description") val launchResult = PayPalPresentAuthChallengeResult.Failure(sdkError) - every { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } returns launchResult + every { payPalWebLauncher.launchPayPalWebCheckout(any()) } returns launchResult val request = PayPalWebCheckoutRequest("fake-order-id") - val result = sut.start(activity, request) + val result = sut.start(request) assertSame(launchResult, result) } @@ -86,7 +86,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeCheckoutAuthRequest(intent, "auth state") } returns successResult - val result = sut.finishStart(intent, "auth state") + val result = sut.finishStart(intent) assertSame(successResult, result) } @@ -99,7 +99,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeCheckoutAuthRequest(intent, "auth state") } returns failureResult - val result = sut.finishStart(intent, "auth state") + val result = sut.finishStart(intent) assertSame(failureResult, result) } @@ -110,7 +110,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeCheckoutAuthRequest(intent, "auth state") } returns canceledResult - val result = sut.finishStart(intent, "auth state") + val result = sut.finishStart(intent) assertSame(canceledResult, result) } @@ -122,7 +122,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeVaultAuthRequest(intent, "auth state") } returns successResult - val result = sut.finishVault(intent, "auth state") + val result = sut.finishVault(intent) as PayPalWebCheckoutFinishVaultResult.Success assertSame("fake-approval-session-id", result.approvalSessionId) @@ -135,7 +135,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeVaultAuthRequest(intent, "auth state") } returns PayPalWebCheckoutFinishVaultResult.Failure(error) - val result = sut.finishVault(intent, "auth state") + val result = sut.finishVault(intent) as PayPalWebCheckoutFinishVaultResult.Failure assertSame(error, result.error) @@ -147,7 +147,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeVaultAuthRequest(intent, "auth state") } returns PayPalWebCheckoutFinishVaultResult.Canceled - val result = sut.finishVault(intent, "auth state") + val result = sut.finishVault(intent) assertTrue(result is PayPalWebCheckoutFinishVaultResult.Canceled) } @@ -157,7 +157,7 @@ class PayPalWebCheckoutClientUnitTest { payPalWebLauncher.completeVaultAuthRequest(intent, "auth state") } returns PayPalWebCheckoutFinishVaultResult.NoResult - val result = sut.finishVault(intent, "auth state") + val result = sut.finishVault(intent) assertTrue(result is PayPalWebCheckoutFinishVaultResult.NoResult) } } diff --git a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt index 155754508..067d13760 100644 --- a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt +++ b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebLauncherUnitTest.kt @@ -52,7 +52,7 @@ class PayPalWebLauncherUnitTest { val fundingSource = PayPalWebCheckoutFundingSource.PAYPAL val request = PayPalWebCheckoutRequest("fake-order-id", fundingSource) - sut.launchPayPalWebCheckout(activity, request) + sut.launchPayPalWebCheckout(request) val expectedUrl = "https://www.sandbox.paypal.com/checkoutnow?" + "token=fake-order-id" + @@ -80,7 +80,7 @@ class PayPalWebLauncherUnitTest { val fundingSource = PayPalWebCheckoutFundingSource.PAYPAL val request = PayPalWebCheckoutRequest("fake-order-id", fundingSource) - sut.launchPayPalWebCheckout(activity, request) + sut.launchPayPalWebCheckout(request) val expectedUrl = "https://www.paypal.com/checkoutnow?" + "token=fake-order-id" + @@ -108,7 +108,7 @@ class PayPalWebLauncherUnitTest { val fundingSource = PayPalWebCheckoutFundingSource.PAYPAL_CREDIT val request = PayPalWebCheckoutRequest("fake-order-id", fundingSource) - sut.launchPayPalWebCheckout(activity, request) + sut.launchPayPalWebCheckout(request) val expectedUrl = "https://www.paypal.com/checkoutnow?" + "token=fake-order-id" + @@ -136,7 +136,7 @@ class PayPalWebLauncherUnitTest { val fundingSource = PayPalWebCheckoutFundingSource.PAY_LATER val request = PayPalWebCheckoutRequest("fake-order-id", fundingSource) - sut.launchPayPalWebCheckout(activity, request) + sut.launchPayPalWebCheckout(request) val expectedUrl = "https://www.paypal.com/checkoutnow?" + "token=fake-order-id" + @@ -163,7 +163,7 @@ class PayPalWebLauncherUnitTest { } returns BrowserSwitchStartResult.Failure(browserSwitchError) val request = PayPalWebCheckoutRequest("fake-order-id") - val result = sut.launchPayPalWebCheckout(activity, request) + val result = sut.launchPayPalWebCheckout(request) as PayPalPresentAuthChallengeResult.Failure assertEquals("error message from browser switch", result.error.errorDescription) } @@ -229,7 +229,7 @@ class PayPalWebLauncherUnitTest { sut.launchPayPalWebVault(activity, vaultRequest) val request = PayPalWebCheckoutRequest("fake-order-id") - val result = sut.launchPayPalWebCheckout(activity, request) + val result = sut.launchPayPalWebCheckout(request) as PayPalPresentAuthChallengeResult.Failure assertEquals("error message from browser switch", result.error.errorDescription) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 507389541..9a4f711f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +activityCompose = "1.10.1" androidGradlePlugin = "8.7.1" androidxAppcompat = "1.3.1" androidxComposeBom = "2024.10.01" @@ -13,6 +14,7 @@ androidxTestCore = "1.5.0" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxTestUiAutomator = "2.2.0" +browser = "1.5.0" browserSwitch = "3.0.0-beta1" constraintLayout = "2.1.0" daggerHilt = "2.51.1" @@ -41,7 +43,9 @@ striktMockk = "0.30.1" [libraries] android-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } androidx-constraintLayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintLayout" } androidx-coreKtx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCoreKtx" } androidx-fragmentKtx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragmentKtx" }