1回限りのアイテム(SkuType.INAPP)で○○日間有効アイテムを実現してみた

# はじめに
Google Play Billing Library を使って期間有効アイテムを実装してみました。
期間有効アイテム： 7間有効チケット、 ３０日有効チケット などのことです。

1回限りのアイテム(SkuType.INAPP)には、有効期限がありませんので、有効期限については自前で管理する必要があります。
アプリ内に保持しておくとアンインストールされたら復元できなくなってしまうため、アイテム購入が終了したらアイテム消費処理と
同時にクラウドに購入情報を保存しなければいけません。
（ここでは購入と同時に消費処理を行うため、その瞬間から有効期限のカウントダウンが開始します）

定期購入（SkuType.SUBS)使えよっていうツッコミはごもっともです (><)

下記のバージョンを使用
```com.android.billingclient:billing:2.0.3
```

# ざっくりとした流れ
1: ユーザーがアイテムをポチった
2: すでに購入済みか確認
 2-a: 購入済みだったらアプリをアイテム有効状態に変更
 2-b: 購入処理を実行

以上！！

# 実装: 購入済みか確認

```kotlin:billingViewModel.kt
    val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Main)
    private val billingCase: BillingUseCase

    // 2: すでに購入済みか確認
    fun restore(context: Context) {
        scope.launch {
            try {
                // ライブラリを接続状態に
                if (!billingCase.startBillingConnection()) {
                    showRestoreError()
                    return@launch
                }
                // アイテムの購入履歴を取得
                billingCase.queryPurchaseHistoryAsync(resultRestore = ::resultRestore)
            } catch (ex: Exception) {
                showRestoreError()
            }
        }
    }

```

```kotlin:BillingUseCase.kt
    private val billingRepository: BillingRepository
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default

    // ライブラリを接続状態に
    suspend fun startBillingConnection() = billingRepository.startBillingConnection()

    // アイテムの購入履歴を取得
    suspend fun queryPurchaseHistoryAsync(resultRestore: KFunction1<@ParameterName(name = "ticketInfo") TicketInformation?, Unit>) {
        withContext(dispatcher) {
            // アイテム購入履歴
            val inAppHistoryList: List<PurchaseHistoryRecord>? = billingRepository.queryInAppHistoryAsync()

            // 履歴がなかったら購入処理へ
            if (inAppHistoryList == null) {
                resultRestore(null)
                return@withContext
            }

            // クラウドへ結果をポストし、有効期限内のアイテムがあるかチェックする
            // postAppHistoryDataの結果をうけて、
            // アプリをアイテム有効状態に変更 or 購入処理 をする
            val postResult = postItemData(inAppHistoryList)
            resultRestore(postResult)

        }
    }
```

```kotlin:BillingRepository.kt
    private var billingClient: BillingClient = BillingClient.newBuilder(context).enablePendingPurchases().setListener(this).build()
    private var broadcastChannel: BroadcastChannel<PurchasesUpdatedResultData> = BroadcastChannel(capacity = 1)

    // ライブラリを接続状態に
    suspend fun startBillingConnection(): Boolean {
        if (billingClient.isReady) {
            return true
        }

        return suspendCoroutine { continuation ->
            billingClient = builder.enablePendingPurchases().setListener(this).build()

            billingClient.startConnection(object : BillingClientStateListener {
                override fun onBillingServiceDisconnected() {
                    // ライブラリを使う前に接続状態にし、この通知は無視する
                }

                override fun onBillingSetupFinished(billingResult: BillingResult) {
                    continuation.resume(billingResult.responseCode == BillingResponseCode.OK)
                }
            })
　　　　　　　// onBillingSetupFinished呼ばれたら返るようにする
            return@suspendCoroutine
        }
    }

    suspend fun queryInAppHistoryAsync(): List<PurchaseHistoryRecord> {
        return suspendCoroutine { continuation ->
            billingClient.queryPurchaseHistoryAsync(BillingClient.SkuType.INAPP) { billingResult, historyRecordList ->
                when (billingResult.responseCode) {
                    BillingResponseCode.OK -> continuation.resume(historyRecordList)
                    else -> continuation.resumeWithException(Exception())
                }
            }
        }
    }
```

# 実装: 購入処理
```kotlin:billingViewModel.kt
    fun purchaseItem(){
    scope.launch {
            try {
                billingCase.purchaseItemAsync(itemId, resultPurchase = ::resultPurchase)
            } catch (ex: Exception) {
                showPurchaseError()
            }
        }
    }


```

```kotlin:BillingUseCase.kt
    suspend fun purchaseItemAsync(itemId: String?, resultPurchase: KFunction1<@ParameterName(name = "ticketInfo") TicketInformation?, Unit>) {
        withContext(dispatcher) {
            val skuList = purchaseId?.let {
                // SKU詳細情報の取得
                billingRepository.querySkuDetailsAsync(it, isTicket)
            } ?: return@withContext

            val purchaseParams = BillingFlowParams.newBuilder()
                    .setSkuDetails(skuList[0])
                    .build()

            // 購入結果を受け取るレシーバ-
            val receiver = billingRepository.createReceiveChannel()
            // 購入実行
            billingRepository.startPurchaseFlow(purchaseParams)
            receiver.consumeEach {
                val responseCode: Int = it.responseCode
                val purchaseList: List<Purchase>? = it.purchases

                if (responseCode == BillingResponseCode.OK) {
                    // 消費処理
                    val consumeList: List<Purchase> = consumeInApp(purchaseList)

                    // クラウドへ結果をポスト
                    if (consumeList.isNotEmpty()) {
                        val postResult = postItemData(consumeList)
                        // アプリをアイテム有効状態に変更
                        resultPurchase(responseCode, postResult)
                    } else {
                        resultPurchase(responseCode, null)
                    }
                } else {
                    resultPurchase(responseCode, null)
                }
                receiver.cancel()
            }
        }
    }

    // 消費処理
    private suspend fun consumeInApp(purchaseList: List<Purchase>?): List<Purchase> {
        val inAppPurchaseList: MutableList<Purchase> = mutableListOf()

        purchaseList?.forEach {purchase ->
            // アイテムなのでisAcknowledgedチェックは必要ないかもしれません
            if (purchase.isAcknowledged) {
                inAppPurchaseList.add(purchase)
                return@forEach
            }
            val consumeResult  = billingRepository.consumePurchase(purchase)
            inAppPurchaseList.add(consumeResult)
        }
        return inAppPurchaseList
    }

```

```kotlin:BillingRepository.kt
    // SKU詳細情報の取得
    suspend fun querySkuDetailsAsync(id: String?): List<SkuDetails> {
        return suspendCoroutine { continuation ->
            val params = SkuDetailsParams.newBuilder()
                    .setSkusList(listOf(id))
                    .setType(BillingClient.SkuType.INAPP)

            billingClient.querySkuDetailsAsync(params.build()) { billingResult, skuDetailsList ->
                if (billingResult.responseCode == BillingResponseCode.OK) {
                    continuation.resume(skuDetailsList)
                } else {
                    continuation.resumeWithException(Exception("responseCode: ${billingResult.responseCode}"))
                }
            }
        }
    }

    fun createReceiveChannel(): ReceiveChannel<PurchasesUpdatedResultData> {
        if (broadcastChannel.isClosedForSend) {
            broadcastChannel = BroadcastChannel(capacity = 1)
        }
        return broadcastChannel.openSubscription()
    }

    // region PurchasesUpdatedListener
    override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
        // レシーバーへ購入結果を通知する
        broadcastChannel.offer(PurchasesUpdatedResultData(billingResult.responseCode, purchases))
    }
    // endregion

    // 消費処理
    // 消費処理を行うことで同じアイテムが再度購入できるようになる。
    suspend fun consumePurchase(purchase: Purchase): Purchase {
        return suspendCoroutine { continuation ->
            val params = ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
            billingClient.consumeAsync(params) { billingResult, _ ->
                when (billingResult.responseCode) {
                    BillingResponseCode.OK -> continuation.resume(purchase)
                    else -> continuation.resumeWithException(Exception())
                }
            }
        }
    }
```

```kotlin:PurchasesUpdatedResultData
data class PurchasesUpdatedResultData(@BillingResponseCode val responseCode: Int, val purchases: List<Purchase>?)
```

# まとめ
・かなり急いで書いたので誤記があれば修正していきたいと思います。
　・定期購読とあわせた使い方も追記できればとおもいます。
・アイテムは消費処理を行うと queryPurchases() では購入履歴が取得できなくなるので、消費済み履歴も取得したい場合は queryPurchaseHistoryAsync() を使用する。
・Google Play Console の[デベロッパーアカウント]>[アカウントの詳細]>[ライセンス テスト]>[テスト用のアクセス権がある Gmail アカウント]に
アカウントを登録すれば実機での確認も簡単。