Unity IAPを試してみた (Yet Another Purchaser.cs)

# おことわり
この記事のコードには、[新しい版 (GitHub)](https://github.com/tetr4lab/UnityIAP-Test)が存在します。

# 前提
- Unity 2018.4.5f1、2018.4.30f1
- Unity IAP 1.22.0、2.2.4
- Apple App Store、Google Play Store
- この記事では、Unity IAPの一部機能を限定的に使用し、汎用性のない部分があります。
    - サーバレス、消費／非消費タイプ使用、購読タイプ未使用
- この記事のソースは、実際のストアでテストしていますが、製品版での使用実績はありません。
    - ソース中のIDは実際と異なります。

# 公式ドキュメント
- マニュアル
    - [2018.4](https://docs.unity3d.com/ja/2018.4/Manual/UnityIAP.html)
- スクリプトリファレンス
    - 2019/08/02時点で、バージョンによって記述があったりなかったりします。
    - 記述あり 5.6、[2017.1](https://docs.unity3d.com/ja/2017.1/ScriptReference/Purchasing.UnityPurchasing.html)、2017.2、2017.3、2018.1、2018.2
    - 記述なし 2017.4、2018.3、2018.4、2019.1

# ネームスペース
- `UnityEngine.Purchasing`
    - 必須のネームスペースです。
- `UnityEngine.Purchasing.Security`
    - レシートの検証で必要なネームスペースです。
    - スクリプトリファレンスに記述が見つかりません。
- `UnityEngine.Purchasing.Extension`
    - この記事では扱いません。

# 初期化
### 初期化の開始
- `UnityPurchasing.Initialize ()`を呼ぶことで、初期化を開始します。

```cs:
UnityPurchasing.Initialize (Purchaser instance, ConfigurationBuilder builder);
```

- 初期化の要求はブロックされず、後に、結果に応じたコールバックがあります。

### イベントハンドラ
- コールバックを受け取るために、`IStoreListener`を継承したクラスのインスタンスが必要です。
    - 必ずしも`MonoBehaviour`を継承する必要はありません。
    - インターフェイス`IStoreListener`では、`OnInitialized ()`、`OnInitializeFailed ()`、`OnPurchaseFailed ()`、`ProcessPurchase ()`の4つのイベントハンドラが必要になります。

### 準備
- `Initialize ()`を呼ぶためには、`ConfigurationBuilder builder`のインスタンスを得る必要があります。
- `ConfigurationBuilder.Instance ()`を呼ぶためには、`IPurchasingModule`を継承したクラスのインスタンスが必要なようですが、この辺りを記載したドキュメントが見つかりません。
- 付属のDemoでは、`StandardPurchasingModule`がそのクラスにあたるようで、そのインスタンスを得て使われています。
- 得られたインスタンス`module`にストアの設定を行い、さらに`builder`インスタンスを得ます。
- 得られたインスタンス`builder`に製品を登録し、検証器を生成して、初期化を開始します。
- ここでは、クラスのコンストラクタで、準備から初期化の開始までを行っています。
    - コンストラクタが`private`なのは、シングルトンで使うためです。

```cs:Purchaser.cs
/// <summary>コンストラクタ</summary>
private Purchaser (IEnumerable<ProductDefinition> products) {
	Debug.Log ("Purchaser.Construct");
	var module = StandardPurchasingModule.Instance ();
	module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
	isGooglePlayStoreSelected = Application.platform == RuntimePlatform.Android && module.appStore == AppStore.GooglePlay;
	isAppleAppStoreSelected = Application.platform == RuntimePlatform.IPhonePlayer; // && module.appStore == AppStore.AppleAppStore;
	validator = new CrossPlatformValidator (GooglePlayTangle.Data (), AppleTangle.Data (), Application.identifier);
	var builder = ConfigurationBuilder.Instance (module);
	builder.AddProducts (products);
	Inventory = new Inventory { };
	UnityPurchasing.Initialize (this, builder);
}
```

### 製品定義
- 先のコンストラクタが受け取って`builder`に登録した製品定義は、製品のIDとタイプのセットです。

```cs:Sample.cs
var products = new [] {
	new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item1", ProductType.Consumable),
	new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item2", ProductType.NonConsumable),
	new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item3", ProductType.NonConsumable),
};
```
- これらはストア・ダッシュボードでの設定と正しく呼応している必要があります。
    - Apple App Storeでは、IDと製品タイプの双方が設定されます。
    - Google Play Storeでは、IDが設定されますが、消費の有無についての設定はありません。
        - 消費タイプでは、アプリを消費したことを申告するだけです。
- この記事では、消費タイプ`Consumable`と非消費タイプ`NonConsumable`だけを扱い、購読タイプは扱いません。

### 初期化の完了
- 初期化に成功したら、得られた`IStoreController`と`IExtensionProvider`を保存します。

```cs:Purchaser.cs
/// <summary>初期化完了</summary>
public void OnInitialized (IStoreController controller, IExtensionProvider extensions) {
	Debug.Log ($"Purchaser.Initialized {controller}, {extensions}");
	appleExtensions = extensions.GetExtension<IAppleExtensions> ();
	appleExtensions.RegisterPurchaseDeferredListener (OnDeferred);
	googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions> ();
	this.controller = controller;
	this.extensions = extensions;
	foreach (var product in controller.products.all) {
		if (!Inventory.ContainsKey (product)) {
			Inventory [product] = possession (product);
		}
	}
}

/// <summary>初期化失敗</summary>
public void OnInitializeFailed (InitializationFailureReason error) {
	Debug.LogError ($"Purchaser.InitializeFailed {error}");
	Unavailable = true;
}
```

- iOSの'Ask to buy'関連、`OnDeferred`はテストできていません。
- `Inventory`については、[後述](#%E7%9B%AE%E9%8C%B2%E3%81%AE%E8%AC%8E)します。

# 製品目録
- 初期化に成功すると、`controller.products.all`で、製品目録を得ることができます。

```cs:Sample.cs
foreach (var product in Purchaser.Products.all) {
	Debug.Log (Purchaser.GetProductProperties (product));
}
```

```cs:Purchaser.cs
/// <summary>製品目録 初期化時の製品IDに対してストアから得た情報</summary>
public static ProductCollection Products => Valid ? instance.controller.products : null;
```

```cs:Purchaser.cs
/// <summary>製品諸元</summary>
public static string GetProperties (this Product product) {
	return string.Join ("\n", new [] {
		$"id={product.definition.id} ({product.definition.storeSpecificId})",
		$"type={product.definition.type}",
		$"enabled={product.definition.enabled}",
		$"available={product.availableToPurchase}",
		$"localizedTitle={product.metadata.localizedTitle}({product.metadata.shortTitle ()})",
		$"localizedDescription={product.metadata.localizedDescription}",
		$"isoCurrencyCode={product.metadata.isoCurrencyCode}",
		$"localizedPrice={product.metadata.localizedPrice}",
		$"localizedPriceString={product.metadata.localizedPriceString}",
		$"transactionID={product.transactionID}",
		$"Receipt has={product.hasReceipt}",
		$"Purchaser.Valid={Purchaser.Valid}",
		$"Receipt validation={Purchaser.ValidateReceipt (product)}",
		$"Possession={Purchaser.Inventory [product]}",
	});
}
```

### 目録の謎
※以下は、Google Play StoreとApple App Store (Sandbox)で確認した内容です。製品でのテストではありません。

- もし、初期化の際に製品定義を渡さなかったらどうなるのでしょうか?
    - その場合、製品目録は基本的に空になります。ただし、購入済みの非消費製品は取得されます。
    - ストアから製品IDのカタログが得られるわけではありません。つまり、ストアに新製品を登録しただけでは、製品に組み込めないのです。
- `ProductDefinition.enabled`は、[スクリプトリファレンス](https://docs.unity3d.com/ja/2017.1/ScriptReference/Purchasing.ProductDefinition.html)では"This flag indicates whether a product should be offered for sale. It is controlled through the cloud catalog dashboard."と説明されています。
    - これを見る限り、ストアのダッシュボードで設定されている有効/無効状態を取得できるように読めますが、実際には常に`true`になります。
    - 例え、ストアに登録されていないIDを指定した場合でも`true`です。全く役に立ちません。
        - ストアにない場合は、`Product.availableToPurchase`は`False`になります。
    - Play Storeで無効にされている製品を購入しようとすると「原因不明の購入エラー」になります。
    - App Storeで無効にされている製品でも、Sandboxでは購入できてしまいます。
- つまり、以下の制約が生じます。
    - ストアに登録されている未知の製品を取得することはできません。
    - ストアでの状態(有効/無効)を取得することはできません。
    - 購入の失敗が、ストアでの無効設定によるものと判別できません。
- その結果、以下のような使い方になります。
    - ストアとは別の手段(あらかじめ組み込む、自前のサーバから取得するなど)で製品定義を保持する必要があります。
    - ストアでの製品の有効/無効は、アプリの使用する製品定義に連動させます。
        - 緊急時以外は、ストア独自に製品を無効化しないようにします。

# 購入
### 購入の開始
- `IStoreController.InitiatePurchase ()`に`Product`を渡すことで、購入が開始されます。

```cs:Purchaser.cs
/// <summary>課金開始</summary>
private bool purchase (Product product) {
	if (product != null && product.Valid ()) {
		Debug.Log ($"Purchaser.InitiatePurchase {product.definition.id} {product.metadata.localizedTitle} {product.metadata.localizedPriceString}");
		controller.InitiatePurchase (product);
		return true;
	}
	return false;
}
```

- 購入の要求はブロックされず、後に、結果に応じたコールバックがあります。

### 購入の完了
- 課金結果のコールバックでは、購入に関わる処理が全て完了したら、`PurchaseProcessingResult.Complete`を返します。
    - 消費タイプの場合は、消費が実行されます。

```cs:Purchaser.cs
/// <summary>課金失敗</summary>
public void OnPurchaseFailed (Product product, PurchaseFailureReason reason) {
	Debug.LogError ($"Purchaser.PurchaseFailed Reason={reason}\n{product.GetProperties ()}");
}

/// <summary>課金結果 有効な消耗品なら保留、それ以外は完了とする</summary>
public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs eventArgs) {
	var validated = ValidateReceipt (eventArgs.purchasedProduct);
	Inventory [eventArgs.purchasedProduct] = validated;
	Debug.Log ($"Purchaser.ProcessPurchase {(validated ? "Validated" : "ValidationError")} {eventArgs.purchasedProduct.GetProperties ()}");
	return (validated && eventArgs.purchasedProduct.definition.type == ProductType.Consumable) ? PurchaseProcessingResult.Pending : PurchaseProcessingResult.Complete;
}
```

- このコードでは、消費タイプでは`PurchaseProcessingResult.Pending`を返し、それ以外では`Complete`を返します。
    - `Pending`を返すと、消費は保留されます。
- この保留状態は、(謎の)クラウドで保持されるためアプリが中断しても失われず、起動毎に`ProcessPurchase ()`へのコールバックが繰り返されます。
    - 保留状態を終わらせるには、`ProcessPurchase ()`で`Complete`を返すか、別途`IStoreController.ConfirmPendingPurchase (product)`を呼びます。

- `Product.hasReceipt`は、起動直後に未購入または消費済みであれば`false`となり、購入完了時には`true`に変化します。
    - しかし、`Complete`を返した場合も、消費を促す`ConfirmPendingPurchase (product)`を行おうとも、その場では`false`には戻りません。
    - つまり、`hasReceipt`を見て消費完了を知ることはできません。
    - また、`ConfirmPendingPurchase (product)`には、結果を知らせるコールバックがありません。
- 従って、保留と消費の状態を判別するためには、Unity-IAPの外側で所持状態を管理する必要があります。

- なお、非消費タイプでは、購入済みの`hasReceipt`は常に`true`になります。


### 所有状態の管理
- このコードでは、`Inventory`という`Dictionary`派生クラスを用意して、製品所有状態を管理しています。
    - 初期化完了のコールバック中で初期化しています。
    - `Inventory [string 製品ID]`または`Inventory [Product 製品]`で真偽値を得ることができます。

```cs:Purchaser.cs
/// <summary>productID基準でProductの在庫を表現する辞書</summary>
public class Inventory : Dictionary<string, bool> {

	/// <summary>Productによるアクセス</summary>
	public bool this [Product product] {
		get { return base [product.definition.id]; }
		set { base [product.definition.id] = value; }
	}

}
```

# 復元
- Appleの規約では、ユーザーがこの処理を明示的に行える必要があるのですが、これを呼ばなくてもUnity-IAPが自動的に復元をしているようなので、それ以上の意味はないように思われます。正直よく分かりません。

```cs:Purchaser.cs
/// <summary>復元</summary>
private void restore (Action<bool> onRestored = null) {
	Debug.Log ("Purchaser.Restore");
	Action<bool> onTransactionsRestored = success => { OnTransactionsRestored (success); onRestored?.Invoke (success); };
	if (isGooglePlayStoreSelected) {
		googlePlayStoreExtensions.RestoreTransactions (onTransactionsRestored);
	} else if (isAppleAppStoreSelected) {
		appleExtensions.RestoreTransactions (onTransactionsRestored);
	}
}
```

```cs:Purchaser.cs
/// <summary>復元完了</summary>
private void OnTransactionsRestored (bool success) {
	Debug.Log ($"Purchaser.Restored {success}");
}
```

# コード全容

```cs:Purchaser.cs
//	Copyright© tetr4lab.

using System;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Security;

/// <summary>UnityIAPを使う</summary>
namespace UnityInAppPuchaser {

#if ALLOW_UIAP

	/// <summary>課金処理</summary>
	public class Purchaser : IStoreListener {

		#region Static

		/// <summary>シングルトン</summary>
		private static Purchaser instance;

		/// <summary>在庫目録 製品の課金状況一覧、消費タイプは未消費を表す</summary>
		public static Inventory Inventory { get; private set; }

		/// <summary>有効 初期化が完了している</summary>
		public static bool Valid => (instance != null && instance.valid);

		/// <summary>使用不能 初期化に失敗した</summary>
		public static bool Unavailable { get; private set; }

		/// <summary>製品目録 初期化時の製品IDに対してストアから得た情報</summary>
		public static ProductCollection Products => Valid ? instance.controller.products : null;

		/// <summary>クラス初期化 製品のIDとタイプの一覧を渡す</summary>
		public static void Init (IEnumerable<ProductDefinition> products) {
			if (instance == null || Unavailable) {
				instance = new Purchaser (products);
			}
		}

		/// <summary>所有検証 有効なレシートが存在する</summary>
		private static bool possession (Product product) {
			return product.hasReceipt && Purchaser.ValidateReceipt (product);
		}

		/// <summary>レシート検証</summary>
		public static bool ValidateReceipt (string productID) {
			return (!string.IsNullOrEmpty (productID) && instance.validateReceipt (instance.controller.products.WithID (productID)));
		}

		/// <summary>レシート検証</summary>
		public static bool ValidateReceipt (Product product) {
			return (instance != null && instance.validateReceipt (product));
		}

		/// <summary>課金 指定製品の課金処理を開始する</summary>
		public static bool Purchase (string productID) {
			if (!string.IsNullOrEmpty (productID) && Valid) {
				return instance.purchase (instance.controller.products.WithID (productID));
			}
			return false;
		}

		/// <summary>課金 指定製品の課金処理を開始する</summary>
		public static bool Purchase (Product product) {
			if (product != null && Valid) {
				return instance.purchase (product);
			}
			return false;
		}

		/// <summary>保留した課金の完了 消費タイプの指定製品の保留していた消費を完了する</summary>
		public static bool ConfirmPendingPurchase (string productID) {
			if (!string.IsNullOrEmpty (productID) && Valid) {
				return instance.confirmPendingPurchase (instance.controller.products.WithID (productID));
			}
			return false;
		}

		/// <summary>保留した課金の完了 消費タイプの指定製品の保留していた消費を完了する</summary>
		public static bool ConfirmPendingPurchase (Product product) {
			if (product != null && Valid) {
				return instance.confirmPendingPurchase (product);
			}
			return false;
		}

		/// <summary>復元 課金情報の復元を行い、結果のコールバックを得ることができる</summary>
		public static void Restore (Action<bool> onRestored = null) {
			if (Valid) { instance.restore (onRestored); }
		}

		#endregion

		/// <summary>コントローラー</summary>
		private IStoreController controller;
		/// <summary>拡張プロバイダ</summary>
		private IExtensionProvider extensions;
		/// <summary>Apple拡張</summary>
		private IAppleExtensions appleExtensions;
		/// <summary>Google拡張</summary>
		private IGooglePlayStoreExtensions googlePlayStoreExtensions;
		/// <summary>AppleAppStore</summary>
		private bool isAppleAppStoreSelected;
		/// <summary>GooglePlayStore</summary>
		private bool isGooglePlayStoreSelected;
		/// <summary>検証機構</summary>
		private CrossPlatformValidator validator;
		/// <summary>有効</summary>
		private bool valid => (controller != null && controller.products != null);

		/// <summary>コンストラクタ</summary>
		private Purchaser (IEnumerable<ProductDefinition> products) {
			Debug.Log ("Purchaser.Construct");
			var module = StandardPurchasingModule.Instance ();
			module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
			isGooglePlayStoreSelected = Application.platform == RuntimePlatform.Android && module.appStore == AppStore.GooglePlay;
			isAppleAppStoreSelected = Application.platform == RuntimePlatform.IPhonePlayer && module.appStore == AppStore.AppleAppStore;
			validator = new CrossPlatformValidator (GooglePlayTangle.Data (), AppleTangle.Data (), Application.identifier);
			var builder = ConfigurationBuilder.Instance (module);
			builder.AddProducts (products);
			Inventory = new Inventory { };
			UnityPurchasing.Initialize (this, builder);
		}

		/// <summary>レシート検証</summary>
		private bool validateReceipt (Product product) {
			if (/*!valid ||*/ !product.hasReceipt) { return false; }
#if UNITY_EDITOR
			return true;
#else
			try {
				var result = validator.Validate (product.receipt);
				Debug.Log ($"Purchaser.validateReceipt Receipt is valid. (id:{product.definition.id})");
				return true;
			} catch (IAPSecurityException ex) {
				Debug.LogError ($"Purchaser.validateReceipt Invalid receipt {product.definition.id}, not unlocking content. {ex}");
				return false;
			}
#endif
		}

		/// <summary>課金開始</summary>
		private bool purchase (Product product) {
			if (product != null && product.Valid ()) {
				Debug.Log ($"Purchaser.InitiatePurchase {product.definition.id} {product.metadata.localizedTitle} {product.metadata.localizedPriceString}");
				controller.InitiatePurchase (product);
				return true;
			}
			return false;
		}

		/// <summary>保留した課金の完了</summary>
		private bool confirmPendingPurchase (Product product) {
			if (product != null && Inventory [product] && possession (product)) {
				controller.ConfirmPendingPurchase (product);
				Inventory [product] = false;
				Debug.Log ($"Purchaser.ConfirmPendingPurchase {product.GetProperties ()}");
				return true;
			}
			return false;
		}

		/// <summary>復元</summary>
		private void restore (Action<bool> onRestored = null) {
			Debug.Log ("Purchaser.Restore");
			Action<bool> onTransactionsRestored = success => { OnTransactionsRestored (success); onRestored?.Invoke (success); };
			if (isGooglePlayStoreSelected) {
				googlePlayStoreExtensions.RestoreTransactions (onTransactionsRestored);
			} else if (isAppleAppStoreSelected) {
				appleExtensions.RestoreTransactions (onTransactionsRestored);
			}
		}

		#region Event Handler

		/// <summary>復元完了</summary>
		private void OnTransactionsRestored (bool success) {
			Debug.Log ($"Purchaser.Restored {success}");
		}

		/// <summary>iOS 'Ask to buy' 未成年者の「承認と購入のリクエスト」 承認または却下されると通常の購入イベントが発生する</summary>
		private void OnDeferred (Product product) {
			Debug.Log ($"Purchaser.Deferred {product.GetProperties ()}");
		}

		/// <summary>初期化完了</summary>
		public void OnInitialized (IStoreController controller, IExtensionProvider extensions) {
			Debug.Log ($"Purchaser.Initialized {controller}, {extensions}");
			appleExtensions = extensions.GetExtension<IAppleExtensions> ();
			appleExtensions.RegisterPurchaseDeferredListener (OnDeferred);
			googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions> ();
			this.controller = controller;
			this.extensions = extensions;
			foreach (var product in controller.products.all) {
				if (!Inventory.ContainsKey (product)) {
					Inventory [product] = possession (product);
				}
			}
		}

		/// <summary>初期化失敗</summary>
		public void OnInitializeFailed (InitializationFailureReason error) {
			Debug.LogError ($"Purchaser.InitializeFailed {error}");
			Unavailable = true;
		}

		/// <summary>課金失敗</summary>
		public void OnPurchaseFailed (Product product, PurchaseFailureReason reason) {
			Debug.LogError ($"Purchaser.PurchaseFailed Reason={reason}\n{product.GetProperties ()}");
		}

		/// <summary>課金結果 有効な消耗品なら保留、それ以外は完了とする</summary>
		public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs eventArgs) {
			var validated = ValidateReceipt (eventArgs.purchasedProduct);
			Inventory [eventArgs.purchasedProduct] = validated;
			Debug.Log ($"Purchaser.ProcessPurchase {(validated ? "Validated" : "ValidationError")} {eventArgs.purchasedProduct.GetProperties ()}");
			return (validated && eventArgs.purchasedProduct.definition.type == ProductType.Consumable) ? PurchaseProcessingResult.Pending : PurchaseProcessingResult.Complete;
		}

		/// <summary>破棄</summary>
		~Purchaser () {
			Debug.Log ("Purchaser.Destruct");
			if (instance == this) {
				instance = null;
				Inventory = null;
				Unavailable = false;
			}
		}

		#endregion

	}

	/// <summary>製品拡張</summary>
	public static class ProductExtentions {

		/// <summary>製品諸元</summary>
		public static string GetProperties (this Product product) {
			return string.Join ("\n", new [] {
				$"id={product.definition.id} ({product.definition.storeSpecificId})",
				$"type={product.definition.type}",
				$"enabled={product.definition.enabled}",
				$"available={product.availableToPurchase}",
				$"localizedTitle={product.metadata.localizedTitle}({product.metadata.shortTitle ()})",
				$"localizedDescription={product.metadata.localizedDescription}",
				$"isoCurrencyCode={product.metadata.isoCurrencyCode}",
				$"localizedPrice={product.metadata.localizedPrice}",
				$"localizedPriceString={product.metadata.localizedPriceString}",
				$"transactionID={product.transactionID}",
				$"Receipt has={product.hasReceipt}",
				$"Purchaser.Valid={Purchaser.Valid}",
				$"Receipt validation={Purchaser.ValidateReceipt (product)}",
				$"Possession={Purchaser.Inventory [product]}",
			});
		}

		/// <summary>有効性 製品がストアに登録されていることを示すが、ストアで有効かどうかには拠らない</summary>
		public static bool Valid (this Product product) {
			return (product.definition.enabled && product.availableToPurchase);
		}

		/// <summary>アプリ名を含まないタイトル</summary>
		public static string shortTitle (this ProductMetadata metadata) {
			return (metadata != null && !string.IsNullOrEmpty (metadata.localizedTitle)) ? (new Regex (@"\s*\(.+\)$")).Replace (metadata.localizedTitle, "") : string.Empty;
		}

	}

	/// <summary>productID基準でProductの在庫を表現する辞書</summary>
	public class Inventory : Dictionary<string, bool> {

		/// <summary>Productによるアクセス</summary>
		public bool this [Product product] {
			get { return base [product.definition.id]; }
			set { base [product.definition.id] = value; }
		}

		/// <summary>Productによる存在確認</summary>
		public bool ContainsKey (Product product) => ContainsKey (product.definition.id);

	}

#endif

}
```

```cs:Sample.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Purchasing;
using UnityInAppPuchaser;

public class Sample : MonoBehaviour {

	[SerializeField] private Transform CatalogHolder = default;
	[SerializeField] private Button RestoreButton = default;

	/// <summary>製品目録</summary>
	private readonly ProductDefinition [] products = new [] {
			new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item1", ProductType.Consumable),
			new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item2", ProductType.NonConsumable),
			new ProductDefinition ("jp.nyanta.tetr4lab.unityiaptest.item3", ProductType.NonConsumable),
		};

	/// <summary>起動</summary>
	private void Start () {
		StartCoroutine (initPurchaser ());
	}

	/// <summary>開始処理</summary>
	private IEnumerator initPurchaser () {
		RestoreButton.interactable = false;
		Purchaser.Init (products);
		yield return new WaitUntil (() => Purchaser.Valid || Purchaser.Unavailable); // 初期化完了を待つ
		if (Purchaser.Valid) {
			Catalog.Create (CatalogHolder);
			foreach (var product in Purchaser.Products.all) {
				CatalogItem.Create (Catalog.ScrollRect.content, product);
			}
		}
		RestoreButton.interactable = true;
	}

	/// <summary>復元ボタン</summary>
	public void OnPushRestoreButton () {
		if (Purchaser.Unavailable) {
			StartCoroutine (initPurchaser ());
		} else if (Purchaser.Valid) {
			Purchaser.Restore (success => {
				if (!success) { ModalDialog.Create (transform.parent, "リストアに失敗しました。\nネットワーク接続を確認してください。"); }
			});
		}
	}

}
```