💲
Android Billing API での IAP決済の方法
現在は RevenueCat SDK 経由での決済にしているが、将来的に自社で管理したいし、公式のやり方がやっぱりわかりにくいのでメモ。だいぶ昔に実装したので間違っている可能性あり。
libs
// Play In-App Update Library
implementation(libs.app.update)
implementation(libs.app.update.ktx)
// Play Billing Library
implementation(libs.billing)
implementation(libs.billing.ktx)
BillingViewModel
これはただ PurchaseManager を呼ぶための ViewModel。MVVMとかViewModelについてはその呼び方やそれが意味するもの、役割など毎回色んな議論があって混乱するが、「ViewModel=Viewの状態を保持するもの」という意味では、コメントにあるようにUIの状態を保持しているわけでもないので BillingViewModelという名前やこの構成は綺麗でないと思われる。
// BillingViewModel
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.billingclient.api.BillingClient
import com.google.firebase.Firebase
import com.google.firebase.auth.auth
import kotlinx.coroutines.launch
class BillingViewModel(
private val purchaseManager: PurchaseManager,
) : ViewModel() {
private val TAG = "BillingViewModel"
val uiState = purchaseManager.billingState // 一旦 UI の状態でなく billingState をそのまま返す
init {
purchaseManager.startBillingConnection()
}
// https://developer.android.com/google/play/billing/integrate?hl=ja#connect_to_google_play
override fun onCleared() {
super.onCleared()
purchaseManager.terminateBillingConnection()
}
fun purchaseIAP(productId: String) {
purchaseManager.queryPurchases(
onSuccess = {
val alreadyPurchased = uiState.value.purchases.any { purchase ->
purchase.products.contains(productId)
}
if (alreadyPurchased) {
Log.d(TAG, "purchaseIAP: already purchased")
return@queryPurchases
}
purchaseManager.queryProductDetails(
productId,
BillingClient.ProductType.INAPP,
onSuccess = {
val productDetails =
uiState.value.productDetailsList.firstOrNull { productDetails ->
productDetails.productId == productId
}
if (productDetails == null) {
Log.e(TAG, "purchaseIAP: productDetails is null")
return@queryProductDetails
}
purchaseManager.launchIapBillingFlow(productDetails)
},
)
}
)
}
fun purchaseSubscription(
productId: String,
basePlanId: String
) {
purchaseManager.queryPurchases(
onSuccess = {
val alreadyPurchased = uiState.value.purchases.any { purchase ->
purchase.products.contains(basePlanId)
}
if (alreadyPurchased) {
Log.d(TAG, "purchaseSubscription: already purchased")
return@queryPurchases
}
// TODO: refactor
purchaseManager.queryProductDetails(
productId,
BillingClient.ProductType.SUBS,
onSuccess = {
val productDetails =
uiState.value.productDetailsList.firstOrNull { productDetails ->
productDetails.subscriptionOfferDetails?.any { it.basePlanId == basePlanId } == true
}
if (productDetails == null) {
Log.e(TAG, "purchaseSubscription: productDetails is null")
return@queryProductDetails
}
val offerToken =
productDetails.subscriptionOfferDetails?.first()?.offerToken
if (offerToken == null) {
Log.e(TAG, "purchaseSubscription: offerToken is null")
return@queryProductDetails
}
purchaseManager.launchSubscriptionBillingFlow(productDetails, offerToken)
},
)
}
)
}
}
PurchaseManger
メインとなる決済処理。
// PurchaseManager
import android.app.Activity
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.google.common.collect.ImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
// https://developer.android.com/google/play/billing/integrate?hl=ja
// https://github.com/miraljaviya/InAppSubscriptionCompose
// https://codelabs.developers.google.com/play-billing-codelab?hl=ja#0
// TODO: RTDN. クロスプラットフォームやコンビニ払いなど
// https://developer.android.com/google/play/billing/getting-ready?hl=ja#configure-rtdn
// https://developer.android.com/google/play/billing/lifecycle?hl=ja
data class BillingState(
val purchases: List<Purchase>,
val productDetailsList: List<ProductDetails>
)
class PurchaseManager(
private val activity: Activity
) {
private val TAG = "PurchaseManager"
private val _billingState = MutableStateFlow(BillingState(emptyList(), emptyList()))
val billingState = _billingState.asStateFlow()
private val purchaseUpdateListener = PurchasesUpdatedListener { result, purchases ->
// override fun onPurchasesUpdated() をここに書いてしまえる
if (result.responseCode == BillingResponseCode.OK && purchases != null) {
for (purchase in purchases) {
acknowledgePurchases(purchase)
}
} else if (result.responseCode == BillingResponseCode.USER_CANCELED) {
// User canceled the purchase
} else {
// Handle other error cases
}
}
private var billingClient: BillingClient = BillingClient.newBuilder(activity)
.setListener(purchaseUpdateListener)
.enablePendingPurchases()
.build()
// 購入が処理されたことを Google に通知する(購入が自動的に払い戻され、利用資格が取り消されないようにするには、3 日以内に行う必要がある)
private fun acknowledgePurchases(purchase: Purchase?) {
if (purchase?.purchaseState == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams
.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
// Step 4: Notify Google
billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
if (billingResult.responseCode == BillingResponseCode.OK) {
val newPurchases = _billingState.value.purchases.toMutableList()
newPurchases.add(purchase)
_billingState.value = _billingState.value.copy(purchases = newPurchases)
}
}
}
}
}
// Google Play に接続する
fun startBillingConnection() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingResponseCode.OK) {
queryPurchases()
}
}
override fun onBillingServiceDisconnected() {
// Handle billing service disconnection
}
})
}
// 購入済みの Product を取得
fun queryPurchases(
onSuccess: () -> Unit = {},
onUserCanceled: () -> Unit = {},
onError: () -> Unit = {},
) {
if (!billingClient.isReady) {
Log.e(TAG, "queryPurchases: BillingClient is not ready")
}
val queryPurchaseParams = QueryPurchasesParams.newBuilder()
.build()
billingClient.queryPurchasesAsync(
queryPurchaseParams
) { result, purchases ->
when (result.responseCode) {
BillingResponseCode.OK -> {
for (purchase in purchases) {
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
val newPurchases = _billingState.value.purchases.toMutableList()
newPurchases.add(purchase)
_billingState.value = _billingState.value.copy(purchases = newPurchases)
}
}
Log.d(TAG, "queryPurchases: Success")
onSuccess()
}
BillingResponseCode.USER_CANCELED -> {
Log.d(TAG, "queryPurchases: User canceled")
onUserCanceled()
}
else -> {
Log.e(TAG, "queryPurchases: Error")
onError()
}
}
}
}
// 購入可能な Product を取得
fun queryProductDetails(
productId: String,
type: String,
onSuccess: () -> Unit = {},
onError: () -> Unit = {},
) {
val queryProductDetailsParams =
QueryProductDetailsParams.newBuilder()
.setProductList(
ImmutableList.of(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(type)
.build(),
)
)
.build()
// queryProductDetails
billingClient.queryProductDetailsAsync(queryProductDetailsParams) { billingResult, productDetailsList ->
if (billingResult.responseCode == BillingResponseCode.OK) {
val newProductDetailsList = _billingState.value.productDetailsList.toMutableList()
newProductDetailsList.addAll(productDetailsList)
_billingState.value =
_billingState.value.copy(productDetailsList = newProductDetailsList)
onSuccess()
} else {
Log.e(TAG, "queryProductDetails: Error")
onError()
}
}
}
fun launchIapBillingFlow(
productDetails: ProductDetails,
) {
if (!billingClient.isReady) {
Log.e(TAG, "launchBillingFlow: BillingClient is not ready")
}
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
// For One-time products, "setOfferToken" method shouldn't be called.
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
// TODO: .setObfuscatedAccountId(uid) // https://developer.android.com/google/play/billing/integrate?hl=ja#user-identifiers
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
}
fun launchSubscriptionBillingFlow(
productDetails: ProductDetails,
offerToken: String
) {
if (!billingClient.isReady) {
Log.e(TAG, "launchBillingFlow: BillingClient is not ready")
}
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
// TODO: .setObfuscatedAccountId(uid) // https://developer.android.com/google/play/billing/integrate?hl=ja#user-identifiers
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
}
fun terminateBillingConnection() {
Log.i(TAG, "Terminating connection")
billingClient.endConnection()
}
}
Discussion