2013年5月28日火曜日

Amazon App Store In-App Purchasing API (アプリ内課金) の実装 #1

AmazonSDKのIn-App Purchasingを使った実装についてメモ。

アプリ内課金の購入処理概略

 In-App Purchasing (IAP) API を使ったアプリ内課金で購入するまでの大まかな処理フローを下図にまとめました。


(※詳細な処理については省略して書いています。)

大雑把に表現するとこんな手順・処理を踏んでアプリ内でアイテムの購入処理を行います。
ここで登場するのは以下の5つです。
  • ユーザー
    アプリを使用し、課金を行う人。
  • Activity
    課金を行うアプリのActivity(画面)。ここではUIスレッドで動作しているActivity。
  • Observer
    PurchasingManagerのレスポンスを監視し、そのレスポンスに対して処理を行うクラス。
  • PurchasingManager
    In-App Purchasing APIと購入情報等のやりとりを行うクラス。
  • AmazonClient
    Amazon App Store (Client)アプリ。ユーザーID管理、Amazon Platformとの通信を行い実際の課金処理を行うためのアプリ。
    実際はIAP-APIとしてPurchasingManagerを経由して情報を受渡されていますが、ここではわかりやすい実体をもつアプリで表記します。

フローを簡単に説明しますと、、、
  1. ユーザーがアプリの画面や確認ダイアログから、アイテム購入ダイアログを表示させる。
  2. ユーザーへAmazonClientのアイテム購入ダイアログを表示させるために、PurchasingManagerにて購入リクエストを送信する。
  3. 購入リクエストをAmazonClientに送信。
  4. 購入リクエストに対して対応するアイテムの購入ダイアログがユーザーに表示され、ユーザーが購入ボタンをタップする。
  5. AmazonClientにて購入処理が行われ、その購入処理結果をObserverにてキャッチする。
  6. レシート情報が送られてくるので、レシート情報をチェック。
  7. レシート情報に従い、購入された課金コンテンツ・機能を利用可能にする。
  8. 購入処理が正常に完了した通知を受け、AmazonClientが購入完了ダイアログをユーザーへ表示して一連の処理が完了する。
主要クラス

IAP-APIの処理にて、キーとなるクラスは以下の2つです。
  • com.amazon.inapp.purchasing.PurchasingManager
    Amazon Platformへのリクエストを行うためのクラスであり、 Amazon Platformからのレスポンスを取得し下記のオブサーバへ返すクラス。
    (フロー図の "PurchasingManager"です。)
  • com.amazon.inapp.purchasing.BasePurchasingObserver
    アプリにてAmazonPlatformからのレスポンスを取得する為に、PurchasingManagerへ登録し レスポンスを監視するクラス(抽象クラス)。
    (フロー図の "Observer" はこのクラスのサブクラスです。)

また、アプリ内課金で使用するクラス、用語について以下に簡単な説明を記述します。
  • アイテム:Item (com.amazon.inapp.purchasing.Item)
    • 課金アイテムのことを表します。商品タイトル、価格、商品説明などの商品情報を含みます。
    • 価格には通貨文字が含まれ、ロケールにもとづいて適切にフォーマットされています。
  • SKU (文字列)
    • 課金アイテムを表すユニークなID。
    • Amazon Developer Portalでログインし、アプリ情報のなかの [In-App Items] メニューにて作成できます。
  • レシート:Receipt (com.amazon.inapp.purchasing.Receipt)
    • ユーザーの全ての購入情報をもつオブジェクトです。
    • 全てのレシートは、レシート検証サービスを経由してアイテムの購入を検証する為に使用できます。 このレシート情報をもとにアプリ内でコンテンツや機能へのアクセス承認を行います。
  • オフセット:Offset (com.amazon.inapp.purchasing.Offset)
    • ページに分けられたレシート情報の位置を表します。
    • オフセットの値は、Base64エンコーディングされておりそのまま見て読める形式ではありません。

ResponceReceiverの登録

 IAP-APIは、全て非同期で処理が実行されます。アプリは、ResponceReceiverクラスを経由してAmazonClientからのBroadcast Intentを受信する必要があります。
 このResponceReceiverクラスは、アプリから直接使用する必要はありません。代わりにアプリに送られてくるBroadcast Intentを受信できる定義をアプリのAndroidManifest.xmlへ定義する必要があります。以下の通り記述してください。

.
.
.

    
        
        
    

.
.
.

※表示上の都合で、<action>タグの閉じるタグを別に設けてますが、「<action... />」で構いません。

BasePurchasingObserver

 Amazon Clientからの Broadcast Intent を取得する Response Receiverの登録が終わったら、次はResponse Receiverからのコールバックを受け取るクラスが必要になります。その為に、BasePurchasingObserverクラスを継承したサブクラスを作成しPurchasingObserverインタフェースを実装してPurchasingManagerへ登録する必要があります。

※このコールバック処理はUIスレッドで実行すると長時間UIスレッドを占有してしまうので通常はAsyncTaskなどの別スレッドで処理します。

以下は、BasePurchasingObserverを継承したサブクラスのサンプルコードです。
こんな感じのクラスを作って、ここでPurchasingManagerを経由してAmazonClientにリクエストした結果を取得し処理を行います。
package com.exsample.account;

import java.util.Date;
import java.util.LinkedList;
import java.util.Map;

import android.app.Activity;
import android.os.AsyncTask;
import android.util.Log;

import com.amazon.inapp.purchasing.BasePurchasingObserver;
import com.amazon.inapp.purchasing.GetUserIdResponse;
import com.amazon.inapp.purchasing.GetUserIdResponse.GetUserIdRequestStatus;
import com.amazon.inapp.purchasing.Item;
import com.amazon.inapp.purchasing.ItemDataResponse;
import com.amazon.inapp.purchasing.Offset;
import com.amazon.inapp.purchasing.PurchaseResponse;
import com.amazon.inapp.purchasing.PurchaseUpdatesResponse;
import com.amazon.inapp.purchasing.PurchasingManager;
import com.amazon.inapp.purchasing.Receipt;
import com.amazon.inapp.purchasing.SubscriptionPeriod;

public class SamplePurchasingObserver extends BasePurchasingObserver {
    
    // ログ出力用タグ
    private final static String TAG = "SamplePurchasingObserver";
    
    // コンストラクタでインスタンス化したアクティビティを受け取り
    // BasePurchasingObserverへコンテキスト渡す.
    public SamplePurchasingObserver(final Activity activity) {
        super(activity);
    }

    //
    // ObserverがPuchasingManagerに登録された時にコールバックします。
    // (PurchasingManager.registerObserver(observer)の後)
    // 
    // @param isSandboxMode サンドボックス環境で実行されているか否か
    //   true  : Amazon Client からの応答を受信している。
    //           (Amazon Client からダウンロードされたapkである場合はこちらを返す。)
    //   false : SDK Testerからの応答を受信している。
    //           (デバッグ、テスト用apkで動かしている場合はこちらを返す。)
    //
    @Override
    public void onSdkAvailable(boolean isSandboxMode) {
        PurchasingManager.initiateGetUserIdRequest();
    }

    // 
    // ユーザーID取得要求(PurchasingManager.initiateGetUserIdRequest())
    // のレスポンスが返った時にコールバックします。
    //
    // @param getUserIdResponse ユーザーID取得要求レスポンス
    //   リクエストが成功している場合、リクエストID、リクエストステータス及び、ユーザIDを含んだ状態で返ります。
    //
    @Override
    public void onGetUserIdResponse(GetUserIdResponse getUserIdResponse) {
        new GetUserIdAsyncTask().execute(getUserIdResponse);
    }

    //
    // アイテムデータ取得要求(PurchasingManager.initiateItemDataRequest())
    // のレスポンスが返った時にコールバックします。
    //
    // @param itemDataResponse アイテムデータ取得要求レスポンス
    //   リクエストが成功している場合、購入可能/購入不可能なアイテムのセットを含んだ状態で返ります。
    //
    @Override
    public void onItemDataResponse(ItemDataResponse itemDataResponse) {
        new ItemDataAsyncTask().execute(itemDataResponse);
    }

    //
    // 購入要求(PurchasingManager.initiatePurchaseRequest())
    // のレスポンスが返った時にコールバックします。
    //
    // @param purchaseResponse 領収書を含む購入要求レスポンス
    //   リクエストが成功している場合、リクエストID、リクエストステータス、購入した領収書を含んだ状態で返ります。
    //
    @Override
    public void onPurchaseResponse(PurchaseResponse purchaseResponse) {
        new PurchaseAsyncTask().execute(purchaseResponse);
    }

    //
    // 購入情報更新要求(PurchasingManager.initiatePurchaseUpdatesRequest())
    // のレスポンスが返った時にコールバックします。
    // この呼び出しは、ユーザーが別のデバイスにアプリをダウンロードした場合や、
    // アプリケーションを初期化・削除後再インストール後などに購入情報を同期する為に使用されます。
    //
    // @param purchaseUpdatesResponse
    //   リクエストが成功している場合、
    //   リクエストID、リクエストステータス、過去購入した領収書、取り消されたSKUのセット、及び該当する場合は次のオフセットを含んだ状態で返ります。
    //
    @Override
    public void onPurchaseUpdatesResponse(PurchaseUpdatesResponse purchaseUpdatesResponse) {
        new PurchaseUpdatesAsyncTask().execute(purchaseUpdatesResponse);
    }
    
    // ユーザー情報取得レスポンスの実処理を実行するAsyncTask.
    // onGetUserIdResponse()へコールバックした後に実行。
    private class GetUserIdAsyncTask extends AsyncTask<GetUserIdResponse, Void, Boolean> {
        @Override
        protected Boolean doInBackground(GetUserIdResponse... params) {
            GetUserIdResponse getUserIdResponse = params[0];

            if (getUserIdResponse.getUserIdRequestStatus() == GetUserIdRequestStatus.SUCCESSFUL) {
                // ユーザーID取得ステータス成功時
                return true;
            } else {
                // ユーザーID取得ステータス失敗時
                Log.d(TAG, "onGetUserIdResponse: Unable to get user ID.");
                return false;
            }
        }
        @Override
        protected void onPostExecute(Boolean result) {
            super.onPostExecute(result);
            if (result) {
                // ユーザーID取得成功時...購入状態(購入/取消)を更新する要求を開始するなどの処理を記述
            } else {
                // ユーザーID取得失敗時...エラーダイアログ表示処理など
            }
        }
    }
    
    //
    // 課金アイテム情報取得レスポンスの実処理を実行するAsyncTask
    // onItemDataResponse()へコールバックした後に実行。
    // ゲームのストアフロントでアプリ内課金アイテムを表示するときにこの情報を使用します。
    //
    private class ItemDataAsyncTask extends AsyncTask<ItemDataResponse, Void, Void> {
        @Override
        protected Void doInBackground(ItemDataResponse... params) {
            final ItemDataResponse itemDataResponse = params[0];
            
            switch (itemDataResponse.getItemDataRequestStatus()) {
            case SUCCESSFUL_WITH_UNAVAILABLE_SKUS:
                // 購入できないアイテムのSKU
                for (final String sku : itemDataResponse.getUnavailableSkus()) {
                    Log.d(TAG, "Unavailable SKU:" + sku);
                }
            case SUCCESSFUL:
                // 購入可能なアイテムのSKU
                final Map<String, Item> items = itemDataResponse.getItemData();
                for (final String key : items.keySet()) {
                    Item i = items.get(key);
                    Log.d(TAG, "Item: " + i.getTitle() + "\n " +
                               "Type: " + i.getItemType() + "\n " +
                               "SKU: " + i.getSku() + "\n " +
                               "Price: " + i.getPrice() + "\n " +
                               "Description: " + i.getDescription() + "\n");
                }
                break;
            case FAILED:
                // レスポンス失敗
                break;
            }
            return null;
        }
    }

    //
    // onPurchaseResponse()を受信した後に呼び出されます。
    // オブザーバが購入の応答を受信したときに開始され、AsyncTaskが正常に返されるとUIが更新されます。
    //
    private class PurchaseAsyncTask extends AsyncTask<PurchaseResponse, Void, Boolean> {
        @Override
        protected Boolean doInBackground(PurchaseResponse... params) {
            final PurchaseResponse purchaseResponse = params[0];
            if (!purchaseResponse.getUserId().equals("[購入リクエストを行ったユーザーID]")) {
                // カレントログインユーザIDがPurchaseレスポンスから返されたユーザIDと違う場合、
                // レスポンスに含まれるユーザーIDの購入情報を更新する。 
                // ※通常はユーザー別にオフセットの値をこどかへ永続化させておき、その値で購入情報を取得する。
                PurchasingManager.initiatePurchaseUpdatesRequest(Offset.fromString("[リクエストに含まれるユーザーのオフセット値]"));
            }

            switch (purchaseResponse.getPurchaseRequestStatus()) {
            case SUCCESSFUL:
                // 購入成功:
                // レシート情報から購入されたアイテムタイプ・レシート情報を検証し、課金アイテムを利用可能にします。
                final Receipt receipt = purchaseResponse.getReceipt();
                switch (receipt.getItemType())
                {
                case CONSUMABLE:
                    // 消費型コンテンツの場合の処理を記述
                    Log.d(TAG, "Purchasing Response Item is CONSUMABLE.\n" + "Sku=" + receipt.getSku());
                    break;
                case ENTITLED:
                    // 買い切り型コンテンツの場合の処理を記述
                    Log.d(TAG, "Purchasing Response Item is ENTITLED.\n" + "Sku=" + receipt.getSku());
                    break;
                case SUBSCRIPTION:
                    // 期間購入型コンテンツの場合の処理を記述
                    Log.d(TAG, "Purchasing Response Item is Subscription.\n" + "Sku=" + receipt.getSku());
                    break;
                default:
                    break;
                }
                return true;
            case ALREADY_ENTITLED:
                // 重複購入:
                // 顧客が既にアイテムを受け取っている場合、レシート情報は戻りません。
                // レスポンスに格納されたリクエストIDと、PurchasingManagerで購入リクエストを送信するときに返されるリクエストIDを照合することによって、
                // 購入処理が重複したのかを判断し、必要な処理を行います。
                Log.d(TAG, "レスポンスに格納されたリクエストID=" + purchaseResponse.getRequestId());
                break;
            case FAILED:
                // 購入失敗:
                // SKUの購入が無効・購入しなかったなどの場合は、FAILEDステータスが返ります。
                // アプリケーションからリクエストされたSKUとDevPortalのSKUに相違がある場合に発生することがあります。
                break;
            default:
                break;
            }
            return false;
        }

        @Override
        protected void onPostExecute(Boolean success) {
            super.onPostExecute(success);
            if (success) {
                // 購入成功/重複購入時
            } else {
                // 購入:キャンセルor失敗時
            }
        }
    }

    //
    // Started when the observer receives a Purchase Updates Response Once the AsyncTask returns successfully, we'll
    // update the UI.
    // 固有ユーザIDに紐づく更新された購入情報を処理...
    // オブサーバがonPurchaseUpdatesResponseを受信したときに開始されUI(画面オブジェクト)を更新する。
    //
    private class PurchaseUpdatesAsyncTask extends AsyncTask<PurchaseUpdatesResponse, Void, Boolean> {
        @Override
        protected Boolean doInBackground(PurchaseUpdatesResponse... params) {
            final PurchaseUpdatesResponse purchaseUpdatesResponse = params[0];

            // 更新購入情報のユーザIDが一致する事を確認
            if (!(purchaseUpdatesResponse.getUserId().equals("[購入リクエストを行ったユーザーID]"))) {
                return false;
            }

            // 何らかの理由で顧客が商品を取り消された場合、これらのアイテムのSKUは失効SKUのセットに含まれます。
            // 取り消されたSKUを取得して取り消された購入情報を更新します。
            // ※注意:
            //  getRevokedSkus()は、Entitlement Content (買い切り型コンテンツ) のみに適用され、
            //  期間購入型コンテンツ(Subscription Content)には適用されない。
            for (final String sku : purchaseUpdatesResponse.getRevokedSkus()) {
                Log.d(TAG, "Revoked Sku:" + sku);
            }
            
            switch (purchaseUpdatesResponse.getPurchaseUpdatesRequestStatus()) {
            case SUCCESSFUL:
                // 最終サブスクリプション期間保持変数
                SubscriptionPeriod latestSubscriptionPeriod = null;

                // サブスクリプション格納コレクション
                final LinkedList<SubscriptionPeriod> currentSubscriptionPeriods = new LinkedList<SubscriptionPeriod>();

                // 更新購入情報の全レシートをを読んでサブスクリプションの期間を取得
                for (final Receipt receipt : purchaseUpdatesResponse.getReceipts()) {
                    // レシートのアイテムタイプを取得してアイテムタイプ別に処理を行う
                    switch (receipt.getItemType()) {
                    case ENTITLED:
                        // 売り切り型コンテンツのレシートだった場合
                        Log.d(TAG, "receipt item type is ENTITLED.");
                        break;
                    case SUBSCRIPTION:
                        // 期間購入型(サブスクリプション)コンテンツのレシートだった場合
                        // サブスクリプションの購入情報更新は、次のいずれかの方法で行うことができます:
                        // 1. レシート情報から、ユーザーが現在有効なサブスクリプションを持っているかどうかを判断する。
                        //    ...有効なサブスクリプションを探すため、レシートから終了日のないサブスクリプションがあるかどうかをチェックする。
                        // 2. レシート情報から、ユーザーのサブスクリプション購入履歴を作成する。
                        //    ...過去の有効なサブスクリプションに基づいてコンテンツをアンロックするアプリでは、顧客の購買履歴を作成する必要があります。
                        //       例) 購入者が雑誌の年間購読サブスクリプションを持っている場合、
                        //           例え顧客が現在有効なサブスクリプションを持っていなくとも、顧客は購入したときから現在も雑誌へのアクセス権を持っていることになります。
                        Log.d(TAG, "Receipt Item Type is Subscription.");
                        
                        if (receipt.getSku().equals("アプリで購入できる商品のSKU")) {
                            // サブスクリプションの期間
                            final SubscriptionPeriod subscriptionPeriod = receipt.getSubscriptionPeriod();
                            // サブスクリプション開始日時
                            final Date startDate = subscriptionPeriod.getStartDate();

                            // 最新の開始日を持つ領収書を探す。
                            // 重複するサブスクリプションがある場合は、現在のサブスクリプションコレクションに追加
                            if (latestSubscriptionPeriod == null || startDate.after(latestSubscriptionPeriod.getStartDate())) {
                                // サブスクリプションの最終日時がNUll又は、
                                // 現在のサブスクリプションの開始日時より最後のサブスクリプション期間の開始日が後の場合
                                currentSubscriptionPeriods.clear();
                                latestSubscriptionPeriod = subscriptionPeriod;
                                currentSubscriptionPeriods.add(latestSubscriptionPeriod);
                            } else if (startDate.equals(latestSubscriptionPeriod.getStartDate())) {
                                // サブスクリプションの最終日時がNullでない又は
                                // 現在のサブスクリプションの開始日時より最後のサブスクリプション期間の開始日時が前の場合
                                currentSubscriptionPeriods.add(receipt.getSubscriptionPeriod());
                            }
                        }
                        break;
                    default:
                        break;
                    }
                }

                // すべての領収書が読まれた後、最後(最新)のサブスクリプションを確認します。
                // サブスクリプション終了日が入っている場合、サブスクリプションは期限切れ(無効)です。
                if (latestSubscriptionPeriod != null) {
                    // 全サブスクリプションのコレクションからサブスクリプション期間を取得
                    for (SubscriptionPeriod subscriptionPeriod : currentSubscriptionPeriods) {
                        if (subscriptionPeriod.getEndDate() != null) {
                            // サブスクリプション期間の終了日がある場合、
                            // そのサブスクリプションは有効ではないと判断し、課金アイテムをロックする処理を入れる。
                            break;
                        }
                    }
                }

                // レシートのオフセット永続化処理
                final Offset newOffset = purchaseUpdatesResponse.getOffset();
                // どこかへ保存...
                
                if (purchaseUpdatesResponse.isMore()) {
                    // レシート情報がまだあある場合
                    Log.d(TAG, "Initiating Another Purchase Updates with offset: " + newOffset.toString());

                    // 現在のオフセットを指定してさらに購入状態(購入/取消)情報を更新する要求を開始。
                    // onPurchaseUpdatesResponse(PurchaseUpdatesResponse) にコールバックします。
                    PurchasingManager.initiatePurchaseUpdatesRequest(newOffset);
                }
                return true;
            case FAILED:
                // 購入情報の取得に失敗した場合。
                return false;
            }
            return false;
        }

        @Override
        protected void onPostExecute(Boolean result)
        {
            super.onPostExecute(result);
            // レスポンスに含まれるリクエストIDや処理結果をもとに処理記述
        }
    }
}

長くなってきたので続きは #2 で。

0 件のコメント:

コメントを投稿