2014/12/29

Androidで非同期HTTP通信

Androidフレームワークに対する疑問の一つに HttpClient が有る。

通信は非同期で行えと口をすっぱくして言うくせに標準の HttpClient は同期式しか用意されていない。 しかもこいつは connect 処理に対してはキャンセルが効かない。 タイムアウトしか出来ないと言う腐った実装だったりする。

非同期通信ライブラリくらい有るだろうと思って調べてみたら3つほど出てきた。

Apache HttpAsyncClient 4.0

Android で動かすとエラーになる。
Android に入っている Apache HttpCore が古いため。
最新の HttpCore を使うにはパッケージ名を変更して別のライブラリとしてインストールする必要が有るらしい。

ボツ。

loopj Android Asynchronous Http Client

定番ぽい雰囲気。
でも標準の HttpClient をラップしているだけで NIO は使って無い。
つまりスレッドの占有状態は変わらない。
しかも API がいまいちダサい。

俺的にちょっと違う。

koush AndroidAsync

NIO 使って1スレッドで動くと書いてある。おぉ求めていた物。
でもサンプルコードがちょこっと有るだけでドキュメントが無い。
ソースも見てみたがコメントすら書いてない。orz

作りかけなのかドキュメント書くのが嫌いなのかは分からんけどいずれにせよライブラリとしては使えない。

だったら自分で書いてやらぁ

腹が立って来たので自分で実装してみることにした。

  • NIO + Selector で通信スレッドを1つで済ませる。
  • API は極力標準の HttpClient をパクって。
  • レスポンスは InputStream と ByteBuffer を選べる用に。
  • Keep-Alive と Chunked-Transfer をサポート。

ぐらいで。

分かってたけど簡単じゃ無いね NIO は。
全部のクラスが状態遷移マシンですわ。

何とか最低限のテストコードを通せました。

InputStream を受け取る POST のサンプルコード。

クラスが Async~ になるだけでほぼ標準のHttpClientと同じように使える。
InputStream は全文をオンメモリに持つので大きいデータには使えない。 PipedInputStream を使うオプションが有ってもいいかもしれない。
ちなみに execute() は UIスレッドで実行してもエラーにならない。

AsyncHttpClient mClient = new AsyncHttpClient();
private void doSend() {
    AsyncHttpPost request = new AsyncHttpPost("http://192.168.0.2/cgi-bin/log.sh");
    HttpEntity httpEntity = new StringEntity("Test data");
    request.setHttpEntity(httpEntity);

    mClient.execute(request, new AsyncHttpListenerBase() {
        @Override
        public void onResponseBody(HttpResponse httpResponse) {
            if (httpResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                // サーバからエラー
            }
            try {
                InputStream is = httpResponse.getEntity().getContent();
                BufferedReader br = new BufferedReader(new InputStreamReader(is));
                String line;
                while ((line = br.readLine()) != null) {
                    Log.i("DEBUG", "->" + line);
                }
                br.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
}

ByteBuffer を受け取る GET のサンプルコード。

ByteBuffer を直接で無く ByteBufferReader で受け取るのは使い終わった ByteBuffer を返して貰うため。(再利用する)
この辺り利便性と実行効率を両立させるのが難しいです。

Google さんは Chunked で応答して来ますがアプリに渡すときには平文に変換しています。ここが実装上の一番面倒なところ。

それと FileChannel って Selector 使えないのね。はじめて知ったわ。

private void doSend2() {
    AsyncHttpGet request = new AsyncHttpGet("http://www.google.co.jp/");
    mClient.execute(request, new AsyncHttpListenerBase() {
        FileChannel mFileChannel;

        @Override
        public boolean isResponseBodyPart() {
            return true;
        }
        @Override
        public void onResponseBodyPart(ByteBufferReader transporter) {
            try {
                if (mFileChannel == null) {
                    @SuppressWarnings("resource")
                    FileOutputStream file = new FileOutputStream(getFilesDir()+"/index.html");
                    mFileChannel = file.getChannel();
                }

                ByteBuffer buffer = transporter.read();
                if (buffer != null) {
                    while (buffer.hasRemaining()) {
                        if (mFileChannel.write(buffer) == -1) break;
                    }
                } else {
                    mFileChannel.close();
                }
                transporter.release(buffer);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        @Override
        public void onResponseBody(HttpResponse httpResponse) {
            // not called.
        }
    });
}

まとめ

結局現状では loopj で妥協するくらいしか無さそう。

今回作ったライブラリはちゃんとテストを行えば実用になりそうな気がする。
興味のある人はこちらからどうぞ

追記

HTTPSも欲しいなと思って作り始めたらどつぼに嵌ったよ。
JSSE のドキュメントがアバウト過ぎて訳分かんねー。
でも何とか Selector + SSLEngine の組み合わせで動かすことに成功。

需要は無いと思うけどソースはこちら


2014/12/06

PureJavaでAndroidのSQLite

Android で SQLite を使うときに結構冗長になるので何とかならないかと考えてみた。

Android 向けの O/R マッパーもぼちぼち出てきているようだけど そもそも Android でそんなに大規模なテーブル構成を使うことも無いので XML とかでテーブル定義書かされるのもウザかったりする。

なので目指すのは標準の Java だけでシンプルに SQLite を扱う方法。

  • 1つのクラスで閉じている。
    • テーブル生成、挿入、更新、削除、検索ができる。
  • カラム名の定義(テーブル定義)は一ヶ所で済む。
  • 定義したカラム名は eclipse の補間やリファクタが効く。
  • コード量が少なく見通しが効く。
  • 将来的に複雑な SQL が使いたくなっても耐えられる。

以上の要件を満たせる用に考えたテンプレがこれ。

package sample;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class SQLiteSample extends SQLiteOpenHelper {
    static final String DB_NAME = "sample.db";
    static final int VERSION = 100;

    public interface Column {
        public String name();
        public String type();
    }

    // テーブル定義
    private static final String SAMPLE_TABLE = "SAMPLE_TABLE";
    public enum SampleCols implements Column {
        _ID("integer primary key autoincrement"),
        FIRST_NAME("text"),
        SECOND_NAME("text");

        // --- 以下、定形 (enumは継承が出来ないので) ---
        private String mType;
        private String mWhere;

        SampleCols(String type) {
            mType = type;
            mWhere = name() + "=?";
        }
        // @formatter:off
        public String type() {return mType;}
        public String where() {return mWhere;}
        public long getLong(Cursor cursor) {return cursor.getLong(cursor.getColumnIndex(name()));}
        public int getInt(Cursor cursor) {return cursor.getInt(cursor.getColumnIndex(name()));}
        public String getString(Cursor cursor) {return cursor.getString(cursor.getColumnIndex(name()));}
        public void put(ContentValues values, long val) {values.put(name(), val);}
        public void put(ContentValues values, int val) {values.put(name(), val);}
        public void put(ContentValues values, String val) {values.put(name(), val);}
        // @formatter:on
    }
    private SampleBean toBean(Cursor cursor) {
        SampleBean data = new SampleBean(
                SampleCols._ID.getLong(cursor),
                SampleCols.FIRST_NAME.getString(cursor),
                SampleCols.SECOND_NAME.getString(cursor)
                );
        return data;
    }
    private ContentValues fromBean(SampleBean bean) {
        ContentValues values = new ContentValues();
        SampleCols._ID.put(values, bean.getId());
        SampleCols.FIRST_NAME.put(values, bean.getFirstName());
        SampleCols.SECOND_NAME.put(values, bean.getSecondName());
        return values;
    }


    SQLiteSample(Context context) {
        super(context, DB_NAME, null, VERSION);
    }
    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(getCreateTableDDL(SAMPLE_TABLE, SampleCols.values()));
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DELETE TABLE " + SAMPLE_TABLE + ";");
        onCreate(db);
    }

    public SampleBean query(long id) {
        SQLiteDatabase db = getReadableDatabase();
        Cursor cursor = db.query(SAMPLE_TABLE, null, SampleCols._ID.where(), toArgument(id), null,null,null);
        if (!cursor.moveToNext()) return null;
        return toBean(cursor);
    }
    public long insert(SampleBean bean) {
        SQLiteDatabase db = getWritableDatabase();
        long id = db.insert(SAMPLE_TABLE, null, fromBean(bean));
        return id;
    }
    public long update(SampleBean bean) {
        SQLiteDatabase db = getWritableDatabase();
        long id = db.update(SAMPLE_TABLE, fromBean(bean), SampleCols._ID.where(), toArgument(bean.getId()));
        return id;
    }
    public long delete(SampleBean bean) {
        SQLiteDatabase db = getWritableDatabase();
        long id = db.delete(SAMPLE_TABLE, SampleCols._ID.where(), toArgument(bean.getId()));
        return id;
    }

    private String[] toArgument(long id) {
        return  new String[] { Long.toString(id) };
    }
    private String getCreateTableDDL(String table, Column[] columns) {
        StringBuilder sbuf = new StringBuilder();
        sbuf.append("CREATE TABLE ").append(table).append('(');
        for (Column column : columns) {
            sbuf.append(column.name()).append(' ').append(column.type()).append(',');
        }
        sbuf.setLength(sbuf.length() - 1);
        sbuf.append(");");
        return sbuf.toString();
    }
}

enum に継承機能が無いのが辛い。

ちなみに ContentProvider にしている例はこちら


2014/11/23

Androidの画像選択で嵌ったのでメモ

Android 5.0(lolipop)がリリースされたのでインストールしてみた。
体感速度で倍ぐらいになってビビったよ。
で、自作の付箋アプリの動作を確認していたら画像選択で落ちやがったのでその対応メモ。

とりあえず、ログを見るとこれで落ちてた。

android.content.ActivityNotFoundException: No Activity found to handle Intent 
{ act=android.intent.action.PICK typ=image/* }

Intent.ACTION_PICK image/* はシステムの画像選択を呼び出すはずだけど無くなったのか?
ググったら KitKat からは Intent.ACTION_OPEN_DOCUMENT に変わったらしい。
でも KitKat 以前は Intent.ACTION_PICK のままである必要がある。うへ

とりあえず、これでどっちでも画像選択が起動するようになったよ。

@TargetApi(Build.VERSION_CODES.KITKAT)
public static void startChoosePicture(Activity context, int code) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        intent.setType("image/*");
        context.startActivityForResult(intent, code);
    } else {
        Intent intent = new Intent(Intent.ACTION_PICK);
        intent.setAction(Intent.ACTION_GET_CONTENT);
        intent.setType("image/*");
        context.startActivityForResult(intent, code);
    }
}

これでOKかと思ったら甘かったね。
OSを再起動すると選択した画像が表示されない。 ログを見たらこんなエラーが出てた。

java.lang.SecurityException: Permission Denial: opening provider com.android.providers.media.MediaDocumentsProvider 
from ProcessRecord{2787cc3a 22044:org.kotemaru.android.postit/u0a109} (pid=22044, uid=10109) 
requires android.permission.MANAGE_DOCUMENTS or android.permission.MANAGE_DOCUMENTS

単純に MANAGE_DOCUMENTS をパーミッションに加えても解決しない。
ググったら見つかったよ。

takePersistableUriPermission()で永続的パーミッションを得ないと再起動したら見えなくなるのか。
そんなの見落とすよ、API考えろよGoogle。

画像URI取得にこれをはさんだらうまく行った。

@TargetApi(Build.VERSION_CODES.KITKAT)
public static Uri getResultChoosePictureUri(Context context, int requestCode, int resultCode, Intent returnedIntent) {
    if (resultCode != Activity.RESULT_OK) return null;
    Uri uri = returnedIntent.getData();
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        final int takeFlags = returnedIntent.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION;
        context.getContentResolver().takePersistableUriPermission(uri, takeFlags);
    }
    return uri;
}

Androidはバージョン間差異が細かすぎるよ。


2014/11/09

Android SDKでjavadoc生成

Android の開発環境で eclipse から javadoc を生成しようとすると例外が出たり文字化けしたりでうまく行かない。

単純に eclipse のメニューから実行するとこうなる。

java.lang.ClassCastException: com.sun.tools.javadoc.ClassDocImpl cannot be cast to com.sun.javadoc.AnnotationTypeDoc

android.jar を bootclasspath に入れたりする必要が有り結局、 専用の build.xml 作って ant で動かすのが一番面倒が無さそう。

忘れると思うのでメモっとく。

build-javadoc.xml:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project default="javadoc">
    <property file="local.properties" />
    <property file="project.properties" />

    <target name="javadoc">
        <javadoc access="private" 
            additionalparam="-encoding UTF-8 -charset UTF-8 "
            bootclasspath="${sdk.dir}/platforms/${target}/android.jar" 
            classpath="libs/android-support-v4.jar"
            destdir="doc" 
            source="1.6" 
            sourcepath="src" >
            <packageset dir="src" defaultexcludes="yes">
                <include name="{パッケージパス}/**" />
            </packageset>
            <link href="file:/${sdk.dir}/docs/reference" />
        </javadoc>
    </target>
</project>

local.properties:

sdk.dir={android-sdksのパス}

project.properties:

target=android-{APIレベル}

2014/11/03

GooglePlayにAndroidアプリ上げてみた

はじめてアプリを Google Play に上げてみました。
作ったのは 普通 の「付箋」アプリ。 なんで作ったかと言うと以外に 普通 の「付箋」アプリって無いんですよね。

ここで 普通 と言っているのは 忘れちゃいけないことをモニタの横に張っておいて、その件が済んだらゴミ箱へ って言う付箋の使い方の出来るアプリ。

具体的には

  1. ホーム画面の好きな所に貼れる。アイコンの上にも。
  2. アプリを起動しなくてもホーム画面に表示されてる。(嫌でも目に付く)
  3. 不要になったらはがせる。(枠も表示されない)
  4. 当然、他のアプリが起動したら表示されない。

だけなんだけど、これだけの事が出来るアプリが無い。
何で出来ないかは実装してみて技術的な問題が色々有る事が分かったけどね。

そんなこんなで完成したのがこのアプリです。

ホーム画面の上に邪魔臭く陣取ります。
ドラッグ&ドロップでゴミ箱にポイできます。
イメージ通りです。

と言うわけで Google Play に登録しました。 Google Play はこちら

検索してみます。

orz
「付箋」で 132 位、「post it」で 56 位と言う惨憺たる結果に。
インストールしてもらえる気がしないw。ライバル多すぎですね。

雑感

実際 Google Play に登録してみて分かったのは登録画面用の画像リソース等が以外に多いこと。
512x512px の高解像度アイコンとか、1024x500px のバナー画像とかが必須項目になっています。

得にアプリを端的に表す「バナー画像」なんて絵心のないプログラマーにはハードル高すぎます。
本気で白画面でごまかそうかと思ったのですが Web でフリーの写真を見つけて加工の許可を頂いて登録する事ができました。

有料アプリは開発者の住所公開も必須になったようだし個人開発者は追い出したいんでしょうかね。


2014/04/30

AndroidのGCMでニコ生アラートを受信してみる

ニコニコ生放送の通知が携帯のキャリアメールにしか届かないので Android で受け取る方法を調べてみた。

ニコ生アラート API

PCアプリ用のニコニコのサーバから生放送開始を受け取る WebAPI があるようです。

ざっくり説明すると

  1. http://live.nicovideo.jp/api/getalertinfo でサーバの IPアドレス と ポート番号 が XML で取れる。
  2. IPアドレス:ポート番号 に生ソケットで繋げて リクエストXML を1つ投げると生放送の開始が XML で落ちてくる。
    • a) ソケットは繋ぎっぱなしで生放送の開始は PUSH で落ちてくる。
    • b) ログインしても全放送の開始が PUSH されてくる。
  3. https://secure.nicovideo.jp/secure/login?site=nicolive_antenna でログインして
  4. http://live.nicovideo.jp/api/getalertstatus でユーザの参加コミュニティを取ってくる。
  5. 2 で PUSH されてくるデータを自前でフィルタリング。

となります。

ここで問題が1つ発生。
全放送の情報が落ちてくると10件/秒くらいのペースとなり、 Android にバックグラウンド処理させるのは無理が有りすぎます。

実際、Google Play 上がっている非公式のニコ生アラートのアプリは電池があっという間に無くなるとコメントが入っていました。

サーバを使う

放送開始の受信はサーバで行うしか無いと言うのが結論です。
幸い、家には 24時間稼働の RasipberryPi が有るのでここで受信とフィルタリングを行い必要な通知のみ GCM メッセージで Android に送ります。

Android アプリは GCM メッセージを受信した時に Android の通知に変換するサービスだけとなるので電池を消耗しません。

実装してみる

得に技術的に新しい事はしていないのでソースの解説とかははしょります。

ソースのSVNだけ置いておきます。

新しいトピックとしては GCM のサーバがバグっていてアプリのアンインストールに 対応できていないことが分かりました。(2014年4月現在)

どういう事かと言うとAndroid側でアプリがアンインストールされてもサーバにそれが通知されない為、 自動でユーザ登録の削除等が行えません。
一度、修正されたようですがその後ロールバックされた状態のようです。

もう一つ、GCM のメッセージは大きく遅延する事が有るのが分かりました。 通常は数秒で届きますが時々数分の遅延が発生します。 最大20分の遅延を確認しています。

実行結果

ユーザ登録します。

ちゃんと参加中のコミュニティの生放送の通知が届きました。

タップすると生放送のURLを起動するのでニコ動の公式アプリがインストールされていればそのまま生放送が見れます。

試して見たい方用に野良ビルドのアプリを上げて置きます。(ご利用は自己責任で)

デフォルトの設定で我が家の RaspberryPi に接続されるのでサーバは不要です。
※アンインストール前には必ず登録解除を行ってください。
※サーバは予告無く停止する事が有ります。ご了承ください。

所感

iPhone のニコ動公式アプリでは通知がサポートされているようなので Android でもいずれサポートされると思いますが 取り合えず今必要だったので作ってみました。


2014/04/25

AndroidのGoogleMapsAPIを試してみた

Android の Google Maps API v2 を試してみました。

API Key の取得

Google のサービスを使うときには付き物のサービスの有効化と API Key を取得します。

プロジェクトの作成の説明ははしょります。

プロジェクトに入ってメニューから「APIs」を選択し「Google maps Android API v2」を有効にします。

メニューから「Cedentails」を選択し「CREATE NEW KEY」をクリックします。

「Android Key」をクリックします。

テスト用なら fingerprint は空でも良いようです。 本番用はちゃんと記述しましょう。
「Create」をクリックすれば API Key が生成されます。

API Key をコピーして控えて置きます。



ライブラリの準備

SDK Manager から extra の 「Google Play services」 をインストールします。

Google Play services を eclipse のプロジェクトにインポートします。
※ jar ファイルのみ持ってくると x86 環境で ClassNotFound が発生するようです。

インポート元は「Existing Android Code into workspace」を選択します。
ローカルファイルを選択しないようにしてください。

プロジェクトのディレクトリは結構深い所にあります。

念のため workspace にコピーは有効にします。

開発プロジェクトのライブラリとして指定します。

開発の準備完了です。

開発コード

公式のサンプルは ${android-sdks}\extras\google\googleplayservices\samples\maps に大量に有ります。

Manifest

パーミッションは記述してある物は基本的に全部必要です。

meta-data 2つは必須です。
API_KEY には先に取得した API Key を指定してください。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.kotemaru.android.gmaptest"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="16" />

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES" />
    <!-- External storage for caching. -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> 
    <!-- My Location -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <meta-data
            android:name="com.google.android.maps.v2.API_KEY"
            android:value="{Google Developer Console で取得したAPI Key}" />
        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />

        <activity
            android:name="org.kotemaru.android.gmaptest.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

レイアウト

基本的に SupportMapFragment を使うようです。
GMapView を使うサンプルも有りましたが面倒なだけでした。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <fragment
        android:id="@+id/map"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        class="com.google.android.gms.maps.SupportMapFragment" />

</RelativeLayout>

ソース

最低限必要となりそうな 初期位置の設定、GPS連動、マーカーの設置 をやってみました。

package org.kotemaru.android.gmaptest;

import android.location.Location;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.widget.Toast;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesClient.ConnectionCallbacks;
import com.google.android.gms.common.GooglePlayServicesClient.OnConnectionFailedListener;
import com.google.android.gms.location.LocationClient;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.GoogleMap.OnMarkerClickListener;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;

public class MainActivity extends FragmentActivity {
    private GoogleMap gMap;
    private LocationClient locationClient;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        setUpMapIfNeeded(); // サンプルに従っているがここでも呼ぶ意味は良く分からない。
    }

    @Override
    protected void onResume() {
        super.onResume();
        setUpMapIfNeeded();
        setUpLocation(true);
    }

    // ---------------------------------------------------------------------
    // マップ初期化処理
    private void setUpMapIfNeeded() {
        if (gMap == null) {
            gMap = ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map))
                    .getMap();
            if (gMap != null) {
                setUpMap();
            }
        }
    }
    private void setUpMap() {
        // 初期座標、拡大率設定
        LatLng latLng = new LatLng(35.684699, 139.753897);
        float zoom = 13; // 2.0~21.0
        gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, zoom));

        // マーカー設置
        gMap.addMarker(new MarkerOptions().position(latLng).title("皇居"));
        gMap.setOnMarkerClickListener(new OnMarkerClickListener() {
            public boolean onMarkerClick(Marker marker) {
                // この marker は保存するとリークすると思われる。
                String msg = "Marker onClick:" + marker.getTitle();
                Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
                return false;
            }
        });
    }

    // --------------------------------------------------------------------------------
    // 以下、GPS連動の設定。
    private void setUpLocation(boolean isManual) {
        if (isManual) {
            // 画面右上にGPSボタンが表示される。
            // タップすると現在地への移動までかってにやってくれる。
            gMap.setMyLocationEnabled(true);
        } else {
            // 現在地を定期的に取得する設定。
            if (locationClient == null) {
                locationClient = new LocationClient(
                        getApplicationContext(),
                        connectionCallbacks,
                        onConnectionFailedListener);
                locationClient.connect();
            }
        }
    }

    ConnectionCallbacks connectionCallbacks = new ConnectionCallbacks() {
        private final LocationRequest locationRequest = LocationRequest.create()
                .setInterval(5000)         // 5 seconds
                .setFastestInterval(5000)
                .setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        @Override
        public void onConnected(Bundle arg0) {
            locationClient.requestLocationUpdates(locationRequest, locationListener);
        }
        @Override
        public void onDisconnected() {
            // nop.
        }
    };
    OnConnectionFailedListener onConnectionFailedListener = new OnConnectionFailedListener() {
        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            // nop.;
        }
    };
    LocationListener locationListener = new LocationListener() {
        @Override
        public void onLocationChanged(Location location) {
            LatLng latLng = new LatLng(location.getLatitude(), location.getLongitude());
            float zoom = 20;
            gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, zoom));
        }
    };
}

GPS連動の所以外はかなり簡単なコードで済んでいます。

マーカーの扱い方がちょっと特殊な感じですがおそらく画面上に表示されていない マーカーをオンメモリにさせない為と思われます。 出典を見失いましたが Marker インスタンスをハンドラの外で使うなと言う記述をどこかで見ました。

実行結果

実機でGPSの動作も確認できています。

感想

JavaScript でやった時とだいぶ違う感じ。(JavaScriptはv3だったけど)

最初 MarkerOptions と Marker の関係が理解できなくて悩んだ。 マーカーに限らずレイヤーの表示は全てこの形態になる様子。

Google Map は機能が膨大なのでAPIを全部把握して置くのは無理そうw


2014/04/12

Android で顔認識を試してみた

本当は顔認証をやりたかったのだけど現状では無理っぽいので とりあえず顔認識まで試してみた。

Camera.FaceDetection を使う方法

Level 14 から追加された API でカメラのプレビュー中に顔を認識してくれる機能があります。

但し、この機能はハードウェア依存らしく機種によって使えたり使えなかったりです。 さらに、顔の各パーツの認識機能も有りますが同様に機種依存です。

手持ちの機種での動作状況です。

  • Nexus5 : 顔認識=○、顔のパーツ=×
  • Nexus7(2012) : 顔認識=×、顔のパーツ=×

顔のパーツの位置がとれれば自前で認証機能を作る事も可能だったのですが顔の位置だけではどうしようもありません。

実装方法は通常のカメラプレビューに以下を追加するだけです。

camera.setFaceDetectionListener(new Camera.FaceDetectionListener(){
    @Override
    public void onFaceDetection(Camera.Face[] faces, Camera camera) {
        // 顔データ処理
    }
});
camera.startFaceDetection();

Camera.Face には以下のフィールドが有りますが Nexus5 で取得できたのは rect と score のみです。

フィールド名説明(API Doc より)
id An unique id per face while the face is visible to the tracker.
leftEyeThe coordinates of the center of the left eye.
rightEyeThe coordinates of the center of the right eye.
mouth The coordinates of the center of the mouth.
rect Bounds of the face.
score The confidence level for the detection of the face.

rect の座標系は特殊なため SurfaceView に書き込むには座標変換が必要になります。

  • プレビュー画像に対し -1000~1000 の相対座標である。
  • 座標(-1000,-1000)が左上、座標(0,0) が画像中心となる。
  • 座標系のプレビュー画像はlandscapeとなる。portraitの場合は90度回転が必要。

score は 50 以上なら高精度だそうです。

サンプル・アプリ

取得できた顔の矩形をプレビューに被せて表示するだけのアプリです。

package org.kotemaru.android.facetest;

import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.hardware.Camera.Face;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class FaceTest1Activity extends Activity {
    private static final String TAG = "FaceTest";

    private Camera camera;
    private SurfaceView preview;
    private SurfaceView overlay;
    private CameraListener cameraListener;
    private OverlayListener overlayListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_facetest);

        preview = (SurfaceView) findViewById(R.id.preview);
        cameraListener = new CameraListener(preview);

        overlay = (SurfaceView) findViewById(R.id.overlay);
        overlayListener = new OverlayListener(overlay);
    }
    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        preview.getHolder().addCallback(cameraListener);
        overlay.getHolder().addCallback(overlayListener);
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            Camera.FaceDetectionListener
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        public CameraListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            try {
                int cameraId = -1;
                // フロントカメラを探す。
                Camera.CameraInfo info = new Camera.CameraInfo();
                for (int id = 0; id < Camera.getNumberOfCameras(); id++) {
                    Camera.getCameraInfo(id, info);
                    if (info.facing == CameraInfo.CAMERA_FACING_FRONT) {
                        cameraId = id;
                        break;
                    }
                }
                camera = Camera.open(cameraId);
                camera.setPreviewDisplay(holder);
                camera.getParameters().setPreviewFpsRange(1, 20);
                camera.setDisplayOrientation(90); // portrate 固定
                // 顔認証機能サポートチェック。
                if (camera.getParameters().getMaxNumDetectedFaces() == 0) {
                    throw new Error("Not supported face detected.");
                }
            } catch (Exception e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {
            surfaceHolder = holder;
            camera.startPreview();
            camera.setFaceDetectionListener(cameraListener);
            camera.startFaceDetection();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.setFaceDetectionListener(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onFaceDetection(Face[] faces, Camera camera) {
            if (faces.length == 0) return;
            Face face = faces[0];
            if (face.score < 30) return;

            overlayListener.drawFace(faceRect2PixelRect(face), Color.RED);
        }

        /**
         * 顔認識範囲を描画用に座標変換する。
         * - Face.rect の座標系はプレビュー画像に対し -1000~1000 の相対座標。
         * - 座標(-1000,-1000)が左上、座標(0,0) が画像中心となる。
         * - 座標系のプレビュー画像はlandscapeとなる。portraitの場合が90度回転が必要。
         * @param face 顔認識情報
         * @return 描画用矩形範囲
         */
        private Rect faceRect2PixelRect(Face face) {
            int w = surfaceView.getWidth();
            int h = surfaceView.getHeight();
            Rect rect = new Rect();

            // フロントカメラなので左右反転、portraitなので座標軸反転
            rect.left = w * (-face.rect.top + 1000) / 2000;
            rect.right = w * (-face.rect.bottom + 1000) / 2000;
            rect.top = h * (-face.rect.left + 1000) / 2000;
            rect.bottom = h * (-face.rect.right + 1000) / 2000;
            //Log.d(TAG, "rect=" + face.rect + "=>" + rect);
            return rect;
        }

    }

    private class OverlayListener implements SurfaceHolder.Callback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        private Paint paint = new Paint();

        public OverlayListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            surfaceHolder.setFormat(PixelFormat.TRANSPARENT);
            paint.setStyle(Style.STROKE);
            paint.setStrokeWidth(surfaceView.getWidth() / 100);
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            surfaceHolder = holder;
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            // nop.
        }

        public void drawFace(Rect rect1, int color) {
            try {
                Canvas canvas = surfaceHolder.lockCanvas();
                if (canvas != null) {
                    try {
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        paint.setColor(color);
                        canvas.drawRect(rect1, paint);
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas);
                    }
                }
            } catch (IllegalArgumentException e) {
                Log.w(TAG, e.toString());
            }
        }

    }
}

実行結果

実行結果はこんな感じになりました。


顔の矩形はほぼリアルタイムにプレビューに追従します。

FaceDetector を使う方法

Camera.FaceDetection は機種依存なので API Level-1 からある FaceDetector を使って同じ事をしてみます。

FaceDetector は低速なのでリアルタイム性は犠牲になります。

FaceDetector の使い方はこれだけです。

FaceDetector faceDetector = new FaceDetector(image.getWidth(), image.getHeight(), MAX_FACE);
FaceDetector.Face[] faces = new FaceDetector.Face[MAX_FACE];
int n = faceDetector.findFaces(image, faces);

プレビューで得た画像を渡せば顔の位置が得られます。

渡す画像は RGB_565 形式で顔が正立の状態でなければなりません。
面倒なのはプレビュー画像のデータは YUV420 形式の landscape 固定なので画像変換処理が必要となる事です。

FaceDetector.Face には以下のメソッドが有ります。 厳密には顔では無く目の位置が取得できると言うことになります。

メソッド名説明(API Doc より)
confidence() Returns a confidence factor between 0 and 1.
getMidPoint(PointF point) Sets the position of the mid-point between the eyes.
eyesDistance() Returns the distance between the eyes.
pose(int euler) Returns the face's pose.

座標系は画像と同じなのでそのまま使用できます。

サンプル・アプリ

取得できた目の位置をプレビューに被せて表示するだけのアプリです。

package org.kotemaru.android.facetest;

import java.util.List;

import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.media.FaceDetector;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class FaceTest2Activity extends Activity {
    private static final String TAG = "FaceTest";

    private Camera camera;
    private SurfaceView preview;
    private SurfaceView overlay;
    private CameraListener cameraListener;
    private OverlayListener overlayListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_facetest);

        preview = (SurfaceView) findViewById(R.id.preview);
        cameraListener = new CameraListener(preview);

        overlay = (SurfaceView) findViewById(R.id.overlay);
        overlayListener = new OverlayListener(overlay);
    }
    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        preview.getHolder().addCallback(cameraListener);
        overlay.getHolder().addCallback(overlayListener);
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            Camera.PreviewCallback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;
        private Rect faceRect = new Rect();;

        public CameraListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            try {
                int cameraId = -1;
                Camera.CameraInfo info = new Camera.CameraInfo();
                for (int id = 0; id < Camera.getNumberOfCameras(); id++) {
                    Camera.getCameraInfo(id, info);
                    if (info.facing == CameraInfo.CAMERA_FACING_FRONT) {
                        cameraId = id;
                        break;
                    }
                }
                camera = Camera.open(cameraId);
                camera.setPreviewDisplay(holder);
                List<Camera.Size> sizes = camera.getParameters().getSupportedPreviewSizes();
                camera.getParameters().setPreviewSize(sizes.get(0).width, sizes.get(0).height);
                camera.getParameters().setPreviewFpsRange(1, 20);
                camera.setDisplayOrientation(90); // portrate 固定
            } catch (Exception e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {
            surfaceHolder = holder;
            camera.setPreviewCallback(this);
            camera.startPreview();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.setPreviewCallback(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onPreviewFrame(byte[] data, Camera camera) {
            Bitmap image = decodePreview(data);

            FaceDetector faceDetector = new FaceDetector(image.getWidth(), image.getHeight(), 1);
            FaceDetector.Face[] faces = new FaceDetector.Face[1];
            int n = faceDetector.findFaces(image, faces);

            if (n>0) {
                PointF midPoint = new PointF(0, 0);
                faces[0].getMidPoint(midPoint); // 顔認識結果を取得
                float eyesDistance = faces[0].eyesDistance(); // 顔認識結果を取得
                faceRect.left = (int) (midPoint.x - eyesDistance * 0.8 );
                faceRect.right = (int) (midPoint.x + eyesDistance * 0.8 );
                faceRect.top = (int) (midPoint.y - eyesDistance * 0.2);
                faceRect.bottom = (int) (midPoint.y + eyesDistance * 0.2 );
            }
            overlayListener.drawFace(faceRect, Color.YELLOW, image);
        }


        private int[] rgb;
        private Bitmap tmpImage ;
        private Bitmap decodePreview(byte[] data) {
            int width = camera.getParameters().getPreviewSize().width;
            int height = camera.getParameters().getPreviewSize().height;
            if (rgb == null) {
                rgb = new int[width*height];
                tmpImage = Bitmap.createBitmap(height ,width , Bitmap.Config.RGB_565);
            }

            decodeYUV420SP(rgb, data, width, height);
            tmpImage.setPixels(rgb, 0, height, 0, 0, height, width);
            return tmpImage;
        }

    }

    private class OverlayListener implements SurfaceHolder.Callback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        private Paint paint = new Paint();

        public OverlayListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            surfaceHolder.setFormat(PixelFormat.TRANSPARENT);
            paint.setStyle(Style.STROKE);
            paint.setStrokeWidth(1);
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            surfaceHolder = holder;
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            // nop.
        }

        public void drawFace(Rect rect1, int color, Bitmap previewImage) {
            try {
                Canvas canvas = surfaceHolder.lockCanvas();
                if (canvas != null) {
                    try {
                        //canvas.drawBitmap(previewImage,0,0, paint);
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        canvas.scale(
                                (float)surfaceView.getWidth()/previewImage.getWidth(), 
                                (float)surfaceView.getHeight()/previewImage.getHeight());
                        paint.setColor(color);
                        canvas.drawRect(rect1, paint);
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas);
                    }
                }
            } catch (IllegalArgumentException e) {
                Log.w(TAG, e.toString());
            }
        }

    }

    // from https://code.google.com/p/android/issues/detail?id=823
    private void decodeYUV420SP(int[] rgb, byte[] yuv420sp, int width, int height) {
        final int frameSize = width * height;

        for (int j = 0; j < height; j++) {
            int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
            for (int i = 0; i < width; i++) {
                int srcp = j*width + i;
                int y = (0xff & ((int) yuv420sp[srcp])) - 16;
                if (y < 0) y = 0;
                if ((i & 1) == 0) {
                    v = (0xff & yuv420sp[uvp++]) - 128;
                    u = (0xff & yuv420sp[uvp++]) - 128;
                }

                int y1192 = 1192 * y;
                int r = (y1192 + 1634 * v);
                int g = (y1192 - 833 * v - 400 * u);
                int b = (y1192 + 2066 * u);

                if (r < 0) r = 0; else if (r > 262143) r = 262143;
                if (g < 0) g = 0; else if (g > 262143) g = 262143;
                if (b < 0) b = 0; else if (b > 262143) b = 262143;

                // 90度回転
                int xx = height-j-1;
                int yy = width-i-1;
                int dstp = yy * height + xx;
                rgb[dstp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
            }
        }
    }
}

実行結果

Nexus7 での実行結果はこんな感じになりました。


思った以上にプレビューに追従してきます。
計ってませんが 5fps くらいは出ている感じで十分実用的です。 プレビュー画像の解像度を最小の 176x144 に指定しているためだと思われます。 (指定できる解像度は機種依存です。)

所感

FaceDetector でもプレビューに耐えられる事が分かったのは大きな収穫。

OpenCV を使って詳細な情報が取れれば自前で顔認証を実装することも不可能じゃ無さそう。


プロフィール
20年勤めた会社がリーマンショックで消滅、紆余曲折を経て現在はフリーランスのSE。 失業をきっかけにこのブログを始める。

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿【android】

リンク

アーカイブ