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

1. はじめに

最終更新日: 2023 年 7 月 11 日

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

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

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

最初の購入オプションでは、2,000 Dash という直接的な特典がユーザーに提供されます。ユーザーは直接購入でき、何度でも購入できます。これは消費可能アイテムと呼ばれ、直接消費され、複数回消費される可能性があるためです。

2 つ目のオプションは、Dash をより美しい Dash にアップグレードします。購入は 1 回限りとなり、無期限に利用できます。このような購入は、アプリで消費できないものの永久に有効であるため、非消費型と呼ばれます。

最後となる 3 つ目の購入オプションは定期購入です。定期購入が有効である間、ユーザーはより速く Dash を取得しますが、定期購入への支払いを停止すると、特典も利用できなくなります。

バックエンド サービス(こちらも用意されている)は Dart アプリとして実行され、購入が行われたことを確認し、Firestore を使用してそれらを保存します。Firestore を使用するとプロセスを簡略化できますが、本番環境アプリでは、あらゆる種類のバックエンド サービスを使用できます。

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

作成するアプリの概要

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

学習 内容

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

必要なもの

  • Android Studio 4.1 以降
  • Xcode 12 以降(iOS 開発用)
  • Flutter SDK

2. 開発環境を設定する

この Codelab を開始するには、コードをダウンロードして、iOS の場合はバンドル ID を、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 からスターター プロジェクトを開きます。スクリーンショットには Android Studio を使用しましたが、Visual Studio Code も有効な選択肢です。どちらのエディタでも、最新の Dart プラグインと Flutter プラグインがインストールされていることを確認します。

作成するアプリは、App Store および Google Play ストアと通信して、どのプロダクトがどのような価格で販売されるかを把握する必要があります。すべてのアプリは一意の ID で識別されます。iOS App Store ではバンドル ID、Android Play ストアではアプリケーション ID です。これらの識別子は通常、リバース ドメイン名表記を使用して作成されます。たとえば、flutter.dev のアプリ内購入アプリを作成する場合は、dev.flutter.inapppurchase を使用します。アプリの識別子を考えてみましょう。ここではプロジェクト設定で識別子を設定します。

まず、iOS のバンドル ID を設定します。

Android Studio でプロジェクトを開いた状態で、iOS フォルダを右クリックして [Flutter] をクリックし、Xcode アプリでモジュールを開きます。

942772eb9a73bfaa.png

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

812f919d965c649a.jpeg

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

5c4733ac560ae8c2.png

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

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

pubspec に依存関係を追加する

pubspec の依存関係に in_app_purchase を追加して、in_app_purchase を pubspec に追加します。

$ cd app
$ flutter pub add in_app_purchase

pubspec.yaml

dependencies:
  ..
  cloud_firestore: ^4.0.3
  firebase_auth: ^4.2.2
  firebase_core: ^2.5.0
  google_sign_in: ^6.0.1
  http: ^0.13.4
  in_app_purchase: ^3.0.1
  intl: ^0.18.0
  provider: ^6.0.2
  ..

[pub get] をクリックしてパッケージをダウンロードするか、コマンドラインで flutter pub get を実行します。

4. App Store をセットアップする

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

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

6e373780e5e24a6f.png

無料アプリと有料アプリの契約がここに表示されます。無料アプリのステータスが有効で、有料アプリのステータスは新規です。利用規約を確認して同意し、必要な情報をすべて入力します。

74c73197472c9aec.png

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

4a100bbb8cafdbbf.jpeg

アプリ ID を登録する

Apple デベロッパー ポータルで新しい ID を作成します。

55d7e592d9a3fc7b.png

アプリ ID を選択

13f125598b72ca77.png

アプリの選択

41ac4c13404e2526.png

説明を入力して、バンドル ID が以前 XCode で設定された値と同じ値と一致するように設定します。

9d2c940ad80deeef.png

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

新しいアプリを作成する

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

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

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

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

3ca2b26d4e391a4c.jpeg

これで、iPhone で [設定] >アプリストア >サンドボックス アカウント。

c7dadad2c1d448fa.jpeg 5363f87efcddaa4.jpeg

アプリ内購入の設定

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

  • dash_consumable_2k: 何度も購入できる消費型アイテム。購入ごとに 2, 000 ダッシュ(アプリ内通貨)が付与されます。
  • dash_upgrade_3d: 消費不可の「アップグレード」ユーザーが一度だけ購入可能な購入をカウントし、クリックする見た目が異なる Dash をユーザーに与えます。
  • dash_subscription_doubler: 登録期間中に、クリックあたり 2 倍の Dash がユーザーに付与される定期購入。

d156b2f5bac43ca8.png

[アプリ内購入 >管理

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

  1. dash_consumable_2k消費アイテムとして設定します。

プロダクト ID として dash_consumable_2k を使用します。この参照名は App Store Connect でのみ使用されます。dash consumable 2k に設定して、購入のローカライズを追加してください。2000 dashes fly out を説明として購入の Spring is in the air を呼び出します。

ec1701834fd8527.png

  1. dash_upgrade_3d非消費型として設定します。

プロダクト ID として dash_upgrade_3d を使用します。参照名を dash upgrade 3d に設定し、購入のローカライズを追加します。Brings your dash back to the future を説明として購入の 3D Dash を呼び出します。

6765d4b711764c30.png

  1. dash_subscription_doubler自動更新による定期購入として設定します。

定期購入のフローが少し異なります。まず、参照名とプロダクト ID を設定する必要があります。

6d29e08dae26a0c4.png

次に、サブスクリプション グループを作成する必要があります。複数のサブスクリプションが同じグループに属している場合、ユーザーは同時に 1 つのサブスクリプションのみに登録できますが、サブスクリプション間で簡単にアップグレードまたはダウングレードできます。こちらのグループに「subscriptions」とお電話ください。

5bd0da17a85ac076.png

次に、定期購入の期間とローカライズを入力します。このサブスクリプションに Jet Engine という名前を付け、説明は Doubles your clicks にします。[保存] をクリックします。

bd1b1d82eeee4cb3.png

[保存] ボタンをクリックしてから、定期購入の価格を追加します。ご希望の価格をお選びください。

d0bf39680ef0aa2e.png

購入リストに 3 件の購入が表示されます。

99d5c4b446e8fecf.png

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

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

新しいアプリを作成する

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

  1. Google Play Console を開きます。
  2. [すべてのアプリ >アプリを作成します。
  3. デフォルトの言語を選択し、アプリのタイトルを追加します。Google Play に表示するアプリの名前を入力します。この名前は後から変更できます。
  4. アプリがゲームであることを指定します。これは後で変更できます。
  5. アプリケーションが無料か有料かを指定します。
  6. Play ストアのユーザーがこのアプリに関する連絡に使用できるメールアドレスを追加します。
  7. コンテンツ ガイドラインと米国輸出法の申告を完了します。
  8. [アプリを作成] を選択します。

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

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

アプリ内購入をテストするには、少なくとも 1 つのビルドが Google Play にアップロードされている必要があります。

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

キーストアを作成する

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

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 パラメータに渡す引数を変更します。

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 ファイルを編集して、アプリの署名を構成します。

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

   def keystoreProperties = new Properties()
   def keystorePropertiesFile = rootProject.file('key.properties')
   if (keystorePropertiesFile.exists()) {
       keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
   }

   android {
         // omitted
   }

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

buildTypes ブロックの前に次のコードを追加します。

   buildTypes {
       release {
           // TODO: Add your own signing config for the release build.
           // Signing with the debug keys for now,
           // so `flutter run --release` works.
           signingConfig signingConfigs.debug
       }
   }

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

   signingConfigs {
       release {
           keyAlias keystoreProperties['keyAlias']
           keyPassword keystoreProperties['keyPassword']
           storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
           storePassword keystoreProperties['storePassword']
       }
   }
   buildTypes {
       release {
           signingConfig signingConfigs.release
       }
   }

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

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

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

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

flutter build appbundle

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

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

この Codelab では、Google によるアプリの署名のみを行います。[Play アプリ署名] で [続行] を押してオプトインします。

ba98446d9c5c40e0.png

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

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

最後に、[内部テストとしての公開を開始] をクリックして、内部テスト版リリースを有効にします。

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

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

  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: 消費不可の「アップグレード」ユーザーが Dash を見た目が変えてクリックできるという仕組みです。
  • dash_subscription_doubler: 登録期間中に、クリックあたり 2 倍の Dash がユーザーに付与される定期購入。

まず、消費可能アイテムと非消費可能アイテムを追加します。

  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 では、バックエンド サービスを使用してユーザーのアクセスを購入します。

バックエンド サービスの使用には、いくつかの利点があります。

  • 取引を安全に確認できます。
  • アプリストアからの請求イベントに対応できます。
  • 購入はデータベースで追跡できます。
  • ユーザーがアプリにシステム クロックを巻き戻してプレミアム機能を提供させるようなことがあってはなりません。

バックエンド サービスを設定するにはさまざまな方法がありますが、Cloud Functions と Firestore、さらに Google 独自の Firebase を使用して行います。

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

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

あとは、独自の Firebase プロジェクトを作成し、Firebase のアプリとバックエンドの両方を構成し、最後にバックエンドをデプロイします。

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

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

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

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

7babb48832fbef29.png

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

e20553e0de5ac331.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 つのプラットフォームを選択して、[iOS] と [Android] を有効にします。

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

firebase_options.dart のオーバーライドに関するプロンプトが表示されたら、[yes] を選択します。

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

Android 向けに Firebase を設定する: 以降の手順

Firebase ダッシュボードで [Project Overview] に移動し、[Settings] を選択して [General] タブを選択します。

[マイアプリ] まで下にスクロールして、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 ハッシュをコピーし、アプリの送信用モーダル ダイアログの最後のフィールドに入力します。

Firebase for iOS を設定する: 以降の手順

Xcodeios/Runnder.xcworkspace を開きます。お好みの IDE でも可能です。

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

Android Studio で ios/ フォルダを右クリックし、flutter をクリックしてから open iOS module in Xcode オプションをクリックします。

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

Key-Value ペアはすでに追加されていますが、値を置換する必要があります。

  1. GoogleService-Info.plist ファイルから REVERSED_CLIENT_ID の値を、周囲の <string>..</string> 要素なしで取得します。
  2. CFBundleURLTypes キーの下の ios/Runner/Info-Debug.plist ファイルと ios/Runner/Info-Release.plist ファイルの両方の値を置き換えます。
<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, で、Scaffold に 2 つのページを含む BottomNavigationBar を持つウィジェット MyHomePage を見つけます。また、このページでは DashCounterDashUpgrades,DashPurchases の 3 つの Provider も作成されます。DashCounter は、Dash の現在のカウントを追跡し、自動的にインクリメントします。DashUpgrades は、Dashes で購入できるアップグレードを管理します。この Codelab では、DashPurchases に焦点を当てます。

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

lib/main.dart

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

また、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!;
  }
}

テストを引き続き機能させるには、テストを少し更新する必要があります。TestIAPConnection の完全なコードについては、GitHub の widget_test.dart をご覧ください。

test/widget_test.dart

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

lib/logic/dash_purchases.dart で、DashPurchases ChangeNotifier のコードに移動します。現在、購入した Dash に追加できるのは DashCounter だけです。

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

lib/logic/dash_purchases.dart

import 'package:in_app_purchase/in_app_purchase.dart';

class DashPurchases extends ChangeNotifier {
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter);
}

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

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

lib/logic/dash_purchases.dart

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final iapConnection = IAPConnection.instance;

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

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

  Future<void> buy(PurchasableProduct product) async {
    // omitted
  }

  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    // Handle purchases here
  }

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

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

これで、アプリが購入の更新を受信しました。次のセクションで購入を行います。

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

$ flutter test

00:01 +1: All tests passed!                                                                                   

8. 購入する

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

PurchasableProduct を適応させる

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;
    }
  }

ストアが利用可能になったら、購入可能なアイテムを読み込みます。以前の Firebase の設定では、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);
    for (var element in response.notFoundIDs) {
      debugPrint('Purchase $element not found');
    }
    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();
  }

最後に、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);
        break;
      case storeKeySubscription:
      case storeKeyUpgrade:
        await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
        break;
      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();
          break;
        case storeKeyConsumable:
          counter.addBoughtDashes(2000);
          break;
        case storeKeyUpgrade:
          _beautifiedDashUpgrade = true;
          break;
      }
    }

    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 エンドポイントを提供します。デフォルトでは、サーバーはルートを提供しません。後ほど、購入確認プロセスを処理するルートを作成します。

すでにスターター コードに含まれている部分の一つが、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 Play Console に移動し、[すべてのアプリ] ページから操作を開始します。
  2. [設定 >API アクセス317fdfb54921f50e.pngGoogle Play Console でプロジェクトの作成または既存のプロジェクトへのリンクを求められた場合は、まずそれを行ってからこのページに戻ります。
  3. サービス アカウントを定義できるセクションを見つけて、[新しいサービス アカウントを作成] をクリックします。1e70d3f8d794bebb.png
  4. ポップアップ表示されたダイアログで、[Google Cloud Platform] のリンクをクリックします。7c9536336dd9e9b4.png
  5. プロジェクトを選択します。表示されない場合は、右上の [アカウント] プルダウン リストで正しい Google アカウントにログインしていることを確認してください。3fb3a25bad803063.png
  6. プロジェクトを選択したら、上部のメニューバーにある [+ サービス アカウントの作成] をクリックします。62fe4c3f8644acd8.png
  7. サービス アカウントの名前を指定します。必要に応じて説明を入力し、目的を思い出して次のステップに進みます。8a92d5d6a3dff48c.png
  8. サービス アカウントに編集者のロールを割り当てます。6052b7753667ed1a.png
  9. ウィザードを終了し、デベロッパー コンソールの [API Access] ページに戻って、[Refresh service accounts] をクリックします。新しく作成したアカウントがリストに表示されます。5895a7db8b4c7659.png
  10. 新しいサービス アカウントに [アクセス権を付与] をクリックします。
  11. 次のページで [売上データ] ブロックまで下にスクロールします。[売上データ、注文、解約アンケートの回答の閲覧] と [注文と定期購入の管理] の両方を選択します。75b22d0201cf67e.png
  12. [ユーザーを招待] をクリックします。70ea0b1288c62a59.png
  13. アカウントが設定されたので、あとは認証情報を生成するだけです。Cloud コンソールに戻り、サービス アカウントのリストでサービス アカウントを見つけ、その他アイコンをクリックして [鍵を管理] を選択します。853ee186b0e9954e.png
  14. 新しい JSON キーを作成してダウンロードします。2a33a55803f5299c.png cb4bf48ebac0364e.png
  15. ダウンロードしたファイルの名前を service-account-google-play.json, に変更し、assets/ ディレクトリに移動します。

lib/constants.dart, を開き、androidPackageId の値を Android アプリ用に選択したパッケージ ID に置き換えます。

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

App Store にアクセスして購入の確認を行うには、共有シークレットを設定する必要があります。

  1. App Store Connect を開きます。
  2. [My Apps] に移動して、アプリを選択します。
  3. サイドバーのナビゲーションで、[アプリ内購入 >管理
  4. リストの右上にある [App-Specific Shared Secret] をクリックします。
  5. 新しいシークレットを生成してコピーします。
  6. lib/constants.dart, を開き、appStoreSharedSecret の値を、生成した共有シークレットに置き換えます。

d8b8042470aaeff.png

b72f4565750e2f40.png

定数の構成ファイル

続行する前に、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 のビルドメソッドの先頭に次のコードを追加します。

lib/pages/purchase_page.dart

import '../logic/firebase_notifier.dart';
import '../model/firebase_state.dart';
import 'login_page.dart';

class PurchasePage extends StatelessWidget {  
  const PurchasePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    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();
    }
    // omitted

アプリからの通話確認エンドポイント

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

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

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

lib/logic/dash_purchases.dart

  FirebaseNotifier firebaseNotifier;

  DashPurchases(this.counter, this.firebaseNotifier) {
    // omitted
  }

DashPurchases を作成して firebaseNotifiermain.dart: に追加します。

lib/main.dart

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

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

lib/logic/firebase_notifier.dart

  User? get user => FirebaseAuth.instance.currentUser;

関数 _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) {
      print('Successfully verified purchase');
      return true;
    } else {
      print('failed request: ${response.statusCode} - ${response.body}');
      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();
            break;
          case storeKeyConsumable:
            counter.addBoughtDashes(1000);
            break;
        }
      }
    }

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

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

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

次に、購入を確認する Cloud Functions の関数をバックエンドで設定します。

購入ハンドラを作成する

両方の店舗で確認フローはほぼ同じであるため、PurchaseHandler 抽象クラスをセットアップして各店舗に個別の実装を行います。

be50c207c5a2a519.png

まず、purchase_handler.dart ファイルを lib/ フォルダに追加します。そこでは、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,
  }) {
    return true;
  }

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

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

購入ハンドラを使用する

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

bin/server.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);
}

({
  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 ペイロードをデコードし、次の情報を抽出します。
  3. userId: 現在ログインしているユーザー ID
  4. source: 使用されているストア(app_store または google_play)。
  5. productData: 前に作成した productDataMap から取得します。
  6. token: 店舗に送信する検証データが含まれます。
  7. ソースに応じて GooglePlayPurchaseHandler または AppStorePurchaseHandler に対して verifyPurchase メソッドを呼び出します。
  8. 検証が成功すると、メソッドはクライアントに Response.ok を返します。
  9. 検証が失敗した場合、メソッドはクライアントに Response.internalServerError を返します。

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

bin/server.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

  @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 do not 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 do not 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

/// 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;
}

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,
  };
}

これで、Google Play での購入を確認し、データベースに保存する必要があります。

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

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 {
   //..
  }

次に、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 秒ごとに _pullMessageFromSubSub メソッドを呼び出すように構成されています。[長さ] は好みに合わせて調整できます。

次に、_pullMessageFromSubSub を作成します。

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/$googlePlayProjectName/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/$googlePlayProjectName/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

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

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

bin/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

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

bin/server.dart

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

Google Play のセットアップ

Pub/Sub トピックから課金イベントを使用するコードを記述しましたが、Pub/Sub トピックは作成しておらず、課金イベントも公開していません。これを設定します。

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

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

すべての Google Play 請求サービスがトピックでパブリッシュされるようになります。

App Store の請求イベントを処理する

次に、App Store の請求イベントについても同じ操作を行います。App Store での購入時の更新処理を実装するには、2 つの方法があります。一つは、お客様が Apple に提供し、Apple がサーバーとの通信に使用する Webhook を実装することです。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;

AppStorePurchaseHandler(
  this.iapRepository,
  this.appStoreServerAPI, // new
)

コンストラクタを変更して、_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

  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

  // 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, // new
    ),
  };

App Store の設定

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

  1. App Store Connect にログインし、[Users and Access] を選択します。
  2. [キーのタイプ >アプリ内購入
  3. [プラス]をタップしますアイコンをクリックして新しいものを追加します。
  4. 名前を付けます(例:「Codelab キー」です。
  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 リポジトリです。リポジトリには hasActiveSubscription, も含まれています。これは、productId storeKeySubscription でステータスが期限切れになっていない購入がある場合に true になります。ユーザーがログインしていない場合、リストは空です。

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((DocumentSnapshot 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

  IAPRepo iapRepo;

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

  @override
  void dispose() {
    iapRepo.removeListener(purchasesUpdate);
    _subscription.cancel();
    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>(),
          ),
          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 Clicker ゲームに適用します。

12. 完了

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

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