💲

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