iT邦幫忙

0

關於Google Billing Api V6 中的二次確認訂單 acknowledge Subscription異動

  • 分享至 

  • xImage
  •  

在今年的2023/11/01,Google要求新架上有使用Google Billing Api的App都必須更新到v5,或者也可以升級到v6,這樣可以在兩年內不必被要求更新
而當中有一些異動就是屬於acknowledgePurchase的部份

acknowledge Purchase

這是什麼呢? 當你跳出系統選單的時候,在使用者確認購買後,經過處理取得訂單流程後,原本你必須取得purchaseToken透過

val params = AcknowledgePurchaseParams.newBuilder()
	.setPurchaseToken(purchaseToken)
	.build()
	
billingClient.acknowledgePurchase(params) { billingResult ->
	response = BillingResponse(billingResult.responseCode)
	bResult = billingResult
}

再次送出驗證給Google,這樣訂單才會成立,否則在三天後Google會自動取消你的訂單,這樣可以避免在訂閱過程後如果剛好App Crash,或是Response傳到 App時候發生了網路錯誤,導致App沒有處理到該筆訂單的結果,讓使用者無法正常的使用到商品或者避免使用者以為沒有購買成功,但實際背景在自動進行訂閱期間了

而他的responseCode可以看到是這樣定義的


@JvmInline
private value class BillingResponse(val code: Int) {
    val isOk: Boolean
        get() = code == BillingClient.BillingResponseCode.OK
    val canFailGracefully: Boolean
        get() = code == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED
    val isRecoverableError: Boolean
        get() = code in setOf(
            BillingClient.BillingResponseCode.ERROR,
            BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
        )
    val isNonrecoverableError: Boolean
        get() = code in setOf(
            BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
            BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
            BillingClient.BillingResponseCode.DEVELOPER_ERROR,
        )
    val isTerribleFailure: Boolean
        get() = code in setOf(
            BillingClient.BillingResponseCode.ITEM_UNAVAILABLE,
            BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
            BillingClient.BillingResponseCode.ITEM_NOT_OWNED,
            BillingClient.BillingResponseCode.USER_CANCELED,
        )
}

但這件事情為什麼要額外拿出來講呢,主要就是v6版本開始建議二次確認這件事情,會需要由你在取得訂單後,需要先將訂單資訊拋送到你自己的server backend

在google的官方sample中有這樣的範例可以參考,他是透過typescript與nodejs 等等並且串接firebase cloud function來進行實作,當然你也可以轉成自己的server架構,不過說實在的這次的sample這樣就變得相當複雜

https://github.com/android/play-billing-samples/tree/master/ClassyTaxiServer

當中訂單是需要跟自己的帳號做關聯的

可以再sample code看到google是這樣標示

	Acknowledge a purchase.
     *
     * https://developer.android.com/google/play/billing/billing_library_releases_notes#2_0_acknowledge
     *
     * Apps should acknowledge the purchase after confirming that the purchase token
     * has been associated with a user. This app only acknowledges purchases after
     * successfully receiving the subscription data back from the server.
     *
     * Developers can choose to acknowledge purchases from a server using the
     * Google Play Developer API. The server has direct access to the user database,
     * so using the Google Play Developer API for acknowledgement might be more reliable.
     * TODO(134506821): Acknowledge purchases on the server.
     * TODO: Remove client side purchase acknowledgement after removing the associated tests.
     * If the purchase token is not acknowledged within 3 days,
     * then Google Play will automatically refund and revoke the purchase.
     * This behavior helps ensure that users are not charged for subscriptions unless the
     * user has successfully received access to the content.
     * This eliminates a category of issues where users complain to developers
     * that they paid for something that the app is not giving to them.

當中重點就是

* TODO(134506821): Acknowledge purchases on the server.
* TODO: Remove client side purchase acknowledgement after removing the associated tests.

在他們之前將會將billingClient中移除,需要以後都在server端進行驗證,並且提到使用server端進行二次驗證會更有可信賴度

這樣的行為可以確保有成功購買前不會被收費

This behavior helps ensure that users are not charged for subscriptions unless the
user has successfully received access to the content.

因此會有這樣的機制,而看起來目前v6還保留在client api中,但在未來將會移除這個可以在app端二次確認的呼叫,目前在sample中也只有留下function而沒有呼叫使用

而App端的kotlin sample code可以參考這的部分

https://github.com/android/play-billing-samples/blob/master/ClassyTaxiAppKotlin/README.md

因此記得在這次的修改,必須增加能透過server自動進行訂單二次確認,如果server遇到當下錯誤,會在商品資訊中得知他是否有進行二次確認了

var isAcknowledged: Boolean = false,

完整的class為

/**
 * Local subscription data. This is stored on disk in a database.
 */
@Entity(tableName = "subscriptions")
data class SubscriptionStatus(
    // Local fields.
    @PrimaryKey(autoGenerate = true)
    var primaryKey: Int = 0,
    var subscriptionStatusJson: String? = null,
    var subAlreadyOwned: Boolean = false,
    var isLocalPurchase: Boolean = false,

    // Remote fields.
    var product: String? = null,
    var purchaseToken: String? = null,
    var isEntitlementActive: Boolean = false,
    var willRenew: Boolean = false,
    var activeUntilMillisec: Long = 0,
    var isGracePeriod: Boolean = false,
    var isAccountHold: Boolean = false,
    var isPaused: Boolean = false,
    var isAcknowledged: Boolean = false,
    var autoResumeTimeMillis: Long = 0
) {

    companion object {
        /**
         * Create a record for a subscription that is already owned by a different user.
         *
         * The server does not return JSON for a subscription that is already owned by
         * a different user, so we need to construct a local record with the basic fields.
         */
        fun alreadyOwnedSubscription(
            product: String,
            purchaseToken: String
        ): SubscriptionStatus {
            return SubscriptionStatus().apply {
                this.product = product
                this.purchaseToken = purchaseToken
                isEntitlementActive = false
                subAlreadyOwned = true
            }
        }
    }

}

主要就是可以得知這個訂購商品目前的狀態,這邊簡單流程就是,當透過billingClient從google取得到purchaseToken後,必須先呼叫registerSubscription跟自己的server註冊這組訂單,這時候會回傳剛剛上面那個response data class,當中sample中會執行updateSubscriptionsFromNetwork()

/**
     * Update the local database with the subscription status from the remote server.
     * This method is called when the app starts and when the user refreshes the subscription
     * status.
     */
    suspend fun updateSubscriptionsFromNetwork(remoteSubscriptions: List<SubscriptionStatus>?) {
        Log.i(TAG, "Updating subscriptions from remote: ${remoteSubscriptions?.size}")

        val currentSubscriptions = subscriptions.value
        val purchases = billingClientLifecycle.subscriptionPurchases.value

        // Acknowledge the subscription if it is not.
        kotlin.runCatching {
            remoteSubscriptions?.let {
                this.acknowledgeRegisteredSubscriptionPurchaseTokens(it)
            }
        }.onFailure {
            Log.e(
                TAG, "Failed to acknowledge registered subscription purchase tokens: " +
                        "$it"
            )
        }.onSuccess { acknowledgedSubscriptions ->
            Log.i(
                TAG, "Successfully acknowledged registered subscription purchase tokens: " +
                        "$acknowledgedSubscriptions"
            )
            val mergedSubscriptions =
                mergeSubscriptionsAndPurchases(
                    currentSubscriptions,
                    acknowledgedSubscriptions,
                    purchases
                )

            // Store the subscription information when it changes.
            localDataSource.updateSubscriptions(mergedSubscriptions)

            // Update the content when the subscription changes.
            var updateBasic = false
            var updatePremium = false
            acknowledgedSubscriptions?.forEach { subscription ->
                when (subscription.product) {
                    Constants.BASIC_PRODUCT -> {
                        updateBasic = true
                    }

                    Constants.PREMIUM_PRODUCT -> {
                        updatePremium = true
                    }
                }
            }

            if (updateBasic) {
                remoteDataSource.updateBasicContent()
            } else {
                // If we no longer own this content, clear it from the UI.
                _basicContent.emit(null)
            }

            if (updatePremium) {
                remoteDataSource.updatePremiumContent()
            } else {
                // If we no longer own this content, clear it from the UI.
                _premiumContent.emit(null)
            }
        }
    }

而這段重點在於他會拿local的purchase 跟剛剛register後的結果,呼叫acknowledgeRegisteredSubscriptionPurchaseTokens()

主要就是找出還沒有被二次確認的訂單,並且在此時再透過acknowledgeSubscription 對自己server 將該purchaseToken進行確認,確認結果依樣會回傳SubscriptionStatus這個data class,

    /**
     * Acknowledge subscriptions that have been registered by the server
     * and update local data source.
     * Returns a list of acknowledged subscriptions.
     *
     */
    private suspend fun acknowledgeRegisteredSubscriptionPurchaseTokens(
        remoteSubscriptions: List<SubscriptionStatus>
    ): List<SubscriptionStatus> {
        return remoteSubscriptions.map { sub ->
            if (!sub.isAcknowledged) {
                val acknowledgedSubs = sub.purchaseToken?.let { token ->
                    sub.product?.let { product ->
                        acknowledgeSubscription(product, token)
                    }
                }
                acknowledgedSubs?.let { subList ->
                    localDataSource.updateSubscriptions(subList)
                    subList.map { sub.copy(isAcknowledged = true) }
                } ?: listOf(sub)
            } else {
                Log.d(TAG, "Subscription is already acknowledged")
                listOf(sub)
            }
        }.flatten()
    }

這時候返回到BillingRepoistory會將所有結果整合,並且再次更新到localDb中當作cache資料

val mergedSubscriptions =
                mergeSubscriptionsAndPurchases(
                    currentSubscriptions,
                    acknowledgedSubscriptions,
                    purchases
                )

然後於下方抓出acknowledgedSubscriptions,也就是確認已經經過二次確認的商品,紀錄商品更新了

            acknowledgedSubscriptions?.forEach { subscription ->
                when (subscription.product) {
                    Constants.BASIC_PRODUCT -> {
                        updateBasic = true
                    }

                    Constants.PREMIUM_PRODUCT -> {
                        updatePremium = true
                    }
                }
            }

最後sample中,會再次跟他自己的server,去取得這個baseContent的一些應該要顯示的內容,譬如成功的圖片網址或者任何要顯示的內容,最後更新到UI上面進行顯示

            if (updateBasic) {
                remoteDataSource.updateBasicContent()
            } else {
                // If we no longer own this content, clear it from the UI.
                _basicContent.emit(null)
            }

            if (updatePremium) {
                remoteDataSource.updatePremiumContent()
            } else {
                // If we no longer own this content, clear it from the UI.
                _premiumContent.emit(null)
            }

大致上v6在這次的 acknowledge 調整會是這樣,其他還有一些額外內容,有機會在更新囉


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言