BillingResult のレスポンス コードを処理する

コレクションでコンテンツを整理 必要に応じて、コンテンツの保存と分類を行います。

Play Billing Library の呼び出しでアクションがトリガーされると、ライブラリは BillingResult レスポンスを返し、結果をデベロッパーに通知します。たとえば、queryProductDetailsAsync を使用してユーザーが利用できる特典を取得する場合、レスポンス コードには OK コードが含まれ、適切な ProductDetails オブジェクトが返されるか、ProductDetails オブジェクトを取得できなかった理由を示す別のレスポンスが含まれます。

すべてのレスポンス コードがエラーとは限りません。BillingResponseCode のリファレンス ページでは、このガイドで説明している各レスポンスについて詳しく説明しています。エラーではないレスポンス コードの例には以下があります。

レスポンス コードがエラーを意味する場合、その原因は一時的なもので、復旧が可能な場合もあります。Play Billing Library メソッドを呼び出したときに、回復可能な状態を示す BillingResponseCode 値が返される場合は、呼び出しを再試行する必要があります。それ以外の場合は一時的なものではないため、再試行はおすすめできません。

一時的なエラーでは、ユーザーのセッション中にエラーが発生したか(ユーザーが購入フローを進めているときなど)、バックグラウンドでエラーが発生したか(onResume 時にユーザーの既存の購入をクエリするときなど)といった、要因に応じて異なる再試行手段が必要になります。以下の再試行手段のセクションでは、さまざまな再試行手段の例を説明し、再試行可能な BillingResult レスポンスのセクションではレスポンス コードごとに最も効果的な手段を紹介します。

レスポンス コードに加えて、一部のエラー レスポンスには、デバッグとロギングを目的としたメッセージもあります。

再試行手段

シンプルな再試行

ユーザーがセッション中の場合は、できる限りエラーによってユーザー エクスペリエンスを妨げることがないよう、シンプルな再試行手段を実行することが推奨されます。このような場合は、終了条件として最大試行回数を設定したシンプルな再試行手段をおすすめします。

次の例はシンプルな再試行手段で、BillingClient 接続を確立するときのエラーを処理する方法です。

class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
  // Initialize the BillingClient.
  private val billingClient = BillingClient.newBuilder(context)
    .setListener(this)
    .enablePendingPurchases()
    .build()

  // Establish a connection to Google Play.
  fun startBillingConnection() {
    billingClient.startConnection(object : BillingClientStateListener {
      override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
          Log.d(TAG, "Billing response OK")
          // The BillingClient is ready. You can now query Products Purchases.
        } else {
          Log.e(TAG, billingResult.debugMessage)
          retryBillingServiceConnection()
        }
      }

      override fun onBillingServiceDisconnected() {
        Log.e(TAG, "GBPL Service disconnected")
        retryBillingServiceConnection()
      }
    })
  }

  // Billing connection retry logic. This is a simple max retry pattern
  private fun retryBillingServiceConnection() {
    val maxTries = 3
    var tries = 1
    var isConnectionEstablished = false
    do {
      try {
        billingClient.startConnection(object : BillingClientStateListener {
          override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
              isConnectionEstablished = true
              Log.d(TAG, "Billing connection retry succeeded.")
            } else {
              Log.e(
                TAG,
                "Billing connection retry failed: ${billingResult.debugMessage}"
              )
            }
          }
        })
      } catch (e: Exception) {
        e.message?.let { Log.e(TAG, it) }
        tries++
      }
    } while (tries <= maxTries && !isConnectionEstablished)
  }
  ...
}

指数バックオフの再試行

Play Billing Library 処理がバックグラウンドで発生し、ユーザー セッション中のユーザー エクスペリエンスに影響しない場合は、指数バックオフの使用をおすすめします。

たとえば、新規購入の確認時などが該当します。この処理はバックグラウンドで発生し、エラーが発生してもリアルタイムで確認を行う必要はないためです。

private fun acknowledge(purchaseToken: String): BillingResult {
  val params = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchaseToken)
    .build()
  var ackResult = BillingResult()
  billingClient.acknowledgePurchase(params) { billingResult ->
    ackResult = billingResult
  }
  return ackResult
}

suspend fun acknowledgePurchase(purchaseToken: String) {

  val retryDelayMs = 2000L
  val retryFactor = 2
  val maxTries = 3

  withContext(Dispatchers.IO) {
    acknowledge(purchaseToken)
  }

  AcknowledgePurchaseResponseListener { acknowledgePurchaseResult ->
    val playBillingResponseCode =
    PlayBillingResponseCode(acknowledgePurchaseResult.responseCode)
    when (playBillingResponseCode) {
      BillingClient.BillingResponseCode.OK -> {
        Log.i(TAG, "Acknowledgement was successful")
      }
      BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> {
        // This is possibly related to a stale Play cache.
        // Querying purchases again.
        Log.d(TAG, "Acknowledgement failed with ITEM_NOT_OWNED")
        billingClient.queryPurchasesAsync(
          QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.SUBS)
            .build()
        )
        { billingResult, purchaseList ->
          when (billingResult.responseCode) {
            BillingClient.BillingResponseCode.OK -> {
              purchaseList.forEach { purchase ->
                acknowledge(purchase.purchaseToken)
              }
            }
          }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.ERROR,
         BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
         BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
       ) -> {
        Log.d(
          TAG,
          "Acknowledgement failed, but can be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        runBlocking {
          exponentialRetry(
            maxTries = maxTries,
            initialDelay = retryDelayMs,
            retryFactor = retryFactor
          ) { acknowledge(purchaseToken) }
        }
      }
      in setOf(
         BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
         BillingClient.BillingResponseCode.DEVELOPER_ERROR,
         BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
       ) -> {
        Log.e(
          TAG,
          "Acknowledgement failed and cannot be retried --
          Response Code: ${acknowledgePurchaseResult.responseCode} --
          Debug Message: ${acknowledgePurchaseResult.debugMessage}"
        )
        throw Exception("Failed to acknowledge the purchase!")
      }
    }
  }
}

private suspend fun <T> exponentialRetry(
  maxTries: Int = Int.MAX_VALUE,
  initialDelay: Long = Long.MAX_VALUE,
  retryFactor: Int = Int.MAX_VALUE,
  block: suspend () -> T
): T? {
  var currentDelay = initialDelay
  var retryAttempt = 1
  do {
    runCatching {
      delay(currentDelay)
      block()
    }
      .onSuccess {
        Log.d(TAG, "Retry succeeded")
        return@onSuccess;
      }
      .onFailure { throwable ->
        Log.e(
          TAG,
          "Retry Failed -- Cause: ${throwable.cause} -- Message: ${throwable.message}"
        )
      }
    currentDelay *= retryFactor
    retryAttempt++
  } while (retryAttempt < maxTries)

  return block() // last attempt
}

再試行可能な BillingResult レスポンス

SERVICE_TIMEOUT(エラーコード -3)

問題

このエラーは、Google Play が応答する前にリクエストがタイムアウトになったことを示します。このようなエラーは Play Billing Library の呼び出しによってリクエストされたアクションの実行遅延などが原因で発生することがあります。

考えられる解決策

これは通常、一時的な問題です。エラーが返されたアクションに応じて、シンプルな再試行手段または指数バックオフの再試行手段のいずれかを使用し、リクエストを再試行します。

以下の SERVICE_DISCONNECTED とは異なり、Google Play 請求サービスへの接続は切断されないため、試行した Play Billing Library 処理を再試行するのみです。

SERVICE_DISCONNECTED(エラーコード -1)

問題

この致命的なエラーは、BillingClient を介したクライアント アプリの Google Play ストア サービスへの接続が切断されたことを示します。

考えられる解決策

できる限りこのエラーを回避するには、Play Billing Library で呼び出しを行う前に、常に BillingClient.isReady() を呼び出して Google Play 開発者サービスへの接続を確認してください。

SERVICE_DISCONNECTED から復旧するには、クライアント アプリは BillingClient.startConnection を使用して接続を再確立する必要があります。

SERVICE_TIMEOUT と同様に、エラーをトリガーしたアクションに応じて、シンプルな再試行または指数バックオフの再試行を行います。

SERVICE_UNAVAILABLE(エラーコード 2)

問題

この一時的なエラーは、Google Play 請求サービスが現在利用できないことを示します。ほとんどの場合、クライアント デバイスと Google Play 請求サービスとの間のネットワーク接続に問題があることが原因です。

考えられる解決策

これは通常、一時的な問題です。エラーが返されたアクションに応じて、シンプルな再試行手段または指数バックオフの再試行手段のいずれかを使用し、リクエストを再試行します。

SERVICE_DISCONNECTED とは異なり、Google Play 請求サービスへの接続は切断されないため、必要なことは試行した処理の再試行です。

BILLING_UNAVAILABLE(エラーコード 3)

問題

このエラーは、購入プロセス中にユーザー請求エラーが発生したことを示します。次のようなケースが考えられます。

  • ユーザーのデバイスの Play ストア アプリが最新ではない。
  • ユーザーがサポート対象外の国に居住している。
  • ユーザーが企業ユーザーであり、企業の管理者によってユーザーによる購入が無効にされている。
  • Google Play がユーザーのお支払い方法に対して請求できない(ユーザーのクレジット カードの有効期限が切れている場合など)。

考えられる解決策

このようなケースでは、自動再試行は役に立たない場合があります。ただし、ユーザーが問題の原因となった事象に対処した場合は、手動での再試行が機能することがあります。たとえば、ユーザーが Play ストアのバージョンをサポートされているバージョンに更新した場合、最初の処理を手動で再試行できます。

ユーザーがセッション中でないときにこのエラーが発生した場合は、再試行しても意味がない場合があります。購入フローの結果として BILLING_UNAVAILABLE エラーが発生した場合、そのユーザーは購入プロセス中に Google Play からフィードバックを受け取り、エラーの発生を認識している可能性が非常に高いです。このような場合は、エラーの発生を示すエラー メッセージを表示し、[再試行] ボタンでユーザーが問題に対応した後に手動で再試行できるようにします。

ERROR(エラーコード 6)

問題

これは Google Play 内部の問題を示す致命的なエラーです。

考えられる解決策

ERROR を引き起こす Google Play の内部の問題は一時的な場合があり、指数バックオフの再試行を実装することで軽減できます。ユーザーがセッション中の場合は、シンプルな再試行をおすすめします。

ITEM_ALREADY_OWNED

問題

このレスポンスは、Google Play ユーザーが購入しようとしている定期購入または 1 回だけの購入アイテムをすでに所有していることを示します。ほとんどの場合、これは一時的なエラーではありません。ただし、Google Play の古いキャッシュが原因の場合もあります。

考えられる解決策

原因がキャッシュの問題ではないこのエラーの発生を防ぐには、ユーザーがすでに所有しているアイテムを販売しないようにします。購入できるアイテムを表示する際にユーザーの利用資格を確認し、それに応じてユーザーが購入できるものをフィルタします。クライアント アプリがキャッシュの問題によりこのエラーを受け取った場合、エラーによって Google Play のキャッシュがトリガーされ、Play のバックエンドの最新データが更新されます。エラーがこのケースに固有の一時的なインスタンスを解決した後で再試行します。ITEM_ALREADY_OWNED が返された後、BillingClient.queryPurchasesAsync() を呼び出し、ユーザーがアイテムを購入したかどうかを確認します。購入していない場合は、シンプルな再試行ロジックを実行して、購入を再試行します。

ITEM_NOT_OWNED

問題

この購入レスポンスは、Google Play ユーザーが置換、確認、消費しようとしている定期購入または 1 回だけの購入アイテムを所有していないことを示します。ほとんどの場合、これは一時的なエラーではありません。ただし、Google Play の古いキャッシュが原因の場合もあります。

考えられる解決策

キャッシュの問題が原因でエラーが発生すると、このエラーによって Google Play のキャッシュがトリガーされ、Play のバックエンドの最新データが更新されます。エラーがこの固有の一時的なインスタンスを解決した後で、シンプルな再試行手段を使って再試行します。ITEM_NOT_OWNED が返された後、BillingClient.queryPurchasesAsync() を呼び出し、ユーザーがアイテムを購入したかどうかを確認します。購入していない場合は、シンプルな再試行ロジックを使用して、購入を再試行します。

再試行不可の BillingResult レスポンス

再試行ロジックを使用してこれらのエラーを復旧することはできません。

FEATURE_NOT_SUPPORTED

問題

再試行不可エラーは、ユーザーのデバイスでは Google Play 請求サービス機能がサポートされていないことを示します。Play ストアのバージョンが古いことが原因の可能性があります。

たとえば、ユーザーのデバイスがアプリ内メッセージングに対応していない場合が挙げられます。

考えられる対応策

Play Billing Library を呼び出す前に、BillingClient.isFeatureSupported() を使用して機能のサポート状況を確認します。

when {
  billingClient.isReady -> {
    if (billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING)) {
       // use feature
    }
  }
}

USER_CANCELED

問題

ユーザーが請求フローの UI を終了しました。

考えられる解決策

これは単なる情報であり、適切に失敗する可能性があります。

ITEM_UNAVAILABLE

問題

このユーザーは Google Play 請求サービスの定期購入または 1 回限りの購入アイテムを購入できません。

考えられる対応策

推奨されているとおり、queryProductDetailsAsync で商品の詳細を更新するようにします。Google Play Console の構成での商品カタログが変更される頻度を踏まえ、必要に応じて追加で更新してください。queryProductDetailsAsync で正しい情報を返すアイテムのみを Google Play 請求サービスで販売するようにしてください。アイテムの利用資格の構成に不整合がないことを確認します。たとえば、ユーザーが購入しようとしている地域では購入できないアイテムをクエリしている可能性があります。アイテムを購入可能にするには、アイテムを有効にしてアプリを公開し、ユーザーの居住国でアプリを入手できるようにする必要があります。

特にテスト中などは、アイテムの構成に問題がないにもかかわらず、ユーザーにこのエラーが表示されることがあります。これは Google のサーバー間で商品の詳細の伝達が遅延していることによる場合があります。しばらくしてからもう一度お試しください。

DEVELOPER_ERROR

問題

これは致命的なエラーで、API の不適切な使用を示します。たとえば、BillingClient.launchBillingFlow に誤ったパラメータを指定すると、このエラーが発生します。

考えられる解決策

さまざまな Play Billing Library の呼び出しを正しく使用していることを確認します。