2013-04-08

Unity 3D + Facebook SDK for Android


底下是將 Facebook SDK 打包成 Facebook Plugin 的步驟,執行的平台是 Android,Unity3D 的版本是 3.5。

1. 在這裡取得 Facebook SDK 。
2. 開啟 eclipse 之後在功能表上選擇 File --> Import...,出現對話框之後在 import source 選擇 Existing Android Code Into Workspace。

3. 在 Root Directory 選擇剛剛下載的 SDK 資料夾 (此範例為 /Users/macaronics/facebook-android-sdk-3.0.1),Project to Import 的部分只要勾選 facebook (Facebook SDK) 即可。

4. 按下 Finish 之後在 Package Explorer 應該可以發現 Eclipse 已經自動產生一些檔案 (FacebookSDK/gen) 並且自動編譯好 facebooksdk.jar (FacebookSDK/bin) 了。

5. 建立一個 Unity Project,並將 facebooksdk.jar 及 res 資料夾複製到 Unity 專案的 Assets/Plugins/Android 資料夾底下。(ps. res 資料夾是指 facebook-android-sdk-3.0.1/facebook/res)

6. 複製 android-support-v4.jar  至 Unity 專案的 Assets/Plugins/Android 資料夾底下。( ps. facebook sdk 裡的 android-support-v4.jar 版本似乎太舊所以在編譯時會出問題,因此檔案可以從 android sdk 裡的 extras/android/support/v4 資料夾取得)

7. 建立 AndroidManifest.xml 檔案至 Assets/Plugins/Android 資料夾,內容如下 :
<?xml version="1.0" encoding="utf-8"?>
<manifest android:versionCode="1" android:versionName="1.0" 
          android:installLocation="preferExternal" 
          package="com.macaronics.fbonandroid" 
          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" 
              />
    <activity android:name="com.facebook.LoginActivity" android:label="@string/app_name" />
  </application>
  <uses-feature android:glEsVersion="0x20000" />
 
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

</manifest>
值得注意的是這裡設定了 activity "com.facebook.LoginActivity" 以及 Permission "INTERNET"。

8. 利用 git ( https://github.com/douglascrockford/JSON-java.git ) 取得 org.json 原始碼並以下述指令編譯 Java (在相同資料夾輸入指令) :
javac *.java -d .
完成之後輸入 :
jar cvfM json.jar org/
然後將產生的 json.jar 檔案置於 Assets/Plugins/Android 資料夾底下。

9. 建立 overrideActivity.jar ,功用在於 relay 系統資訊給 facebook SDK。首先建立 overrideActivity.java 檔案如下 :
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);
    }
    
    public interface cbSaveInstState{
        public boolean cbSaveInstState(Bundle outState);
    }

    protected cbEvent ie;
    protected cbSaveInstState isis;
    protected Bundle mSavedInstState;

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

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

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data){
        Log.d("Unity::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);
        }
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        Log.d("Unity::overrideActivity", "onSaveInstanceState called!");
    
        boolean ret =false;
        
        if (isis !=null)
        {
            try{
                ret =isis.cbSaveInstState(outState);
            }
            catch(Exception e){
                ret =false;
            }
        }
        
        if (ret ==false){
            super.onSaveInstanceState(outState);
        }
    }
    
    static public void registerOnActivityResultCBFunc(final cbEvent pcbfunc){
        if (inst !=null)
            inst.ie =pcbfunc;
    }
    
    static public void registerOnSaveInstanceSataeCBFunc(final cbSaveInstState pcbfunc){
        if (inst !=null)
            inst.isis =pcbfunc;
    }
    
    static public Bundle getSavedInstanceState()
    {
        if (inst !=null)
            return inst.mSavedInstState;
        
        return null;
    }
} 
編譯指令為 :
javac ./overrideActivity.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity3.5.6/Unity/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar -d .
其中,引入的 library 包含了 android-10/android.jar (Android 2.3.3) ,以及 Unity 的 classes.jar。編譯完成後將其打包成 overrideActivity.jar :
jar cvfM ../overrideActivity.jar com/
將產生的 overrideActivity.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下。此外,須注意資料夾 com 要刪除,以免後續執行 jar 時,打包到舊的 com package。

10. 建立 fbWrapper.jar (主要用於介接 facebook SDK 及 Unity),首先建立 fbWrapper.java 檔案如下 :
package com.macaronics;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import android.os.Bundle;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;
import android.util.Base64;
import android.util.Log;
import android.view.Menu;
import android.view.View;
import android.widget.Toast;

import com.facebook.*;
import com.facebook.widget.WebDialog;
import com.facebook.widget.WebDialog.OnCompleteListener;
import com.facebook.model.GraphObject;

import com.unity3d.player.UnityPlayer;
import com.macaronics.iab.overrideActivity;

import org.json.JSONArray;


public class fbWrapper
{
    private String myHash;
    private String mEventHandler;
    private Activity mActivity;
    private String mAppId;
    
    public fbWrapper(String packageName, String applicationId, String strEventHandler){

        mActivity =UnityPlayer.currentActivity;
        mEventHandler =strEventHandler;
        mAppId =applicationId;
        
        //--------------------
        //fetch keyhash
        try
        {
            PackageInfo info =mActivity.getPackageManager().getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
            for (Signature sig : info.signatures){
                MessageDigest md =MessageDigest.getInstance("SHA");
                md.update(sig.toByteArray());
                myHash =Base64.encodeToString(md.digest(), Base64.DEFAULT);
            }
        }
        catch(NameNotFoundException e){
            Log.d("Unity::fbWrapper()", "error on fetch haskkey -name not found");
        }
        catch(NoSuchAlgorithmException e){
            Log.d("Unity::fbWrapper()", "error on fetch haskkey -no such algorithm");
        }

        
        //--------------------
        //register callback (onActivityResult)
        overrideActivity.registerOnActivityResultCBFunc(
            new overrideActivity.cbEvent(){
                public boolean cbEvent(int requestCode, int resultCode, Intent data)
                {
                    Session.getActiveSession().onActivityResult(mActivity, requestCode, resultCode, data);
                    return false;
                }
            });
        
        //--------------------
        //register callback (onSaveInstanceState)
        overrideActivity.registerOnSaveInstanceSataeCBFunc(
            new overrideActivity.cbSaveInstState(){
                public boolean cbSaveInstState(Bundle outState)
                {
                    Session session = Session.getActiveSession();
                    Session.saveSession(session, outState);
                    return false;
                }
            });
        
        //--------------------
  //facebook
        Bundle savedInstanceState =overrideActivity.getSavedInstanceState();
  Settings.addLoggingBehavior(LoggingBehavior.INCLUDE_ACCESS_TOKENS);
  Session session =Session.getActiveSession();
        if (session == null) {
            if (savedInstanceState != null) {
                session = Session.restoreSession(mActivity, null, statusCallback, savedInstanceState);
                Log.d("Unity::onCreate()", "Session.restoreSession()");
            }
            if (session == null) {
             session = new Session.Builder(mActivity).setApplicationId(mAppId).build();
                Log.d("Unity::onCreate()", "new Session.Builder(this)");
            }
            
            Session.setActiveSession(session);
            if (session.getState().equals(SessionState.CREATED_TOKEN_LOADED)) {
                session.openForRead(new Session.OpenRequest(mActivity).setCallback(statusCallback));
                Log.d("Unity::onCreate()", "openForRead()");
            }
        }        
    }

    public void dispose()
    {
        Log.d("Unity::dispose()", "dispose called");
    }
    
    public boolean isSessionOpened()
    {
        Session session =Session.getActiveSession();
        if (session !=null && session.isOpened()==true && session.getAccessToken() !=null)
        {
            return true;
        }
        
        return false;
    }

    public String getHash()
    {
        return myHash;
    }
    
    public void login()
    {
        Session session = Session.getActiveSession();
        if (!session.isOpened() && !session.isClosed()) {
            Log.d("Unity::onLogin()","session.openForRead()");
            session.openForRead(new Session.OpenRequest(mActivity).setCallback(statusCallback));
        }else if (session !=null){
            Log.d("Unity::onLogin()","Session.openActiveSession()");

            //regenerate session
            Session newsession = new Session.Builder(mActivity).setApplicationId(mAppId).build();
            Session.setActiveSession(newsession);
            newsession.openForRead(new Session.OpenRequest(mActivity).setCallback(statusCallback));
        }
        else
        {
         Log.d("Unity", "error -session ==null");
        }
    }
    
    public void logout()
    {
     Log.d("Unity::onLogout()","on logout called !");
        Session session = Session.getActiveSession();
        if (!session.isClosed()) {
            session.closeAndClearTokenInformation();
            Log.d("Unity::onLogout()","session.closeAndClearTokenInformation()");
        }
    }
    
    public void invokeQuery(String fqlQueryStr)
    {
     Session session =Session.getActiveSession();
     if (session.isOpened()==false)
     {
      Log.d("Unity::invokeQuery()", "invalid session");
      return;
     }

     Bundle params =new Bundle();
     params.putString("q", fqlQueryStr);

     final Request request =new Request(session,
       "/fql",
       params,
       HttpMethod.GET,
       new Request.Callback(){
        public void onCompleted(Response response){
                        GraphObject graphObject = response.getGraphObject();
                        if (graphObject !=null)
                        {
                            if (graphObject.getProperty("data")!=null &&
                                mEventHandler !=null)
                            {
                                try{
                                    String arr =graphObject.getProperty("data").toString();
                                    JSONArray pj =new JSONArray(arr);
                                    UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"true\",\"dat\":"+pj.toString()+"}");
                                }
                                catch(Exception e)
                                {
                                    if (mEventHandler!=null)
                                        UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"false\"}");
                                }
                                
                            }
                            else
                            {
                                if (mEventHandler!=null)
                                    UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"false\"}");
                            }
                            
                        }
                        else
                        {
                            if (mEventHandler!=null)
                                UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"false\"}");
                        }
                        
        }

       });

     Log.d("Unity::invokeQuery()", "send fql request...");
        
        mActivity.runOnUiThread(new Runnable(){
            public void run(){
                Request.executeBatchAsync(request);                
            }
        });


    }

    public void sendRequest(String reqMessage)
    {
     final Session session =Session.getActiveSession();
     if (session.isOpened()==false)
     {
      Log.d("Unity::SendRequest()", "invalid session");
      return;
     }

     final Bundle params =new Bundle();
     params.putString("message", reqMessage);

     final Activity act =mActivity;

        mActivity.runOnUiThread(new Runnable(){
            public void run(){
                
                WebDialog requestDialog =(
                    new WebDialog.RequestsDialogBuilder(act,
                        session.getActiveSession(),
                        params))
                        .setOnCompleteListener(new OnCompleteListener(){

                            @Override
                            public void onComplete(Bundle values,
                                    FacebookException error){
                                if (error !=null)
                                {
                                    if (error instanceof FacebookOperationCanceledException){
                                        Toast.makeText(act.getApplicationContext(),
                                        "Request cancelled",
                                        Toast.LENGTH_SHORT).show();
                                    }
                                    else
                                    {
                                        Toast.makeText(act.getApplicationContext(), "Network Error", Toast.LENGTH_SHORT).show();
                                    }
                                }
                                else
                                {
                                    final String requestId =values.getString("request");
                                    if (requestId !=null)
                                    {
                                        Toast.makeText(act.getApplicationContext(),
                                        "Request sent",
                                        Toast.LENGTH_SHORT).show();
                                    }
                                    else
                                    {
                                        Toast.makeText(act.getApplicationContext(),
                                        "Request cancelled",
                                        Toast.LENGTH_SHORT).show();
                                    }
                                }
                            }
                    })
                    .build();
                requestDialog.show();
                
            }
        });

    }
    
    private Session.StatusCallback statusCallback = new SessionStatusCallback();
    private class SessionStatusCallback implements Session.StatusCallback {
        @Override
        public void call(Session session, SessionState state, Exception exception) {
            if (state.isOpened())
            {
                Log.d("Unity::onSessionStateChange()", "state.isOpened()");
            }
            else
            {
                Log.d("Unity::onSessionStateChange()", "state.isOpened() ===false");
            }
        }
    }

}
其中除了 login 及 logout 外,還提供了兩個重要的功能,一是 FQL Query,另一個是 Requests Dialog。執行編譯的指令為 :
javac ./fbWrapper.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar:./facebooksdk.jar:./overrideActivity.jar:./android-support-v4.jar:./json.jar -d .
其中,引入的 library 包含了 android-10/android.jar (Android 2.3.3),Unity 的 classes.jar,facebooksdk.jar,先前編譯的 overrideActivity.jar,json.jar,以及 android-support-v4.jar。編譯完成後將其打包成 fbWrapper.jar :
jar cvfM ../fbWrapper.jar com/
將產生的 fbWrapper.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下。直至目前為止,資料夾底下應該有這些檔案 :

11. 建立 fbWrapper.cs ,讓 fbWrapper class 更適用於 Unity 的 Component 架構之環境,程式碼如下 :
using UnityEngine;

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

public class fbWrapper : MonoBehaviour {

    AndroidJavaObject fbWrapperObj =null;
    public static fbWrapper inst =null;

 // Use this for initialization
 void Start () {
        inst =this;
 }

    void OnApplicationQuit(){
        dispose();
        inst =null;
    }

    public bool init(string unity_bundle_id, string fb_app_id)
    {
        dispose();
        fbWrapperObj =new AndroidJavaObject("com.macaronics.fbWrapper", new object[3]{unity_bundle_id, fb_app_id, "fbWrapper"});

        return true;
    }

    public void dispose()
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("dispose");
            fbWrapperObj.Dispose();
            fbWrapperObj =null;
        }
    }

    void msgReceiver(string msg)
    {
        if (tmpCBFunc !=null)
        {
            Dictionary<string, object> cache =(Dictionary<string, object>)MiniJSON.Json.Deserialize(msg);
            if (cache.ContainsKey("ret")==true)
            {
                string ret =(string)cache["ret"];
                if (ret=="false")
                {
                    tmpCBFunc(false, null);
                }
                else
                {
                    if (cache.ContainsKey("dat")==true)
                    {
                        tmpCBFunc(true, cache["dat"]);
                    }
                }
            }
            else
            {
                tmpCBFunc(false, null);
            }
        }
    }

    //目前是否已登入
    public bool isSessionAvailable()
    {
        if(fbWrapperObj==null)
            return false;

        return fbWrapperObj.Call<bool>("isSessionOpened");
    }

    //登入
    public bool login()
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("login");
            return true;
        }

        return false;
    }

    //登出
    public bool logout()
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("logout");
            return true;
        }
        return false;
    }

    public string getHashKey()
    {
        string myhash =fbWrapperObj.Call<string>("getHash");
        if (myhash ==null)
            myhash ="package name not found";

        return myhash;
    }

    public delegate void queryCBFunc(bool ret, object data);
    protected queryCBFunc tmpCBFunc =null;
    public bool invokeQuery(string fql, queryCBFunc pcbfunc)
    {
        tmpCBFunc =pcbfunc;
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("invokeQuery", new object[1]{fql});
            return true;
        }
        return false;
    }

    //送出邀請
    public bool sendRequest(string message)
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("sendRequest", new object[1]{message});
            return true;
        }
        return false;
    }

}
在 Unity 建立一個 GameObject 並命名為 fbWrapper,然後將 fbWrapper.cs 設定成此 GameObject 之 Component。

此外值得注意的是,函數 init 的第一個參數是 Unity 之 Bundle Identifier,第二個參數是 Facebook App ID,此 ID 應該在建立 Facebook App 時,由 Facebook 所提供,如圖所示 :

函數 getHashKey 所回傳的字串必須設定至 Native Android App 之 Key Hash 欄位 ,如圖所示 :

12. 底下是簡單的 fbWrapper 應用範例,建立一個  C# script 名稱為 test.cs 並設定成為 GameObject "Main Camera" 之 Component。
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class test : MonoBehaviour {

 // Use this for initialization
    string myhash =null;
 void Start () {
        fbWrapper.inst.init("com.macaronics.fbonandroid", "FB_APP_ID");
        myhash =fbWrapper.inst.getHashKey();
 }

    void OnGUI()
    {
        if (GUI.Button(new Rect(10,10,100,100), "login"))
        {
            fbWrapper.inst.login();
            myhash =fbWrapper.inst.getHashKey();
        }

        if (GUI.Button(new Rect(10,120,100,100), "logout"))
        {
            fbWrapper.inst.logout();
            queryresult =null;
            myhash =null;
        }

        if (fbWrapper.inst.isSessionAvailable())
        {
            if (GUI.Button(new Rect(10,230,100,100), "invoke query"))
            {
                fbWrapper.inst.invokeQuery("SELECT uid, name, pic_square, is_app_user FROM user WHERE uid IN (SELECT uid2 FROM friend WHERE uid1 =me())", queryCB);
            }

            if (GUI.Button(new Rect(10,340,100,100), "send request"))
            {
                fbWrapper.inst.sendRequest("check out my app !");
            }
        }

        if (myhash !=null)
        {
            GUI.Label(new Rect(120, 10, Screen.width-120, 40), "Hash Key: "+myhash);
        }
        if (queryresult !=null)
        {
            GUI.Label(new Rect(120, 50, Screen.width-120, Screen.height-50), queryresult);
        }
    }

    string queryresult =null;
    void queryCB(bool ret, object data)
    {
        string tmpstr ="";
        List<object> OBJARR =(List<object>)data;
        for (int i=0;i<OBJARR.Count;++i)
        {
            
            Dictionary<string, object> items =(Dictionary<string, object>)OBJARR[i];
            foreach(KeyValuePair<string, object> item in items)
            {
                tmpstr +=(item.Key+":"+item.Value+"\n");
            }
        }

        queryresult =tmpstr;
    }
}
將 Script 內的 FB_APP_ID 換成當初建立 FB APP 時,Facebook 給予的 App ID。此外,"com.macaronics.fbonandroid" 是本範例所設定之 Bundle Identifier 。另外,呼叫 invokeQuery 函數的第一個參數是 FQL 字串,第二個參數是作為回傳結果用的 Callback Function,回傳的結果字串屬於 JSON 格式,範例把字串內容解析並顯示出來。

13. 設定 Untiy 載入 Scene 之後,各 Script 的執行順序,將 fbWrapper 設定早於預設的時間 (Edit --> Project Settings --> Script Execution Order)。

14. 當一切設定就緒之後 (記得設定 Player Settings 之 Other Settings 的 Bundle Identifier),嘗試 Build apk 檔案,Build 完成之後,在 Unity 專案資料夾下會多出一個 Temp 資料夾,在路徑 Temp/StagingArea/gen/com/macaronics/fbonandroid/ 找到檔案 R.java。

打開此檔案,將 package com.macaronics.fbonandroid; 改為 package com.facebook.android; 之後另存成 R.java 在別的資料夾。

15. 編譯 R.java :
javac R.java -d .
打包成為 jar 檔案 :
jar cvfM facebooksdk_R.jar com/
將檔案放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下。

16. 重新編譯 apk 檔案。

此範例的原始碼可以從這裡 ( http://github.com/phardera/unity3d_fb_android.git ) 取得。