Firebase で Android の定期購入 (Google Billing Library) その3

久しぶりに定期購入を実装したら色々ハマったので同じように困っちゃっている方の参考になれば幸いです。 その3では、理解した結果の実装を公開します。 他の記事も合わせてどうぞ。

準備

購入情報の記録は Firestore Database に保存します。 「/purchases」配下にドキュメントが作成されるので認証ユーザーがアクセスできるようにルールを設定しておきます。
    match /purchases {
      allow read, write: if request.auth.uid != null;
      match /{allChildren=**} {
        allow read, write: if request.auth.uid != null;
      }
    }

サーバサイド Functions

サーバサイドは Firebase の Functions で実装します。 デプロイする前に、「Google Play Console Developer プロジェクト」に作成したサービスアカウントの認証情報を登録します。 秘密鍵はJSON形式でダウンロードした鍵ファイルの「”private_key”」項目から抽出します。スクリプト内で復元するので、ヘッダー、フッター、改行を除去して一行の文字列にして設定します。
firebase functions:config:set env.client_email="purchases@xxx.iam.gserviceaccount.com"
firebase functions:config:set env.private_key="MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwgg..."
環境変数に登録できたら、TypeScript のソースを index.ts などに保存してデプロイしてください。
import * as functions from "firebase-functions";
import {google} from "googleapis";
import admin = require("firebase-admin");
admin.initializeApp(functions.config().firebase);
const db = admin.firestore();

interface StringKeyObject {
    [key: string]: any;
}

enum SubscriptionNotificationTypes {
    // 定期購入がアカウントの一時停止から復帰した。
    SUBSCRIPTION_RECOVERED = 1,

     // アクティブな定期購入が更新された。
    SUBSCRIPTION_RENEWED = 2,

    // 定期購入が自発的または非自発的にキャンセルされた。
    // 自発的なキャンセルの場合、ユーザーがキャンセルしたときに送信されます。
    SUBSCRIPTION_CANCELED = 3,

    // 新しい定期購入が購入された。
    SUBSCRIPTION_PURCHASED = 4,

    // 定期購入でアカウントが一時停止された(有効な場合)。
    SUBSCRIPTION_ON_HOLD = 5,

    // 定期購入が猶予期間に入った(有効な場合)。
    SUBSCRIPTION_IN_GRACE_PERIOD = 6,

    // ユーザーが [Play] > [アカウント] > [定期購入] から
    // 定期購入を再有効化した(定期購入の再開にはオプトインが必要)。
    SUBSCRIPTION_RESTARTED = 7,

    // 定期購入の料金変更がユーザーによって確認された。
    SUBSCRIPTION_PRICE_CHANGE_CONFIRMED = 8,

    // 定期購入の契約期間が延長された。
    SUBSCRIPTION_DEFERRED = 9,

     // 定期購入が一時停止された。
    SUBSCRIPTION_PAUSED = 10,

     // 定期購入の一時停止スケジュールが変更された。
    SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED = 11,

    // 有効期限前にユーザーが定期購入を取り消した。
    SUBSCRIPTION_REVOKED = 12,

     // 定期購入が期限切れになった。
    SUBSCRIPTION_EXPIRED = 13,
}

const authClient = new google.auth.JWT({
  email: functions.config().env.client_email,
  key: getPrivateKey(functions.config().env.private_key),
  scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});

const playDeveloperApiClient = google.androidpublisher({
  version: "v3",
  auth: authClient,
});

const getSubscription = async (
    token: string,
    subscriptionId: string,
    packageName: string) => {
  await authClient.authorize();
  return playDeveloperApiClient.purchases.subscriptions.get({
    packageName,
    subscriptionId,
    token,
  });
};

/**
 * 秘密鍵に変換する
 * @param {string} privateKey - 文字列
 * @return {string} - 秘密鍵の書式
 */
function getPrivateKey(privateKey: string): string {
  const key = chunkSplit(privateKey, 64, "\n");
  const pkey = "-----BEGIN PRIVATE KEY-----\n" +
        key +
        "-----END PRIVATE KEY-----\n";

  return pkey;
}

/**
 * 指定幅で分割してセパレータで結合した文字列に変換する
 * @param {string} str - 変換元の文字列
 * @param {number} len - 分割する文字数
 * @param {string} end - 結合するセパレータ
 * @return {string} - 変換後の文字列
 */
function chunkSplit(str: string, len: number, end: string): string {
  const match = str.match(new RegExp(".{0," + len + "}", "g"));
  if (!match) {
    return "";
  }

  return match.join(end);
}


/**
 * 購入トークンに紐づけられたドキュメントのIDを取得
 * @param {string} purchaseToken - 購入トークン
 * @return {string} - ドキュメントID
 */
async function queryPurchaseDocId(
    purchaseToken: string): Promise<string | null> {
  const snapShot = await db.collection("purchases")
      .where("purchaseToken", "==", purchaseToken)
      .get();

  let docId = null;
  snapShot.forEach((doc) => {
    if (doc.id) {
      docId = doc.id;
    }
  });

  console.log(`return docId: ${docId}`);
  return docId;
}

/**
 * 購入を登録
 * @param {string} packageName - パッケージ名
 * @param {string} subscriptionId - SKU
 * @param {string} purchaseToken - 購入トークン
 * @param {string} userId - ユーザID
 * @param {string} orderId - オーダID
 * @param {number} startTimeMillis - 購入開始時刻
 * @param {number} expiryTimeMillis - 購入開始時刻
 */
async function insertPurchase(
    packageName: string,
    subscriptionId: string,
    purchaseToken: string,
    userId: string,
    orderId: string,
    startTimeMillis: number,
    expiryTimeMillis: number
) {
  console.log("insertPurchase");

  await db.collection("purchases").add({
    purchaseToken: purchaseToken,
    subscriptionId: subscriptionId,
    packageName: packageName,
    notificationType: 4,
    paymentState: 1,
    orderId: orderId,
    autoRenewing: true,
    cancelReason: -1,
    userId: userId,
    valid: true,
    startTime: admin.firestore.Timestamp.fromMillis(Number(startTimeMillis)),
    expiryTime: admin.firestore.Timestamp.fromMillis(Number(expiryTimeMillis)),
  });
}

/**
 * 購入データを更新
 * @param {number} notificationType - 通知タイプ
 * @param {number} purchaseToken - 購入トークン
 * @param {string} subscriptionId - SKU
 * @param {string} packageName - パッケージ名
 */
async function updatePurchase(notificationType: number,
    purchaseToken: string,
    subscriptionId: string,
    packageName: string) {
  console.log(`updatePurchase(${notificationType})`);

  const response = await getSubscription(
      purchaseToken,
      subscriptionId,
      packageName);

  const subscriptions = response.data;

  const paymentState = subscriptions.paymentState || -1;
  const orderId = subscriptions.orderId || "";
  const expiryTime = admin.firestore.Timestamp
      .fromMillis(Number(subscriptions.expiryTimeMillis));
  const cancelReason = subscriptions.cancelReason || -1;
  const autoRenewing = subscriptions.autoRenewing || false;

  const expiresAt = subscriptions.expiryTimeMillis;
  const valid = (Number(expiresAt) >= Date.now());

  const docId = await queryPurchaseDocId(purchaseToken);
  if (docId == null) {
    throw new functions.https.HttpsError("not-found",
        "Purchase data does not exist: " +
      `purchaseToken : ${purchaseToken}`
    );
  }

  await db.collection("purchases").doc(docId).set({
    notificationType: notificationType,
    paymentState: paymentState,
    orderId: orderId,
    expiryTime: expiryTime,
    cancelReason: cancelReason,
    autoRenewing: autoRenewing,
    valid: valid,
  }, {merge: true});
}

/**
 * 購入データを削除する
 * @param {number} purchaseToken - 購入トークン
 */
async function deletePurchase(purchaseToken: string) {
  console.log(`deletePurchase: ${purchaseToken}`);

  const docId = await queryPurchaseDocId(purchaseToken);
  if (docId == null) {
    // 同一人物がキャンセルして期限が切れた時と
    // 再度定期購入した時の2回 SUBSCRIPTION_EXPIRED が飛んでくる
    console.log("Purchase data has already been deleted");
  } else {
    await db.collection("purchases").doc(docId).delete();
  }
}

exports.verifyPurchaseForAndroid = functions.https
    .onCall(async (data, context) => {
      const {packageName, subscriptionId, purchaseToken} = data;
      const userId = context?.auth?.uid || "";

      // token の重複チェック (存在しなければOK)
      const docId = await queryPurchaseDocId(purchaseToken);
      if (docId != null) {
        throw new functions.https.HttpsError("already-exists",
            "Purchase data already exists");
      }

      // token の正当性チェック (最新の購読情報を取得)
      const response = await getSubscription(
          purchaseToken,
          subscriptionId,
          packageName);
      const subscriptions = response.data;

      // 取得に失敗した場合は検証失敗とする
      if (!subscriptions || response.status !== 200) {
        throw new functions.https.HttpsError("invalid-argument",
            "receipt not found");
      }

      // 購入状態が 1 でなければ検証失敗とする
      if (subscriptions.paymentState !== 1) {
        throw new functions.https.HttpsError("failed-precondition",
            `receipt payment state invalid: ${subscriptions.paymentState}`);
      }

      // 有効期限を確認し、期限切れの場合は検証は失敗とする
      if (subscriptions["expiryTimeMillis"]) {
        if (Number(subscriptions.expiryTimeMillis) <= Date.now()) {
          throw new functions.https.HttpsError("deadline-exceeded",
              "subscriptions expired");
        }
      }

      // ここまでの検証がOKなら、購入情報を登録
      await insertPurchase(packageName,
          subscriptionId,
          purchaseToken,
          userId,
          subscriptions.orderId || "",
          Number(subscriptions.startTimeMillis),
          Number(subscriptions.expiryTimeMillis)
      );

      if (subscriptions.linkedPurchaseToken) {
        // linked があったら、そっちは削除
        await deletePurchase(subscriptions.linkedPurchaseToken);
      }

      return {
        message: "Purchase verification successfully.",
      };
    });

exports.billingPubSub = functions.pubsub
    .topic("billing-notification")
    .onPublish(async (message) => {
      const messageBody = message.data ?
            JSON.parse(Buffer.from(message.data, "base64").toString()) :
            null;

      if (messageBody) {
        const {subscriptionNotification, packageName} = messageBody;

        if (subscriptionNotification) {
          const {notificationType, purchaseToken, subscriptionId} =
            subscriptionNotification;

          console.log(`notificationType: ${notificationType}`);

          switch (notificationType) {
            case SubscriptionNotificationTypes.SUBSCRIPTION_RECOVERED:
            case SubscriptionNotificationTypes.SUBSCRIPTION_RESTARTED:
            case SubscriptionNotificationTypes.SUBSCRIPTION_RENEWED: {
              await updatePurchase(notificationType,
                  purchaseToken,
                  subscriptionId,
                  packageName);
              break;
            }
            case SubscriptionNotificationTypes.SUBSCRIPTION_REVOKED:
            case SubscriptionNotificationTypes.SUBSCRIPTION_EXPIRED: {
              await deletePurchase(purchaseToken);
              break;
            }
            case SubscriptionNotificationTypes.SUBSCRIPTION_CANCELED: {
              await updatePurchase(notificationType,
                  purchaseToken,
                  subscriptionId,
                  packageName);
              break;
            }
            case SubscriptionNotificationTypes.SUBSCRIPTION_ON_HOLD: {
              await updatePurchase(notificationType,
                  purchaseToken,
                  subscriptionId,
                  packageName);
              break;
            }
            case SubscriptionNotificationTypes.SUBSCRIPTION_IN_GRACE_PERIOD: {
              await updatePurchase(notificationType,
                  purchaseToken,
                  subscriptionId,
                  packageName);
              break;
            }
            default:
              break;
          }
        }
      }
    });

クライアントサイド Android モジュール

流用させていただいたので、絶対必要かどうか理解し切れていませんが、まずは定義系。
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult

enum class BillingSkuType(@BillingClient.SkuType val skuType: String) {
    /** 定期購読 */
    Subscription(BillingClient.SkuType.SUBS),

    /** 消費不可のアプリ内アイテム */
    InAppNonConsumable(BillingClient.SkuType.INAPP),

    /** 消費可能なアプリ内アイテム */
    InAppConsumable(BillingClient.SkuType.INAPP),
}

enum class BillingItem(val sku: String, val billingSkuType: BillingSkuType) {
    AdFreeSubscription("ad_free.subscription_01", BillingSkuType.Subscription),
    TestAdFreeSubscription("test.ad_free.subscription_01", BillingSkuType.Subscription);

    companion object {
        fun findBySku(sku: String): BillingItem? = values().find { it.sku == sku }
    }

    fun skusList(): List<String> = listOf(sku)

}

sealed class BillingStatus {
    object SetupSuccess : BillingStatus()
    object BillingFlow : BillingStatus()
    object PendingPurchase : BillingStatus()
    object AcknowledgeSuccess : BillingStatus()
    object ServiceDisconnected : BillingStatus()
    object NoPreviousPlanPurchaseHistory : BillingStatus()
    data class Error(val billingResult: BillingResult) : BillingStatus()
}
次に Firestore Database のドキュメントに対応するクラス。こちらはオリジナルです。 doc.toObject(Subscription::class.java) するので、ProGuard で難読化しないようにしてください。
import com.google.firebase.Timestamp

class Subscription {
    var userId: String = ""
    var subscriptionId: String = ""
    var packageName: String = ""
    var valid: Boolean = false
    var purchaseToken: String = ""
    var paymentState: Int = -1
    var notificationType: Int = 0
    var startTime: Timestamp = Timestamp.now()
    var expiryTime: Timestamp = Timestamp.now()
    var orderId: String = ""
    var autoRenewing: Boolean = false
    var cancelReason: Int = -1
}
続いてビューモデルです。こちらも参考サイトから流用させていただきました。 ただ、私なりに改変を加えているので、オリジナルの思いから外れてしまっているかも知れません。その場合はすみません。
import android.app.Activity
import android.app.Application
import android.util.Log
import androidx.lifecycle.*
import com.android.billingclient.api.*
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.ktx.functions
import com.google.firebase.ktx.Firebase

class BillingViewModel(val application: Application) : ViewModel(), BillingClientStateListener,
    PurchasesUpdatedListener, AcknowledgePurchaseResponseListener, LifecycleObserver {

    private val _skuDetails = MutableLiveData<SkuDetails>()
    val skuDetails: LiveData<SkuDetails> = _skuDetails

    private val _subscription = MutableLiveData<Subscription>()
    val subscription: LiveData<Subscription> = _subscription

    private val _billingStatus = MutableLiveData<BillingStatus>()
    val billingStatus: LiveData<BillingStatus> = _billingStatus

    private val billingClient = BillingClient.newBuilder(application)
        .enablePendingPurchases()
        .setListener(this)
        .build()

    override fun onPurchasesUpdated(
        billingResult: BillingResult,
        purchases: MutableList<Purchase>?
    ) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
            purchases.forEach {
                acknowledgePurchase(it)
            }
        } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
            // cancel
        } else {
            // any other error
        }
    }

    override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK)
            _billingStatus.postValue(BillingStatus.SetupSuccess)
        else
            _billingStatus.postValue(BillingStatus.Error(billingResult))
    }

    override fun onBillingServiceDisconnected() = Unit

    override fun onAcknowledgePurchaseResponse(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            _billingStatus.postValue(BillingStatus.AcknowledgeSuccess)
        } else {
            _billingStatus.postValue(BillingStatus.Error(billingResult))
        }
    }

    override fun onCleared() {
        super.onCleared()
        billingClient.endConnection()
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
    fun onCreate() {
        startConnection()
    }

    fun purchase(activity: Activity, billingItem: BillingItem) {
        getSkuDetails(billingItem) { skuDetails ->
            val flowParams = BillingFlowParams.newBuilder()
                .setSkuDetails(skuDetails)
                .build()
            _billingStatus.postValue(BillingStatus.BillingFlow)
            billingClient.launchBillingFlow(activity, flowParams)
        }
    }

    private fun startConnection() {
        billingClient.startConnection(this)
    }

    private fun getSkuDetails(
        billingItem: BillingItem,
        onResponse: (skuDetails: SkuDetails) -> Unit
    ) {
        val skuDetailsParams = SkuDetailsParams.newBuilder()
            .setSkusList(billingItem.skusList())
            .setType(billingItem.billingSkuType.skuType)
            .build()
        billingClient.querySkuDetailsAsync(skuDetailsParams) { billingResult: BillingResult, skuDetailList: List<SkuDetails>? ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
                skuDetailList != null &&
                skuDetailList.isNotEmpty()
            ) {
                onResponse(skuDetailList[0])
            } else {
                _billingStatus.postValue(BillingStatus.Error(billingResult))
            }
        }
    }

    private fun acknowledgePurchase(purchase: Purchase) {

        val data = hashMapOf(
            "packageName" to purchase.packageName,
            "subscriptionId" to purchase.skus[0],
            "purchaseToken" to purchase.purchaseToken
        )

        Firebase.functions
            .getHttpsCallable("verifyPurchaseForAndroid")
            .call(data)
            .addOnSuccessListener {
                if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED
                    && purchase.isAcknowledged.not()
                ) {
                    val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
                        .setPurchaseToken(purchase.purchaseToken)
                        .build()
                    billingClient.acknowledgePurchase(acknowledgePurchaseParams, this)
                } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) {
                    _billingStatus.postValue(BillingStatus.PendingPurchase)
                }
            }
            .addOnFailureListener {
                _billingStatus.postValue(
                    BillingStatus.Error(
                        BillingResult.newBuilder().setResponseCode(99).build()
                    )
                )
            }
    }

    fun querySubscription() {
        val user = FirebaseAuth.getInstance().currentUser

        val db = FirebaseFirestore.getInstance()
        db.collection("purchases")
            .whereEqualTo("userId", user?.uid)
            .whereEqualTo("packageName", application.packageName)
            .whereEqualTo("paymentState", 1)
            .whereEqualTo("valid", true)
            .get()
            .addOnSuccessListener {
                for (doc in it) {
                    doc.toObject(Subscription::class.java).apply {
                        _subscription.postValue(this)
                    }
                }
            }
    }

    fun querySkuDetails(billingItem: BillingItem) {
        getSkuDetails(billingItem) { skuDetails ->
            _skuDetails.postValue(skuDetails)
        }
    }
}

class BillingViewModelFactory(val application: Application) : ViewModelProvider.Factory {

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return BillingViewModel(application) as T
    }

}
最後に購入画面のフラグメントです。 出来上がりのイメージを先に。 (宣伝:Google Play で公開しているToDo 管理アプリです)
この画面を実現するためのレイアウトの定義です。 文字列リソースは省略させていただきますので、スクリーンショットを参考に適宜定義ください。
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="billingViewModel"
            type="com.catoocraft.core.ui.BillingViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".SubscriptionsFragment">

        <TextView
            android:id="@+id/title"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="32dp"
            android:layout_marginEnd="24dp"
            android:text=""
            android:textAppearance="@style/TextAppearance.AppCompat.Body1"
            android:textSize="16sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/description"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="24dp"
            android:layout_marginEnd="24dp"
            android:text=""
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/title" />

        <TextView
            android:id="@+id/price"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="24dp"
            android:layout_marginEnd="24dp"
            android:text=""
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/description" />

        <View
            android:id="@+id/divider"
            android:layout_width="0dp"
            android:layout_height="1dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="24dp"
            android:layout_marginEnd="8dp"
            android:background="?android:attr/listDivider"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/price" />

        <TextView
            android:id="@+id/orderIdLabel"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="24dp"
            android:layout_marginEnd="24dp"
            android:text="@string/orderId"
            android:textAppearance="@style/TextAppearance.AppCompat.Body1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/divider" />

        <TextView
            android:id="@+id/orderId"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="4dp"
            android:layout_marginEnd="24dp"
            android:text="@string/noData"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/orderIdLabel" />

        <TextView
            android:id="@+id/expiryTimeLabel"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="24dp"
            android:text="@string/expiryTime"
            android:textAppearance="@style/TextAppearance.AppCompat.Body1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/orderId" />

        <TextView
            android:id="@+id/expiryTime"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="4dp"
            android:layout_marginEnd="24dp"
            android:text="@string/noData"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/expiryTimeLabel" />

        <TextView
            android:id="@+id/autoRenewingLabel"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="24dp"
            android:text="@string/autoRenewing"
            android:textAppearance="@style/TextAppearance.AppCompat.Body1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/expiryTime" />

        <TextView
            android:id="@+id/autoRenewing"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="24dp"
            android:layout_marginTop="4dp"
            android:layout_marginEnd="24dp"
            android:text="@string/noData"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/autoRenewingLabel" />

        <Button
            android:id="@+id/purchase"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/action_purchase"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/autoRenewing" />


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
フラグメントの実装は、定期購入が1年単位なことを前提にしているところがあるので、そうでない場合など、適宜対処ください。 あと少し。
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.format.DateUtils.*
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import java.time.Period

class SubscriptionsFragment : Fragment() {

    private val billingViewModel by activityViewModels<BillingViewModel> {
        BillingViewModelFactory(
            requireActivity().application
        )
    }

    private var purchased = false

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val binding = FragmentSubscriptionsBinding.inflate(inflater, container, false).also {
            it.lifecycleOwner = this
        }
        binding.purchase.setOnClickListener {
            if (purchased) {
                startPlayActivity()
            } else {
                billingViewModel.purchase(requireActivity(), BillingItem.AdFreeSubscription)
            }
        }
        billingViewModel.skuDetails.observe(viewLifecycleOwner, {
            it?.let { sku ->
                val period = Period.parse(sku.subscriptionPeriod)
                binding.title.text = sku.title
                binding.description.text = sku.description
                val years = resources.getQuantityString(R.plurals.years, period.years, period.years)
                binding.price.text = resources.getString(R.string.xPerY, sku.price, years)
                binding.purchase.isEnabled = true
            }
        })
        billingViewModel.subscription.observe(viewLifecycleOwner, {
            it?.let { subscriptions ->
                binding.orderId.text = subscriptions.orderId
                binding.expiryTime.text =
                    formatDateTime(
                        context, subscriptions.expiryTime.toDate().time,
                        FORMAT_SHOW_YEAR or FORMAT_SHOW_DATE or FORMAT_ABBREV_ALL
                    )
                binding.autoRenewing.text =
                    getString(if (subscriptions.autoRenewing) R.string.yes else R.string.no)
                binding.purchase.text = getString(R.string.action_manage_subscription)
                purchased = true
            }
        })
        billingViewModel.billingStatus.observe(viewLifecycleOwner, { billingStatus ->
            when (billingStatus) {
                BillingStatus.AcknowledgeSuccess -> {
                    billingViewModel.querySubscription()
                }
            }
        })

        binding.purchase.isEnabled = false

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        billingViewModel.querySkuDetails(BillingItem.AdFreeSubscription)
    }

    private fun startPlayActivity() {
        try {
            val sku = billingViewModel.skuDetails.value?.sku
            val packageName = requireContext().packageName
            startActivity(
                Intent(
                    Intent.ACTION_VIEW,
                    Uri.parse("https://play.google.com/store/account/subscriptions?sku=${sku}&package=${packageName}")
                )
            )
        } catch (e: ActivityNotFoundException) {
            // showToast("Cant open the browser")
            e.printStackTrace()
        }
    }
}
最後に MainActivity で ViewModel の初期化を行う部分です。
class MainActivity : AppCompatActivity() {

    val billingViewModel: BillingViewModel by viewModels { BillingViewModelFactory(application) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycle.addObserver(billingViewModel)
    }
}

最後に

何かしら抜けや漏れがあるかも知れませんが、できるだけ動くようになったコードを転記してみました。 また、TypeScript などサーバサイドの実装はあまり経験がないので、変な書き方などしているところがあれば、コメントいただけると助かります。

最後の最後にもう一度、参考にさせていただいたサイト

貴重な情報を公開いただきありがとうございます。
  • 【公式】Google Play の課金システム … 一通り読む必要があると思います。最初はよくわからない部分もあると思うので、実装進めていく中で読み返して理解を深めれば良いかと。
  • 【Flutter + Firebase】アプリ内課金(IAP)のステップバイステップ実装ガイド【レシート検証】 … サーバサイドの実装を流用させていただきました。
  • Setting up Google Billing using Flutter and Firebase … サーバサイドの PubSub の実装を流用させていただきました。
  • [DroidKaigi 2020] Re:ゼロから始める Play Billing Lib … クライアントサイドの実装を流用させていただきました。
  • Firebase で Android の定期購入 (Google Billing Library) その3” に対して3件のコメントがあります。

    コメントを残す

    メールアドレスが公開されることはありません。