💰

Google Play Billing Library 入門4 消費型/非消費型アイテムの購入

に公開

アイテムのテスト購入をできるようにする

  • アプリ側:購入完了後、purchaseTokenをバックエンドに送信
  • バックエンド側:Google Play Developer APIで検証
  • バックエンド側:検証OKなら商品を付与
    セキュリティの観点から上記が必要、しかし今回はバックエンド知識が乏しいのとアプリだけに集中したいため省く

以下完成形ダイアグラム
https://gitdiagram.com/giglancer/samplebilling

消費型と非消費型の違い

消費型:ソシャゲでよく見る石、コイン
非消費型:プレミアム機能開放、広告除去

Google Play Console上だと消費型と非消費型のアイテムの区別はない
あるのはアプリ側のフローだけ
消費型アイテムとして扱いたいものは購入フローを立ち上げる、購入承認をする、消費する
非消費型アイテムとして扱いたいものは購入フローを立ち上げる、購入承認をする

データ層

ProductDataSource
購入したアイテムを取得する

suspend fun queryPurchase(
        productType: String = BillingClient.ProductType.INAPP
    ): Result<List<Purchase>> {
        if (!billingClientWrapper.isReady) {
            return Result.failure(Exception("Billing client is not ready"))
        }

        return suspendCancellableCoroutine { continuation ->
            val params = QueryPurchasesParams.newBuilder()
                .setProductType(productType)
                .build()
            billingClientWrapper.billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    continuation.resume(Result.success(purchases))
                } else {
                    continuation.resume(
                        Result.failure(Exception(billingResult.debugMessage))
                    )
                }
            }
        }
    }

PurchaseDataSource
observePurchaseUpdatesはGoogle Play Billingの購入イベントをKotlin Flowで監視する
後はコメント通り

@Singleton
class PurchaseDataSource @Inject constructor(
    private val billingClientWrapper: BillingClientWrapper
) {
    fun observePurchaseUpdates(): Flow<PurchaseUpdate> = callbackFlow {
        val listener = PurchasesUpdatedListener { billingResult, purchases ->
             when (billingResult.responseCode) {
                BillingClient.BillingResponseCode.OK -> {
                    if (purchases.isNullOrEmpty()) {
                        trySend(PurchaseUpdate.Failure(BillingClient.BillingResponseCode.ERROR))
                    } else {
                        purchases.forEach { purchase ->
                            trySend(PurchaseUpdate.Success(purchase))
                        }
                    }
                }
                BillingClient.BillingResponseCode.USER_CANCELED -> {
                    trySend(PurchaseUpdate.UserCancelled)
                }
                else -> {
                    trySend(PurchaseUpdate.Failure(billingResult.responseCode))
                }
            }
        }
        billingClientWrapper.initialize(listener)

        awaitClose {
            // 監視するためにFlowを終了させない
        }
    }

    // OSの購入フローを呼び出す
    suspend fun launchPurchaseFlow(
        activity: Activity,
        productDetails: ProductDetails
    ): Result<Unit> = suspendCancellableCoroutine { continuation ->
        if (!billingClientWrapper.isReady) {
            continuation.resume(Result.failure(Exception("Billing client is not ready")))
            return@suspendCancellableCoroutine
        }

        val productDetailsParamsList = listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .build()
        )

        val billingFlowParams = BillingFlowParams.newBuilder()
            .setProductDetailsParamsList(productDetailsParamsList)
            .build()

        val billingResult = billingClientWrapper.billingClient.launchBillingFlow(
            activity,
            billingFlowParams
        )

        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            continuation.resume(Result.success(Unit))
        } else {
            continuation.resume(
                Result.failure(Exception("Failed to launch billing flow: ${billingResult.debugMessage}"))
            )
        }
    }

    // 非消費可能アイテムの承認
    suspend fun acknowledgePurchase(purchase: Purchase): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            if (!billingClientWrapper.isReady) {
                return@withContext Result.failure(Exception("Billing client not ready"))
            }

            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && purchase.isAcknowledged) {
                return@withContext Result.success(Unit)
            }

            val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()

            val billingResult = billingClientWrapper.billingClient.acknowledgePurchase(
                acknowledgePurchaseParams
            )

            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                Result.success(Unit)
            } else {
                Result.failure(Exception("Failed to acknowledge purchase: ${billingResult.debugMessage}"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    // 消費可能アイテムの承認
    suspend fun consumePurchase(purchase: Purchase): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            if (!billingClientWrapper.isReady) {
                return@withContext Result.failure(Exception("Billing client not ready"))
            }

            // 既に承認済みの場合はスキップ
            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && purchase.isAcknowledged) {
                return@withContext Result.success(Unit)
            }

            val params = ConsumeParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()

            val consumeResult = billingClientWrapper.billingClient.consumePurchase(params)

            if (consumeResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                Result.success(Unit)
            } else {
                Result.failure(
                    Exception("Failed to consume purchase: ${consumeResult.billingResult.debugMessage}")
                )
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

BillingRepositoryImpl

@Singleton
class BillingRepositoryImpl @Inject constructor(
    private val billingClientWrapper: BillingClientWrapper,
    private val productDataSource: ProductDataSource,
    private val purchaseDataSource: PurchaseDataSource,
) : BillingRepository {
    private val _connectionState = MutableStateFlow<BillingConnectionState>(BillingConnectionState.Disconnected)
    override val connectionState: Flow<BillingConnectionState> = _connectionState.asStateFlow()

    override val purchaseUpdate: Flow<PurchaseUpdate> = purchaseDataSource.observePurchaseUpdates()

    override suspend fun initialize(): Result<Unit> {
        _connectionState.value = BillingConnectionState.Connecting

        return billingClientWrapper.connect()
            .onSuccess {
                _connectionState.value = BillingConnectionState.Connected
            }
            .onFailure {
                _connectionState.value = BillingConnectionState.Error(
                    it.message ?: "Unknown error"
                )
            }

    }

    override suspend fun getProducts(productIds: List<String>): Result<ProductList> {
        return productDataSource.queryProducts(productIds)
            .map { products ->
                ProductList(products = products)
            }
    }

    override suspend fun getPurchases(): Result<List<Purchase>> {
        return productDataSource.queryPurchase()
    }

    override suspend fun launchPurchaseFlow(
        activity: Activity,
        productDetails: ProductDetails
    ): Result<Unit> {
        return purchaseDataSource.launchPurchaseFlow(activity, productDetails)
    }

    override suspend fun consumePurchase(purchase: Purchase): Result<Unit> {
        return purchaseDataSource.consumePurchase(purchase)
    }

    override suspend fun acknowledgePurchase(purchase: Purchase): Result<Unit> {
        return purchaseDataSource.acknowledgePurchase(purchase)
    }

    override fun disconnect() {
        billingClientWrapper.disconnect()
        _connectionState.value = BillingConnectionState.Disconnected
    }
}

プレゼンテーション層

BillingViewModel

@HiltViewModel
class BillingViewModel @Inject constructor(
    private val initializeBillingUseCase: InitializeBillingUseCase,
    private val getProductsUseCase: GetProductsUseCase,
    private val getPurchasesUseCase: GetPurchasesUseCase,
    private val observePurchaseUpdatesUseCase: ObservePurchaseUpdatesUseCase,
    private val purchaseProductUseCase: PurchaseProductUseCase,
    private val acknowledgePurchaseUseCase: AcknowledgePurchaseUseCase,
    private val consumePurchaseUseCase: ConsumePurchaseUseCase,
) : ViewModel() {
    private val _billingUiState = MutableStateFlow(BillingUiState(status = Status.Loading))
    val billingUiState: StateFlow<BillingUiState> = _billingUiState.asStateFlow()

    val notConsumedPurchases: List<String>
        get() = listOf("test1", "test5")

    val consumedPurchases: List<String>
        get() = listOf("test2", "test3", "test4")


    init {
        observePurchaseUpdates()
        initializeBilling()
    }

    fun purchaseProduct(activity: Activity, productDetails: ProductDetails) {
        viewModelScope.launch {
            purchaseProductUseCase(activity, productDetails)
                .onSuccess {
                    Log.d("BillingViewModel", "Purchase flow launched successfully")
                    // 購入ダイアログが表示される
                    // 結果はobservePurchaseUpdatesで受け取る
                }
                .onFailure { error ->
                    Log.e("BillingViewModel", "Failed to launch purchase flow: ${error.message}")
                    _billingUiState.value =
                        _billingUiState.value.copy(status = Status.Error("購入のフローの起動に失敗しました"))
                }
        }
    }

    private fun observePurchaseUpdates() {
        viewModelScope.launch {
            observePurchaseUpdatesUseCase().collect { update ->
                val currentState = _billingUiState.value.status
                if (currentState !is Status.Success) return@collect

                when (update) {
                    is PurchaseUpdate.Failure -> {
                        Log.d("BillingViewModel", "Purchase failed")
                        _billingUiState.value =
                            _billingUiState.value.copy(status = Status.Error("購入に失敗しました"))
                    }

                    is PurchaseUpdate.Success -> {
                        Log.d("BillingViewModel", "Purchase success:")
                        handleSuccessfulPurchase(update.purchase)
                    }

                    PurchaseUpdate.UserCancelled -> {
                        Log.d("BillingViewModel", "Purchase cancelled")
                        _billingUiState.value =
                            _billingUiState.value.copy(status = Status.Error("購入がキャンセルされました"))
                    }
                }
            }
        }
    }

    private fun initializeBilling() {
        viewModelScope.launch {
            _billingUiState.value = BillingUiState(status = Status.Loading)

            initializeBillingUseCase()
                .onSuccess {
                    Log.d("BillingViewModel", "Billing client connected")
                    loadPurchases()
                    loadProducts()
                }
                .onFailure {
                    Log.d("BillingViewModel", "Billing client connection failed")
                    _billingUiState.value =
                        _billingUiState.value.copy(status = Status.Error("Billingクライアントの接続に失敗しました"))
                }
        }
    }

    private fun handleSuccessfulPurchase(purchase: Purchase) {
        viewModelScope.launch {
            // 購入に含まれる商品のタイプをチェック
            val hasNonConsumable = purchase.products.any { it in notConsumedPurchases }
            val hasConsumable = purchase.products.any { it in consumedPurchases }

            // 購入を承認
            when {
                // 非消費型アイテム
                hasNonConsumable && !hasConsumable -> {
                    acknowledgePurchaseUseCase(purchase)
                        .onSuccess {
                            Log.d("BillingViewModel", "Purchase acknowledged")
                            _billingUiState.value = _billingUiState.value.copy(
                                status = Status.Success,
                                purchases = _billingUiState.value.purchases + purchase,
                                pendingPurchases = _billingUiState.value.pendingPurchases.filter { current ->
                                    current.purchaseToken != purchase.purchaseToken
                                }
                            )
                        }
                        .onFailure { error ->
                            Log.e("BillingViewModel", "Acknowledge failed: ${error.message}")
                            _billingUiState.value =
                                _billingUiState.value.copy(status = Status.Error("購入の承認に失敗しました"))
                        }
                }
                // 消費型アイテム
                !hasNonConsumable && hasConsumable -> {
                    consumePurchaseUseCase(purchase)
                        .onSuccess {
                            Log.d("BillingViewModel", "Purchase consumed")
                            _billingUiState.value = _billingUiState.value.copy(
                                status = Status.Success,
                                pendingPurchases = _billingUiState.value.pendingPurchases.filter { current ->
                                    current.purchaseToken != purchase.purchaseToken
                                }
                            )
                        }
                        .onFailure {
                            Log.e("BillingViewModel", "Consume failed: ${it.message}")
                            _billingUiState.value = _billingUiState.value.copy(status = Status.Error("購入の承認に失敗しました"))
                        }
                }
                else -> {
                    Log.e("BillingViewModel", "Unknown product types: ${purchase.products}")
                    _billingUiState.value = _billingUiState.value.copy(
                        status = Status.Error("不明な商品が含まれています")
                    )
                }
            }
            loadPurchases()
        }
    }

    private fun loadPurchases() {
        viewModelScope.launch {
            getPurchasesUseCase()
                .onSuccess { purchases ->
                    Log.d("BillingViewModel", "Purchases loaded: $purchases")
                    _billingUiState.value = _billingUiState.value.copy(
                        status = Status.Success,
                        purchases = purchases.filter { it.isAcknowledged || it.purchaseState == Purchase.PurchaseState.PURCHASED },
                        pendingPurchases = purchases.filter { it.purchaseState == Purchase.PurchaseState.PENDING }
                    )
                }
                .onFailure {
                    // 購入履歴の取得失敗はエラー状態にしない(商品表示は継続)
                    Log.d("BillingViewModel", "Failed to load purchases: ${it.message}")
                }
        }
    }

    private fun loadProducts() {
        viewModelScope.launch {
            val productIds = notConsumedPurchases + consumedPurchases
            getProductsUseCase(productIds)
                .onSuccess {
                    Log.d("BillingViewModel", "Products loaded: ${it.products}")
                    _billingUiState.value = _billingUiState.value.copy(
                        status = Status.Success,
                        products = it.products,
                    )
                }
                .onFailure {
                    Log.d("BillingViewModel", "Failed to load products: ${it.message}")
                    _billingUiState.value =
                        _billingUiState.value.copy(status = Status.Error("商品の読み込みに失敗しました"))
                }
        }
    }

    fun retry() {
        initializeBilling()
    }

    fun isPurchased(productId: String): Boolean {
        return _billingUiState.value.purchases.any { purchase ->
            purchase.products.contains(productId)
        }
    }
}

https://github.com/giglancer/SampleBilling

https://developer.android.com/google/play/billing/integrate?hl=ja

Discussion