2013-03-25

Unity 3D + Google Play In-app Billing (IAB)


底下是 Unity 介接 Google Play In-app Billing (IAB) 金流的步驟,使用的環境是 OSX,IAB 是 V3 版 :

1. 編譯 IInAppBillingService.java

a. 按照 GooglePlay 官方網站的指示 (http://developer.android.com/training/in-app-billing/preparing-iab-app.html) 產生此檔案,其實這是一個自動產生的檔案,詳細方法可以參考連結網頁敘述。此外值得注意的是,程式碼也可以由 git 從此處下載 :

git clone https://code.google.com/p/marketbilling/

(附註 : 也許現在已經更新了,但文章撰寫的當下,若是從 Android SDK Manager 勾選 Google Play Billing Library 下載的版本有 Bug,所以才由 git 下載。 ref :
http://stackoverflow.com/questions/14397343/google-play-in-app-billing-version-3-crash-on-item-already-owned-and-missing )

b. 編譯 IInAppBillingService.java,指令如下 (此處假設 android sdk 之資料夾路徑在 /Users/macaronics/android-sdks/) :

javac ./IInAppBillingService.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar -d .

其中 -cp 指示編譯時參考之 library (android-10, Android 2.3.3)。編譯完成後輸入 :

jar cvfM ./iiabs.jar com/

產生的 iiabs.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。
注意要刪除資料夾 com,以免後續執行 jar 時,打包到舊的 com package。

2. 編譯 class IabHelper

a. 此類別由九個檔案組成,方便用來介接 Google IAB,九個 java 檔案應該位於前步驟下載之程式碼的 marketbilling/v3/src/com/example/android/trivialdrivesample/util 底下。

b. 逐一修改九個檔案裡的 "package com.example.android.trivialdrivesample.util" 改為你的 package name,例如  package com.macaronics.iab.util。

c. 建立欲編譯的檔案的 list,在 Terminal 底下的話直接輸入 cat > sources 然後依序輸入九個檔案檔名後按 control+d 儲存離開。

在 terminal 底下利用 cat 建立 sources file

d. 編譯 class IabHelper,輸入指令如下 :

javac @sources -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Users/macaronics/UnityIabProj/Assets/Plugins/Android/iiabs.jar -d .

其中 @sources 表示參考檔案 sources 裡的路徑。此外,引入的 library 除了 android-10/android.jar (Android 2.3.3) 外,還包含前一步驟所編譯的檔案 iiabs.jar。完成後接著輸入 :

jar cvfM ./iabhelper.jar com/

產生的 iabhelper.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。


3. 處理 onActivityResult 的結果訊息

a. 為了讓 IabHelper 能在 Activity 結束時處理 Activity 的結果,這裡我們需要自己撰寫繼承 UnityPlayerActivity 的類別來將結果 Relay 給 IabHelper。底下是範例的程式碼 :
package com.macaronics.iab;

import com.unity3d.player.UnityPlayerActivity;

import android.os.Bundle;
import android.util.Log;
import android.content.Intent;

public class overrideActivity extends UnityPlayerActivity {
    public interface cbEvent{
        public boolean cbEvent(int requestCode, int resultCode, Intent data);
    }

    protected cbEvent ie;
    static protected overrideActivity inst;
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        inst =this;
        
        // print debug message to logcat
        Log.d("overrideActivity", "onCreate called!");
    }

    @Override
    public void onDestroy(){
        super.onDestroy();
        inst =null;
        Log.d("overrideActivity", "onDestroy called!");
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data){
        Log.d("overrideActivity", "onActivityResult called!");
        
        boolean ret =false;
        if (ie !=null){
            try{
                ret =ie.cbEvent(requestCode, resultCode, data);
            }
            catch(Exception e){
                ret =false;
            }
        }

        if (ret ==false){
            super.onActivityResult(requestCode, resultCode, data);
        }
    }
    
    static public void registerOnActivityResultCBFunc(final cbEvent pcbfunc){
        if (inst !=null)
            inst.ie =pcbfunc;
    }
} 
其中函數 registerOnActivityResultCBFunc 只能記憶單一個 call back function,讀者可以把功能擴充成使用佇列來記憶更多的 call back function。

b. 同樣的編譯這個 class :

javac ./overrideActivity.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar -d .

其中引入的 library 有 android-10/android.jar (Android 2.3.3),及 Unity 的 classes.jar。然後輸入 :

jar cvfM ./overrideActivity.jar com/

產生的 overrideActivity.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。


4. 撰寫 iabWrapper.java

a. 主要用於介接 IabHelper 及 Unity ,提供基本的下單功能。程式碼分別敘述如下 :
package com.macaronics.iab;

import com.unity3d.player.UnityPlayer;

import android.app.Activity;
import android.util.Log;
import android.os.Bundle;
import android.os.Looper;
import android.content.Intent;

import java.util.*;

import com.macaronics.iab.util.*;
import com.macaronics.iab.overrideActivity;
值得注意的是這裡引入 UnityPlayer,步驟2編譯的 IabHelper (com.macaronics.iab.util),及步驟3編譯的 overrideActivity (com.macaronics.iab.overrideActivity)。

底下是 class iabWrapper 的私有變數部分 :
public class iabWrapper{
    private Activity mActivity;
    private IabHelper mHelper;
    private String mEventHandler;  
    ...
其中 mEventHandler 是回呼 Unity 時,接收訊息的 GameObject 名稱。此 class 有一個 Constructor 函數,三個成員函數及兩個給 IabHelper 回呼的函數,分別敘述如下 :

Constructor 函數在呼叫時給予 GooglePlay API PublicKey (由 GooglePlay 提供),以及回呼 Unity 時,接收訊息的 GameObject 之名稱 :
public iabWrapper(String base64EncodedPublicKey, String strEventHandler){
    mActivity =UnityPlayer.currentActivity;
    mEventHandler =strEventHandler;

    if (mHelper !=null){
        dispose();
    }

    mHelper =new IabHelper(mActivity, base64EncodedPublicKey);
    mHelper.enableDebugLogging(true);

    mHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() {
        public void onIabSetupFinished(IabResult result){
            if (!result.isSuccess()){
                //回呼 Unity GameObject 之函數 "msgReceiver", 並傳送字串訊息 (JSON 格式)
                UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"1\",\"ret\":\"false\",\"desc\":\""+result.toString()+"\"}");
                dispose();
                return;
            }
            
            //回呼 Unity GameObject 之函數 "msgReceiver", 並傳送字串訊息 (JSON 格式)
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"1\",\"ret\":\"true\",\"desc\":\""+result.toString()+"\"}");

            //register mHelper
            //向 overrideActivity 註冊 onActivityResult 回呼函數,並將資料 Relay 給 mHelper
            overrideActivity.registerOnActivityResultCBFunc(
                new overrideActivity.cbEvent(){
                    public boolean cbEvent(int requestCode, int resultCode, Intent data)
                    {
                            
                        if (mHelper.handleActivityResult(requestCode, resultCode, data)){
                            return true;
                        }
                        else{
                            return false;
                        }
                    }
                }
            );
        }
    });
}
其中的 UnitySendMessage 用來呼叫 Unity GameObject 之函數 "msgReceiver", 並傳送字串訊息 (JSON 格式)。此外值得注意的是此 Constructor 函數還向 overrideActivity 註冊用來接收 onActivityResult 訊息的回呼函數。底下的dispose 函數主要用於釋放 mHelper :
public void dispose()
{
    if (mHelper !=null)
    {
        mHelper.dispose();
    }
    mHelper =null;
}
purchase 函數會啟動購買商品的介面,其中第一項參數是 Product SKU (在 GooglePlay 設定的產品 SKU),第二項是回呼 onActivityResult 函數時用來分辨購買動作的 reqCode,第三項是用來確認此筆講買是否合法的 payloadString (購買成功之後, GooglePlay 回覆的資訊裡會包含此字串。)。
public void purchase(String strSKU, String reqCode, String payloadString)
{
    int intVal =Integer.parseInt(reqCode);
    if (mHelper !=null)
        mHelper.launchPurchaseFlow(mActivity, strSKU, intVal, mPurchaseFinishedListener, payloadString);
}
底下是完成購買之後回呼的函數 (onIabPurchaseFinished),這裡把接收的資訊以 JSON 格式轉送給 Unity :
IabHelper.OnIabPurchaseFinishedListener mPurchaseFinishedListener =new IabHelper.OnIabPurchaseFinishedListener() {
    public void onIabPurchaseFinished(IabResult result, Purchase purchase) {
        if (result.isFailure()){
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"2\",\"ret\":\"false\",\"desc\":\"\",\"sign\":\"\"}");
            return;
        }
  
        boolean ret =false;
        String result_json ="";
        String result_sign ="";
        if (purchase !=null){
            ret =true;
            result_json =purchase.getOriginalJson().replace('\"', '\'');
            result_sign =purchase.getSignature();
        }

        UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"2\",\"ret\":\""+ret+"\",\"desc\":\""+result_json+"\",\"sign\":\""+result_sign+"\"}");
    }
};
產品在購買 (Purchase) 之後若再次購買則會失敗,在 Console 裡可以看到 "Item already owned" 訊息。若產品的類型屬於消耗性的產品,則需要執行 consume 指令才可再次購買 :
public void consume(String itemType, String jsonPurchaseInfo, String signature)
{
    String transedJSON =jsonPurchaseInfo.replace('\'', '\"');
    if (mHelper ==null)
        return;

    Purchase pp =null;
    try{
        pp =new Purchase(itemType, transedJSON, signature);
    }
    catch(Exception e){
        pp=null;
    }

    if (pp !=null){
        final Purchase currpp =pp;
        mActivity.runOnUiThread(new Runnable(){
            public void run(){
                mHelper.consumeAsync(currpp, mConsumeFinishedListener);
            }
        });
    }
}
其中參數 itemType 在此範例為 "inapp",jsonPurchaseInfo 及 signature 是在 purchase 完成後, IabHelper 回呼 onIabPurchaseFinished 所給的資料。consume 完成之後回呼的函數如下 :
IabHelper.OnConsumeFinishedListener mConsumeFinishedListener =new IabHelper.OnConsumeFinishedListener() {
    public void onConsumeFinished(Purchase purchase, IabResult result) {
        if (result.isSuccess()){
            Log.d("iabWrapper", "Consumption successful. Provisioning");
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"3\",\"ret\":\"true\",\"desc\":\""+purchase.getOriginalJson().replace('\"', '\'')+"\",\"sign\":\""+purchase.getSignature()+"\"}");
        }
        else{
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"3\",\"ret\":\"false\",\"desc\":\"\",\"sign\":\"\"}");
        }
    }
};

b. 編譯 iabWrapper.java

javac ./iabWrapper.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar:/Users/macaronics/UnityIabProj/Assets/Plugins/Android/iabhelper.jar:/Users/macaronics/UnityIabProj/Assets/Plugins/Android/overrideActivity.jar -d .

其中引入的 library 有 android-10/android.jar (Android 2.3.3), Unity 的 classes.jar,先前編譯的 iabhelper.jar 及 overrideActivity.jar。然後輸入 :

jar cvfM ./iabWrapper.jar com/

產生的 iabWrapper.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。


5. 設定 Permission ( AndroidManifest.xml )

若要執行 GooglePlay Billing 功能則要設定 AndroidManifest.xml,將底下的 AndroidManifest.xml 置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android 資料夾。
<?xml version="1.0" encoding="utf-8"?>
<manifest android:versionCode="1" android:versionName="1.0" 
          android:installLocation="preferExternal" 
          package="com.macaronics.iab" 
          xmlns:android="http://schemas.android.com/apk/res/android">
  <supports-screens android:anyDensity="true" android:smallScreens="true" 
                    android:normalScreens="true" android:largeScreens="true" 
                    android:xlargeScreens="true" />
  <application android:label="@string/app_name" 
               android:icon="@drawable/app_icon" android:debuggable="false">
    <activity android:label="@string/app_name" 
              android:name="com.macaronics.iab.overrideActivity"
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"              
              >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              />
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerNativeActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <meta-data android:name="android.app.lib_name" android:value="unity" />
      <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" 
                 android:value="false" />
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.VideoPlayer" 
              android:screenOrientation="behind" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale" 
              />
  </application>
  <uses-feature android:glEsVersion="0x20000" />
 
  <uses-permission android:name="com.android.vending.BILLING" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

</manifest>

注意其中的 package="com.macaronics.iab" 要改成你自己的 package 名稱,與 Unity 裡的 Bundle Identifier 相同。此外,這裡還設定了主要啟動的 Activity 為 overrideActivity,以及 uses-permission。

到目前為止 /Users/macaronics/UnityIabProj/Assets/Plugins/Android 資料夾底下應該會有這些檔案 (iabhelper.jar, iabWrapper.jar, iiabs.jar, overrideActivity.jar, AndroidManifest.xml ) :

到目前為止應該要有的檔案

6. Unity 部分的 iabWrapper.cs

利用 AndroidJNI 介接 Java 程式 (注意為了接收 java 回傳的字串訊息,此 script (component) 必須加入到名為 iabWrapper 的 GameObject 裡)。 :
using UnityEngine;

using System.Collections;
using System.Collections.Generic;

public class iabWrapper : MonoBehaviour 
{
    public delegate void cbFunc(object[] retarr);
    cbFunc iabSetupCB =null;
    cbFunc iabPurchaseCB =null;
    cbFunc iabConsumeCB =null;

    AndroidJavaObject mIABHelperObj =null;
    static iabWrapper g_inst =null;

    void Start(){
        g_inst =this;
    }

    static public void init(string base64EncodedPublicKey, cbFunc tmpIabSetupCBFunc){
        if (g_inst ==null)
            return;

        g_inst.iabSetupCB =tmpIabSetupCBFunc;

        dispose();
        g_inst.mIABHelperObj =new AndroidJavaObject("com.macaronics.iab.iabWrapper", new object[2]{base64EncodedPublicKey, "iabWrapper"});
    }

    static public void dispose(){
        if (g_inst ==null)
            return;

        if (g_inst.mIABHelperObj !=null){
            g_inst.mIABHelperObj.Call("dispose");
            g_inst.mIABHelperObj.Dispose();
            g_inst.mIABHelperObj =null;
        }
    }

        ...

其中 init 負責呼叫 iabWrapper.java 之建構函數並建立該物件,dispose 則是負責刪除釋放 iabWrapper.java 物件。底下是主要的兩個功能,purchase 和 consume :
static public void purchase(string strSKU, int reqCode, string payload, cbFunc tmpIabPurchaseCBFunc){
    if (g_inst ==null)
        return;

    g_inst.iabPurchaseCB =tmpIabPurchaseCBFunc;

    if (g_inst.mIABHelperObj !=null){
        g_inst.mIABHelperObj.Call("purchase", new object[3]{strSKU, reqCode.ToString(), payload});
    }
}

static public void consume_inapp(string strPurchaseJsonInfo, string strSignature, cbFunc tmpIabConsumeCBFunc){
    if (g_inst ==null)
        return;

    g_inst.iabConsumeCB =tmpIabConsumeCBFunc;

    if (g_inst.mIABHelperObj !=null){
        g_inst.mIABHelperObj.Call("consume", new object[3]{"inapp", strPurchaseJsonInfo, strSignature});
    }
}
其中先將 callback 函數記錄起來,然後呼叫其對應的 java function。最後一部分是 msgReceiver,負責接收 java 回呼的結果並將資訊回呼對應的 callback function (先前呼叫 purchase 或是 consume 所給予的 callback function)。
void msgReceiver(string msg){
    if (g_inst ==null)
        return;
 
    //parse json
    Dictionary<string, object> cache =(Dictionary<string, object>)MiniJSON.Json.Deserialize(msg);

    //dispatch msg
    if (cache.ContainsKey("code")==true){
        int val =0;
        int.TryParse((string)cache["code"], out val);
        switch(val){
            case 0:{
                //unknown
                Debug.Log("Unity-iabWrappe :cannot parse cache[code]");

            }
            break;

            case 1:{
                //OnIabSetupFinishedListener
                if (cache.ContainsKey("ret")==true){
                    string retval =(string)cache["ret"];
                    if (retval =="true"){
                        //可使用
                        if (iabSetupCB !=null){
                            iabSetupCB( new object[1]{true} );
                        }

                    }
                    else if (retval =="false"){
                        //不可使用
                        if (iabSetupCB !=null)
                        {
                            iabSetupCB( new object[1]{false} );
                        }
                    }else{
                        Debug.Log("Unity-iabWrapper :cannot parse cache[ret], code=1");
                    }
                }
            }
            break;

            case 2:{
                //onIabPurchaseFinished
                if (cache.ContainsKey("ret")==true){
                    string retval =(string)cache["ret"];
                    if (retval =="true"){
                        //可使用
                        if (iabPurchaseCB !=null){
                            iabPurchaseCB( new object[3]{true, (string)cache["desc"], (string)cache["sign"]} );
                        }

                    }
                    else if (retval =="false"){
                        //不可使用
                        if (iabPurchaseCB !=null)
                        {
                            iabPurchaseCB( new object[3]{false, "", ""} );
                        }

                    }
                    else{
                        Debug.Log("Unity-iabWrapper  :cannot parse cache[ret], code=2");
                    }
                }
            }
            break;

            case 3:{
                //OnConsumeFinishedListener
                if (cache.ContainsKey("ret")==true){
                    string retval =(string)cache["ret"];
                    if (retval =="true"){
                        //可使用
                        if (iabConsumeCB !=null)
                        {
                            iabConsumeCB( new object[3]{true, (string)cache["desc"], (string)cache["sign"]} );
                        }

                    }else if (retval =="false"){
                        //不可使用
                        if (iabConsumeCB !=null)
                        {
                            iabConsumeCB( new object[3]{false, "", ""} );
                        }

                    }
                    else{
                        Debug.Log("Unity-iabWrapper :cannot parse cache[ret], code=3");

                    }
                }
            }
            break;
        }
    }
}
程式碼先將 JSON 字串轉為 object 物件並依照物件種類做對應的處理,其中 purchase 的 callback (case 2),回傳的參數依序為 : 1. 是否成功,2. purchase 結果字串以及 3. purchase 結果之 signature (可作為驗証之用,參考這篇)。


7. 實作 IAB APP

建立一個 script 名為 main,並將此 script 加入至 Camera。這裡實作一個 purchase 功能,並在 purchase 完成之後立刻執行 consume。
using UnityEngine;
using UnityEngine;
using System.Collections;
 
public class main : MonoBehaviour {
 
    // Use this for initialization
    void Start () {
        iabWrapper.init(
            "PUBLIC_KEY",
            delegate(object[] ret){
                if (true ==(bool)ret[0]){
                    Debug.Log("iab successfully initialized");
                }
                else{
                    Debug.Log("failed to initialize iab");
                }
            });
    }
 
    void OnGUI(){
        if (GUI.Button(new Rect(0, 0, 100, 100), "purchase")){
            iabWrapper.purchase("PRODUCT_SKU", 10001, "PRODUCT_SKU_AND_USER_ID_AND_DATE",
                delegate(object[] ret){
                    if (false ==(bool)ret[0]){
                        Debug.Log("purchase cancelled");
                    }
                    else{
                        string purchaseinfo =(string)ret[1];
                        string signature =(string)ret[2];
                        iabWrapper.consume_inapp(purchaseinfo, signature, 
                            delegate(object[] ret2){
                                if (false ==(bool)ret2[0])
                                {
                                    Debug.Log("failed to consume product");
                                }
                            });
                    }
                });
        }
    }
 
    void OnApplicationQuit(){
        iabWrapper.dispose();
    }
}
其中的 PUBLIC_KEY,更換成你的 Public Key,代入 purchase 函數的參數更改成你的參數。範例原始碼可以從這裡取得 : http://github.com/phardera/unity3d_googleplay_iab.git

2013-03-17

NodeJS 驗證 Google Play In-app Billing Signature

使用 IabHelper 可以方便介接 Google Play 的金流系統 (In-App billing, IAB) ,IabHelper 提供一個用來驗證下單結果 (使用者下單之後,由 GooglePlay IAB 回傳的結果 ) 的函數(Security.verifyPurchase),它主要用來確定資料是否有被竄改或是不完整。
若 App 的架構具有後端伺服器,則 IAB 回傳的結果應該由伺服器端來做驗證,底下是以 NodeJS 為例子的驗證程序 :
var crypto =require('crypto');
var verifySignature =function(publicKey, purchaseData, signature)
{
    var genPublicKey =function(key)
    {
        var chunkSize, chunks, str;
        str = key;
        chunks = [];
        chunkSize = 64;
        while (str) {
            if (str.length < chunkSize) {
                chunks.push(str);
                break;
            } else {
                chunks.push(str.substr(0, chunkSize));
                str = str.substr(chunkSize);
            }
        }
        str = chunks.join("\n");
        str = '-----BEGIN PUBLIC KEY-----\n' + str + '\n-----END PUBLIC KEY-----';
        return str;
    }
    
    return crypto.createVerify('RSA-SHA1').update(purchaseData).verify(genPublicKey(publicKey), signature, 'base64');
}


回傳若為 true 則資料正確。

參考來源 : https://github.com/nothing2lose/node-InAppBilling