💰

Google Play Billing Library 入門3 アイテムを表示する

に公開

Google Play Consoleで設定したアイテムをアプリで表示する

以下ダイアグラムは購入処理のの完成形
https://gitdiagram.com/giglancer/samplebilling

今回関係ある個所は

  • MainActivity
  • BillingViewModel
  • BillingRepository
  • initialBillingUseCase
  • GetProductUseCase
  • BillingRepository
  • ProductDataSorce
  • BillingClientWrapper
    詳細は以下Githubで確認してください
    https://github.com/giglancer/SampleBilling

データ層

BillingClientWrapper
BillingLibraryの初期化と接続を行う

@Singleton
class BillingClientWrapper @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private var _billingClient: BillingClient? = null
    val billingClient: BillingClient
        get() = _billingClient ?: throw IllegalStateException("Billing client is not initialized.")

    val isReady: Boolean
        get() = _billingClient?.isReady == true

    fun initialize(purchasesUpdatedListener: PurchasesUpdatedListener) {
        _billingClient = BillingClient.newBuilder(context)
            .setListener(purchasesUpdatedListener)
            .enablePendingPurchases(
                PendingPurchasesParams.newBuilder()
                    .enableOneTimeProducts()
                    .build()
            )
            .enableAutoServiceReconnection()
            .build()
    }

    suspend fun connect(): Result<Unit> = suspendCancellableCoroutine { continuation ->
        if (_billingClient == null) {
            continuation.resume(Result.failure(Exception("BillingClient not initialized")))
            return@suspendCancellableCoroutine
        }

        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingServiceDisconnected() {
                // 自動再接続が有効
            }

            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    continuation.resume(Result.success(Unit))
                } else {
                    continuation.resume(Result.failure(Exception(billingResult.debugMessage)))
                }
            }
        })
    }

    fun disconnect() {
        _billingClient?.endConnection()
        _billingClient = null
    }
}

ProductDataSource
Google Play Consoleで設定したアイテムのアイテムのIDを渡してアイテム情報を取得する

@Singleton
class ProductDataSource @Inject constructor(
    private val billingClientWrapper: BillingClientWrapper
) {
    @OptIn(ExperimentalCoroutinesApi::class)
    suspend fun queryProducts(
        productIds: List<String>,
        productType: String = BillingClient.ProductType.INAPP
    ): Result<List<ProductDetails>> {
        if (!billingClientWrapper.isReady) {
            return Result.failure(Exception("Billing client is not ready"))
        }
        return suspendCancellableCoroutine { continuation ->
            val productList = productIds.map { productId ->
                QueryProductDetailsParams.Product.newBuilder()
                    .setProductId(productId)
                    .setProductType(productType)
                    .build()
            }

            val params = QueryProductDetailsParams.newBuilder()
                .setProductList(productList)
                .build()

            billingClientWrapper.billingClient.queryProductDetailsAsync(params) { billingResult, queryProductDetailsResult ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    continuation.resume(
                        Result.success(queryProductDetailsResult.productDetailsList)
                    )
                } else {
                    continuation.resume(
                        Result.failure(Exception(billingResult.debugMessage))
                    )
                }
            }
        }
    }
}

BillingViewModel
viewModelの初期化と同時にBillingLibraryと接続する
仮でローカルで持っているアイテムのIDを使用してBillingサーバーに問い合わせする
_billingUiStateで保持している products: List<ProductDetailsをUIで使用すればアイテムリストが表示される

@Singleton
class BillingRepositoryImpl @Inject constructor(
    private val billingClientWrapper: BillingClientWrapper,
    private val productDataSource: ProductDataSource,
) : 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 fun disconnect() {
        billingClientWrapper.disconnect()
        _connectionState.value = BillingConnectionState.Disconnected
    }
}

ドメイン層はシンプルなインターフェースなので省略

プレゼンテーション層

@HiltViewModel
class BillingViewModel @Inject constructor(
    private val initializeBillingUseCase: InitializeBillingUseCase,
    private val getProductsUseCase: GetProductsUseCase,
) : 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 {
        initializeBilling()
    }

    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 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("商品の読み込みに失敗しました"))
                }
        }
    }
}

Discussion