Play Billing 2026:launchBillingFlow 返回不代表購買完成
Play Billing in 2026: the purchase isn't done when launchBillingFlow returns
Play Billing 購買不會在 launchBillingFlow 返回時完成,付款結果須由 listener 後續回報。新整合建議採用 Play Billing Library 9.1.0;PENDING 不授權,已付款項目須先驗證及授權,再於三日內確認或消耗。
Google Play Billing is the only way to sell digital features inside a Play app. Its library has one job: connect your app to Google Play so the user can pay and your app can find out what they own.
Almost every billing bug comes from forgetting that purchases can arrive at any time, from anywhere, and must be confirmed back to Google.
The trap
The trap is treating launchBillingFlow() like a checkout that returns "paid". It does not.
The result arrives later in a listener. Sometimes that is after the app was closed. Sometimes it arrives as PENDING (cash, some wallets). Sometimes it comes from a different device. And if your app does not acknowledge a purchase within three days, Google's integration guide says it is automatically refunded and the entitlement revoked.
Versions and deadlines
At the time of writing (October 2026), the current release is Play Billing Library 9.1.0 (June 18, 2026), with 9.0.0 released on May 19, 2026. Google gives every major version a two-year life. Its "Play Billing Library version deprecation" page lists the dates after which new apps and updates must use a newer version (checked October 13, 2026):
| Version | New apps and updates must move on by | Extension until |
|---|---|---|
| 7 | Aug 31, 2026 | Nov 1, 2026 |
| 8 | Aug 31, 2027 | Nov 1, 2027 |
| 9 | Aug 31, 2028 | Nov 1, 2028 |
So by August 31, 2026, new apps and updates already had to use version 8 or later. Starting a new integration today means version 9.1.0, which buys you until August 31, 2028.
If you are upgrading old code, these release-note changes break builds:
-
8.0.0 removed
queryPurchaseHistory(),querySkuDetailsAsync(), the parameterlessenablePendingPurchases()and thequeryPurchasesAsync(skuType, ...)overload. In-app items are now called one-time products. New:enableAutoServiceReconnection(). -
8.1.0 raised
minSdkVersionto 23 and added suspended subscriptions. -
9.0.0 changed the response for a blocked Play Store (for example a kids mode) from
ERRORtoBILLING_UNAVAILABLE.
One client, connected
Google recommends one active BillingClient connection, opened when the app starts or returns to the foreground, and strongly recommends automatic service reconnection. With it, the library reconnects by itself when you call an API after a disconnect.
class BillingRepository(context: Context, private val scope: CoroutineScope) :
PurchasesUpdatedListener {
private val _purchases = MutableStateFlow<List<Purchase>>(emptyList())
val purchases: StateFlow<List<Purchase>> = _purchases
private val client = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.enableAutoServiceReconnection()
.build()
override fun onPurchasesUpdated(
result: BillingResult,
purchases: List<Purchase>?,
) {
when (result.responseCode) {
BillingResponseCode.OK -> purchases?.forEach(::process)
BillingResponseCode.USER_CANCELED -> Unit
else -> Log.w(TAG, "Update error ${result.responseCode}")
}
}
}
Product prices come from Google Play, localized and formatted for the user. Never hard-code a price string in the app. Query it with queryProductDetailsAsync().
Launching is only the start
The purchase sheet is Google's, not yours. You pass the ProductDetails and an offer token:
fun buy(activity: Activity, details: ProductDetails, accountHash: String?) {
val offerToken =
details.oneTimePurchaseOfferDetailsList?.firstOrNull()?.offerToken
?: details.subscriptionOfferDetails?.firstOrNull()?.offerToken
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.apply { offerToken?.let { setOfferToken(it) } }
.build()
val flow = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.apply { accountHash?.let { setObfuscatedAccountId(it) } }
.build()
client.launchBillingFlow(activity, flow) // result: onPurchasesUpdated
}
Note the comment on the last line. Nothing about the payment is known when that call returns.
setObfuscatedAccountId() is optional. Google's guide says an obfuscated version of your own user ID helps Google detect fraud and helps you attribute purchases. Never pass an email address or anything readable.
PENDING grants nothing
PENDING means the user has started paying by a delayed method. Grant nothing yet. PURCHASED means paid: verify, grant, then acknowledge (or consume) within three days.
private fun process(purchase: Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PENDING -> {
// Tell the user payment is pending. Grant nothing.
}
Purchase.PurchaseState.PURCHASED -> {
// 1. Verify (on your server if you have one, Chapter 7).
// 2. Grant the entitlement (Chapter 6).
// 3. Acknowledge, unless it is a consumable you consume instead.
if (!purchase.isAcknowledged) acknowledge(purchase)
}
else -> Unit
}
_purchases.update { current ->
val token = purchase.purchaseToken
current.filterNot { it.purchaseToken == token } + purchase
}
}
private fun acknowledge(purchase: Purchase) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
client.acknowledgePurchase(params) { result ->
if (result.responseCode != BillingResponseCode.OK) {
Log.w(TAG, "Ack failed ${result.responseCode}; retry on refresh")
}
}
}
Consumables (coins, credits) are different. You consume them with consumeAsync() or the KTX consumePurchase(), which both acknowledges the purchase and lets the user buy it again. A remove-ads unlock is never consumed.
Never trust only the listener
Google lists the cases the listener misses:
- the network drops after payment
- the user bought on another device
- a promotion was redeemed outside the app
- a pending payment completed while the app was closed
The fix is one call whenever the client connects and whenever the app comes back to the foreground:
fun refreshPurchases() {
val types = listOf(ProductType.INAPP, ProductType.SUBS)
types.forEach { type ->
client.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(type).build()
) { result, list ->
if (result.responseCode == BillingResponseCode.OK) {
list.forEach(::process)
}
}
}
}
Because process() acknowledges anything not yet acknowledged, this one refresh also retries failed acknowledgements, well inside the three-day window.
Testing without spending money
- License testers. Add tester Google accounts in Play Console. Google's testing guide says license testers can test with debug builds and sideloaded apps (the package name must match the app in Play Console) and get test payment methods that never charge real money.
-
Test cards for the hard cases. License testers get a slow card that approves after a few minutes, one that declines after a few minutes, and one that approves then charges back. Use them to prove that PENDING grants nothing and that a later approval is picked up by
refreshPurchases(). - Play Billing Lab. Google's Play Billing Lab app lets you change the Play country for a test, repeat trial or introductory offers with the same account, and speed up subscription state changes.
- Internal testing track. Products can only be created after a build with the library has been published to some track. Purchases in apps on the internal test track or in draft are subject to spend limits.
Checklist
- Play Billing Library 9.1.0 for new work.
- One
BillingClientwithenableAutoServiceReconnection(). - Grant nothing for PENDING.
- Verify, grant, then acknowledge or consume within three days.
- Call
queryPurchasesAsyncon every connect and every return to the foreground. - Test with license testers and the slow test cards before release.
This is adapted from my book Get Paid for Your Android App (Android the Right Way, Book 4). The free sample is here and the full book is here.
Drafted with AI help and edited by the author; based on his book.
來源:DEV Community · dev.to