2010年1月24日日曜日

2010年1月23日土曜日

Android用のTwitterクライアントを作ってみる3

以前作った機能をほぼそのままServiceが管理するように作成
その上で

  • 一定時間毎に hometimeline を取得
  • ステータスバーへの通知
  • サービスからのコールバック
みたいな機能をつけました。


一定時間毎に hometimelineを取得
 private static final int GET_TWITTER_HOME_TIMELINE = 1;
 Handler mHandler = new Handler(){
 public void dispatchMessage(Message msg) {
  switch( msg.what){
  case GET_TWITTER_HOME_TIMELINE:
   mHandler.removeMessages(GET_TWITTER_HOME_TIMELINE);
   mHandler.sendEmptyMessageDelayed(GET_TWITTER_HOME_TIMELINE, intervaltime);
   // TODO
  }
 };

至って単純です。
mHandler.sendEmptyMessageDelayedで適当な時間後に自分自身を呼び出すだけです。


ステータスバーへの通知
 NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
 Notification notification = new Notification(R.drawable.icon, getString(R.string.Service_message1), System.currentTimeMillis());
 PendingIntent contentIntent = PendingIntent.getActivity(this, 0, new Intent(this, 呼び出すアクティビティ名.class), 0);
 // 第二引数がアイコン横の太字
 // 第三引数が通知内容(?)
 notification.setLatestEventInfo(this, getString(R.string.app_name), getString(R.string.Service_message1), contentIntent);
 notification.flags = Notification.FLAG_AUTO_CANCEL; // 通知を一度使用したらステータスバーから自動的に消える
 // 第一引数は通知ID
 manager.notify(R.string.app_name, notification); // ステータスバーへの通知

詳細はTaosoftwareさんのブログとか
http://www.taosoftware.co.jp/blog/2009/04/android_notification.html
こちらAndroidの受託開発とかやってるみたいですね。
うちの会社と規模も良く似てるwww
でもAndroid特化(?)というのは羨ましいような・・・。


サービスからのコールバック
 //*** ICallbackListener を宣言するaidlファイル
 interface ICallbackListener {
  void receiveMessage(String message);
 }
 //*** サービス側ソース
 //コールバックアクティビティの管理リスト
 private RemoteCallbackList listeners = new RemoteCallbackList();
 //リスナーの追加
 public void addListener(ICallbackListener listener) throws RemoteException {
  listeners.register(listener);
  //削除はunregister で
 }
 //コールバック内容
 int numListeners = listeners.beginBroadcast(); //listeners.finishBroadcast();までスレッドセーフ
 for( int i = 0; i < numListeners; i++){
  try {
   listeners.getBroadcastItem(i).receiveMessage( "コールバック内容");
  } catch (RemoteException e) {
   Log.e("CallbackService", e.getMessage(), e);
  }
 }
 listeners.finishBroadcast();
 //*** コールバックを受け取るアクティビティのソース
 private ICallbackListener listener = new ICallbackListener.Stub() {
  public void receiveMessage(String message) throws RemoteException {
  // TODO メッセージに対する処理
  }
 };

え~、あ~
あくまで備忘録なんで、足りない部分は脳内補完してください。
コピペだけじゃ動きません。


と、まあこんな感じで作ると
twitter に新着があると

  • アクティビティが起動してない場合は通知バーへ通知
  • アクティビティが起動してたらコールバックを使用して直接描画
みたいなことができます。
ちなみに新着はhometimelineの一番上の発言内容が、以前と異なるかどうかで判断w
なにかいい方法あればいいんですが。
API制限を考えるとhometimelineの取得間隔はそれなりに考えものです。

マニフェスト情報

・サービスを別プロセスにする
<service android:name="クラス名" android:process=":適当な名称">
ちなみにコロンは必須のようです。
ぶっちゃけ別プロセスにしなくても動きますが、本曰くアクティビティと異なるプロセスで動作させることで、
『アクティビティのみを停止させることができ、メモリの節約が期待できます。』
とのことです。
まあ、なんか別プロセスの方がサービスぽいですしね。


新着があると通知が


開くとこんな感じ


通知バーから起動


アクティビティが起動しているあいだは、新着があってもステータスバーへの通知はなし



アイコンがノーマルってのが悲しいですね。
ここまで来るとアイコンを作成する能力が少しでも欲しい・・・。
ちなみに実機で動作させると、認証とかに意外と時間がかかります。
認証中とかに「通信中・・・」みたいなダイアログ出したり、発言が反映したことをアクティビティに伝えるetc細かい機能が欲しくなります。


参考 「Android プログラミング入門」

  • 第二部 3.5.2 ノーティフィケーションを使ってBMIの計算結果を通知する
  • 第二部 第4章 サービス


追記
上記で別プロセスの場合コロンは必須と書きましたが(ていうか本にそう書いてあったが)、正しくは

  • 「同一パッケージ内のコンポーネントしか実行できないプライベートなプロセスとして」別プロセスを作る場合
というような感じのときにはコロンを頭において適当な名前を付けるようです。(と別の章で書いてありました)
プライベートなプロセスとグローバルなプロセスを、アプリによっては使い分ける必要があるんでしょうが、とりあえずそんな高度(?)なアプリはまだ作らないんでその辺は曖昧で( ^ω^)・・・
参考 「Android プログラミング入門」
  • 第四部 1.2.1 プロセスの共有

2010年1月17日日曜日

Android Serviceで困ったこと2

前回のの続き

ええ、マニュアル読めってことで完了します。
何がって、サービスのライフサイクル。
bindService で起動されたサービスと startService で起動されたサービスのライフサイクルが異なることをソース書いて気がつきました。

基本以下のようです。

  • bindService で起動 → bindしているアプリ全てが unbind したら終了
  • startServiceで起動 → stopService or stopSelf で終了
ただし、startService で起動しようとも、bindすることは可能であり、stopを呼んでもbindしているやつがいる場合は終了できません。

なので、とにかく常駐させたい場合はstartServiceで起動するしかなさそうです。
やっぱりマニュアルは読まないとね。
まあ、言い訳するならば、「自分でソース書いてあちこちにログがけして、サイクルを学ぶことには意味がある!」ってことにします。
http://developer.android.com/intl/ja/guide/topics/fundamentals.html

Android Serviceで困ったこと

例によって、マニュアルを読むと解決することが半分。
ソース落として中身をしっかり見れば解決するであろうことが半分。
とりあえず後者に関する内容です。

bindService() 直後にはサービスを利用できない。

具体例としては以下のようなやつはNG
private IService service;
private ServiceConnection conn = new ServiceConnection() {

 public void onServiceConnected(ComponentName arg0, IBinder arg1) {
  // TODO 自動生成されたメソッド・スタブ
  service = IService.Stub.asInterface(arg1);
 }

 public void onServiceDisconnected(ComponentName arg0) {
  // TODO 自動生成されたメソッド・スタブ
  service = null;
 }
};
private void onChk1(){
 Intent intent = new Intent(IService.class.getName());
 bindService(intent, conn, BIND_AUTO_CREATE);
 service.serviceAPI(); // ここで落ちます
}

ようはbindService を呼んですぐにonServiceConnected() まで到達しないということです。
上記のような場合、onChk1 を完全に抜けてからでないと絶対に到達しませんでした。

以下NG集

private void onChk2(){
 Intent intent = new Intent(IService.class.getName());
 bindService(intent, conn, BIND_AUTO_CREATE);

 while( service != null){ // 無限ループに陥る
  wait(1000);
 }
 service.serviceAPI();
}
private void onChk3(){
 Intent intent = new Intent(IService.class.getName());
 bindService(intent, conn, BIND_AUTO_CREATE);

 new Thread(){ // スレッド化しても無駄
  @Override
  public void run() {
   while( service != null){ // 無限ループに陥る
    wait(1000);
   }
   service.serviceAPI();
  }
 }.start();
}

ちなみにbindService と service.serviceAPI を別関数化し、スレッド化して呼んでもダメなものはダメでした。
もちろんonChk3 のようなスレッドの作り方が良くないのかもしれませんが。
その辺、javaに詳しい人なら即座に解決策がでるんでしょうか?

では、なんでこんなことをしようと思ったかと言うと、
・onCreate / onResume 時に サービスから初期値を取得したい
という状況が発生したからです。

一応以下の方法でそれっぽくなりますが、なんかもっと簡単な方法ないものでしょうか。
完全解決という感じではないし。
自分でメッセージループ管理しだすと、VCチックな感じも・・・。
ただ、この面倒な感じは、BREWアプリを想像させるような・・・。
まあBREWなんかと比較したら失礼ですね。あれはマジ終わってる。

onCreate でサービスをバインドし、初期値もサービスから取得っぽく実装
private IService service;
private ServiceConnection conn = new ServiceConnection() {
 // 略
};

private static final int _MY_BIND_SERVICE = 1;
private static final int _MY_INIT_DATA = 2;
private Handler mHandler = new Handler(){
 @Override
 public void dispatchMessage(Message msg) {
  // TODO 自動生成されたメソッド・スタブ
  switch( msg.what){
  case _MY_BIND_SERVICE:
   removeMessages(_MY_BIND_SERVICE);
   _bind();
   break;
  case _MY_INIT_DATA:
   removeMessages(_MY_INIT_DATA);
   _init_data();
   break;
  default:
   super.dispatchMessage(msg);
  }
 }
};
public void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 mHandler.sendEmptyMessage(_MY_BIND_SERVICE);
 mHandler.sendEmptyMessage(_MY_INIT_DATA);
}

private void _bind(){
 if( service != null){
  return;
 }
 Intent intent = new Intent(IService.class.getName());
 bindService(intent, conn, BIND_AUTO_CREATE);
}

private void _init_data(){
 if(service == null){
  mHandler.sendEmptyMessage(_MY_BIND_SERVICE);
  mHandler.sendEmptyMessageDelayed(_MY_INIT_DATA, 500);
  return;
 }
 service.serviceAPI(...);
 // 略
}



改めて書くとなんと強引な…。
しかもあくまで「onCreate内でやってるっぽい」だけで、普通にonCreateそのものは終わってますから。
そういう意味では完全解決ではありませんね。
removeMessagesを読んでいるのは、処理が重複しないように念のために呼んでいるだけです。

Android アプリの多重起動禁止

相変わらず、マニュアルとか読まないので今回はじめて知ったこと。

Androidにおけるアプリの多重起動禁止方法
多分ソース的にも回避する方法があるような気がするんですが。

少なくとも簡単に調べた結果、Winアプリみたいにmutex的な排他方法とかは見つかりませんでした。

で、肝心の多重起動禁止方法

  • Activity のLaunchモード(launchMode 属性)を変更する
http://developer.android.com/intl/ja/guide/topics/fundamentals.html

起動モードは

  • standard(default)
  • singleTop
  • singleTask
  • singleInstance
とあり、とりあえず下二つのいずれかを選択しておけば多重起動はせずに済みそうです。

簡単な方法で一応確認

適当なアクティビティを一つ作って

onCreate ~ onDestroy を実装し、Logだけ吐くように設定

でもって以下のように動作させる。

起動→ホームボタン→再度アプリをメニューから起動(ホーム長押しではない)

ログ見ればすぐに分かりますが、

  • standard の場合、onStop のログのあとに onCreate が
  • それ以外の場合、onStop のログのあとに onRestart が
当然前者の場合、「戻るボタン」を押して終了したと思いきや、もう一つが現れます。

後者は「戻るボタン」を押せば、デスクトップが表示されます。

singleTop が多重起動しなかったわけは、上記サイトを確認。

というか、ホームボタン長押し→表示されるアプリ一覧から起動でonPause からの復帰というの、今回初めて知ったし・・・。

多分条件次第ではホームボタン長押しからでも多重起動するんじゃないかな?と思います(複数のActivityを抱えるアプリだとありそう)

いやしかし、みんな開発ガイドとか読んでるんかな?

対象の部分が日本語じゃなかったら絶対読んでない・・・。

ついでに言うとなんとなくしか理解していませんがw


2010/01/17 追記 ぐぐってたらこちらのブログの記事でIntentのCategoryとExtraとFlagの一覧表を作られていました。

Intentで呼び出す場合にフラグを考慮すれば、もちろん条件次第でしょうが多重起動は回避できそうです。

勉強になりました。

気がついていなかった

GoogleIME の64bit版出てるし。

さっぱり気がついていなかったwww

Google.cn 撤退確定情報と同時に気がつきました。

2010年1月10日日曜日

Android用のTwitterクライアントを作ってみる2

前回のアプリをもうちょっとのリッチに・・・
っていうか、あんなのじゃアプリというにもおこがましいですよねw
というわけで、どう考えても必要な機能を追加してみました。

ID/Pass の設定

前回のソースを軸に ID等を設定するActivityを作成し、Intentを使用して呼び出します。
ID等の保管には SharedPreferences を使用するので、一度設定すればアプリがアンインストールされるまで「たぶん」記憶されます。
以下のような感じで作成します。

  • 本体Activity(Main.java)
    • アプリ起動
    • ID/Passが設定されているかを確認
    • されてなければ設定画面を呼び出す
    • ID設定Activityの呼び出しは、menuボタン押下時のオプションメニューに追加
    • 後は前回と同じような感じ
  • ID設定Activity(SetID.java)
    • ID/Passの現在設定を取得しEditTextに設定
    • OKが押されたら、ID等を認証確認し問題なければ設定
    • Cancelが押されたら、現在設定のまま終了(ただし現在設定すらない場合はNG)

layout/main.xml

前回から変更なし


layout/setid.xml

ボタン二つとEditText二つ備えたlayoutを作成すればよし。
android:inputType="textPassword" をEditTextに指定すれば、いかにもパスワード入力っぽくなります。


AndroidManifest.xml

アクティビティを増やしたので、追加したものはしっかり記述する。
忘れると実行時に怒られます。
・<activity android:name=".SetID"></activity>


value/strings.xml

文字列をlayoutで設定した場合は、このxmlを編集します。


SetID.java
package com.omokageru.ak.twitter;

import java.io.IOException;

import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;

import android.app.Activity;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

public class SetID extends Activity {

 private String orgid;
 private String orgpass;
 private String tempid;
 private String temppass;

 private EditText idEdit;
 private EditText passEdit;
 private Button saveButton;
 private Button cancelButton;

 /* (非 Javadoc)
  * @see android.app.Activity#onCreate(android.os.Bundle)
  */
 @Override
 protected void onCreate(Bundle savedInstanceState) {
  // TODO 自動生成されたメソッド・スタブ
  super.onCreate(savedInstanceState);
  setContentView(R.layout.setid);

  idEdit = (EditText) findViewById(R.id.setid_edit);
  passEdit = (EditText) findViewById(R.id.setpass_edit);
  saveButton = (Button) findViewById(R.id.button_save);
  cancelButton = (Button) findViewById(R.id.button_cancel);

  // プリファレンスからID/Passを取得
  SharedPreferences preferences = getSharedPreferences( getString(R.string.preferences_name), MODE_PRIVATE);
  orgid = preferences.getString( getString(R.string.preferences_id), "");
  orgpass = preferences.getString( getString(R.string.preferences_pass), "");

  // 初期値の設定
  idEdit.setText(orgid);
  passEdit.setText(orgpass);
  tempid = "";
  temppass = "";

  saveButton.setOnClickListener(new OnClickListener() {

   public void onClick(View v) {
    // TODO 自動生成されたメソッド・スタブ
    tempid = idEdit.getEditableText().toString();
    temppass = passEdit.getEditableText().toString();

    // tempid == null とか tempid == "" だと引っかからなかったので
    if( tempid.length() == 0 || temppass.length() == 0){
     Toast.makeText( SetID.this, "ID / pass の設定がありません", Toast.LENGTH_LONG).show();
     return;
    }

    // id/pass が正しいかを確認
    if( !chkid()){
     Toast.makeText( SetID.this, "ID / pass を見直してください", Toast.LENGTH_LONG).show();
     return;
    }

    // 新しい ID/pass を設定して終了
    SharedPreferences preferences = getSharedPreferences( getString(R.string.preferences_name), MODE_PRIVATE);
    SharedPreferences.Editor editor = preferences.edit();
    editor.putString( getString(R.string.preferences_id), tempid);
    editor.putString( getString(R.string.preferences_pass), temppass);
    editor.commit();
    setResult(RESULT_OK);
    finish();

   }
  });

  cancelButton.setOnClickListener(new OnClickListener() {

   public void onClick(View v) {
    // TODO 自動生成されたメソッド・スタブ
    // cancel といえども、まったく設定のない場合は許可しない
    if( orgid.length() == 0 || orgpass.length() == 0){
     Toast.makeText( SetID.this, "ID / pass の設定がありません", Toast.LENGTH_LONG).show();
     return;
    }
    setResult(RESULT_CANCELED);
    finish();
   }
  });
 }

 private boolean chkid(){
  try {
   DefaultHttpClient httpClient = new DefaultHttpClient();
   Credentials cred = new UsernamePasswordCredentials( tempid, temppass);
   httpClient.getCredentialsProvider().setCredentials( new AuthScope("twitter.com", 80), cred);

   HttpGet get = new HttpGet( "http://twitter.com/account/verify_credentials.json");
   HttpResponse response = httpClient.execute(get);

   // 失敗時の処理
   if(response.getStatusLine().getStatusCode() != HttpStatus.SC_OK){
    return false;
   }

   // 一応セッションを閉じておく
   HttpPost post = new HttpPost( "http://twitter.com/account/end_session.json");
   httpClient.execute(post);

   return true;

  } catch (ClientProtocolException e) {
   // TODO 自動生成された catch ブロック
   Toast.makeText( SetID.this, "予期せぬエラー SetID#chkid ClientProtocolException", Toast.LENGTH_LONG).show();
  } catch (IOException e) {
   // TODO 自動生成された catch ブロック
   Toast.makeText( SetID.this, "予期せぬエラー SetID#chkid IOException", Toast.LENGTH_LONG).show();
  }
  return false;
 }


}

なお、ID/Passの認証のチェックには
http://twitter.com/account/verify_credentials.format
を使用。

「この API は認証情報(BASIC認証で使うユーザ名とパスワードの組み合わせ)が有効かどうかを確認するのに利用することができる」
とありましたので、そのようにしました。

一応認証に成功したら
http://twitter.com/account/end_session.format
を呼び出しています。

TwitterAPIの仕様書を日本語に翻訳してくれている方がいらっしゃいますので、詳細はそちらを
観測気球様 Twitter API 仕様書 (勝手に日本語訳シリーズ)


Main.java
/*
 *
 * HTTPステータスコード(レスポンス)
 * 200 OK:                    成功
 * 304 Not Modified:          新しい情報はない
 * 400 Bad Request:           API の実行回数制限に引っ掛かった、などの理由でリクエストを却下した
 * 401 Not Authorized:        認証失敗
 * 403 Forbidden:             権限がないAPI を実行しようとした(following ではない protected なユーザの情報を取得しようとした、など)
 * 404 Not Found:             存在しない API を実行しようとしたり、存在しないユーザを引数で指定して API を実行しようとした
 * 500 Internal Server Error: Twitter 側で何らかの問題が発生している
 * 502 Bad Gateway:           Twitter のサーバが止まっている、あるいはメンテ中
 * 503 Service Unavailable:   Twitter のサーバの負荷が高すぎて、リクエストを裁き切れない状態になっている
 */

package com.omokageru.ak.twitter;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;

import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;

import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

public class Main extends Activity {

 private Button updateButton;
 private Button reloadButton;
 private EditText updateEdit;
 private WebView webView;

 private DefaultHttpClient httpClient;
 private String strpass;
 private String strid;


 /* (非 Javadoc)
  * @see android.app.Activity#onCreateOptionsMenu(android.view.Menu)
  */
 /**
  * オプションメニューの追加
  */
 @Override
 public boolean onCreateOptionsMenu(Menu menu) {
  // TODO 自動生成されたメソッド・スタブ
  super.onCreateOptionsMenu(menu);

  // 第二引数で設定するItemIDで
  // onMenuItemSelected で判定する
  menu.add( 0, 0, 0,R.string.Set_id_pass);
  return true;
 }

 /* (非 Javadoc)
  * @see android.app.Activity#onMenuItemSelected(int, android.view.MenuItem)
  */
 /**
  * 選択されたオプションメニューの動作
  */
 @Override
 public boolean onMenuItemSelected(int featureId, MenuItem item) {
  // TODO 自動生成されたメソッド・スタブ
  super.onMenuItemSelected(featureId, item);

  switch( item.getItemId()){
  case 0: //R.string.Set_id_pass
   GoSetTwitterIDAndPassActivity();
   break;
  }
  return true;
 }

 /* (非 Javadoc)
  * @see android.app.Activity#onActivityResult(int, int, android.content.Intent)
  */
 /**
  * 子アクティビティからの結果を受け取る
  */
 @Override
 protected void onActivityResult(int requestCode, int resultCode, Intent data) {
  // TODO 自動生成されたメソッド・スタブ
//  super.onActivityResult(requestCode, resultCode, data);
  if (resultCode == RESULT_OK) {
   SetTwitterIDAndPass(false);
  }
 }

 /** Called when the activity is first created. */
 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);

  updateButton = (Button) findViewById(R.id.UpdateButton);

  // ボタン押下時の処理
  updateButton.setOnClickListener(new OnClickListener() {

   public void onClick(View v) {
    // TODO 自動生成されたメソッド・スタブ

    if(!chkid(false)){
     return;
    }

    // 通信を伴うので別スレッドで
    new Thread(){
     @Override
     public void run() {
      // TODO 自動生成されたメソッド・スタブ
      updateButton.setClickable(false);
      reloadButton.setClickable(false);
      String str = updateEdit.getEditableText().toString();
      UpdateTwitter(str);
      reloadButton.setClickable(true);
      updateButton.setClickable(true);
     }
    }.start();
   }
  });

  reloadButton = (Button) findViewById(R.id.ReloadButton);

  reloadButton.setOnClickListener(new OnClickListener() {

   public void onClick(View view) {
    // TODO 自動生成されたメソッド・スタブ

    if(!chkid(false)){
     return;
    }

    // 通信を伴うので別スレッドで
    new Thread(){
     @Override
     public void run() {
      updateButton.setClickable(false);
      reloadButton.setClickable(false);
      GetAndWriteHomeTimeLine();
      reloadButton.setClickable(true);
      updateButton.setClickable(true);
     }
    }.start();
   }
  });

  updateEdit = (EditText) findViewById(R.id.UpdateEdit);
  webView = (WebView) findViewById(R.id.twitter_web);

  SetTwitterIDAndPass(true);
    }

 /**
  * Twitterへつぶやく
  * @param str
  * 投稿内容
  */
 private synchronized void UpdateTwitter( String str){
  try {
   // パラメータstatus=hoge でhogeを発言(必須)
   HttpPost post = new HttpPost( "http://twitter.com/statuses/update.json?status=" + str);
   // 一応これで発言するはず
   HttpResponse response = httpClient.execute(post);

   // 失敗時の処理
   if(response.getStatusLine().getStatusCode() != HttpStatus.SC_OK){
    Toast.makeText( Main.this, "API制限か、Twitter ID/Password が間違っている可能性があります", Toast.LENGTH_LONG).show();
    return;
   }

   // ここからの描画はなぜか利かない・・・
   GetAndWriteHomeTimeLine();

  } catch (ClientProtocolException e) {
   // TODO 自動生成された catch ブロック
   Toast.makeText( Main.this, "予期せぬエラー Main#UpdateTwitter ClientProtocolException", Toast.LENGTH_LONG).show();
  } catch (IOException e) {
   // TODO 自動生成された catch ブロック
   Toast.makeText( Main.this, "予期せぬエラー Main#UpdateTwitter IOException", Toast.LENGTH_LONG).show();
  }

 }

 /**
  * friend_timelineは将来廃止予定らしいので home_timeline を取得し表示。
* http://d.hatena.ne.jp/nowokay/20091030 を参考にしました。 */ private synchronized void GetAndWriteHomeTimeLine(){ try { // webView.clearCache(false); HttpGet get = new HttpGet( "http://twitter.com/statuses/home_timeline.json"); // これで取得するはず HttpResponse response = httpClient.execute(get); // 失敗時の処理 if(response.getStatusLine().getStatusCode() != HttpStatus.SC_OK){ Toast.makeText( Main.this, "API制限か、Twitter ID/Password が間違っている可能性があります", Toast.LENGTH_LONG).show(); return; } //解析と出力 //サーバーからのデータを取得 InputStream is = response.getEntity().getContent(); InputStreamReader isr = new InputStreamReader(is); StringWriter strin = new StringWriter(); BufferedReader buf = new BufferedReader(isr); for(String line; (line = buf.readLine()) != null;){ strin.write(line); } buf.close(); isr.close(); is.close(); //出力準備 StringWriter strout = new StringWriter(); PrintWriter out = new PrintWriter(strout); out.println("<html><head><title>Twitter testapites</title></head>"); out.println("<body>"); //JSONデータからタイムラインを取得してHTMLを生成 JSONTokener token = new JSONTokener(strin.toString()); JSONArray arr = new JSONArray(token); for(int i = 0; i < arr.length(); i++){ JSONObject obj = arr.getJSONObject(i); JSONObject user = obj.getJSONObject("user"); out.println("
"); out.println(""); out.println("" + user.getString("screen_name") + "
" ); out.println(obj.get("text") + "
"); // ここで出力を確認しているが、発言内容が含まれているにもかかわらず、 // update側からはなぜか更新されない。 Log.i("t_test", "" + obj.get("text")); out.println("
"); } out.println("</body></html>"); strin.close(); out.close(); // 二回呼び出すと反映される・・・ // invalidate() の意味は?? webView.loadData(strout.toString(), "text/html", "utf-8"); webView.loadData(strout.toString(), "text/html", "utf-8"); strout.close(); // 描画更新 // 本体スレッドからの呼び出しではない場合に備えpostInvalidateを使用。 // webView.postInvalidate(); // Log.i("t_test", "Invalidate"); } catch (ClientProtocolException e) { // TODO 自動生成された catch ブロック Toast.makeText( Main.this, "予期せぬエラー Main#GetAndWriteHomeTimeLine ClientProtocolException", Toast.LENGTH_LONG).show(); } catch (JSONException e) { // TODO 自動生成された catch ブロック Toast.makeText( Main.this, "予期せぬエラー Main#GetAndWriteHomeTimeLine JSONException", Toast.LENGTH_LONG).show(); } catch (IOException e) { // TODO 自動生成された catch ブロック Toast.makeText( Main.this, "予期せぬエラー Main#GetAndWriteHomeTimeLine IOException", Toast.LENGTH_LONG).show(); } } /** * TwitterのIDとPassを設定する * @param bCreate * 初回起動時はtrueを指定 */ private void SetTwitterIDAndPass( boolean bCreate){ updateButton.setClickable(false); reloadButton.setClickable(false); // プリファレンスからID/Passを取得 SharedPreferences preferences = getSharedPreferences( getString(R.string.preferences_name), MODE_PRIVATE); strid = preferences.getString( getString(R.string.preferences_id), ""); strpass = preferences.getString( getString(R.string.preferences_pass), ""); if(!chkid( bCreate)){ return; } httpClient = new DefaultHttpClient(); Credentials cred = new UsernamePasswordCredentials( strid, strpass); httpClient.getCredentialsProvider().setCredentials( new AuthScope("twitter.com", 80), cred); GetAndWriteHomeTimeLine(); updateButton.setClickable(true); reloadButton.setClickable(true); } /** * Twitter ID/pass が設定されているかを確認 * @param bCreate * 初回起動時はtrueを指定 * @return * boolean値 */ private boolean chkid( boolean bCreate){ if(strid.length() == 0 || strpass.length() == 0){ // 初回起動時にid/passが未設定なら、設定画面を呼び出す if(bCreate){ GoSetTwitterIDAndPassActivity(); } return false; } return true; } /** * Twitter ID/pass を設定するActivityへ移動 */ private void GoSetTwitterIDAndPassActivity(){ try{ Intent intent = new Intent(Main.this, SetID.class); startActivityForResult(intent, 0); } catch( ActivityNotFoundException e){ Toast.makeText( Main.this, "起動先のActivityが見つかりません", Toast.LENGTH_LONG).show(); } } }

前回からちょこちょこっと変更した箇所もありますが、大きくは

  • Intentを使って別のActivityを呼び出す
  • menuボタンを押した時に、オプションメニューが出る
くらいでしょう。

相変わらず、Activityのサイクルを考慮したコードなんか書いていません。
あげく、TwitterAPIの使用回数制限とかも無視しまくり。
流用しよう、なんていう奇特な方が勝手に直してくれることを期待しています。
ってか、いいサンプルあったり、素敵な修正がある場合は教えてくださいw

次は

  • 何分かに一回 home_timeline を取得に行く
  • 変更があったら通知バーに通知する
というようなサービスでも作りますか?

動かすとこんな感じ。

なんかメニュー画面が出るとか、それっぽい感じがします。

初回起動時、IDとpassを設定


とりあえず入力


認証できなかった場合


認証成功すると、前回作成したような画面が


menuボタン押下時
急にそれっぽく見えるw


menuボタンからID設定画面へ
前回設定したidとpasswordが。(もちろんpassword とかは適当なものに打ち直していますよw)


android:inputType="textPassword" を設定している場合。
なんだかそれっぽいw



参考 「Android プログラミング入門」
  • 第二部 3.2.2 サブアクティビティからの応答を受け付ける
  • 第二部 5.1 プリファレンス
  • 第三部 1.3.5 メニュー

2010年1月9日土曜日

Android用のTwitterクライアントを作ってみる

もう猫も杓子もTwitterな昨今いかがお過ごしでしょうか。

というわけですごい単純なTwitterClientを作成したいと思います。
ちなみにこちらの「きしだのはてな」さんのサンプルをす~っごく参考にしました。
http://d.hatena.ne.jp/nowokay/20091030
また、
「ところで、HTMLの最初のほうに変更がないと、loadDataしても画面が更新されないのはなんでなんだぜ?」
と発言されていますが、本当になぜなんだぜ?

ちなみに

  • キャッシュかな?と思い、WebView.clearCache でキャシュをクリアするも意味なし
  • 描画に更新かければいいのかと考え、WebView.postInvalidate()を呼ぶも意味なし
  • WebView.loadData を2回呼ぶと更新される・・・。←とりあえず暫定で採用
  • きしだのはてなさんのサンプルのように、上のほうの文字列を変更する
という状況でした。
わけわかんね。


layout/main.xml



 
  
   
   
  
 

 
 
 



ボタンを2つ並べて表示するために、TableLayoutを使用。
また、2つボタンのサイズを統一するためにlayout_weightの値をそろえる。


AndroidManifest.xml ネットに繋ぐ前提となるので
・<uses-permission android:name="android.permission.INTERNET"></uses-permission>
を追加

Main.java
package com.omokageru.ak.twitter;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;

import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.MultiAutoCompleteTextView.Tokenizer;

public class Main extends Activity {
 private Button updateButton;
 private Button reloadButton;
 private EditText updateEdit;
 private WebView webView;

 private DefaultHttpClient httpClient;

 /** Called when the activity is first created. */
 @Override
 public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);

  updateButton = (Button) findViewById(R.id.UpdateButton);

  // ボタン押下時の処理
  updateButton.setOnClickListener(new OnClickListener() {

   public void onClick(View v) {
    // TODO 自動生成されたメソッド・スタブ

    // 通信を伴うので別スレッドで
    new Thread(){
     @Override
     public void run() {
      // TODO 自動生成されたメソッド・スタブ
      updateButton.setClickable(false);
      reloadButton.setClickable(false);
      String str = updateEdit.getEditableText().toString();
      UpdateTwitter(str);
      reloadButton.setClickable(true);
      updateButton.setClickable(true);
     }
    }.start();
   }
  });

  reloadButton = (Button) findViewById(R.id.ReloadButton);

  reloadButton.setOnClickListener(new OnClickListener() {

   public void onClick(View view) {
    // TODO 自動生成されたメソッド・スタブ

    // 通信を伴うので別スレッドで
    new Thread(){
     @Override
     public void run() {
      updateButton.setClickable(false);
      reloadButton.setClickable(false);
      GetAndWriteHomeTimeLine();
      reloadButton.setClickable(true);
      updateButton.setClickable(true);
     }
    }.start();
   }
  });

  updateEdit = (EditText) findViewById(R.id.UpdateEdit);
  webView = (WebView) findViewById(R.id.twitter_web);

  httpClient = new DefaultHttpClient();
  Credentials cred = new UsernamePasswordCredentials( "Twitter User ID", "Password");//userとpassを
  httpClient.getCredentialsProvider().setCredentials( new AuthScope("twitter.com", 80), cred);

  GetAndWriteHomeTimeLine();
 }

 /**
  * Twitterへつぶやく
  * @param str
  * 投稿内容
  */
 private synchronized void UpdateTwitter( String str){
  try {
   // パラメータstatus=hoge でhogeを発言
   // status は必須パラメータ
   HttpPost post = new HttpPost( "http://twitter.com/statuses/update.json?status=" + str);
   // 一応これで発言するはず
   HttpResponse response = httpClient.execute(post);

   // 失敗時の処理
   if(response.getStatusLine().getStatusCode() != HttpStatus.SC_OK){
    return;
   }

   // ここからの描画はなぜか利かない・・・
   GetAndWriteHomeTimeLine();

  } catch (ClientProtocolException e) {
   // TODO 自動生成された catch ブロック
  } catch (IOException e) {
   // TODO 自動生成された catch ブロック
  }

 }

 /**
  * friend_timelineは将来廃止予定らしいので home_timeline を取得し表示。<br />
  * http://d.hatena.ne.jp/nowokay/20091030 を参考にしました。
  */
 private synchronized void GetAndWriteHomeTimeLine(){
  try {
   //   webView.clearCache(false);

   HttpGet get = new HttpGet( "http://twitter.com/statuses/home_timeline.json");
   // これで取得するはず
   HttpResponse response = httpClient.execute(get);

   // 失敗時の処理
   if(response.getStatusLine().getStatusCode() != HttpStatus.SC_OK){
    return;
   }

   //解析と出力
   //サーバーからのデータを取得
   InputStream is = response.getEntity().getContent();
   InputStreamReader isr = new InputStreamReader(is);
   StringWriter strin = new StringWriter();
   BufferedReader buf = new BufferedReader(isr);
   for(String line; (line = buf.readLine()) != null;){
    strin.write(line);
   }
   buf.close();
   isr.close();
   is.close();


   //出力準備
   StringWriter strout = new StringWriter();
   PrintWriter out = new PrintWriter(strout);
   out.println("<html><head><title>Twitter testapites</title></head>");
   out.println("<body>");
   //JSONデータからタイムラインを取得してHTMLを生成
   JSONTokener token = new JSONTokener(strin.toString());
   JSONArray arr = new JSONArray(token);
   for(int i = 0; i < arr.length(); i++){
    JSONObject obj = arr.getJSONObject(i);
    JSONObject user = obj.getJSONObject("user");
    out.println("<div style='border-bottom: 1px solid #888888'>");
    out.println("<img style='width:48px; height:48px;' src='" + user.get("profile_image_url") + "' align='left'>");
    out.println("<bold><font color='#6666ff'>" + user.getString("screen_name") + "</font></bold><br />" );
    out.println(obj.get("text") + "<br clear='all' />");
    // ここで出力を確認しているが、発言内容が含まれているにもかかわらず、
    // loadData一回ではなぜか更新されない。
    Log.i("t_test", "" + obj.get("text"));
    out.println("</div>");
   }
   out.println("</body></html>");

   strin.close();
   out.close();

   // 二回呼び出すと反映される・・・
   // invalidate() の意味は??
   webView.loadData(strout.toString(), "text/html", "utf-8");
   webView.loadData(strout.toString(), "text/html", "utf-8");


   strout.close();

   // 描画更新
   // 本体スレッドからの呼び出しではない場合に備えpostInvalidateを使用。
   //   webView.postInvalidate();
   //   Log.i("t_test", "Invalidate");


  } catch (ClientProtocolException e) {
   // TODO 自動生成された catch ブロック
  } catch (JSONException e) {
   // TODO 自動生成された catch ブロック
  } catch (IOException e) {
   // TODO 自動生成された catch ブロック
  }

 }
}

一応発言と描画には synchronized をつけているので、重複して呼び出して落ちるってことはないと思いますが、念のため作業中はボタンを無効化しています。
失敗時の処理はめんどいので放置。
こんなソースを参考にする場合は、その辺はきっちりやることをお勧めします。

ちなみにhttp://twitter.com/statuses/home_timeline.json をたたくとこんな感じで返ってきます。
twitter_json_sample.txt


動かすとこんなかんじ。
起動時に取得に行きます。


発言すると、発言がきちんと反映

とりあえずメデタシメデタシ