整合 Google Pay 至 Android App

Photo by Jamie Davies on Unsplash
Photo by Jamie Davies on Unsplash
Google Pay 是 Google 的行動支付服務。我們可以用 Google Pay 在實體店家的 NFC 刷卡,也可以在 apps 中購買商品。另外,Google Wallet 可以管理信用卡,所以我們不需要隨身攜帶數張信用卡。

Google Pay 是 Google 的行動支付服務。我們可以用 Google Pay 在實體店家的 NFC 刷卡,也可以在 apps 中購買商品。另外,Google Wallet 可以管理信用卡,所以我們不需要隨身攜帶數張信用卡。本文章將介紹,如何將 Google Pay 整合至 Android app。

完整程式碼可以在 下載。

準備測試環境

在開始寫程式之前,讓我們先設定好測試環境。在本文章中,我們將在 Android emulaltor 中,使用 Google Pay 付款。

首先,這個 emulator 必須要支援 Google Play。在 emulator 的 Settings 中,登入你的 Google Account。然後,打開 test card suite group,如下圖。

Google Pay API Test Cards Allowlist
Google Pay API Test Cards Allowlist

點擊 Join group。這樣你的 Google Account 就會有測試的 cards。

Join Google Pay API Test Cards Allowlist
Join Google Pay API Test Cards Allowlist

引入依賴

在 app level 的 build.gradle.kts 中,加入以下依賴。

dependencies {
    implementation("com.google.android.gms:play-services-wallet:19.3.0-beta01")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.2")
}

初始化 PayButton

在 main_activity.xml 中,宣告 PayButton。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.google.android.gms.wallet.button.PayButton
        android:id="@+id/pay_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

在 MainActivity 中,初始化 PayButton。我們可以藉由 setButtonTheme() 和 setButtonType() 來設定 PayButton 的樣式與標題。更多的詳情,請參考 Brand guidelines

class MainActivity : AppCompatActivity() {
    companion object {
        private const val TAG = "MainActivity"
    }

    private lateinit var binding: MainActivityBinding
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = MainActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)

        initPayButton()
    }

    private fun initPayButton() {
        try {
            val buttonOptions = ButtonOptions.newBuilder()
                .setButtonTheme(ButtonTheme.LIGHT)
                .setButtonType(ButtonType.PAY)
                .setAllowedPaymentMethods(viewModel.getAllowedPaymentMethods().toString())
                .build()
            binding.payButton.initialize(buttonOptions)

            binding.payButton.setOnClickListener {
                // TODO: pay
            }
        } catch (e: JSONException) {
            Log.e(TAG, "Error on getting payment methods", e)
        }
    }
}

除了 PayButton 的樣式之外,我們還要設定 allowed payment methods。Google Pay API 並沒有提供相關的資料結構,而是使用 JSON。其 JSON 的內容定義在 PaymentMethod

class MainViewModel(application: Application) : AndroidViewModel(application) {
    fun getAllowedPaymentMethods(withToken: Boolean): JSONArray {
        val jsonObject = getCardPaymentMethod(withToken)
        return JSONArray().put(jsonObject)
    }

    private fun getCardPaymentMethod(): JSONObject {
        return JSONObject().apply {
            put("type", "CARD")

            put("parameters", JSONObject().apply {
                put("allowedAuthMethods", JSONArray().apply {
                    put("PAN_ONLY")
                    put("CRYPTOGRAM_3DS")
                })

                put("allowedCardNetworks", JSONArray().apply {
                    put("AMEX")
                    put("DISCOVER")
                    put("JCB")
                    put("MASTERCARD")
                    put("VISA")
                })

                put("billingAddressRequired", true)
                put("billingAddressParameters", JSONObject().apply {
                    put("format", "FULL")
                })
            })
        }
    }
}

檢查 Google Pay 是否可用

設定好 PayButton 之後,我們要檢查 Google Pay 是否 ready。如果 Google Pay 是 ready 的話,我們才顯示 PayButton。

class MainActivity : AppCompatActivity() {
    ...

    private fun initPayButton() {
        ...

        binding.payButton.visibility = View.INVISIBLE
        viewModel.isGooglePayReady.observe(this) {
            if (it) {
                binding.payButton.visibility = View.VISIBLE
            } else {
                binding.payButton.visibility = View.INVISIBLE
                Toast.makeText(
                    this,
                    "Google Pay is not available on this device",
                    Toast.LENGTH_LONG
                ).show()
            }
        }
    }
}

在 MainViewModel 中,我們宣告 PaymentsClient,並且設定它使用測試環境。利用 PaymentsClient.isReadyToPay() 來檢查是否可以在此裝置上使用 Google Pay。它需要一個 IsReadyToPayRequest。與在初始化 PayButton 時一樣,我們必須要設定 allowed payment methods。

class MainViewModel(application: Application) : AndroidViewModel(application) {
    companion object {
        private const val TAG = "MainViewModel"
    }

    val isGooglePayReady: MutableLiveData<Boolean> = MutableLiveData()

    private val paymentsClient: PaymentsClient

    init {
        val walletOptions = Wallet.WalletOptions.Builder()
            .setEnvironment(WalletConstants.ENVIRONMENT_TEST)
            .build()
        paymentsClient = Wallet.getPaymentsClient(application, walletOptions)

        viewModelScope.launch {
            checkIfGooglePayIsReady()
        }
    }

    private suspend fun checkIfGooglePayIsReady() {
        try {
            val jsonRequest = JSONObject().apply {
                put("apiVersion", 2)
                put("apiVersionMinor", 0)
                put("allowedPaymentMethods", getAllowedPaymentMethods())
            }
            val request = IsReadyToPayRequest.fromJson(jsonRequest.toString())
            Log.d(TAG, jsonRequest.toString())
            val task = paymentsClient.isReadyToPay(request)
            if (task.await()) {
                isGooglePayReady.postValue(true)
            } else {
                Log.e(TAG, "Error on requesting if Google Pay is ready")
                isGooglePayReady.postValue(false)
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error on checking if Google Pay is ready", e)
            isGooglePayReady.postValue(false)
        }
    }
}

執行 Payment

最後一步就是付款了。我們要呼叫 PaymentsClient.loadPaymentData() 來執行付款。它要求一個 PaymentDataRequest。在 PaymentDataRequest 中,我們需要填入付款的金額、貨幣和 PaymentMethod。與設定 PayButton 和呼叫 PaymentsClient.isReadyToPay() 時不一樣的是,我們還要在 PaymentMethods 中,設定 TokenizationSpecification。我們需要在 TokenizationSpecification 中填入 gateway 和 merchant ID。

PaymentsClient.loadPaymentData() 回傳 true 時,表示付款完成。如果它回傳 false 時,且其 exception 是 ResolvableApiException 的話,表示還沒有進行付款,因為它要求我們要顯示付款的畫面。我們可以藉由 task.resolution 取得一個 PendingIntent

class MainViewModel(application: Application) : AndroidViewModel(application) {
    private fun getCardPaymentMethod(withToken: Boolean): JSONObject {
        return JSONObject().apply {
            put("type", "CARD")

            put("parameters", JSONObject().apply {
                put("allowedAuthMethods", JSONArray().apply {
                    put("PAN_ONLY")
                    put("CRYPTOGRAM_3DS")
                })

                put("allowedCardNetworks", JSONArray().apply {
                    put("AMEX")
                    put("DISCOVER")
                    put("JCB")
                    put("MASTERCARD")
                    put("VISA")
                })

                put("billingAddressRequired", true)
                put("billingAddressParameters", JSONObject().apply {
                    put("format", "FULL")
                })
            })

            if (withToken) {
                put("tokenizationSpecification", JSONObject().apply {
                    put("type", "PAYMENT_GATEWAY")
                    put("parameters", JSONObject().apply {
                        put("gateway", "example")
                        put("gatewayMerchantId", "exampleGatewayMerchantId")
                    })
                })
            }
        }
    }

    fun pay(priceCents: Long) {
        getPaymentDataRequest(priceCents).addOnCompleteListener { task ->
            if (task.isSuccessful) {
                paymentSuccess.postValue(task.result)
            } else {
                when (val e = task.exception) {
                    is ResolvableApiException -> paymentUi.postValue(e.resolution)
                    is ApiException -> paymentError.postValue(e)
                    else -> paymentError.postValue(e)
                }
            }
        }
    }

    private fun getPaymentDataRequest(priceCents: Long): Task<PaymentData> {
        // ex: If priceCents is 950, price will be 9.50
        val price = BigDecimal(priceCents)
            .divide(BigDecimal(100))
            .setScale(2, RoundingMode.HALF_EVEN)
            .toString()

        val jsonRequest = JSONObject().apply {
            put("apiVersion", 2)
            put("apiVersionMinor", 0)
            put("allowedPaymentMethods", getAllowedPaymentMethods(true))
            put("transactionInfo", JSONObject().apply {
                put("totalPrice", price)
                put("totalPriceStatus", "FINAL")
                put("countryCode", "US")
                put("currencyCode", "USD")
            })
            put("merchantInfo", JSONObject().put("merchantName", "Example Merchant"))

            put("shippingAddressParameters", JSONObject().apply {
                put("phoneNumberRequired", false)
                put("allowedCountryCodes", JSONArray(listOf("US", "GB")))
            })
            put("shippingAddressRequired", true)
        }

        val request = PaymentDataRequest.fromJson(jsonRequest.toString())
        Log.d(TAG, jsonRequest.toString())
        return paymentsClient.loadPaymentData(request)
    }
}
class MainActivity : AppCompatActivity() {
    companion object {
        private const val TAG = "MainActivity"
    }

    private lateinit var binding: MainActivityBinding
    private val viewModel: MainViewModel by viewModels()

    private val resolvePaymentForResult = registerForActivityResult(
        ActivityResultContracts.StartIntentSenderForResult()
    ) { result: ActivityResult ->
        when (result.resultCode) {
            RESULT_OK -> {
                val resultData = result.data
                if (resultData != null) {
                    val paymentData = PaymentData.getFromIntent(resultData)
                    paymentData?.let {
                        handlePaymentSuccess(it)
                    }
                }
            }

            RESULT_CANCELED -> {
                // The user cancelled the payment
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = MainActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)

        initPayButton()
        initViewModel()
    }

    ...

    private fun initViewModel() {
        viewModel.paymentUi.observe(this) {
            resolvePaymentForResult.launch(IntentSenderRequest.Builder(it).build())
        }

        viewModel.paymentSuccess.observe(this) {
            handlePaymentSuccess(it)
        }

        viewModel.paymentError.observe(this) {
            Log.e(TAG, "Payment failed", it)
        }
    }

    private fun handlePaymentSuccess(paymentData: PaymentData) {
        val json = JSONObject(paymentData.toJson())
        val token = json.getJSONObject("paymentMethodData")
            .getJSONObject("tokenizationData")
            .optString("token")
        Log.d(TAG, "token = $token")
        // Send the token to the server

        startActivity(Intent(this, PaymentSuccessActivity::class.java))
    }
}

我們 launch 這個 PendingIntent 後,就會出現以下的付款畫面。我們可以看到帳號中已經有一些測試的 cards。

Google Pay UI.
Google Pay UI.

付款成功後,我們會收到一個 PaymentData response。我們可以從這個 response 中取得一個 token,最後將這個 token 送到 server。

結語

Google Pay 的整合並不難,而且官方還提供了範例程式碼,其中有 Java + XML 和 Kotlin + Compose。另外,它的 requests 和 responses 都是 JSON,使得使用起來不是那麼地方便。

參考

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Hans-Jurgen Mager on Unsplash
Read More

Kotlin Coroutine 教學

Kotlin 的 coroutine 是用來取代 thread。它不會阻塞 thread,而且還可以被取消。Coroutine core 會幫你管理 thread 的數量,讓你不需要自行管理,這也可以避免不小心建立過多的 thread。
Read More