Oauth認証をServiceで行う(あるいは、Serviceの結果をBroadcastReceiverで受け取る)

AndroidでTwitter4Jを使ってOauth認証を行う方法は以前[Android][Twitter4J]AndroidでTwitter4Jを使うときのノウハウまとめ(前半)と言う記事で紹介したんですが、何と言うか、単刀直入に言うとバグっています。

具体的にどのような事象が発生するかと言うと、色々あるんですが、一番困ったのはstartActivityForResultを使ってOauth認証後のアクセストークンを手に入れることが出来ないと言うところです。そんなわけで今回は「Oauth認証後にちゃんとActivityにコールバックが送られる処理」を作ることを目標にします。

そもそもの話

そもそも、以下の理由から考えてもOauth認証のために1つActivityを作ること自体が間違っている気がします。

  • アプリ内の画面を遷移させる必要が全くない
  • HTTP通信が発生するので絶対に非同期処理にする必要がある

こう言ったものを処理するにはService、しかも確実に非同期で処理する必要があるならば、IntentServiceで実装するのが筋ってもんじゃないでしょうか。

ServiceからActivityへコールバックを送るための仕組み

これまた以前に[Android]Serviceとのプロセス間通信でデータを送受信すると言う記事でちょっとだけ書きました。

左記の記事ではContext#bindServiceを使う方式をメインに紹介しましたが、今回は別にバインドしなくていいのでContext#startServiceを使います。そしてServiceから1回だけコールバックしてもらいたい場合はBroadcastReceiverを使うのが普通です。

方式としてはこんなイメージです。

Activityを起動
↓
ActivityのどこかでActivityとBroadcastReceiverを紐付ける
↓
ActivityのどこかでServiceを起動する
↓
Service内でOauth認証を行う
↓
Oauth認証後、ServiceからBroadcastReceiverを起動する
↓
Activityに紐付いたBroadcastReceiverが起動する
↓
成し遂げたぜ。

BroadcastReceiverの継承と登録

先にBroadcastReceiverを作ってしまいましょう。基本的にはonReceiveだけ実装すればOKです。

public abstract class CallbackBroadcastReceiver extends BroadcastReceiver {

    public static final String ACTION_CALLBACK = "twitter4j.auth.action.callback";
    public static final String KEY_DATA = "data";

    static class Data implements Serializable {
        AccessToken token;
        Exception exception;
        boolean isSuccess;

        static Data create(AccessToken token) {
            Data data = new Data();
            data.token = token;
            data.isSuccess = true;
            return data;
        }

        static Data create(Exception exception) {
            Data data = new Data();
            data.exception = exception;
            data.isSuccess = false;
            return data;
        }
    }

    public static Intent createIntent(Data data) {
        Intent i = new Intent();
        i.setAction(ACTION_CALLBACK);
        i.putExtra(KEY_DATA, data);
        return i;
    }

    public static IntentFilter createIntentFilter() {
        IntentFilter filter = new IntentFilter();
        filter.addAction(ACTION_CALLBACK);
        return filter;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        Data data = (Data) intent.getSerializableExtra(KEY_DATA);

        try {
            if(data.isSuccess) {
                onSuccess(data.token);
            } else {
                onError(data.exception);
            }
        } finally {
            getApplicationContext().unregisterReceiver(this);
        }
    }

    public abstract void onSuccess(AccessToken token);
    public abstract void onError(Exception exception);
}

ServiceからはCallbackBroadcastReceiver#DataをIntent経由で送ってもらうことを想定しています。Oauth認証に成功していたらonSuccessへ、失敗していたらonErrorへ委譲します。

Activityとの紐付けはContext#registerReceiverを使います。

// 適当なところからのonClick想定
public void test(View v) {
    registerReceiver(new CallbackBroadcastReceiver() {
        @Override
        public void onSuccess(AccessToken token) {
            // TODO:成功時処理
        }

        @Override
        public void onError(Exception exception) {
            // TODO:失敗時処理
        }
    }, CallbackBroadcastReceiver.createIntentFilter());

    // TODO: startService
}

このように、BroadcastReceiverはAndroidManifestに記述しなくても使用することが出来ます。ただしその場合は必ずIntentFilterを作成する必要があるので、適当に独自のActionを定義しておきましょう。

Activity側とService側でActionが一致していないと当然コールバックとして機能しないので、BroadcastReceiver内に適当なstaticメソッドを作っておくと捗ります。

// Serviceでつかう
public static Intent createIntent(Data data) {
    Intent i = new Intent();
    i.setAction(ACTION_CALLBACK);
    i.putExtra(KEY_DATA, data);
    return i;
}

// Activityでつかう
public static IntentFilter createIntentFilter() {
    IntentFilter filter = new IntentFilter();
    filter.addAction(ACTION_CALLBACK);
    return filter;
}

IntentServiceの継承

IntentServiceの実装にあたって、とりあえず必要なものは以下の通りです。

後者はabstractメソッドなので当然必須なんですが、前者はなんで必要かと言うと、ないと起動しないからです。と言うのも、Serviceのコンストラクタは引数なしのものしかないのに対し、IntentServiceはStringを一つ受け取るコンストラクタしかないからです。

当然Serviceを立ち上げる側はそのServiceを引数なしのコンストラクタで作ろうとするんですが、IntentServiceとしては引数なしのコンストラクタが定義されてないので作れないってわけですね。設計的にどうなのって気持ちはありますが我慢しましょう。

public class OauthService extends IntentService {

    public OauthService() {
        super("OauthService");
    }

    public OauthService(String name) {
        super(name);
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        // FIXME: implement
    }
}

Oauth処理を組み込む

それじゃあonHandleIntent内にOauth認証に関する処理を記述していきましょう。consumer_keyとconsumer_secretはIntentで渡すようにします。

public class OauthService extends IntentService {

    public static final String KEY_CONSUMER_KEY = "consumerKey";
    public static final String KEY_CONSUMER_SECRET = "consumerSecret";

    public OauthService() {
        super("OauthService");
    }

    public OauthService(String name) {
        super(name);
    }
    
    public static Intent createIntent(String consumerKey, String consumerSecret, Context context) {
        Intent intent = new Intent(context, OauthService.class);
        intent.putExtra(KEY_CONSUMER_KEY, consumerKey);
        intent.putExtra(KEY_CONSUMER_SECRET, consumerSecret);

        return intent;
    }
    
    @Override
    protected void onHandleIntent(Intent intent) {
        if(intent.hasExtra(KEY_CONSUMER_KEY)){
            startOauth(intent);
        } else {
            if(intent.getData == null) {
                throw new IllegalStateException();
            }
            getAccessToken(intent);
        }
    }

    /**
     * Oauth認証開始
     * @param intent
     */
    private void startOauth(Intent intent) {
        String consumerKey = intent.getStringExtra(KEY_CONSUMER_KEY);
        String consumerSecret = intent.getStringExtra(KEY_CONSUMER_SECRET);
        String callbackUri = "oauth://callback";

        Configuration conf = new ConfigurationBuilder()
                                    .setOAuthConsumerKey(consumerKey)
                                    .setOAuthConsumerSecret(consumerSecret)
                                    .build();

        OAuthAuthorization oauth = new OAuthAuthorization(conf);
        oauth.setOAuthAccessToken(null);

        String uri;
        try {
            uri = oauth.getOAuthRequestToken(callbackUri).getAuthorizationURL();
        } catch (TwitterException e) {
            e.printStackTrace();
            // error callback
            CallbackBroadcastReceiver.Data data = CallbackBroadcastReceiver.Data.create(e);
            sendBroadcast(CallbackBroadcastReceiver.createIntent(data));
            return;
        }

        try {
            serialize(oauth, "oauth.dat");
        } catch (IOException e) {
            e.printStackTrace();
            // error callback
            CallbackBroadcastReceiver.Data data = CallbackBroadcastReceiver.Data.create(e);
            sendBroadcast(CallbackBroadcastReceiver.createIntent(data));
            return;
        }

        Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
        i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(i);
    }

    /**
     * AccessToken取得
     * @param intent
     */
    private void getAccessToken(Intent intent) {
        OAuthAuthorization oauth = null;

        try {
            oauth = deserialize("oauth.dat");
        } catch(Exception e) {
            e.printStackTrace();
            // error callback
            CallbackBroadcastReceiver.Data data = CallbackBroadcastReceiver.Data.create(e);
            sendBroadcast(CallbackBroadcastReceiver.createIntent(data));
            return;
        }
        
        String verifier = intent.getData().getQueryParameter("oauth_verifier");
        AccessToken accessToken;
        try {
            accessToken = oauth.getOAuthAccessToken(verifier);
        } catch (TwitterException e) {
            e.printStackTrace();
            // error callback
            CallbackBroadcastReceiver.Data data = CallbackBroadcastReceiver.Data.create(e);
            sendBroadcast(CallbackBroadcastReceiver.createIntent(data));
            return;
        }

        // success callback
        CallbackBroadcastReceiver.Data data = CallbackBroadcastReceiver.Data.create(accessToken);
        sendBroadcast(CallbackBroadcastReceiver.createIntent(data));
    }

    private void serialize(OAuthAuthorization obj, String fileName) throws IOException {
        try {
            FileOutputStream fos = openFileOutput(fileName, MODE_PRIVATE);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(statuses)
        } finally {
            if(fos != null) {
                if(oos != null) oos.close();
                fos.close();
            }
        }
    }

    private OAuthAuthorization deserialize(String fileName) throws Exception {
        try {
            FileInputStream fis = openFileInput(path);
            ObjectInputStream ois = new ObjectInputStream(fis);
            return (OAuthAuthorization) ois.readObject();
        } finally {
            if(fis != null) {
                if(ois != null) ois.close();
                fis.close();
            }

            deleteFile(fileName);
        }
    }
}

AndroidManifestはこんな感じになります。android:nameのパッケージ名は適当に書き換えてください。

<service android:name="inujini_.hatate.service.OauthService" android:exported="false">
    <intent-filter>
        <action android:name="android.intent.action.View" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="oauth" android:host="callback"/>
    </intent-filter>
</service>

ちょっとトリッキーなところとして、OAuthAuthorizationシリアライズ / デシリアライズがあります。

TwitterOauth認証画面へ遷移するときと、Oauth認証が終わってOauthServiceへリダイレクトされた後のOAuthAuthorizationは必ず同じものを使う必要があります。しかし、仕組み上どうしてもそれぞれ別のOauthServiceインスタンスを使わざるをえません。

static変数で持っておくなどの対処も考えられますが、うっかりGCされた日には「Twitter側では認証できたのにアプリ側でエラーになったからクソ」と罵られても文句は言えませんし、再現性もない可能性があるので、いっそのこと一回ファイルに吐き出してしまったほうが安全かなと。

中継用のActivityをつくる

で、実際に動かしてみると、動きません。実際にはOauth認証が成功するところまで行きますが、コールバック用URIにリダイレクトする際に404になってしまいます。

Twitter側でOauth処理が完了するとandroid.intent.category.BROWSABLEとしてoauth://callback〜と言うURIが投げられるのですが、Serviceがそれを捕まえてくれません。どうもServiceはandroid.intent.category.BROWSABLEのIntentを無視するみたいです。

いやまぁ…。「BROWSABLE」なのにServiceで受け取るってこと自体、普通に考えたら間違いなくおかしなことなんですが…。

仕方ないのでリダイレクトを受け取ってServiceに投げ直すためだけのActivityを作成します。

public class OauthActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent intent = new Intent(getApplicationContext(), OauthService.class);
        intent.setData(getIntent().getData());
        intent.putExtra(OauthService.KEY_FINISHED_OAUTH, true);
        startService(intent);
        finish();
    }
}

Manifestはこんな感じ。

<activity android:name="inujini_.hatate.OauthActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="oauth" android:host="callback"/>
    </intent-filter>
</activity>

<service android:name="inujini_.hatate.service.OauthService" android:exported="false" />

結局Activityを作らなきゃいけないのが悲しいですね。

まとめ

後は実例…と思ったんですが、いつものクソアプリのソースでお茶を濁します。

また、今回作ったものの完全版も紹介しておきます。この記事内のコードは叩き台の要素が強いので、多少リファクタリングする予定です。

Oauth認証に限らず、何らかのIntentを交互に飛ばしあうような処理ならこの手段が使えるはずです。ちょっと具体的な例は思いつきませんが、アプリから自前のHTTPサーバへ飛ばす→ブラウザで操作させる→操作結果をアプリに反映させる、とか、そんなことも出来るはずです。