Skip to content
Draft
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
1 change: 1 addition & 0 deletions CorePayments/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.paypal.android.corepayments

import android.content.Intent

data class ChromeCustomTabsResult(val resultCode: Int, val intent: Intent?)
Original file line number Diff line number Diff line change
@@ -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<Uri, ChromeCustomTabsResult>() {
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)
}
Original file line number Diff line number Diff line change
@@ -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<String, String?>.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<String, Any>().apply {
requestJSON.keys().forEach { put(it, requestJSON[it]) }
}
properties.putAll(properties)
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun MutableMap<String, String?>.toBase64EncodedJSON(): String {
val propertiesAsJSON = JSONObject(this)
val requestJSONBytes: ByteArray? =
propertiesAsJSON.toString().toByteArray(StandardCharsets.UTF_8)
return Base64.encodeToString(requestJSONBytes, Base64.DEFAULT)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.paypal.android.corepayments

import androidx.annotation.RestrictTo

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
open class SessionStore {
val properties: MutableMap<String, String?> = mutableMapOf()

fun clear() = properties.clear()
fun restore(base64EncodedJSON: String) =
properties.restoreFromBase64EncodedJSON(base64EncodedJSON)

fun toBase64EncodedJSON() = properties.toBase64EncodedJSON()
}
1 change: 1 addition & 0 deletions Demo/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
) {
Expand All @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<Bundle>("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<Bundle>("pay_pal_web_view_model")
}

fun createOrder() {
viewModelScope.launch {
createOrderState = ActionState.Loading
Expand All @@ -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()) {
Expand All @@ -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)
Expand All @@ -131,6 +162,7 @@ class PayPalWebViewModel @Inject constructor(
}
}
}
return null
}

fun completeOrder(context: Context) {
Expand All @@ -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 -> {
Expand All @@ -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()
// }
}
}
Loading
Loading