2011年6月1日

AndroidでURLを開く度に自作のアプリを起動する (4) HTTP通信を行ってタイトルを取得する



前回まででSQLiteデータベースに履歴一覧を保存出来る様になり、少しは本格的(?)にアプリらしくなって来た。

今回はURLからWebページのタイトルを取得して表示する様に変更したい。

スレッドを使って非同期にHTTP通信を行ない、処理結果をListViewに反映すると言う、このブログの流れからすると今までで最高レベルの難易度(笑)になる内容だ。実際、作っていて「あ、そうだったのか」と気付かされる事が多くとても勉強になった。プログラミングは実際に手を動かして試行錯誤しながら習得するに限ると改めて感じた。


前回までの画面

今回の画面
URLだけでなくページのタイトルも表示
すると一気に実用的(な感じ)になった。
ついでにListViewの各行の背景にグラ
デーションを使ってみた。




URLからHTMLを取得する


まずはHTTP通信でURLが示すWebページにアクセスしてHTML文字列を取得する部分だ。HttpUtilというクラスをプロジェクトに追加して、そこに色々書いて行く事にした。

そのHttpUtilクラスに作ったgetHtmlメソッドはこんな感じになった。

public static String getHtml(String url){
        String result = null;
        
        HttpGet httpGet = new HttpGet(url);
        DefaultHttpClient client = new DefaultHttpClient();
        HttpParams httpParams = client.getParams();
        
        HttpConnectionParams.setConnectionTimeout(httpParams, 1000 * 10);   //接続のタイムアウト(ms)
        HttpConnectionParams.setSoTimeout(httpParams, 1000 * 60);           //データ取得のタイムアウト(ms)
        client.setParams(httpParams);

        try {
            // レスポンスを取得
            HttpResponse httpResponse = client.execute(httpGet);
            int status = httpResponse.getStatusLine().getStatusCode();

            if (HttpStatus.SC_OK == status){
                //Content-Typeを取得
                Header[] headers = httpResponse.getHeaders("Content-Type");
                if (headers.length > 0) { 
                    String contentType = "";
                    contentType = headers[0].getValue();
                    if (contentType.contains("text/html")){

                        //HTMLを取得
                        HttpEntity entity = httpResponse.getEntity();
                        if (entity != null){
                            byte[] arr = EntityUtils.toByteArray(entity);
                            
                            //文字エンコーディングを判定
                            String encoding = detectEncoding(arr);
                            if (encoding == null) encoding = findEncoding(entity, arr);
                            
                            //判定されたエンコーディングでバイト配列から文字列に変換
                            result = new String(arr, encoding);
                            
                            //entityのリソースを解放
                            entity.consumeContent();            
                        }
                    }
                }
            }
        
        } catch (ClientProtocolException e) {
            Log.d(TAG, e.getMessage());
        } catch (IOException e) {
            Log.d(TAG, e.getMessage());
        } finally {
            //HTTPクライアントを終了させる
            client.getConnectionManager().shutdown();
        }
        
        return result;
    }


予想もしなかった部分でつまずいた。文字エンコーディングの判別だった。

SDK標準のDefaultHttpClientクラスが上手く処理してくれるのかと思っていたらそうではなく、取得したバイト配列を文字列に変換する部分は自分でやらないと行けない様だ。

最初は自前でResponseヘッダのContent-Typeを見たりmetaタグのcharsetの値を見たりして、なんとか上手く動くようにはなったのだが、それでもたまに表示したタイトルが文字化けしている事があった。なぜだろうと該当のWebページのソースを表示して見ると、metaタグでの指定はEUCなのに実際にはUTF-8でエンコーディングされている、などという様にResponseでの指定内容と実際のエンコーディングが異なっているケースがある事が分かった。

結局、Responseに含まれているエンコーディング指定は完全には信用出来ないのだ。ガーン!

それで色々調べた挙句、「juniversalchardet.jar」というライブラリを利用させてもらう事にした。
juniversalchardet - Java port of universalchardet - Google Project Hosting

juniversalchardet - Javaについて

このライブラリを使っている部分はこんな感じ。
private static String detectEncoding(byte[] arr){
        byte[] buf = new byte[4096];
        ByteArrayInputStream stream = new ByteArrayInputStream(arr);
        UniversalDetector detector = new UniversalDetector(null);

        try {
            int nread;
            while ((nread = stream.read(buf)) > 0 && !detector.isDone()) {
                detector.handleData(buf, 0, nread);
            }
        } catch (IOException e) {
            Log.d(TAG, e.getMessage());
        }
        detector.dataEnd();
        return detector.getDetectedCharset();
    }


2011/06/02 追記:
下のページではjuniversalchardetについて「デコード時の文字コードがEUC-JPだった場合に、判定に失敗する可能性が非常に高い。」と書かれているので、要注意。
エンコード時の文字コードが不明なURLをJavaでデコード | grush-blog




HTMLからタイトルを抜き出す


HTMLが取得出来たら、次は<title>タグの始まりと終わりを探してその間の文字列を抜き出せばOKだ。

正規表現を使えば楽勝だ、と思っていたら、これも意外と手こずってしまった。

単純に
<title>タイトルの文字列</title>
という形になっていれば問題無いのだが、
<title id='aaa'>タイトルの文字列</title>
などの様に開始タグに属性が付いている事もある。

それに、
<title>(改行)
タイトルの(改行)
文字列(改行)
</title>
みたいにtitleタグの中に改行が入っているケースもある。正規表現は行単位に処理される(と思う)ので、開始タグと終了タグの間に改行が入ってしまうと途端に話がややこしくなる。(多分。)

改行については最初に除去してから処理すればいいかと思ってやってみたけれども、そうすると文字列が長い場合に大量にCPUを使って戻って来なくなったりするので簡単には行かなそうだった。

そんなこんなで結局正規表現を使うのは止めて indexOf を駆使(?)して自前で抜き出す事にした。

本当はDOMを操作するライブラリなどを使えばこの辺を簡単にしてくれるメソッドが用意されているのかも知れない。

実際のタイトル抽出部分のコードがどうなったかは、こちらから参照可能だ。javaの達人であればもっとキレイに書けると思うので、ちょっと恥ずかしい。

それから、HTML文字列に数値文字参照(例:&#65374;→ 「〜」)が含まれている場合や、実体参照(例:&gt;→「>」)が含まれている場合もあったので、タイトルを抜き出した後それぞれ変換処理をかませるようにしておいた。

単にHTMLからタイトルを抜き出すだけの処理なのに、結構色々あるものだ。

とここまで書いて今また一つ気付いてしまった。 コメントアウトされたtitleタグへの対応だ。
<!-- <title>コメントアウトされたタイトル</title>-->
<title>抽出して欲しいタイトル</title>
というパターンになっていると今回のコードだとコメントアウトされた方のタイトルを抜き出してしまう。こう言うケースにも対応しようと思うと、やはり何らかのDOMライブラリを使う方がいいのかも知れない。




全ソースと次の目標


今回のソースでHTTP通信の処理は別スレッドで行う様になっている。Androidプログラミングでは必須とも言えるマルチスレッド化については、長くなりそうなので別のエントリで書こうと思う。

ダウンロードはこちらから。
mikehibm/android-browser-intent03 at intent04 - GitHub


AndroidでURLを開く度に自作のアプリを起動する







.

EclipseでAndroidのプログラムを実行しようとして変なxml.outファイルが出来てしまう場合

EclipseでAndroidの開発をしている時にすごく気になる事があった。

CTRL+F11で「実行」のはずが、何も起こらずに、代わりによく見ると変なファイルが出来ているのだ。

エディタでXMLファイルを開いている場合に発生する。

javaのソースを開いている場合は問題ないので、CTRL+F6を押してjavaのソースファイルを開いた状態にしてから実行する様に気を付けていたのだが、急いでいる時はついつい忘れてしまって、不便な事この上ない。

その解決方法が今日やっと見つかった。
C/J Prog's Blog: EclipseでF11を押すとAndroidManifest.xml.outが生成されるときの対処
- EclipseにADT PluginとWTP Pluginをインストールしている
- Androidプロジェクトで作業している
- xmlファイルを開いている
- (Ctrl+)F11を押す

これはEclipse WTPの機能で、xmlファイルに対して Run As > XSL Transformation を実行したことになるため。

以下の設定を変更すればOK。

Window > Preferences > Run/Debug > Launching を開き、
"Always launch the previously launched application' in the 'Launch Operation' section."
のラジオボタンを選択する。

との事。助かりました。

これですっきりした。ww







.

2011年5月20日

AndroidでURLを開く度に自作のアプリを起動する (3) SQLiteデータベースを使う



先日のBloggerの障害の影響でこの前書いたエントリが消えてしまった。「ほぼ全てのデータが復旧された」との事だったが、僕が先週の金曜日に朝6時に起きて1時間半もかけて書いた文章はとうとう戻って来なかった。(涙)
Googleの「Blogger」が20時間半に及ぶサービス障害、週末にほぼ復旧 - ニュース:ITpro
やっぱりブログ記事もどこかにバックアップしておいた方が良いのだろうか。と言ってもローカルに保存すればハードディスクのクラッシュや記録メディアの紛失が心配だし、別のクラウドサービスに保存するにしても結局データが消えてしまうリスクは無くならない。考え出すと切りがない。。。

気を取り直して、部分的に残っていた下書きからもう一度書き直す事にした。


さて、


前回までで「ブラウザで表示したURLの履歴をメールで送信する」という目的は一応達成出来た。

ただしこの状態だと、何らかの理由でこのアプリのプロセスが終了させられた場合に履歴の一覧が消えてしまうという問題がある。単純に他のアプリに隠れて画面の裏に回っただけではすぐにプロセスが終了する事はないのだけれども、他のアプリがメモリを大量に使用して空きメモリが不足した場合や、単に長い時間プロセスがアクティブにならなかった場合などにシステムの判断で終了させられる事になる。

今回はプロセスが終了しても履歴の一覧を保持出来る様に、SQLiteのデータベースに保存する様に変更したい。

データベースを開く


AndroidでのSQLiteデータベースの扱い方は大体下のリンク先を見れば把握出来る。
Androidアプリのデータ保存方法の一つ「SQLite」の使い方 SQLiteOpenHelper編 | mucchinのAndroid戦記

Androidアプリでのデータベース基礎 ~速習! Androidアプリケーション開発(4)~(1/3):CodeZine

データを簡単に保存する方法(SQLite編) « Tech Booster
データベースを新規に作成するのではなく、予めアプリのリソースとして組み込んでおいたものをコピーする方法はこちら。
Y.A.M の 雑記帳: Android あらかじめ作成した SQLite database をアプリに取り込む

今回は1テーブルしか使わないので、HistoryDbというクラスを作ってその中でデータベースに関する操作をすべて行う事にしようと思う。もし複数のテーブルがある場合は、データベース全体を表すクラスと各テーブルに対応したクラスに分けた方が多分すっきりするだろうと思う。

とりあえずまずは init() メソッドを作って、最初に必ずこれを呼び出してデータベースが無ければ作成する様にした。
public static void init(String pkgName) throws Exception{
    DbPath = "/data/data/" + pkgName + "/" + DBNAME;
    SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(DbPath, null);
    try {
        //テーブルが無ければ作成する。
        createTable(db);
    } catch (Exception e) {
        throw e;
    } finally{
        db.close();
    }
}
 
private static void createTable(SQLiteDatabase db) {
    String sql = "CREATE TABLE IF NOT EXISTS " + TBL_HISTORY
  + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT, title TEXT);";
    db.execSQL(sql);

    //urlにインデックスを追加。
    sql = "CREATE INDEX IF NOT EXISTS 'main'.'ix_history_url' ON 'history' ('url' ASC)";
    db.execSQL(sql);
}



データベースから一覧を取得する


HistoryDbクラスに selectAll() というメソッドを作ってその中で全件を取得する。取得した結果をループして、1レコード分の情報をHistoryDbクラスのインスタンスのプロパティ(正確には手抜きしたのでパブリックなメンバー変数)にセットし、ArrayListに追加して行く。
public static ArrayList selectAll() throws Exception{
    ArrayList array = new ArrayList();
  
    SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(DbPath, null);
    try {
        //全件を取得する。
        String[] columns = {"_id", "url", "title"};
        String where = null;
        String having = null;
        String group_by = null;
        String order_by = "_id DESC";
  
        Cursor cursor = db.query(TBL_HISTORY, columns, where, null, group_by, having, order_by);
        while (cursor.moveToNext()){
            HistoryDb hist = new HistoryDb();
            hist.id = cursor.getInt(cursor.getColumnIndex("_id"));
            hist.url = cursor.getString(cursor.getColumnIndex("url"));
            hist.title = cursor.getString(cursor.getColumnIndex("title"));
            array.add(hist);
        }
    } catch (Exception e) {
        throw e;
    } finally{
        db.close();
    }
    return array;
}


結果のArrayListを受け取ったActivity側では、ArrayListの情報をまたループしてListViewにバインドされた adapter に入れ直している。ちょっと冗長なやり方になってしまった。
//DBから全件取得。
ArrayList array = HistoryDb.selectAll();

//ListViewに表示。
adapter.clear();
for (HistoryDb hist : array) {
    adapter.add(hist.url);
}




データベースに書き込む


HistoryDbクラスに save() メソッドを作って保存処理を書く。既にDBに存在しているかどうかは本来は主キーである「_id」の値で判断するべきだが、今回はURLでも一意になるのでURLを検索キーとして使った。まずUPDATEを実行してその結果が0件であればINSERTを行うというロジックになっている。
public static void save(String url, String title) throws Exception {
    SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(DbPath, null);
    try {
        //同じURLが既にあれば更新する。無ければ挿入する。
        insertOrUpdateHistory(db, url, title);
    } catch (Exception e) {
        throw e;
    } finally{
        db.close();
    }
}
 
private static void insertOrUpdateHistory(SQLiteDatabase db, String url, String title) {
    ContentValues values = new ContentValues();
    values.put("url", url);
    values.put("title", title);

    String[] args = { url };

    //同じURLが既に保存されていれば更新する。
    int n = db.update(TBL_HISTORY, values, "url = ?", args );
    if (n == 0){
        //無ければ挿入する。
        db.insert(TBL_HISTORY, null, values);
    }
}




メニューにアイコンを付ける


メニューにアイコンを付けて見た。すると一気に見栄えが良くなった。アイコン一つで、不思議なものだ。アイコンの指定方法は、メニューを定義しているXMLファイル内のitemタグの属性として、
android:icon="@android:drawable/ic_menu_XXX"
を付けるだけだ。


Androidに標準で含まれているアイコンの一覧は、こちらで調べられる。
Taosoftware: Android メニューアイコン



今回の全ソース


今回の全ソースはこちらからダウンロード出来る様になっている。
mikehibm/android-browser-intent03 - GitHub

さて、ここまででデータベースに履歴一覧を保存出来る様になり、少しはアプリケーションらしくなって来た。

せっかくここまで来たら、一覧にURLだけでなくページのタイトルも表示したくなって来た。タイトルはインテントでは飛んで来ないので、アプリ内でHTTP通信を行って取得する必要がありそうだ。それから、ページがリダイレクトされた場合にリダイレクト元とリダイレクト先の両方のURLが履歴として残ってしまうという問題も、出来ればなんとかしたい。

さて、上手く行くかどうか、それは次回のお楽しみという事で。


AndroidでURLを開く度に自作のアプリを起動する







.