在今年的2023/11/01,Google要求新架上有使用Google Billing Api的App都必須更新到v5,或者也可以升級到v6,這樣可以在兩年內不必被要求更新
而當中有一些異動就是屬於acknowledgePurchase
的部份
這是什麼呢? 當你跳出系統選單的時候,在使用者確認購買後,經過處理取得訂單流程後,原本你必須取得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取得到purchaseToke
n後,必須先呼叫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 調整會是這樣,其他還有一些額外內容,有機會在更新囉