Flutter アプリにアプリ内購入を追加する

1. はじめに

Flutter アプリにアプリ内購入を追加するには、アプリと Play ストアを正しく設定し、購入を確認して、定期購入特典などの必要な権限を付与する必要があります。

この Codelab では、アプリ(提供)に 3 種類のアプリ内購入を追加し、Firebase を使用した Dart バックエンドでこれらの購入を確認します。提供されたアプリ「Dash Clicker」には、Dash マスコットを通貨として使用するゲームが含まれています。次の購入オプションを追加します。

  1. 2,000 個の Dash を一度に購入できる購入オプション(繰り返し購入可能)。
  2. 古いスタイルの Dash をモダンなスタイルの Dash にするための 1 回限りのアップグレード購入。
  3. 自動生成されたクリック数を 2 倍にするサブスクリプション。

最初の購入オプションでは、ユーザーに 2,000 個のダッシュが直接付与されます。ユーザーが直接利用でき、何度でも購入できます。これは直接使用され、複数回使用できるため、消耗品と呼ばれます。

2 つ目のオプションでは、Dash がより美しい Dash にアップグレードされます。購入は 1 回のみで、永続的に利用できます。このような購入は、アプリで消費することはできないが、有効期限がないため、非消費アイテムと呼ばれます。

3 つ目の最後の購入オプションはサブスクリプションです。定期購入が有効な間は、ユーザーはダッシュをより早く獲得できますが、定期購入の支払いを停止すると、特典もなくなります。

バックエンド サービス(こちらも提供されます)は Dart アプリとして実行され、購入が行われたことを確認し、Firestore を使用して保存します。Firestore はプロセスを簡素化するために使用されますが、本番環境アプリでは任意のタイプのバックエンド サービスを使用できます。

300123416ebc8dc1.png 7145d0fffe6ea741.png 646317a79be08214.png

作成するアプリの概要

  • アプリを拡張して、消耗品購入と定期購入をサポートします。
  • また、購入したアイテムを確認して保存するように Dart バックエンド アプリを拡張します。

学習内容

  • 購入可能なアイテムで App Store と Google Play ストアを設定する方法。
  • ストアと通信して購入を検証し、Firestore に保存する方法。
  • アプリ内購入を管理する方法。

必要なもの

2. 開発環境を設定する

この Codelab を始めるには、コードをダウンロードし、iOS のバンドル識別子と Android のパッケージ名を変更します。

コードをダウンロードする

コマンドラインから GitHub リポジトリのクローンを作成するには、次のコマンドを使用します。

git clone https://github.com/flutter/codelabs.git flutter-codelabs

または、GitHub の CLI ツールがインストールされている場合は、次のコマンドを使用します。

gh repo clone flutter/codelabs flutter-codelabs

サンプルコードは、一連の Codelab のコードを含む flutter-codelabs ディレクトリにクローンされます。この Codelab のコードは flutter-codelabs/in_app_purchases にあります。

flutter-codelabs/in_app_purchases の下のディレクトリ構造には、名前付きの各ステップの最後に到達するべき場所の一連のスナップショットが含まれています。スターター コードはステップ 0 にあります。次のように移動します。

cd flutter-codelabs/in_app_purchases/step_00

スキップしたり、ステップ後の状態を確認したりする場合は、目的のステップの名前が付いたディレクトリをご覧ください。最後のステップのコードは complete フォルダにあります。

スターター プロジェクトをセットアップする

お好みの IDE で step_00/app からスターター プロジェクトを開きます。スクリーンショットには Android Studio を使用しましたが、Visual Studio Code も優れた選択肢です。どちらのエディタでも、最新の Dart プラグインと Flutter プラグインがインストールされていることを確認してください。

作成するアプリは、App Store と Play ストアと通信して、どの商品がどの価格で利用可能かを確認する必要があります。すべてのアプリは一意の ID で識別されます。iOS App Store ではバンドル ID、Android Play ストアではアプリケーション ID と呼ばれます。これらの識別子は通常、逆ドメイン名表記を使用して作成されます。たとえば、flutter.dev 用のアプリ内購入アプリを作成する場合は、dev.flutter.inapppurchase を使用します。アプリの識別子を考えます。この識別子をプロジェクト設定で設定します。

まず、iOS のバンドル ID を設定します。そのためには、Xcode アプリで Runner.xcworkspace ファイルを開きます。

a9fbac80a31e28e0.png

Xcode のフォルダ構造では、Runner プロジェクトが最上位にあり、FlutterRunnerProducts の各ターゲットは Runner プロジェクトの下にあります。[Runner] をダブルクリックしてプロジェクト設定を編集し、[Signing & Capabilities] をクリックします。[Team] フィールドに、選択したばかりのバンドル ID を入力して、チームを設定します。

812f919d965c649a.jpeg

これで Xcode を閉じて Android Studio に戻り、Android の設定を完了できます。そのためには、android/app,build.gradle.kts ファイルを開き、applicationId(下のスクリーンショットの 24 行目)を iOS バンドル識別子と同じアプリケーション ID に変更します。iOS ストアと Android ストアの ID は同じである必要はありませんが、同じにしておくとエラーが発生しにくいため、この Codelab では同じ識別子を使用します。

e320a49ff2068ac2.png

3. プラグインをインストールする

この Codelab のこのパートでは、in_app_purchase プラグインをインストールします。

pubspec に依存関係を追加する

プロジェクトの依存関係に in_app_purchase を追加して、pubspec に in_app_purchase を追加します。

$ cd app
$ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface
Resolving dependencies... 
Downloading packages... 
  characters 1.4.0 (1.4.1 available)
  flutter_lints 5.0.0 (6.0.0 available)
+ in_app_purchase 3.2.3
+ in_app_purchase_android 0.4.0+3
+ in_app_purchase_platform_interface 1.4.0
+ in_app_purchase_storekit 0.4.4
+ json_annotation 4.9.0
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
  provider 6.1.5 (6.1.5+1 available)
  test_api 0.7.6 (0.7.7 available)
Changed 5 dependencies!
7 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

pubspec.yaml を開き、dependencies のエントリとして in_app_purchase が、dev_dependencies のエントリとして in_app_purchase_platform_interface がリストされていることを確認します。

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  cloud_firestore: ^6.0.0
  cupertino_icons: ^1.0.8
  firebase_auth: ^6.0.1
  firebase_core: ^4.0.0
  google_sign_in: ^7.1.1
  http: ^1.5.0
  intl: ^0.20.2
  provider: ^6.1.5
  logging: ^1.3.0
  in_app_purchase: ^3.2.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  in_app_purchase_platform_interface: ^1.4.0

4. App Store を設定する

iOS でアプリ内購入を設定してテストするには、App Store で新しいアプリを作成し、購入可能なアイテムを作成する必要があります。公開したり、審査のために Apple にアプリを送信したりする必要はありません。これを行うには、デベロッパー アカウントが必要です。お持ちでない場合は、Apple デベロッパー プログラムに登録してください。

アプリ内購入を利用するには、App Store Connect で有料アプリの有効な契約も必要です。https://appstoreconnect.apple.com/ にアクセスし、[契約 / 税金 / 銀行口座情報] をクリックします。

11db9fca823e7608.png

ここでは、無料アプリと有料アプリの契約を確認できます。無料アプリのステータスは「有効」、有料アプリのステータスは「新規」である必要があります。利用規約を確認し、同意して、必要な情報をすべて入力してください。

74c73197472c9aec.png

すべてが正しく設定されると、有料アプリのステータスが有効になります。有効な契約がないとアプリ内購入を試すことができないため、これは非常に重要です。

4a100bbb8cafdbbf.jpeg

アプリ ID を登録する

Apple Developer Portal で新しい識別子を作成します。developer.apple.com/account/resources/identifiers/list にアクセスし、[Identifiers] ヘッダーの横にあるプラスアイコンをクリックします。

55d7e592d9a3fc7b.png

アプリ ID を選択する

13f125598b72ca77.png

アプリの選択

41ac4c13404e2526.png

説明を入力し、バンドル ID を Xcode で以前に設定した値と同じ値に設定します。

9d2c940ad80deeef.png

新しいアプリ ID の作成方法について詳しくは、デベロッパー アカウントのヘルプをご覧ください。

新しいアプリを作成する

一意のバンドル識別子を使用して、App Store Connect で新しいアプリを作成します。

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

新しいアプリの作成方法や契約の管理方法について詳しくは、App Store Connect ヘルプをご覧ください。

アプリ内購入をテストするには、サンドボックス テストユーザーが必要です。このテストユーザーは iTunes に接続しないでください。アプリ内購入のテストでのみ使用します。Apple アカウントですでに使用されているメールアドレスは使用できません。[ユーザーとアクセス] で [サンドボックス] に移動し、新しいサンドボックス アカウントを作成するか、既存のサンドボックス Apple ID を管理します。

2ba0f599bcac9b36.png

[設定] > [デベロッパー] > [Sandbox Apple アカウント] に移動して、iPhone でサンドボックス ユーザーを設定できるようになりました。

74a545210b282ad8.png eaa67752f2350f74.png

アプリ内購入を設定する

次に、3 つの購入可能なアイテムを設定します。

  • dash_consumable_2k: 何度も購入できる消費型アイテム。購入ごとに 2, 000 ダッシュ(アプリ内通貨)がユーザーに付与されます。
  • dash_upgrade_3d: 1 回のみ購入可能な「アップグレード」購入。ユーザーは、クリックできる外観の異なるダッシュを入手できます。
  • dash_subscription_doubler: 定期購入期間中、1 回のクリックで獲得できるダッシュの数が 2 倍になる定期購入。

a118161fac83815a.png

[アプリ内購入] に移動します。

指定した ID でアプリ内購入を作成します。

  1. dash_consumable_2k消費アイテムとして設定します。商品 ID として dash_consumable_2k を使用します。参照名は App Store Connect でのみ使用されるため、dash consumable 2k に設定します。1f8527fc03902099.png 空き情報を設定します。商品はサンドボックス ユーザーの国で利用可能である必要があります。bd6b2ce2d9314e6e.png 価格を追加し、価格を $1.99 または他の通貨での同等の価格に設定します。926b03544ae044c4.png 購入のローカライズを追加します。説明として 2000 dashes fly out を使用して購入 Spring is in the air を呼び出します。e26dd4f966dcfece.png クチコミのスクリーンショットを追加します。商品が審査に送信される場合を除き、コンテンツは重要ではありませんが、アプリが App Store から商品を取得する際に必要な「送信準備完了」状態にするには、コンテンツが必要です。25171bfd6f3a033a.png
  2. dash_upgrade_3dNon-consumable として設定します。商品 ID として dash_upgrade_3d を使用します。参照名を dash upgrade 3d に設定します。説明として Brings your dash back to the future を使用して購入 3D Dash を呼び出します。価格を $0.99 に設定します。dash_consumable_2k プロダクトと同じ方法で、在庫状況を設定し、レビューのスクリーンショットをアップロードします。83878759f32a7d4a.png
  3. dash_subscription_doubler自動更新の定期購入として設定します。定期購入のフローは少し異なります。まず、サブスクリプション グループを作成する必要があります。複数の定期購入が同じグループに属している場合、ユーザーは同時に 1 つの定期購入のみに登録できますが、これらの定期購入間でアップグレードまたはダウングレードできます。このグループを subscriptions と呼びます。393a44b09f3cd8bf.png また、サブスクリプション グループのローカライズを追加します。595aa910776349bd.png 次に、サブスクリプションを作成します。[Reference Name] を dash subscription doubler に、[Product ID] を dash_subscription_doubler に設定します。7bfff7bbe11c8eec.png 次に、定期購入期間(1 週間)とローカライズを選択します。このサブスクリプションに名前 Jet Engine と説明 Doubles your clicks を付けます。価格を $0.49 に設定します。dash_consumable_2k プロダクトと同じ方法で、在庫状況を設定し、レビューのスクリーンショットをアップロードします。44d18e02b926a334.png

リストに商品が表示されます。

17f242b5c1426b79.png d71da951f595054a.png

5. Google Play ストアを設定する

App Store と同様に、Play ストアでもデベロッパー アカウントが必要です。まだお持ちでない場合は、アカウントを登録します。

新しいアプリを作成する

Google Play Console で新しいアプリを作成します。

  1. Play Console を開きます。
  2. [すべてのアプリ] > [アプリを作成] を選択します。
  3. デフォルトの言語を選択し、アプリのタイトルを追加します。タイトルには、Google Play に表示するアプリの名前を入力します。この名前は後から変更できます。
  4. アプリがゲームであることを指定します。これは後で変更できます。
  5. アプリが無料か有料かを指定します。
  6. 「コンテンツ ガイドライン」と「米国輸出法」の宣言に記入します。
  7. [アプリを作成] を選択します。

アプリを作成したら、ダッシュボードに移動し、[アプリを設定する] セクションのすべてのタスクを完了します。ここでは、コンテンツ レーティングやスクリーンショットなど、アプリに関する情報を入力します。13845badcf9bc1db.png

アプリケーションに署名する

アプリ内購入をテストするには、Google Play にアップロードされたビルドが 1 つ以上必要です。

そのためには、リリースビルドがデバッグ鍵以外の鍵で署名されている必要があります。

キーストアを作成する

既存のキーストアがある場合は、次のステップに進みます。存在しない場合は、コマンドラインで次のコマンドを実行して作成します。

Mac/Linux では、次のコマンドを使用します。

keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

Windows では、次のコマンドを使用します。

keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key

このコマンドは、key.jks ファイルをホーム ディレクトリに保存します。ファイルを別の場所に保存する場合は、-keystore パラメータに渡す引数を変更します。Keep the

keystore

ファイルを非公開にします。公開ソース管理にチェックインしないでください。

アプリからキーストアを参照する

キーストアへの参照を含む <your app dir>/android/key.properties という名前のファイルを作成します。

storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=key
storeFile=<location of the key store file, such as /Users/<user name>/key.jks>

Gradle で署名を設定する

<your app dir>/android/app/build.gradle.kts ファイルを編集して、アプリの署名を構成します。

android ブロックの前に、プロパティ ファイルからキーストア情報を追加します。

import java.util.Properties
import java.io.FileInputStream

plugins {
    // omitted
}

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

android {
    // omitted
}

key.properties ファイルを keystoreProperties オブジェクトに読み込みます。

buildTypes ブロックを次のように更新します。

   buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
        }
    }

モジュールの build.gradle.kts ファイルの signingConfigs ブロックに署名構成情報を設定します。

   signingConfigs {
        create("release") {
            keyAlias = keystoreProperties["keyAlias"] as String
            keyPassword = keystoreProperties["keyPassword"] as String
            storeFile = keystoreProperties["storeFile"]?.let { file(it) }
            storePassword = keystoreProperties["storePassword"] as String
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
        }
    }

アプリのリリースビルドが自動的に署名されるようになりました。

アプリへの署名について詳しくは、developer.android.comアプリへの署名をご覧ください。

最初のビルドをアップロードする

署名用にアプリを構成したら、次のコマンドを実行してアプリをビルドできるようになります。

flutter build appbundle

このコマンドはデフォルトでリリースビルドを生成し、出力は <your app dir>/build/app/outputs/bundle/release/ にあります。

Google Play Console のダッシュボードで、[テストとリリース] > [テスト] > [クローズド テスト] に移動し、新しいクローズド テスト リリースを作成します。

次に、ビルドコマンドで生成された app-release.aab App Bundle をアップロードします。

[保存]、[リリースのレビュー] の順にクリックします。

最後に、[クローズド テストへのロールアウトを開始] をクリックして、クローズド テスト リリースを有効にします。

テストユーザーを設定する

アプリ内購入をテストするには、テスターの Google アカウントを Google Play Console の次の 2 か所に追加する必要があります。

  1. 特定のテストトラック(内部テスト)
  2. ライセンス テスターとして

まず、テスターを内部テストトラックに追加します。[テストとリリース > テスト > 内部テスト] に戻り、[テスター] タブをクリックします。

a0d0394e85128f84.png

[メーリング リストを作成] をクリックして、新しいメーリング リストを作成します。リストに名前を付け、アプリ内購入のテストへのアクセス権が必要な Google アカウントのメールアドレスを追加します。

次に、リストのチェックボックスをオンにして、[変更を保存] をクリックします。

次に、ライセンス テスターを追加します。

  1. Google Play Console の [すべてのアプリ] ビューに戻ります。
  2. [設定] > [ライセンス テスト] に移動します。
  3. アプリ内購入のテストを行う必要があるテスターのメールアドレスを同じように追加します。
  4. [ライセンス レスポンス] を RESPOND_NORMALLY に設定します。
  5. [変更を保存] をクリックします。

a1a0f9d3e55ea8da.png

アプリ内購入を設定する

次に、アプリ内で購入可能なアイテムを設定します。

App Store と同様に、3 つの異なる購入を定義する必要があります。

  • dash_consumable_2k: 何度も購入できる消費型アイテム。購入ごとに 2, 000 ダッシュ(アプリ内通貨)がユーザーに付与されます。
  • dash_upgrade_3d: 1 回のみ購入可能な「アップグレード」の消費型アイテム。ユーザーがクリックできる外観の異なる Dash が提供されます。
  • dash_subscription_doubler: 定期購入期間中、1 回のクリックで獲得できるダッシュの数が 2 倍になる定期購入。

まず、消耗品と非消耗品を追加します。

  1. Google Play Console に移動し、アプリを選択します。
  2. [収益化] > [商品] > [アプリ内アイテム] に移動します。
  3. [アイテムを作成] c8d66e32f57dee21.png をクリックします。
  4. 商品に関する必要な情報をすべて入力します。商品 ID が、使用する ID と完全に一致していることを確認します。
  5. [保存] をクリックします。
  6. [有効にする] をクリックします。
  7. 消費されない「アップグレード」購入についても同じ手順を繰り返します。

次に、サブスクリプションを追加します。

  1. Google Play Console に移動し、アプリを選択します。
  2. [収益化] > [商品] > [定期購入] に移動します。
  3. [サブスクリプションを作成] をクリックします。32a6a9eefdb71dd0.png
  4. 定期購入に必要な情報をすべて入力します。商品 ID が、使用する ID と完全に一致していることを確認します。
  5. [保存] をクリックします。

これで、購入が Google Play Console で設定されます。

6. Firebase を設定する

この Codelab では、バックエンド サービスを使用してユーザーの購入を検証し、追跡します。

バックエンド サービスを使用すると、次のようなメリットがあります。

  • 取引を安全に確認できます。
  • アプリストアから課金イベントに反応できます。
  • 購入はデータベースで追跡できます。
  • ユーザーがシステム時計を巻き戻してアプリをだまし、プレミアム機能を利用することはできません。

バックエンド サービスの設定方法は数多くありますが、ここでは Google 独自の Firebase を使用して、Cloud Functions と Firestore を使用して設定します。

バックエンドの作成はこの Codelab の範囲外と見なされるため、スターター コードには、基本的な購入を処理する Firebase プロジェクトがすでに含まれています。

Firebase プラグインもスターター アプリに含まれています。

残りの作業は、独自の Firebase プロジェクトを作成し、アプリとバックエンドの両方を Firebase 用に構成して、最後にバックエンドをデプロイすることです。

Firebase プロジェクトを作成する

Firebase コンソールに移動して、新しい Firebase プロジェクトを作成します。この例では、プロジェクトを Dash Clicker と呼びます。

バックエンド アプリでは、購入を特定のユーザーに関連付けるため、認証が必要です。これには、Firebase の認証モジュールと Google ログインを使用します。

  1. Firebase ダッシュボードで [認証] に移動し、必要に応じて有効にします。
  2. [Sign-in method] タブに移動し、[Google] ログイン プロバイダを有効にします。

fe2e0933d6810888.png

Firebase の Firestore データベースも使用するため、これも有効にします。

d02d641821c71e2c.png

Cloud Firestore のルールを次のように設定します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /purchases/{purchaseId} {
      allow read: if request.auth != null && request.auth.uid == resource.data.userId
    }
  }
}

Flutter 向け Firebase を設定する

Flutter アプリに Firebase をインストールするおすすめの方法は、FlutterFire CLI を使用することです。設定ページの説明に沿って操作します。

flutterfire configure を実行するときに、前のステップで作成したプロジェクトを選択します。

$ flutterfire configure

i Found 5 Firebase projects.
? Select a Firebase project to configure your Flutter application with ›
❯ in-app-purchases-1234 (in-app-purchases-1234)
  other-flutter-codelab-1 (other-flutter-codelab-1)
  other-flutter-codelab-2 (other-flutter-codelab-2)
  other-flutter-codelab-3 (other-flutter-codelab-3)
  other-flutter-codelab-4 (other-flutter-codelab-4)
  <create a new project>

次に、2 つのプラットフォームを選択して、iOSAndroid を有効にします。

? Which platforms should your configuration support (use arrow keys & space to select)? ›
✔ android
✔ ios
  macos
  web

firebase_options.dart のオーバーライドについて確認するメッセージが表示されたら、[はい] を選択します。

? Generated FirebaseOptions file lib/firebase_options.dart already exists, do you want to override it? (y/n) › yes

Android 向け Firebase の設定: その他の手順

Firebase ダッシュボードで、[プロジェクトの概要] に移動し、[設定] を選択して、[全般] タブを選択します。

[アプリ] まで下にスクロールし、[dashclicker (android)] アプリを選択します。

b22d46a759c0c834.png

デバッグモードで Google ログインを許可するには、デバッグ証明書の SHA-1 ハッシュ フィンガープリントを指定する必要があります。

デバッグ用の署名証明書のハッシュを取得する

Flutter アプリ プロジェクトのルートで、ディレクトリを android/ フォルダに変更し、署名レポートを生成します。

cd android
./gradlew :app:signingReport

署名鍵の長いリストが表示されます。デバッグ証明書のハッシュを探しているので、Variant プロパティと Config プロパティが debug に設定されている証明書を探します。キーストアは通常、ホームフォルダの .android/debug.keystore にあります。

> Task :app:signingReport
Variant: debug
Config: debug
Store: /<USER_HOME_FOLDER>/.android/debug.keystore
Alias: AndroidDebugKey
MD5: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA1: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA-256: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
Valid until: Tuesday, January 19, 2038

SHA-1 ハッシュをコピーし、アプリ送信モーダル ダイアログの最後のフィールドに入力します。

最後に、flutterfire configure コマンドを再度実行して、署名構成を含めるようにアプリを更新します。

$ flutterfire configure
? You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the valus in your existing `firebase.json` file to configure your project? (y/n) › yes
✔ You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the values in your existing `firebase.json` file to configure your project? · yes

iOS 用の Firebase の設定: その他の手順

Xcodeios/Runner.xcworkspace を開きます。または、任意の IDE を使用します。

VSCode で ios/ フォルダを右クリックし、open in xcode をクリックします。

Android Studio で ios/ フォルダを右クリックし、flutteropen iOS module in Xcode オプションの順にクリックします。

iOS で Google ログインを許可するには、ビルド plist ファイルに CFBundleURLTypes 構成オプションを追加します。(詳しくは、google_sign_in パッケージのドキュメントをご覧ください)。この場合、ファイルは ios/Runner/Info.plist です。

Key-Value ペアはすでに存在しますが、値を置き換える必要があります。

  1. <string>..</string> 要素で囲まれていない GoogleService-Info.plist ファイルから REVERSED_CLIENT_ID の値を取得します。
  2. ios/Runner/Info.plist ファイルの CFBundleURLTypes キーの値を置き換えます。
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <!-- TODO Replace this value: -->
            <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
            <string>com.googleusercontent.apps.REDACTED</string>
        </array>
    </dict>
</array>

これで、Firebase の設定は完了です。

7. 購入の更新をリッスンする

この Codelab のパートでは、プロダクトを購入するためのアプリを準備します。このプロセスには、アプリの起動後に購入の更新とエラーをリッスンすることが含まれます。

購入の更新をリッスンする

main.dart, で、2 つのページを含む BottomNavigationBar を持つ Scaffold を持つウィジェット MyHomePage を見つけます。このページでは、DashCounterDashUpgrades,DashPurchases の 3 つの Provider も作成します。DashCounter は、現在のダッシュ数を追跡し、自動的にインクリメントします。DashUpgrades は、Dash で購入できるアップグレードを管理します。この Codelab では、DashPurchases を中心に説明します。

デフォルトでは、プロバイダのオブジェクトは、そのオブジェクトが最初にリクエストされたときに定義されます。このオブジェクトはアプリの起動時に購入の更新を直接リッスンするため、lazy: false を使用してこのオブジェクトの遅延読み込みを無効にします。

lib/main.dart

ChangeNotifierProvider<DashPurchases>(
  create: (context) => DashPurchases(
    context.read<DashCounter>(),
  ),
  lazy: false,                                             // Add this line
),

InAppPurchaseConnection のインスタンスも必要です。ただし、アプリをテスト可能にするには、接続をモックする手段が必要です。これを行うには、テストでオーバーライドできるインスタンス メソッドを作成し、main.dart に追加します。

lib/main.dart

// Gives the option to override in tests.
class IAPConnection {
  static InAppPurchase? _instance;
  static set instance(InAppPurchase value) {
    _instance = value;
  }

  static InAppPurchase get instance {
    _instance ??= InAppPurchase.instance;
    return _instance!;
  }
}

次のようにテストを更新します。

test/widget_test.dart

import 'package:dashclicker/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';     // Add this import
import 'package:in_app_purchase_platform_interface/src/in_app_purchase_platform_addition.dart'; // And this import

void main() {
  testWidgets('App starts', (tester) async {
    IAPConnection.instance = TestIAPConnection();          // Add this line
    await tester.pumpWidget(const MyApp());
    expect(find.text('Tim Sneath'), findsOneWidget);
  });
}

class TestIAPConnection implements InAppPurchase {         // Add from here
  @override
  Future<bool> buyConsumable({
    required PurchaseParam purchaseParam,
    bool autoConsume = true,
  }) {
    return Future.value(false);
  }

  @override
  Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) {
    return Future.value(false);
  }

  @override
  Future<void> completePurchase(PurchaseDetails purchase) {
    return Future.value();
  }

  @override
  Future<bool> isAvailable() {
    return Future.value(false);
  }

  @override
  Future<ProductDetailsResponse> queryProductDetails(Set<String> identifiers) {
    return Future.value(
      ProductDetailsResponse(productDetails: [], notFoundIDs: []),
    );
  }

  @override
  T getPlatformAddition<T extends InAppPurchasePlatformAddition?>() {
    // TODO: implement getPlatformAddition
    throw UnimplementedError();
  }

  @override
  Stream<List<PurchaseDetails>> get purchaseStream =>
      Stream.value(<PurchaseDetails>[]);

  @override
  Future<void> restorePurchases({String? applicationUserName}) {
    // TODO: implement restorePurchases
    throw UnimplementedError();
  }

  @override
  Future<String> countryCode() {
    // TODO: implement countryCode
    throw UnimplementedError();
  }
}                                                          // To here.

lib/logic/dash_purchases.dart で、DashPurchasesChangeNotifier のコードに移動します。現時点では、購入した Dash に追加できるのは DashCounter のみです。

ストリーム サブスクリプション プロパティ _subscriptionStreamSubscription<List<PurchaseDetails>> _subscription; 型)、IAPConnection.instance,、インポートを追加します。変更後のコードは次のようになります。

lib/logic/dash_purchases.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';           // Add this import

import '../main.dart';                                           // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  StoreState storeState = StoreState.available;
  late StreamSubscription<List<PurchaseDetails>> _subscription;  // Add this line
  List<PurchasableProduct> products = [
    PurchasableProduct(
      'Spring is in the air',
      'Many dashes flying out from their nests',
      '\$0.99',
    ),
    PurchasableProduct(
      'Jet engine',
      'Doubles you clicks per second for a day',
      '\$1.99',
    ),
  ];

  bool get beautifiedDash => false;

  final iapConnection = IAPConnection.instance;                  // And this line

  DashPurchases(this.counter);

  Future<void> buy(PurchasableProduct product) async {
    product.status = ProductStatus.pending;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchased;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchasable;
    notifyListeners();
  }
}

_subscription はコンストラクタで初期化されるため、late キーワードが _subscription に追加されます。このプロジェクトはデフォルトで null 許容でない(NNBD)ように設定されています。つまり、null 許容として宣言されていないプロパティには null 以外の値が必要です。late 修飾子を使用すると、この値の定義を遅らせることができます。

コンストラクタで、purchaseUpdated ストリームを取得し、ストリームのリッスンを開始します。dispose() メソッドで、ストリーム サブスクリプションをキャンセルします。

lib/logic/dash_purchases.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  StoreState storeState = StoreState.notAvailable;         // Modify this line
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [
    PurchasableProduct(
      'Spring is in the air',
      'Many dashes flying out from their nests',
      '\$0.99',
    ),
    PurchasableProduct(
      'Jet engine',
      'Doubles you clicks per second for a day',
      '\$1.99',
    ),
  ];

  bool get beautifiedDash => false;

  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter) {                            // Add from here
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }                                                        // To here.

  Future<void> buy(PurchasableProduct product) async {
    product.status = ProductStatus.pending;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchased;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchasable;
    notifyListeners();
  }
                                                           // Add from here
  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    // Handle purchases here
  }

  void _updateStreamOnDone() {
    _subscription.cancel();
  }

  void _updateStreamOnError(dynamic error) {
    //Handle error here
  }                                                        // To here.
}

これで、アプリは購入の更新を受け取れるようになりました。次のセクションでは、購入を行います。

先に進む前に、「flutter test"」でテストを実行して、すべてが正しく設定されていることを確認します。

$ flutter test

00:01 +1: All tests passed!

8. 購入する

この Codelab のパートでは、既存のモック商品を実際に購入可能な商品に置き換えます。これらの商品はストアから読み込まれ、リストに表示されます。商品をタップすると購入できます。

購入可能なプロダクトを適応させる

PurchasableProduct にはモック商品が表示されます。purchasable_product.dartPurchasableProduct クラスを次のコードに置き換えて、実際のコンテンツを表示するように更新します。

lib/model/purchasable_product.dart

import 'package:in_app_purchase/in_app_purchase.dart';

enum ProductStatus { purchasable, purchased, pending }

class PurchasableProduct {
  String get id => productDetails.id;
  String get title => productDetails.title;
  String get description => productDetails.description;
  String get price => productDetails.price;
  ProductStatus status;
  ProductDetails productDetails;

  PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable;
}

dash_purchases.dart, で、ダミー購入を削除し、空のリスト List<PurchasableProduct> products = []; に置き換えます。

利用可能な購入を読み込む

ユーザーが購入できるようにするには、ストアから購入を読み込みます。まず、ストアが利用可能かどうかを確認します。ストアが利用できない場合、storeStatenotAvailable に設定すると、ユーザーにエラー メッセージが表示されます。

lib/logic/dash_purchases.dart

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
  }

ストアが利用可能になったら、利用可能な購入を読み込みます。前述の Google Play と App Store の設定では、storeKeyConsumablestoreKeySubscription,storeKeyUpgrade が表示されます。購入予定の商品が利用できない場合は、この情報をコンソールに出力します。この情報をバックエンド サービスに送信することもできます。

await iapConnection.queryProductDetails(ids) メソッドは、見つからなかった ID と見つかった購入可能な商品の両方を返します。レスポンスの productDetails を使用して UI を更新し、StoreStateavailable に設定します。

lib/logic/dash_purchases.dart

import '../constants.dart';

// ...

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
    const ids = <String>{
      storeKeyConsumable,
      storeKeySubscription,
      storeKeyUpgrade,
    };
    final response = await iapConnection.queryProductDetails(ids);
    products = response.productDetails
        .map((e) => PurchasableProduct(e))
        .toList();
    storeState = StoreState.available;
    notifyListeners();
  }

コンストラクタで loadPurchases() 関数を呼び出します。

lib/logic/dash_purchases.dart

  DashPurchases(this.counter) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();                                       // Add this line
  }

最後に、storeState フィールドの値を StoreState.available から StoreState.loading: に変更します。

lib/logic/dash_purchases.dart

StoreState storeState = StoreState.loading;

購入可能な商品を表示する

purchase_page.dart ファイルについて考えてみましょう。PurchasePage ウィジェットには、StoreState に応じて _PurchasesLoading_PurchaseList,_PurchasesNotAvailable, が表示されます。ウィジェットには、次のステップで使用されるユーザーの過去の購入履歴も表示されます。

_PurchaseList ウィジェットには購入可能な商品のリストが表示され、DashPurchases オブジェクトに購入リクエストが送信されます。

lib/pages/purchase_page.dart

class _PurchaseList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var purchases = context.watch<DashPurchases>();
    var products = purchases.products;
    return Column(
      children: products
          .map(
            (product) => _PurchaseWidget(
              product: product,
              onPressed: () {
                purchases.buy(product);
              },
            ),
          )
          .toList(),
    );
  }
}

正しく設定されていれば、Android ストアと iOS ストアで利用可能な商品を確認できます。各コンソールに入力した購入内容が反映されるまでには、時間がかかることがあります。

ca1a9f97c21e552d.png

dash_purchases.dart に戻り、商品を購入する関数を実装します。消費型アイテムと非消費型アイテムを区別するだけで済みます。アップグレードと定期購入商品は消費型ではありません。

lib/logic/dash_purchases.dart

  Future<void> buy(PurchasableProduct product) async {
    final purchaseParam = PurchaseParam(productDetails: product.productDetails);
    switch (product.id) {
      case storeKeyConsumable:
        await iapConnection.buyConsumable(purchaseParam: purchaseParam);
      case storeKeySubscription:
      case storeKeyUpgrade:
        await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
      default:
        throw ArgumentError.value(
          product.productDetails,
          '${product.id} is not a known product',
        );
    }
  }

続行する前に、変数 _beautifiedDashUpgrade を作成し、それを参照するように beautifiedDash ゲッターを更新します。

lib/logic/dash_purchases.dart

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;

_onPurchaseUpdate メソッドは購入の更新を受け取り、購入ページに表示される商品のステータスを更新し、購入をカウンター ロジックに適用します。購入を処理した後で completePurchase を呼び出すことが重要です。これにより、ストアは購入が正しく処理されたことを認識できます。

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
    List<PurchaseDetails> purchaseDetailsList,
  ) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      switch (purchaseDetails.productID) {
        case storeKeySubscription:
          counter.applyPaidMultiplier();
        case storeKeyConsumable:
          counter.addBoughtDashes(2000);
        case storeKeyUpgrade:
          _beautifiedDashUpgrade = true;
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

9. バックエンドを設定する

購入のトラッキングと検証に進む前に、それらをサポートする Dart バックエンドを設定します。

このセクションでは、dart-backend/ フォルダをルートとして作業します。

次のツールがインストールされていることを確認します。

ベース プロジェクトの概要

このプロジェクトの一部は、この Codelab の範囲外と見なされるため、スターター コードに含まれています。始める前に、スターター コードに何が含まれているかを確認して、構造を把握しておくことをおすすめします。

このバックエンド コードはマシンでローカルに実行できます。デプロイする必要はありません。ただし、開発デバイス(Android または iPhone)からサーバーが実行されるマシンに接続できる必要があります。そのためには、同じネットワークに接続している必要があり、マシンの IP アドレスを知っておく必要があります。

次のコマンドを使用してサーバーを実行してみてください。

$ dart ./bin/server.dart

Serving at http://0.0.0.0:8080

Dart バックエンドは、shelfshelf_router を使用して API エンドポイントを提供します。デフォルトでは、サーバーはルートを提供しません。後で、購入確認プロセスを処理するルートを作成します。

スターター コードにすでに含まれている部分の 1 つは、lib/iap_repository.dartIapRepository です。Firestore やデータベース全般の操作方法を学ぶことは、この Codelab の趣旨に沿っていないため、スターター コードには、Firestore で購入を作成または更新するための関数と、それらの購入に関するすべてのクラスが含まれています。

Firebase へのアクセス権を設定する

Firebase Firestore にアクセスするには、サービス アカウント アクセスキーが必要です。Firebase プロジェクトの設定を開いて [サービス アカウント] セクションに移動し、[新しい秘密鍵の生成] を選択して生成します。

27590fc77ae94ad4.png

ダウンロードした JSON ファイルを assets/ フォルダにコピーし、ファイル名を service-account-firebase.json に変更します。

Google Play へのアクセスを設定する

購入の確認のために Google Play ストアにアクセスするには、これらの権限を持つサービス アカウントを生成し、その JSON 認証情報をダウンロードする必要があります。

  1. Google Cloud コンソールで Google Play Android Developer API のページにアクセスします。629f0bd8e6b50be8.png Google Play Console でプロジェクトの作成または既存のプロジェクトへのリンクを求められた場合は、まずその操作を行ってから、このページに戻ってください。
  2. 次に、サービス アカウント ページに移動し、[+ サービス アカウントを作成] をクリックします。8dc97e3b1262328a.png
  3. [サービス アカウント名] を入力し、[作成して続行] をクリックします。4fe8106af85ce75f.png
  4. [Pub/Sub サブスクライバー] ロールを選択し、[完了] をクリックします。a5b6fa6ea8ee22d.png
  5. アカウントを作成したら、[キーの管理] に移動します。eb36da2c1ad6dd06.png
  6. [鍵を追加 > 新しい鍵を作成] を選択します。e92db9557a28a479.png
  7. JSON キーを作成してダウンロードします。711d04f2f4176333.png
  8. ダウンロードしたファイルの名前を service-account-google-play.json, に変更し、assets/ ディレクトリに移動します。
  9. 次に、Google Play Console の [ユーザーと権限] ページに移動します。28fffbfc35b45f97.png
  10. [新しいユーザーを招待] をクリックし、先ほど作成したサービス アカウントのメールアドレスを入力します。メールアドレスは、[サービス アカウント] ページのテーブルで確認できます。e3310cc077f397d.png
  11. アプリケーションに [売上データの表示] 権限と [注文と定期購入の管理] 権限を付与します。a3b8cf2b660d1900.png
  12. [ユーザーを招待] をクリックします。

もう 1 つ必要な作業は、lib/constants.dart, を開き、androidPackageId の値を Android アプリ用に選択したパッケージ ID に置き換えることです。

Apple App Store へのアクセスを設定する

購入の検証のために App Store にアクセスするには、共有シークレットを設定する必要があります。

  1. App Store Connect を開きます。
  2. [マイアプリ] に移動し、アプリを選択します。
  3. サイドバーのナビゲーションで、[General] > [App information] に移動します。
  4. [アプリ固有の共有シークレット] ヘッダーの下にある [管理] をクリックします。ad419782c5fbacb2.png
  5. 新しいシークレットを生成してコピーします。b5b72a357459b0e5.png
  6. lib/constants.dart, を開き、appStoreSharedSecret の値を生成した共有シークレットに置き換えます。

定数の構成ファイル

続行する前に、lib/constants.dart ファイルで次の定数が構成されていることを確認してください。

  • androidPackageId: Android で使用されるパッケージ ID(com.example.dashclicker など)
  • appStoreSharedSecret: 購入の確認を行うために App Store Connect にアクセスするための共有シークレット。
  • bundleId: iOS で使用されるバンドル ID(com.example.dashclicker など)

当面の間、残りの定数は無視してかまいません。

10. 購入を確認する

購入の検証の一般的なフローは、iOS と Android で似ています。

どちらのストアでも、購入が行われるとアプリケーションにトークンが届きます。

このトークンはアプリからバックエンド サービスに送信され、バックエンド サービスは提供されたトークンを使用して、それぞれのストアのサーバーで購入を検証します。

バックエンド サービスは、購入を保存するかどうかを選択し、購入が有効かどうかをアプリに返信できます。

ユーザーのデバイスで実行されるアプリケーションではなく、バックエンド サービスがストアで検証を行うことで、たとえば、ユーザーがシステム時計を巻き戻してプレミアム機能にアクセスすることを防ぐことができます。

Flutter 側を設定する

認証の設定

購入をバックエンド サービスに送信するため、購入時にユーザーが認証されていることを確認する必要があります。認証ロジックのほとんどはスターター プロジェクトにすでに追加されています。ユーザーがまだログインしていない場合に PurchasePage にログインボタンが表示されるようにするだけで済みます。PurchasePage の build メソッドの先頭に次のコードを追加します。

lib/pages/purchase_page.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../logic/dash_purchases.dart';
import '../logic/firebase_notifier.dart';                  // Add this import
import '../model/firebase_state.dart';                     // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import '../repo/iap_repo.dart';
import 'login_page.dart';                                  // And this one as well

class PurchasePage extends StatelessWidget {
  const PurchasePage({super.key});

  @override
  Widget build(BuildContext context) {                     // Update from here
    var firebaseNotifier = context.watch<FirebaseNotifier>();
    if (firebaseNotifier.state == FirebaseState.loading) {
      return _PurchasesLoading();
    } else if (firebaseNotifier.state == FirebaseState.notAvailable) {
      return _PurchasesNotAvailable();
    }

    if (!firebaseNotifier.loggedIn) {
      return const LoginPage();
    }                                                      // To here.

    // ...

アプリから通話確認エンドポイントを呼び出す

アプリで、http post 呼び出しを使用して Dart バックエンドの /verifypurchase エンドポイントを呼び出す _verifyPurchase(PurchaseDetails purchaseDetails) 関数を作成します。

選択したストア(Play ストアの場合は google_play、App Store の場合は app_store)、serverVerificationDataproductID を送信します。サーバーは、購入が検証されたかどうかを示すステータス コードを返します。

アプリの定数で、サーバー IP をローカルマシンの IP アドレスに構成します。

lib/logic/dash_purchases.dart

import 'dart:async';
import 'dart:convert';                                     // Add this import

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;                   // And this import
import 'package:in_app_purchase/in_app_purchase.dart';

import '../constants.dart';
import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';
import 'firebase_notifier.dart';                           // And this one

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  FirebaseNotifier firebaseNotifier;                       // Add this line
  StoreState storeState = StoreState.loading;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [];

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;

  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter, this.firebaseNotifier) {     // Update this line
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();
  }

main.dart:DashPurchases の作成とともに firebaseNotifier を追加します。

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
          ),
          lazy: false,
        ),

FirebaseNotifier に User のゲッターを追加して、ユーザー ID を購入確認関数に渡せるようにします。

lib/logic/firebase_notifier.dart

  Future<FirebaseFirestore> get firestore async {
    var isInitialized = await _isInitialized.future;
    if (!isInitialized) {
      throw Exception('Firebase is not initialized');
    }
    return FirebaseFirestore.instance;
  }

  User? get user => FirebaseAuth.instance.currentUser;     // Add this line

  Future<void> load() async {
    // ...

関数 _verifyPurchaseDashPurchases クラスに追加します。この async 関数は、購入が検証されたかどうかを示すブール値を返します。

lib/logic/dash_purchases.dart

  Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
    final url = Uri.parse('http://$serverIp:8080/verifypurchase');
    const headers = {
      'Content-type': 'application/json',
      'Accept': 'application/json',
    };
    final response = await http.post(
      url,
      body: jsonEncode({
        'source': purchaseDetails.verificationData.source,
        'productId': purchaseDetails.productID,
        'verificationData':
            purchaseDetails.verificationData.serverVerificationData,
        'userId': firebaseNotifier.user?.uid,
      }),
      headers: headers,
    );
    if (response.statusCode == 200) {
      return true;
    } else {
      return false;
    }
  }

購入を適用する直前に、_handlePurchase_verifyPurchase 関数を呼び出します。購入は、確認が済んでから適用する必要があります。本番環境のアプリでは、これをさらに指定して、たとえばストアが一時的に利用できない場合にトライアル サブスクリプションを適用できます。ただし、この例では、購入が正常に確認されたときに購入を適用します。

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
    List<PurchaseDetails> purchaseDetailsList,
  ) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      // Send to server
      var validPurchase = await _verifyPurchase(purchaseDetails);

      if (validPurchase) {
        // Apply changes locally
        switch (purchaseDetails.productID) {
          case storeKeySubscription:
            counter.applyPaidMultiplier();
          case storeKeyConsumable:
            counter.addBoughtDashes(2000);
          case storeKeyUpgrade:
            _beautifiedDashUpgrade = true;
        }
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

アプリで、購入を検証する準備が整いました。

バックエンド サービスを設定する

次に、バックエンドで購入を確認するためのバックエンドを設定します。

購入ハンドラをビルドする

両方のストアの確認フローはほぼ同じであるため、ストアごとに個別の実装で抽象 PurchaseHandler クラスを設定します。

be50c207c5a2a519.png

まず、lib/ フォルダに purchase_handler.dart ファイルを追加します。このファイルで、定期購入と定期購入以外の 2 種類の購入を検証するための 2 つの抽象メソッドを含む抽象 PurchaseHandler クラスを定義します。

lib/purchase_handler.dart

import 'products.dart';

/// Generic purchase handler,
/// must be implemented for Google Play and Apple Store
abstract class PurchaseHandler {
  /// Verify if non-subscription purchase (aka consumable) is valid
  /// and update the database
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  });

  /// Verify if subscription purchase (aka non-consumable) is valid
  /// and update the database
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  });
}

ご覧のとおり、各メソッドには 3 つのパラメータが必要です。

  • userId: ログインしたユーザーの ID。購入をユーザーに関連付けることができます。
  • productData: 商品に関するデータ。これは後ほど定義します。
  • token: ストアからユーザーに提供されたトークン。

また、これらの購入ハンドラを使いやすくするために、サブスクリプションと非サブスクリプションの両方に使用できる verifyPurchase() メソッドを追加します。

lib/purchase_handler.dart

  /// Verify if purchase is valid and update the database
  Future<bool> verifyPurchase({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    switch (productData.type) {
      case ProductType.subscription:
        return handleSubscription(
          userId: userId,
          productData: productData,
          token: token,
        );
      case ProductType.nonSubscription:
        return handleNonSubscription(
          userId: userId,
          productData: productData,
          token: token,
        );
    }
  }

これで、どちらの場合も verifyPurchase を呼び出すだけで済みますが、実装は別々です。

ProductData クラスには、さまざまな購入可能なアイテムに関する基本情報が含まれています。これには、アイテム ID(SKU とも呼ばれます)と ProductType が含まれます。

lib/products.dart

class ProductData {
  final String productId;
  final ProductType type;

  const ProductData(this.productId, this.type);
}

ProductType は、定期購入または定期購入以外にできます。

lib/products.dart

enum ProductType { subscription, nonSubscription }

最後に、同じファイルで商品のリストがマップとして定義されます。

lib/products.dart

const productDataMap = {
  'dash_consumable_2k': ProductData(
    'dash_consumable_2k',
    ProductType.nonSubscription,
  ),
  'dash_upgrade_3d': ProductData(
    'dash_upgrade_3d',
    ProductType.nonSubscription,
  ),
  'dash_subscription_doubler': ProductData(
    'dash_subscription_doubler',
    ProductType.subscription,
  ),
};

次に、Google Play ストアと Apple App Store のプレースホルダ実装を定義します。Google Play で始めるには:

lib/google_play_purchase_handler.dart を作成し、先ほど作成した PurchaseHandler を拡張するクラスを追加します。

lib/google_play_purchase_handler.dart

import 'dart:async';

import 'package:googleapis/androidpublisher/v3.dart' as ap;

import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';

class GooglePlayPurchaseHandler extends PurchaseHandler {
  final ap.AndroidPublisherApi androidPublisher;
  final IapRepository iapRepository;

  GooglePlayPurchaseHandler(this.androidPublisher, this.iapRepository);

  @override
  Future<bool> handleNonSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }

  @override
  Future<bool> handleSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }
}

今のところ、ハンドラ メソッドに対して true を返します。これについては後で説明します。

お気づきかもしれませんが、コンストラクタは IapRepository のインスタンスを受け取ります。購入ハンドラは、このインスタンスを使用して、購入に関する情報を後で Firestore に保存します。Google Play と通信するには、提供された AndroidPublisherApi を使用します。

次に、アプリストア ハンドラについても同様の手順を行います。lib/app_store_purchase_handler.dart を作成し、PurchaseHandler を拡張するクラスを再度追加します。

lib/app_store_purchase_handler.dart

import 'dart:async';

import 'package:app_store_server_sdk/app_store_server_sdk.dart';

import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';

class AppStorePurchaseHandler extends PurchaseHandler {
  final IapRepository iapRepository;

  AppStorePurchaseHandler(this.iapRepository);

  @override
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }

  @override
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }
}

これで、これで、購入ハンドラが 2 つになりました。次に、購入確認 API エンドポイントを作成します。

購入ハンドラを使用する

bin/server.dart を開き、shelf_route を使用して API エンドポイントを作成します。

bin/server.dart

import 'dart:convert';

import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/products.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

Future<void> main() async {
  final router = Router();

  final purchaseHandlers = await _createPurchaseHandlers();

  router.post('/verifypurchase', (Request request) async {
    final dynamic payload = json.decode(await request.readAsString());

    final (:userId, :source, :productData, :token) = getPurchaseData(payload);

    final result = await purchaseHandlers[source]!.verifyPurchase(
      userId: userId,
      productData: productData,
      token: token,
    );

    if (result) {
      return Response.ok('all good!');
    } else {
      return Response.internalServerError();
    }
  });

  await serveHandler(router.call);
}

({String userId, String source, ProductData productData, String token})
getPurchaseData(dynamic payload) {
  if (payload case {
    'userId': String userId,
    'source': String source,
    'productId': String productId,
    'verificationData': String token,
  }) {
    return (
      userId: userId,
      source: source,
      productData: productDataMap[productId]!,
      token: token,
    );
  } else {
    throw const FormatException('Unexpected JSON');
  }
}

このコードは次の処理を行っています。

  1. 前に作成したアプリから呼び出される POST エンドポイントを定義します。
  2. JSON ペイロードをデコードし、次の情報を抽出します。
    1. userId: ログインしたユーザーの ID
    2. source: 使用されたストア(app_store または google_play)。
    3. productData: 以前に作成した productDataMap から取得します。
    4. token: ストアに送信する検証データが含まれます。
  3. ソースに応じて、GooglePlayPurchaseHandler または AppStorePurchaseHandler のいずれかの verifyPurchase メソッドを呼び出します。
  4. 検証が成功すると、メソッドはクライアントに Response.ok を返します。
  5. 検証に失敗した場合、メソッドはクライアントに Response.internalServerError を返します。

API エンドポイントを作成したら、2 つの購入ハンドラを構成する必要があります。これには、前の手順で取得したサービス アカウント キーを読み込み、Android Publisher API や Firebase Firestore API などのさまざまなサービスへのアクセスを設定する必要があります。次に、異なる依存関係を持つ 2 つの購入ハンドラを作成します。

bin/server.dart

import 'dart:convert';
import 'dart:io'; // new

import 'package:firebase_backend_dart/app_store_purchase_handler.dart'; // new
import 'package:firebase_backend_dart/google_play_purchase_handler.dart'; // new
import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/iap_repository.dart'; // new
import 'package:firebase_backend_dart/products.dart';
import 'package:firebase_backend_dart/purchase_handler.dart'; // new
import 'package:googleapis/androidpublisher/v3.dart' as ap; // new
import 'package:googleapis/firestore/v1.dart' as fs; // new
import 'package:googleapis_auth/auth_io.dart' as auth; // new
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

Future<Map<String, PurchaseHandler>> _createPurchaseHandlers() async {
  // Configure Android Publisher API access
  final serviceAccountGooglePlay =
      File('assets/service-account-google-play.json').readAsStringSync();
  final clientCredentialsGooglePlay =
      auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
  final clientGooglePlay =
      await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
    ap.AndroidPublisherApi.androidpublisherScope,
  ]);
  final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);

  // Configure Firestore API access
  final serviceAccountFirebase =
      File('assets/service-account-firebase.json').readAsStringSync();
  final clientCredentialsFirebase =
      auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
  final clientFirebase =
      await auth.clientViaServiceAccount(clientCredentialsFirebase, [
    fs.FirestoreApi.cloudPlatformScope,
  ]);
  final firestoreApi = fs.FirestoreApi(clientFirebase);
  final dynamic json = jsonDecode(serviceAccountFirebase);
  final projectId = json['project_id'] as String;
  final iapRepository = IapRepository(firestoreApi, projectId);

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
    ),
  };
}

Android の購入を確認する: 購入ハンドラを実装する

次に、Google Play 購入ハンドラの実装を続けます。

Google は、購入の確認に必要な API とやり取りするための Dart パッケージをすでに提供しています。これらは server.dart ファイルで初期化され、GooglePlayPurchaseHandler クラスで使用されます。

定期購入以外の購入のハンドラを実装します。

lib/google_play_purchase_handler.dart

  /// Handle non-subscription purchases (one time purchases).
  ///
  /// Retrieves the purchase status from Google Play and updates
  /// the Firestore Database accordingly.
  @override
  Future<bool> handleNonSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    print(
      'GooglePlayPurchaseHandler.handleNonSubscription'
      '($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
    );

    try {
      // Verify purchase with Google
      final response = await androidPublisher.purchases.products.get(
        androidPackageId,
        productData.productId,
        token,
      );

      print('Purchases response: ${response.toJson()}');

      // Make sure an order ID exists
      if (response.orderId == null) {
        print('Could not handle purchase without order id');
        return false;
      }
      final orderId = response.orderId!;

      final purchaseData = NonSubscriptionPurchase(
        purchaseDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.purchaseTimeMillis ?? '0'),
        ),
        orderId: orderId,
        productId: productData.productId,
        status: _nonSubscriptionStatusFrom(response.purchaseState),
        userId: userId,
        iapSource: IAPSource.googleplay,
      );

      // Update the database
      if (userId != null) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
        await iapRepository.createOrUpdatePurchase(purchaseData);
      } else {
        // If we don't know the user ID, a previous entry must already
        // exist, and thus we'll only update it.
        await iapRepository.updatePurchase(purchaseData);
      }
      return true;
    } on ap.DetailedApiRequestError catch (e) {
      print(
        'Error on handle NonSubscription: $e\n'
        'JSON: ${e.jsonResponse}',
      );
    } catch (e) {
      print('Error on handle NonSubscription: $e\n');
    }
    return false;
  }

定期購入購入ハンドラも同様に更新できます。

lib/google_play_purchase_handler.dart

  /// Handle subscription purchases.
  ///
  /// Retrieves the purchase status from Google Play and updates
  /// the Firestore Database accordingly.
  @override
  Future<bool> handleSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    print(
      'GooglePlayPurchaseHandler.handleSubscription'
      '($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
    );

    try {
      // Verify purchase with Google
      final response = await androidPublisher.purchases.subscriptions.get(
        androidPackageId,
        productData.productId,
        token,
      );

      print('Subscription response: ${response.toJson()}');

      // Make sure an order ID exists
      if (response.orderId == null) {
        print('Could not handle purchase without order id');
        return false;
      }
      final orderId = extractOrderId(response.orderId!);

      final purchaseData = SubscriptionPurchase(
        purchaseDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.startTimeMillis ?? '0'),
        ),
        orderId: orderId,
        productId: productData.productId,
        status: _subscriptionStatusFrom(response.paymentState),
        userId: userId,
        iapSource: IAPSource.googleplay,
        expiryDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.expiryTimeMillis ?? '0'),
        ),
      );

      // Update the database
      if (userId != null) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
        await iapRepository.createOrUpdatePurchase(purchaseData);
      } else {
        // If we don't know the user ID, a previous entry must already
        // exist, and thus we'll only update it.
        await iapRepository.updatePurchase(purchaseData);
      }
      return true;
    } on ap.DetailedApiRequestError catch (e) {
      print(
        'Error on handle Subscription: $e\n'
        'JSON: ${e.jsonResponse}',
      );
    } catch (e) {
      print('Error on handle Subscription: $e\n');
    }
    return false;
  }
}

注文 ID の解析を容易にするメソッドと、購入ステータスを解析する 2 つのメソッドを追加します。

lib/google_play_purchase_handler.dart

NonSubscriptionStatus _nonSubscriptionStatusFrom(int? state) {
  return switch (state) {
    0 => NonSubscriptionStatus.completed,
    2 => NonSubscriptionStatus.pending,
    _ => NonSubscriptionStatus.cancelled,
  };
}

SubscriptionStatus _subscriptionStatusFrom(int? state) {
  return switch (state) {
    // Payment pending
    0 => SubscriptionStatus.pending,
    // Payment received
    1 => SubscriptionStatus.active,
    // Free trial
    2 => SubscriptionStatus.active,
    // Pending deferred upgrade/downgrade
    3 => SubscriptionStatus.pending,
    // Expired or cancelled
    _ => SubscriptionStatus.expired,
  };
}

/// If a subscription suffix is present (..#) extract the orderId.
String extractOrderId(String orderId) {
  final orderIdSplit = orderId.split('..');
  if (orderIdSplit.isNotEmpty) {
    orderId = orderIdSplit[0];
  }
  return orderId;
}

これで、Google Play での購入が検証され、データベースに保存されるようになります。

次に、iOS 向け App Store での購入に進みます。

iOS での購入を確認する: 購入ハンドラを実装する

App Store での購入の検証には、app_store_server_sdk という名前のサードパーティの Dart パッケージがあり、このパッケージを使用するとプロセスが簡単になります。

まず、ITunesApi インスタンスを作成します。サンドボックス構成を使用し、エラーのデバッグを容易にするためにロギングを有効にします。

lib/app_store_purchase_handler.dart

  final _iTunesAPI = ITunesApi(
    ITunesHttpClient(ITunesEnvironment.sandbox(), loggingEnabled: true),
  );

Google Play API とは異なり、App Store では定期購入と非定期購入の両方に同じ API エンドポイントが使用されます。つまり、両方のハンドラに同じロジックを使用できます。同じ実装を呼び出すように統合します。

lib/app_store_purchase_handler.dart

  @override
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) {
    return handleValidation(userId: userId, token: token);
  }

  @override
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) {
    return handleValidation(userId: userId, token: token);
  }

  /// Handle purchase validation.
  Future<bool> handleValidation({
    required String userId,
    required String token,
  }) async {

    // See next step
  }

次に、handleValidation を実装します。

lib/app_store_purchase_handler.dart

  /// Handle purchase validation.
  Future<bool> handleValidation({
    required String userId,
    required String token,
  }) async {
    print('AppStorePurchaseHandler.handleValidation');
    final response = await _iTunesAPI.verifyReceipt(
      password: appStoreSharedSecret,
      receiptData: token,
    );
    print('response: $response');
    if (response.status == 0) {
      print('Successfully verified purchase');
      final receipts = response.latestReceiptInfo ?? [];
      for (final receipt in receipts) {
        final product = productDataMap[receipt.productId];
        if (product == null) {
          print('Error: Unknown product: ${receipt.productId}');
          continue;
        }
        switch (product.type) {
          case ProductType.nonSubscription:
            await iapRepository.createOrUpdatePurchase(
              NonSubscriptionPurchase(
                userId: userId,
                productId: receipt.productId ?? '',
                iapSource: IAPSource.appstore,
                orderId: receipt.originalTransactionId ?? '',
                purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.originalPurchaseDateMs ?? '0'),
                ),
                type: product.type,
                status: NonSubscriptionStatus.completed,
              ),
            );
            break;
          case ProductType.subscription:
            await iapRepository.createOrUpdatePurchase(
              SubscriptionPurchase(
                userId: userId,
                productId: receipt.productId ?? '',
                iapSource: IAPSource.appstore,
                orderId: receipt.originalTransactionId ?? '',
                purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.originalPurchaseDateMs ?? '0'),
                ),
                type: product.type,
                expiryDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.expiresDateMs ?? '0'),
                ),
                status: SubscriptionStatus.active,
              ),
            );
            break;
        }
      }
      return true;
    } else {
      print('Error: Status: ${response.status}');
      return false;
    }
  }

これで、App Store での購入が検証され、データベースに保存されるはずです。

バックエンドを実行する

この時点で、dart bin/server.dart を実行して /verifypurchase エンドポイントをサービングできます。

$ dart bin/server.dart
Serving at http://0.0.0.0:8080

11. 購入を追跡する

ユーザーの購入をトラッキングするには、バックエンド サービスで行うことをおすすめします。これは、バックエンドがストアからのイベントに応答できるため、キャッシュ保存による古い情報に遭遇する可能性が低く、改ざんされにくいからです。

まず、これまで構築してきた Dart バックエンドを使用して、バックエンドでストア イベントの処理を設定します。

バックエンドでストア イベントを処理する

ストアは、定期購入の更新など、発生した課金イベントをバックエンドに通知できます。これらのイベントをバックエンドで処理して、データベース内の購入情報を最新の状態に保つことができます。このセクションでは、Google Play ストアと Apple App Store の両方で設定します。

Google Play の請求イベントを処理する

Google Play は、Cloud Pub/Sub トピックと呼ばれるものを通じて課金イベントを提供します。これらは、メッセージをパブリッシュしたり、メッセージを消費したりできるメッセージ キューです。

これは Google Play 固有の機能であるため、この機能を GooglePlayPurchaseHandler に含めます。

まず、lib/google_play_purchase_handler.dart を開き、PubsubApi インポートを追加します。

lib/google_play_purchase_handler.dart

import 'package:googleapis/pubsub/v1.dart' as pubsub;

次に、PubsubApiGooglePlayPurchaseHandler に渡し、クラス コンストラクタを変更して、次のように Timer を作成します。

lib/google_play_purchase_handler.dart

class GooglePlayPurchaseHandler extends PurchaseHandler {
  final ap.AndroidPublisherApi androidPublisher;
  final IapRepository iapRepository;
  final pubsub.PubsubApi pubsubApi; // new

  GooglePlayPurchaseHandler(
    this.androidPublisher,
    this.iapRepository,
    this.pubsubApi, // new
  ) {
    // Poll messages from Pub/Sub every 10 seconds
    Timer.periodic(Duration(seconds: 10), (_) {
      _pullMessageFromPubSub();
    });
  }

Timer は 10 秒ごとに _pullMessageFromPubSub メソッドを呼び出すように構成されています。[期間] はご希望に応じて調整できます。

次に、_pullMessageFromPubSub を作成します。

lib/google_play_purchase_handler.dart

  /// Process messages from Google Play
  /// Called every 10 seconds
  Future<void> _pullMessageFromPubSub() async {
    print('Polling Google Play messages');
    final request = pubsub.PullRequest(maxMessages: 1000);
    final topicName =
        'projects/$googleCloudProjectId/subscriptions/$googlePlayPubsubBillingTopic-sub';
    final pullResponse = await pubsubApi.projects.subscriptions.pull(
      request,
      topicName,
    );
    final messages = pullResponse.receivedMessages ?? [];
    for (final message in messages) {
      final data64 = message.message?.data;
      if (data64 != null) {
        await _processMessage(data64, message.ackId);
      }
    }
  }

  Future<void> _processMessage(String data64, String? ackId) async {
    final dataRaw = utf8.decode(base64Decode(data64));
    print('Received data: $dataRaw');
    final dynamic data = jsonDecode(dataRaw);
    if (data['testNotification'] != null) {
      print('Skip test messages');
      if (ackId != null) {
        await _ackMessage(ackId);
      }
      return;
    }
    final dynamic subscriptionNotification = data['subscriptionNotification'];
    final dynamic oneTimeProductNotification =
        data['oneTimeProductNotification'];
    if (subscriptionNotification != null) {
      print('Processing Subscription');
      final subscriptionId =
          subscriptionNotification['subscriptionId'] as String;
      final purchaseToken = subscriptionNotification['purchaseToken'] as String;
      final productData = productDataMap[subscriptionId]!;
      final result = await handleSubscription(
        userId: null,
        productData: productData,
        token: purchaseToken,
      );
      if (result && ackId != null) {
        await _ackMessage(ackId);
      }
    } else if (oneTimeProductNotification != null) {
      print('Processing NonSubscription');
      final sku = oneTimeProductNotification['sku'] as String;
      final purchaseToken =
          oneTimeProductNotification['purchaseToken'] as String;
      final productData = productDataMap[sku]!;
      final result = await handleNonSubscription(
        userId: null,
        productData: productData,
        token: purchaseToken,
      );
      if (result && ackId != null) {
        await _ackMessage(ackId);
      }
    } else {
      print('invalid data');
    }
  }

  /// ACK Messages from Pub/Sub
  Future<void> _ackMessage(String id) async {
    print('ACK Message');
    final request = pubsub.AcknowledgeRequest(ackIds: [id]);
    final subscriptionName =
        'projects/$googleCloudProjectId/subscriptions/$googlePlayPubsubBillingTopic-sub';
    await pubsubApi.projects.subscriptions.acknowledge(
      request,
      subscriptionName,
    );
  }

追加したコードは、10 秒ごとに Google Cloud の Pub/Sub トピックと通信して、新しいメッセージをリクエストします。次に、_processMessage メソッドで各メッセージを処理します。

このメソッドは、受信メッセージをデコードし、定期購入と定期購入以外の両方について、各購入に関する更新情報を取得します。必要に応じて、既存の handleSubscription または handleNonSubscription を呼び出します。

各メッセージは _askMessage メソッドで確認応答する必要があります。

次に、必要な依存関係を server.dart ファイルに追加します。PubsubApi.cloudPlatformScope を認証情報の構成に追加します。

bin/server.dart

import 'package:googleapis/pubsub/v1.dart' as pubsub;      // Add this import

  final clientGooglePlay = await auth
      .clientViaServiceAccount(clientCredentialsGooglePlay, [
        ap.AndroidPublisherApi.androidpublisherScope,
        pubsub.PubsubApi.cloudPlatformScope,               // Add this line
      ]);

次に、PubsubApi インスタンスを作成します。

bin/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

最後に、これを GooglePlayPurchaseHandler コンストラクタに渡します。

bin/server.dart

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
      pubsubApi,                                           // Add this line
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
    ),
  };

Google Play のセットアップ

pub/sub トピックから課金イベントを使用するコードは記述しましたが、pub/sub トピックは作成しておらず、課金イベントも公開していません。セットアップしましょう。

まず、Pub/Sub トピックを作成します。

  1. constants.dartgoogleCloudProjectId の値を Google Cloud プロジェクトの ID に設定します。
  2. Google Cloud コンソールの Cloud Pub/Sub ページにアクセスします。
  3. Firebase プロジェクトに移動し、[+ トピックを作成] をクリックします。d5ebf6897a0a8bf5.png
  4. 新しいトピックに、constants.dartgooglePlayPubsubBillingTopic に設定された値と同じ名前を付けます。この場合は、play_billing という名前を付けます。別のものを選択した場合は、必ず constants.dart を更新してください。トピックを作成します。20d690fc543c4212.png
  5. Pub/Sub トピックのリストで、作成したトピックのその他アイコン(縦に並んだ 3 つの点)をクリックし、[権限を表示] をクリックします。ea03308190609fb.png
  6. 右側のサイドバーで、[プリンシパルを追加] を選択します。
  7. ここで、google-play-developer-notifications@system.gserviceaccount.com を追加して、Pub/Sub パブリッシャーのロールを付与します。55631ec0549215bc.png
  8. 権限の変更を保存します。
  9. 作成したトピックのトピック名をコピーします。
  10. Google Play Console を再度開き、[すべてのアプリ] リストからアプリを選択します。
  11. 下にスクロールして、[収益化] > [収益化のセットアップ] に移動します。
  12. トピック全体を入力し、変更を保存します。7e5e875dc6ce5d54.png

これで、すべての Google Play の課金イベントがトピックに公開されるようになります。

アプリストアの課金イベントを処理する

次に、App Store の課金イベントについても同様の手順を行います。App Store で購入のアップデートを処理する効果的な方法は 2 つあります。1 つは、Apple に提供する Webhook を実装し、Apple がそれを使用してサーバーと通信する方法です。2 つ目の方法は、この Codelab で説明する、App Store Server API に接続して定期購入情報を手動で取得する方法です。

この Codelab で 2 つ目の解決策に焦点を当てているのは、Webhook を実装するにはサーバーをインターネットに公開する必要があるためです。

本番環境では、両方を用意することが理想的です。App Store からイベントを取得する Webhook と、イベントを見逃した場合や定期購入ステータスを再確認する必要がある場合の Server API。

まず、lib/app_store_purchase_handler.dart を開き、AppStoreServerAPI 依存関係を追加します。

lib/app_store_purchase_handler.dart

  final AppStoreServerAPI appStoreServerAPI;                 // Add this member

  AppStorePurchaseHandler(
    this.iapRepository,
    this.appStoreServerAPI,                                  // And this parameter
  );

_pullStatus メソッドを呼び出すタイマーを追加するようにコンストラクタを変更します。このタイマーは 10 秒ごとに _pullStatus メソッドを呼び出します。このタイマーの時間は必要に応じて調整できます。

lib/app_store_purchase_handler.dart

  AppStorePurchaseHandler(this.iapRepository, this.appStoreServerAPI) {
    // Poll Subscription status every 10 seconds.
    Timer.periodic(Duration(seconds: 10), (_) {
      _pullStatus();
    });
  }

次に、_pullStatus メソッドを次のように作成します。

lib/app_store_purchase_handler.dart

  /// Request the App Store for the latest subscription status.
  /// Updates all App Store subscriptions in the database.
  /// NOTE: This code only handles when a subscription expires as example.
  Future<void> _pullStatus() async {
    print('Polling App Store');
    final purchases = await iapRepository.getPurchases();
    // filter for App Store subscriptions
    final appStoreSubscriptions = purchases.where(
      (element) =>
          element.type == ProductType.subscription &&
          element.iapSource == IAPSource.appstore,
    );
    for (final purchase in appStoreSubscriptions) {
      final status = await appStoreServerAPI.getAllSubscriptionStatuses(
        purchase.orderId,
      );
      // Obtain all subscriptions for the order ID.
      for (final subscription in status.data) {
        // Last transaction contains the subscription status.
        for (final transaction in subscription.lastTransactions) {
          final expirationDate = DateTime.fromMillisecondsSinceEpoch(
            transaction.transactionInfo.expiresDate ?? 0,
          );
          // Check if subscription has expired.
          final isExpired = expirationDate.isBefore(DateTime.now());
          print('Expiration Date: $expirationDate - isExpired: $isExpired');
          // Update the subscription status with the new expiration date and status.
          await iapRepository.updatePurchase(
            SubscriptionPurchase(
              userId: null,
              productId: transaction.transactionInfo.productId,
              iapSource: IAPSource.appstore,
              orderId: transaction.originalTransactionId,
              purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                transaction.transactionInfo.originalPurchaseDate,
              ),
              type: ProductType.subscription,
              expiryDate: expirationDate,
              status: isExpired
                  ? SubscriptionStatus.expired
                  : SubscriptionStatus.active,
            ),
          );
        }
      }
    }
  }

このメソッドは次のように動作します。

  1. IapRepository を使用して、Firestore から有効な定期購入のリストを取得します。
  2. 注文ごとに、App Store Server API に定期購入ステータスをリクエストします。
  3. 定期購入の購入の最後の取引を取得します。
  4. 有効期限を確認します。
  5. Firestore でサブスクリプションのステータスを更新します。期限切れの場合は、その旨がマークされます。

最後に、App Store Server API へのアクセスを構成するために必要なコードをすべて追加します。

bin/server.dart

import 'package:app_store_server_sdk/app_store_server_sdk.dart';  // Add this import
import 'package:firebase_backend_dart/constants.dart';            // And this one.


  // add from here
  final subscriptionKeyAppStore = File(
    'assets/SubscriptionKey.p8',
  ).readAsStringSync();

  // Configure Apple Store API access
  var appStoreEnvironment = AppStoreEnvironment.sandbox(
    bundleId: bundleId,
    issuerId: appStoreIssuerId,
    keyId: appStoreKeyId,
    privateKey: subscriptionKeyAppStore,
  );

  // Stored token for Apple Store API access, if available
  final file = File('assets/appstore.token');
  String? appStoreToken;
  if (file.existsSync() && file.lengthSync() > 0) {
    appStoreToken = file.readAsStringSync();
  }

  final appStoreServerAPI = AppStoreServerAPI(
    AppStoreServerHttpClient(
      appStoreEnvironment,
      jwt: appStoreToken,
      jwtTokenUpdatedCallback: (token) {
        file.writeAsStringSync(token);
      },
    ),
  );
  // to here

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
      pubsubApi,
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
      appStoreServerAPI,                                     // Add this argument
    ),
  };

App Store の設定

次に、App Store を設定します。

  1. [App Store Connect] にログインし、[Users and Access] を選択します。
  2. [Integrations] > [Keys] > [In-App Purchase] に移動します。
  3. プラスアイコンをタップして新しいものを追加します。
  4. 「Codelab key」などの名前を付けます。
  5. 鍵を含む p8 ファイルをダウンロードします。
  6. アセット フォルダに SubscriptionKey.p8 という名前でコピーします。
  7. 新しく作成した鍵から鍵 ID をコピーし、lib/constants.dart ファイルの appStoreKeyId 定数に設定します。
  8. 鍵リストの最上部にある発行者 ID をコピーし、lib/constants.dart ファイルの appStoreIssuerId 定数に設定します。

9540ea9ada3da151.png

デバイスでの購入を追跡する

購入を追跡する最も安全な方法はサーバーサイドで行うことです。クライアントのセキュリティを確保することは難しいですが、アプリが定期購入ステータス情報に基づいて動作できるように、情報をクライアントに返す方法が必要です。購入情報を Firestore に保存することで、データをクライアントと同期し、自動的に最新の状態に保つことができます。

アプリにはすでに IAPRepo が含まれています。これは、List<PastPurchase> purchases のすべてのユーザーの購入データを含む Firestore リポジトリです。リポジトリには、期限切れでないステータスの productId storeKeySubscription を含む購入がある場合に true になる hasActiveSubscription, も含まれています。ユーザーがログインしていない場合、リストは空になります。

lib/repo/iap_repo.dart

  void updatePurchases() {
    _purchaseSubscription?.cancel();
    var user = _user;
    if (user == null) {
      purchases = [];
      hasActiveSubscription = false;
      hasUpgrade = false;
      return;
    }
    var purchaseStream = _firestore
        .collection('purchases')
        .where('userId', isEqualTo: user.uid)
        .snapshots();
    _purchaseSubscription = purchaseStream.listen((snapshot) {
      purchases = snapshot.docs.map((document) {
        var data = document.data();
        return PastPurchase.fromJson(data);
      }).toList();

      hasActiveSubscription = purchases.any(
        (element) =>
            element.productId == storeKeySubscription &&
            element.status != Status.expired,
      );

      hasUpgrade = purchases.any(
        (element) => element.productId == storeKeyUpgrade,
      );

      notifyListeners();
    });
  }

購入ロジックはすべて DashPurchases クラスにあり、定期購入の適用や削除はここで行う必要があります。そこで、iapRepo をクラスのプロパティとして追加し、コンストラクタで iapRepo を割り当てます。次に、コンストラクタでリスナーを直接追加し、dispose() メソッドでリスナーを削除します。最初は、リスナーは空の関数でかまいません。IAPRepoChangeNotifier であり、Firestore の購入が変更されるたびに notifyListeners() を呼び出すため、購入した商品が変更されると常に purchasesUpdate() メソッドが呼び出されます。

lib/logic/dash_purchases.dart

import '../repo/iap_repo.dart';                              // Add this import

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  FirebaseNotifier firebaseNotifier;
  StoreState storeState = StoreState.loading;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [];
  IAPRepo iapRepo;                                           // Add this line

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;
  final iapConnection = IAPConnection.instance;

  // Add this.iapRepo as a parameter
  DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    iapRepo.addListener(purchasesUpdate);
    loadPurchases();
  }

  Future<void> loadPurchases() async {
    // Elided.
  }

  @override
  void dispose() {
    _subscription.cancel();
    iapRepo.removeListener(purchasesUpdate);                 // Add this line
    super.dispose();
  }

  void purchasesUpdate() {
    //TODO manage updates
  }

次に、main.dart. のコンストラクタに IAPRepo を指定します。Provider でリポジトリがすでに作成されているため、context.read を使用してリポジトリを取得できます。

lib/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
            context.read<IAPRepo>(),                         // Add this line
          ),
          lazy: false,
        ),

次に、purchaseUpdate() 関数のコードを記述します。dash_counter.dart, では、applyPaidMultiplier メソッドと removePaidMultiplier メソッドで乗数がそれぞれ 10 または 1 に設定されるため、定期購入がすでに適用されているかどうかを確認する必要はありません。サブスクリプションのステータスが変更されたら、購入可能なアイテムのステータスも更新して、購入ページにすでに有効であることを表示できるようにします。アップグレードが購入されたかどうかに基づいて _beautifiedDashUpgrade プロパティを設定します。

lib/logic/dash_purchases.dart

  void purchasesUpdate() {
    var subscriptions = <PurchasableProduct>[];
    var upgrades = <PurchasableProduct>[];
    // Get a list of purchasable products for the subscription and upgrade.
    // This should be 1 per type.
    if (products.isNotEmpty) {
      subscriptions = products
          .where((element) => element.productDetails.id == storeKeySubscription)
          .toList();
      upgrades = products
          .where((element) => element.productDetails.id == storeKeyUpgrade)
          .toList();
    }

    // Set the subscription in the counter logic and show/hide purchased on the
    // purchases page.
    if (iapRepo.hasActiveSubscription) {
      counter.applyPaidMultiplier();
      for (var element in subscriptions) {
        _updateStatus(element, ProductStatus.purchased);
      }
    } else {
      counter.removePaidMultiplier();
      for (var element in subscriptions) {
        _updateStatus(element, ProductStatus.purchasable);
      }
    }

    // Set the Dash beautifier and show/hide purchased on
    // the purchases page.
    if (iapRepo.hasUpgrade != _beautifiedDashUpgrade) {
      _beautifiedDashUpgrade = iapRepo.hasUpgrade;
      for (var element in upgrades) {
        _updateStatus(
          element,
          _beautifiedDashUpgrade
              ? ProductStatus.purchased
              : ProductStatus.purchasable,
        );
      }
      notifyListeners();
    }
  }

  void _updateStatus(PurchasableProduct product, ProductStatus status) {
    if (product.status != ProductStatus.purchased) {
      product.status = ProductStatus.purchased;
      notifyListeners();
    }
  }

これで、バックエンド サービスで定期購入とアップグレードのステータスが常に最新の状態に保たれ、アプリと同期されるようになりました。アプリはこれに応じて動作し、定期購入とアップグレードの機能を Dash クリッカー ゲームに適用します。

12. 完了

お疲れさまでした。Codelab を完了しました。この Codelab の最終的なコードは、android_studio_folder.png complete フォルダで確認できます。

詳細については、別の Flutter Codelab をご覧ください。