2012年8月21日火曜日

AndroidのAccountManager経由でGoogleのOAuth2認証を行うには?(外部ライブラリ完全非依存版)

以前にAndroidでOAuth2認証をする方法については書いたことがある。ただし、WebView経由だった。

琴線探査: AndroidでGoogleのOAuth2認証を行うには?(外部ライブラリ完全非依存版)

WebView経由では必ずユーザーにIDとパスワードを入力してもらう必要があるが、AccountManager経由だとすでにアカウント情報が端末に入力してあるなら入力してもらう必要がなくなる。

ただ、AccountManager経由のOAuth2は必ずしもユーザーフレンドリーとは言えない部分がある。

琴線探査: Androidのアカウントマネージャー経由のOAuth2は本当にユーザーフレンドリーなのか?
琴線探査: AndroidのAccountManagerを使ったOAuth2認証は何だか怪しい

しかし、多くのユーザーにとってはアカウント情報の入力を省いてくれる方がユーザーフレンドリーと言えるのかもしれないと思った。そこで今度はAccountManager経由で行う方法をまとめてみようと思う。

このようなテストアプリを作った。


Eclipseプロジェクトファイル:AccountManagerOAuth2.zip (694KB)

多くの処理はtasks-android-sampleを参考にした。

プログラミングに入る前にAndroidManifest.xmlでパーミッションを設定しておかなければならない。

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />

これらのパーミッションは全て必要だ。

まずは「ユーザー情報取得」ボタンをクリックする。するとonClickBtnUserInfo()が呼び出される。

protected static final String AUTH_TOKEN_TYPE_PROFILE = "oauth2:https://www.googleapis.com/auth/userinfo.profile";
  public void onClickBtnUserInfo(View view) {
    Log.v("onClickBtnUserInfo", "ユーザー情報取得ボタンクリック");
    startRequest(AUTH_TOKEN_TYPE_PROFILE);
  }

AUTH_TOKEN_TYPE_PROFILEはGoogleのユーザー情報を取得するためのAPIのURLだ。このメソッドによりstartRequest()が呼び出される。

protected void startRequest(String authTokenType) {
    Log.v("startRequest", "リクエスト開始 - リクエスト先:" + authTokenType);
    this.authTokenType = authTokenType;
    if (accountName == null) {
      Log.v("startRequest", "アカウントが選択されていない");
      chooseAccount();
    } else {
      getAuthToken();
    }
  }

起動直後はaccountNameはnullなのでchooseAccount()が呼び出される。

protected static final String ACCOUNT_TYPE = "com.google";
  protected void chooseAccount() {
    Log.v("chooseAccount", "AuthToken取得開始(アカウント選択)");
    accountManager.getAuthTokenByFeatures(ACCOUNT_TYPE, authTokenType, null, AccountManagerOAuth2Activity.this, null, null,
        new AccountManagerCallback() {
          public void run(AccountManagerFuture future) {
            onGetAuthToken(future);
          }
        },
        null);
  }

accountManager.getAuthTokenByFeatures()は端末に入力されているアカウント数によって異なる動作をする。一連の画面はアプリではなくOSが用意する画面だ。

アカウントがゼロなら、アカウントを作成する画面になる。アカウントを作成した後、アクセス許可画面に誘導される。


アカウントが1つなら、選択画面が出ずに、すぐアクセス許可画面が表示される。

アクセス許可画面

ここで問題なのは、URLがそのまま出てしまっていること。ICS以降だと分かりやすい説明が出る場合もあるようだけど、それ以前のOSでは諦めるしかないようだ。

アカウントが2つ以上なら、アカウントを選択する画面になって、選択するとアクセス許可画面が表示される。



アクセス許可画面でAllowかDenyをするとonGetAuthToken()が呼び出される。

protected void onGetAuthToken(AccountManagerFuture<Bundle> future) {
    try {
      Bundle bundle = future.getResult();
      accountName = bundle.getString(AccountManager.KEY_ACCOUNT_NAME);
      authToken = bundle.getString(AccountManager.KEY_AUTHTOKEN);
      Log.v("onGetAuthToken", "AuthToken取得完了 accountName=" + accountName + " authToken=" + authToken + " authTokenType=" + authTokenType);
      if (authTokenType.equals(AUTH_TOKEN_TYPE_PROFILE)) {
        getUserInfo(); //ユーザー情報取得開始
      } else if (authTokenType.equals(AUTH_TOKEN_TYPE_ADSENSE)) {
        getAdSenseReport(); //レポート取得開始
      }
    } catch (OperationCanceledException e) {
      Log.v("onGetAuthToken", "AuthToken取得キャンセル");
    } catch (Exception e) {
      Log.v("onGetAuthToken", "AuthToken取得失敗", e);
    }
  }

Denyした場合はfuture.getResult()した段階でOperationCanceledExceptionがスローされる。つまりキャンセル扱い。

Allowした場合はOAuth2の処理をOSが自動的に行った結果、accountNameとauthTokenを取得できる。その後はauthTokenTypeに設定された値に従ってgetUserInfo()が呼び出される。

getUserInfo()で正常にユーザー情報が取得できたら、次は「AdSenseレポート取得」ボタンをクリックする。するとonClickBtnAdSense()が呼び出される。

protected static final String AUTH_TOKEN_TYPE_ADSENSE = "oauth2:https://www.googleapis.com/auth/adsense.readonly";
  public void onClickBtnAdSense(View view) {
    Log.v("onClickBtnAdSense", "AdSenseレポート取得ボタンクリック");
    startRequest(AUTH_TOKEN_TYPE_ADSENSE);
  }

ここでまたstartRequest()が呼び出される。ただし、引数がAUTH_TOKEN_TYPE_ADSENSEになっていることに注意。

protected void startRequest(String authTokenType) {
    Log.v("startRequest", "リクエスト開始 - リクエスト先:" + authTokenType);
    this.authTokenType = authTokenType;
    if (accountName == null) {
      Log.v("startRequest", "アカウントが選択されていない");
      chooseAccount();
    } else {
      getAuthToken();
    }
  }

今回はaccountNameがnullではないのでgetAuthToken()が呼び出される。

protected static final int REQUEST_CODE_AUTH = 0;
  protected void getAuthToken() {
    Account account = null;
    Account[] accounts = accountManager.getAccounts();
    for (int i = 0; i < accounts.length; i++) {
      account = accounts[i];
      if (account.name.equals(accountName)) {
        break;
      }
    }
    Log.v("getAuthToken", "AuthToken取得開始");
    accountManager.getAuthToken(account, authTokenType, true,
        new AccountManagerCallback<Bundle>() {
          public void run(AccountManagerFuture<Bundle> future) {
            try {
              Bundle bundle = future.getResult();
              if (bundle.containsKey(AccountManager.KEY_INTENT)) {
                //まだAPIアクセス許可が出ていない場合にgetAuthToken()すると
                //BundleにKEY_INTENTが含まれる。この場合AuthTokenはNULLとなる。
                Log.v("getAuthToken", "アクセス許可画面へ");
                Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT);
                //「FLAG_ACTIVITY_NEW_TASK」の前の「~」はビット反転演算子
                //これをしないとアクセス許可画面でのボタンクリックを待たずにonActivityResult()が呼ばれてしまう
                intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_NEW_TASK);
                startActivityForResult(intent, REQUEST_CODE_AUTH);
              } else if (bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) {
                onGetAuthToken(future);
              }
            } catch (Exception e) {
              Log.v("getAuthToken", "AuthToken取得失敗", e);
            }
          }
        },
        null);
  }

まずaccountNameからaccountを検索する。そして、そのaccountを使ってaccountManager.getAuthToken()を呼ぶ。

この先の動作はAPIの使用許可が出ているかどうかで異なる。

アクセス許可が出ている場合は、すぐにAuthTokenを取得できるのでonGetAuthToken()を呼ぶ。

アクセス許可が出ていない場合は、すぐにAuthTokenを取得できないので、Intentを使ってアクセス許可画面を呼び出す。今回はアクセス許可が出ていないのでこのケース。


ここでAllowされてもDenyされてもonActivityResult()が呼ばれる。

@Override
  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    Log.v("onActivityResult", "requestCode=" + requestCode + " resultCode=" + resultCode);
    switch (requestCode) {
      case REQUEST_CODE_AUTH:
        if (resultCode == RESULT_OK) {
          getAuthToken();
        } else {
          Log.v("onActivityResult", "アクセス許可画面で拒否された");
        }
        break;
    }
  }

アクセス許可画面でAllowされたら既出のgetAuthToken()を再度呼び出す。

今度はアクセス許可が出ているので「アクセス許可が出ている場合」にあたり、getAuthToken()内で既出のonGetAuthToken()が呼ばれる。

そして今回はonGetAuthToken()内でgetAdSenseReport()が呼ばれる。

protected void getAdSenseReport() {
    Log.v("getAdSenseReport", "AdSenseレポート取得開始");
    String url = "https://www.googleapis.com/adsense/v1.1/reports"
        + "?startDate=2012-07-26"
        + "&endDate=2012-08-02"
        + "&metric=EARNINGS"
        + "&dimension=DATE"
        + "&key=" + API_KEY
        + "&access_token=" + authToken;
    AsyncTaskGetJson task = new AsyncTaskGetJson();
    task.setListener(new OnResultEventListener() {
      @Override
      public void onResult(JSONObject json) {
        TextView tv = (TextView) findViewById(R.id.textView);
        String msg = "";
        if (json == null) {
          msg = "AdSenseレポート取得失敗";
          Log.v("getAdSenseReport", msg);
          dialog.dismiss();
        } else if (json.toString().contains(KEY_AUTH_ERROR)) {
          msg = "AdSenseレポート取得失敗(認証エラー)";
          Log.v("getAdSenseReport", msg + " AuthTokenを破棄して再取得");
          accountManager.invalidateAuthToken(ACCOUNT_TYPE, authToken);
          startRequest(AUTH_TOKEN_TYPE_ADSENSE);
        } else {
          msg = "AdSenseレポート取得成功\njson=" + json.toString();
          Log.v("getAdSenseReport", msg);
          dialog.dismiss();
        }
        tv.setText(msg);
      }
    });
    task.execute(url);
    dialog.setMessage("AdSenseレポート取得中");
    dialog.show();
  }

authTokenは一定期間過ぎると無効になるので再取得する必要がある

一定時間が過ぎたauthTokenを使ってAPIにリクエストすると「"code":401」を含むjsonが返される。上では「else if (json.toString().contains(KEY_AUTH_ERROR))」のケースだ。

このケースではaccountManager.invalidateAuthToken()した後startRequest()を再度呼び出す。すると更新されたauthTokenを取得できるようになり、エラー無くリクエストできるようになる。

以上のようにしてAccountManager経由のOAuth2認証で複数のGoogle APIにアクセスできる。

OAuth2の処理については隠蔽されているので全く分からない。通常必要なClientIDやClientSecretも必要ないらしい。APIにリクエスト投げる時につけてやったほうがいいのかな?この部分についてはもう少し調べる必要がある。