Android アプリに定期購入を実装する

# 概要

Android アプリに [Google Play 課金システム](https://developer.android.com/google/play/billing?hl=ja)の [定期購入](https://developer.android.com/google/play/billing/subscriptions?hl=ja) を実装するときのポイントを簡単にまとめておきます。

# 設定

## Google Cloud Platform - Pub/Sub

[リアルタイム デベロッパー通知を構成する](https://developer.android.com/google/play/billing/getting-ready?hl=ja#configure-rtdn)
基本的にはこちらの内容です。

定期購入されたときの通知を受け取るトピックを作成していきます。

### トピック作成

- GCP の Pub/Sub から「トピックを作成」

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/0c5eb0d0-a941-37af-80d4-d7eca416fa72.png />

- トピック ID を入力して「トピックを作成」

<img width=320 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/92593ea8-2804-6dce-3db0-63cf4f6a4ca7.png />

### サブスクリプション作成

- 作成したトピックに「サブスクリプションを作成」

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/ebbf8d17-f1bc-09f9-1b26-b832f94188b1.png />

- サブスクリプション ID を入力して CREATE

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/56997e26-df23-a218-17d5-86fdd976697d.png />

### トピックに権限付与

- 作成したトピックを選択して「メンバーを追加」

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/1e51c400-30e4-9948-b223-6611a46c2526.png />

- メンバー追加して保存

新しいメンバーに `google-play-developer-notifications@system.gserviceaccount.com` を入力、
ロールに `Pub/Sub パブリッシャー` を選択

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/9c3462c2-6bf3-4163-bd5b-4c8fc6313e87.png />

以上で Google Cloud Platform - Pub/Sub の設定は終了です。

## Google Play Console

基本的に [アプリでリアルタイム デベロッパー通知を有効にする](https://developer.android.com/google/play/billing/getting-ready?hl=ja#enable-rtdn) と [アイテムを作成して構成する](https://developer.android.com/google/play/billing/getting-ready?hl=ja#products) の内容になります。

### 収益化のセットアップ

- 先ほど作成したトピックのトピック名を入力

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/231b92aa-2a5e-f294-dfbf-8d6cc938e560.png />

入力したら 「テスト通知を送信」 してエラーが表示されないことを確認してください。
もしエラーが表示されたらトピック名が間違えてるか権限付与されていない可能性があります。

### 定期購入を作成

- アイテム ID などを入力して「保存」（各入力項目は [Play Console ヘルプ - 定期購入の作成](https://support.google.com/googleplay/android-developer/answer/140504?hl=ja&ref_topic=3452890) 参照）

<img width=640 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/bd55290a-7cd5-7b2f-27ad-ac3b556b5d40.png />

以上で Google Play Console の設定は終了です。


# 実装

ここからは具体的な実装方法になります。

- アプリから定期購入処理を呼び出す処理

と定期購入処理が完了したあとに Google から通知される

- リアルタイムデベロッパー通知を受け取る処理

の 2 つを実装する必要があります。

## アプリから定期購入処理を呼び出す処理

主に [Google Play Billing Library をアプリに統合する](https://developer.android.com/google/play/billing/integrate?hl=ja) の内容になります。

### BillingClient の初期化など

```kotlin
private lateinit var billingClient: BillingClient
private var autosubscriptionSkuDetails: SkuDetails? = null

private fun billingSetup() {
    // 購入処理のコールバック
    val purchaseUpdateListener = PurchasesUpdatedListener { billingResult, purchases ->
        Log.d("BILLING_TEST_TAG", "billingResult.responseCode: ${billingResult.responseCode}")
    }

    // BillingClient の初期化
    billingClient = BillingClient.newBuilder(this)
        .setListener(purchaseUpdateListener)
        .enablePendingPurchases()
        .build()

    // セットアップ処理のコールバック
    val billingClientStateListener: BillingClientStateListener = object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                // 商品情報取得
                val skuList = listOf(
                    // 「定期購入を作成」で入力した定期購入のアイテム ID
                    "subscription.product.id"
                )
                val params = SkuDetailsParams.newBuilder().setSkusList(skuList).setType(BillingClient.SkuType.SUBS).build()

                billingClient.querySkuDetailsAsync(params) { querySkuBillingResult, skuDetailsResult ->
                    if (querySkuBillingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                        skuDetailsResult ?: return@querySkuDetailsAsync
                        for (skuDetails: SkuDetails in skuDetailsResult) {
                            if (skuDetails.sku == "subscription.product.id") {
                                autosubscriptionSkuDetails = skuDetails
                                return@querySkuDetailsAsync
                            }
                        }
                    } else {
                        Log.d(
                            BILLING_TEST_TAG,
                            "querySkuDetailsAsync error. responseCode:${querySkuBillingResult.responseCode}"
                        )
                    }
                }
            } else {
                Log.d(BILLING_TEST_TAG, "Setup error. responseCode:${billingResult.responseCode}")
            }
        }

        override fun onBillingServiceDisconnected() {
            Log.d(
                "BILLING_TEST_TAG",
                "Try to restart the connection on the next request to Google Play by calling the startConnection() method."
            )
        }
    }

    // 接続開始
    billingClient.startConnection(billingClientStateListener)
}
```

### 購入処理の呼び出し

```kotlin
val skuDetails: SkuDetails? = autosubscriptionSkuDetails
if (skuDetails != null) {
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setSkuDetails(skuDetails)
        .build()

    // 購入フローを開始
    billingClient.launchBillingFlow(this, billingFlowParams)
}
```

ここまで実装すれば以下のような購入画面が表示できると思います。

<img width=320 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/8f5a3ba5-f492-cff5-005e-3b4615cd4819.png />

ちなみにテスト購入できるようにするためにいくつか設定が必要なので [Google Play Billing Library 統合をテストする](https://developer.android.com/google/play/billing/test?hl=ja) を参照してください。

以上でアプリから定期購入処理を呼び出す処理は終了です。

## リアルタイムデベロッパー通知を受け取る処理

[定期購入を販売する](https://developer.android.com/google/play/billing/subscriptions?hl=ja) 
こちらに記載されているライフサイクルを処理します。

定期購入されると 「収益化のセットアップ」 で設定したトピックに対してメッセージが送信されてくるので
それをトリガーに GoogleCloudPlatform の Cloud Functions で処理します。

```typescript:functions/src/googlePlayRtdn/index.ts
import { Message } from 'firebase-functions/lib/providers/pubsub';
import { EventContext } from 'firebase-functions';

import * as JsonPrune from 'json-prune';
import * as Functions from 'firebase-functions';

class GooglePlayRtdnEndpoint {
    public static topicName = 'test';

    public async run(resolve: any, reject: any, message: Message, context?: EventContext) {
        const data = message.json;
        if (!data.subscriptionNotification && !data.oneTimeProductNotification && !data.testNotification) {
            return reject({ data, error: new Error(`PubSub message was invalid JSON: ${JsonPrune(data)}`) });
        }

        console.log(JsonPrune(data));
        return resolve();
    }
}

const googlePlayRtdnEndpoint = new GooglePlayRtdnEndpoint();

export const googlePlayRtdn = Functions.region('asia-northeast1')
    .pubsub.topic(GooglePlayRtdnEndpoint.topicName)
    .onPublish((message: Message, context?: EventContext) => {
        return new Promise((resolve, reject) => googlePlayRtdnEndpoint.run(resolve, reject, message, context)).catch(error => {
            console.error(error);
        });
    });
```

こちらのソースコードを GoogleCloudPlatform にデプロイします。

GoogleCloudPlatform の Cloud Functions に `googlePlayRtdn` という関数が追加されていることを確認してください。

以上でリアルタイムデベロッパー通知を受け取る処理は終了です。

# 動作確認

アプリからテスト購入を実行します。
GoogleCloudPlatform の Cloud Functions `googlePlayRtdn` の「ログ」に

```json
{
    "version": "1.0",
    "packageName": "******",
    "eventTimeMillis": "1610083333446",
    "subscriptionNotification": {
        "version": "1.0",
        "notificationType": 4,
        "purchaseToken": "************************************",
        "subscriptionId": "subscription.product.id"
    }
}
```

こんな感じの json が出力されれば成功です。

あとは `notificationType` によってどういう処理を実行させるかを判断します。
`notificationType` の種類などは [リアルタイム デベロッパー通知のリファレンス ガイド](https://developer.android.com/google/play/billing/rtdn-reference?hl=ja) を参照してください。

有料会員かどうか、は [Firebase の Custom Claims](https://firebase.google.com/docs/auth/admin/custom-claims) を使用するのが良いと思います。