2014/02/13

AndroidでWebViewとNativeのハイブリッドアプリ

最近、WebViewを使ったWebとNativeのハイブリッドアプリが流行っている。

iPhoneとAndroidで同一アプリを開発する場合、Web部分が共通化できるメリットが大きいからだろう。

と言うわけで WebView を使ったハイブリッドアプリの作り方を調べてみた。

iPhoneってどうしてるの?

私は iPhone はやってません。(だって開発するだけで金取られるんだもんw)
とは言え iPhone との互換性を無視しては意味ないのでやり方だけは調べて置きます。

ググると一杯出てきますが、通信のリクエストをフックして Native のコードで WebAPI を擬似的に実装するようです。

JavaScriptとの接続方法としては無理が無く良い方法だと思われます。

一方、Android は...

WebView#addJavascriptInterface() を使って Java のオブジェクトを登録すると JavaScript からそのまま使えると言う直接的な方法です。

が、これ結構致命的なセキュリホールが報告されています。
JavaScript から getClass() 等も呼べてしまうため、 WebView に信頼できないサイトの JavaScript が紛れ込むと何でも出来てしまいます。

スマホの運用条件を考えると PKI でサーバを固め、 アプリも XSS に細心の注意を払う必要がありそうです。
現実的にはかなりハードル高いっすね。

但し、API Level 17 (4.2.2) からは @JavascriptInterface アノテーションが追加されメソッドのアクセス制限ができる用になったので この問題は解決されています。
って、最近過ぎるだろw

それはそれとして使ってみる

最近のブラウザはカメラやバイブレーションの API も最初から持っていて以外に追加機能のネタが無かったりします。

なのでクロスドメイン可能な XMLHttpRequestXS とか追加してさらにセキュリティホールを広げて見たいと思います。

Javaオブジェクトの登録

基本ドキュメントの通りですが登録しているのはファクトリです。 ファクトリから得た Java オブジェクトも JavaScript から使えます。

登録処理抜粋:

private class SampleWebViewClient extends WebViewClient {
    @Override
    public void onPageStarted (WebView webview, String url, Bitmap favicon) {
        // 拡張XMLHttpRequestファクトリの初期化。
        XMLHttpRequestXSFactory factory = getXMLHttpRequestXSFactory();
        factory.setAccessControlList(_accessControlList);
        factory.setWebView(webview);
        webview.addJavascriptInterface(factory, "XMLHttpRequestXSFactory");
    }
    …省略
}

登録されるクラス抜粋:

public class XMLHttpRequestXSFactory {
    private WebView _webview;
    private AccessControlList _accessControlList;
    public XMLHttpRequestXSFactory() { }
    @JavascriptInterface
    public XMLHttpRequestXS getXMLHttpRequestXS() {
        return new XMLHttpRequestXS(this);
    }
    …省略
}

登録名に abc.XMLHttpRequestXSFactory とかしてみましたがダメでした。 navigator.~ もダメです。 グローバルの直下のみに登録できるようです。

登録のタイミングは onCreate() だけで無く WebViewClient#onPageStarted() にも必要なようです。

JavaScript から Java の呼び出し

登録されたJavaオブジェクトの呼び出しはほぼそのままです。 但し、フィールドにはアクセス出来ません。

JavaScript抜粋:

function XMLHttpRequestXS() {
    this._native = XMLHttpRequestXSFactory.getXMLHttpRequestXS();
    …省略
};
XMLHttpRequestXS.prototype = {
    open : function(method, url, async) {
        var error = this._native.open(method, url, async);
        if (error) throw error;
    },
    …省略
}

呼ばれ側Java抜粋:

public class XMLHttpRequestXS {
    …省略
    @JavascriptInterface
    public String open(String type, String url, boolean isAsync) throws Exception {
        Log.d(TAG,"open:"+type+" "+url);
        try {
            _isAsync = isAsync;
            if (GET.equalsIgnoreCase(type)) {
                _request = new HttpGet(url);
            } else {
                _request = new HttpPost(url);
            }
            _factory.checkDomain(_request.getURI());

            String cookie = CookieManager.getInstance().getCookie(url);
            _request.setHeader("Cookie", cookie);
            setReadyState(OPENED);
            return null;
        } catch (Throwable t) {
            Log.e(TAG, t.getMessage(), t);
            setReadyState(ERROR);
            return t.getMessage();
        }
    }
    …省略
}

ここで気になったのはメソッドへの引数です。 ドキュメントには JS -> Java の変換ルールが見つけられませんでした。

実際に試した方の情報では String,int,double,boolean,int[],String[] が受け取れたようです。

とりあえず、プリミティブまでは大丈夫そうな気がします。
JSON変換できるオブジェクトならこんな感じで渡せるようです。

Android.test(JSON.stringify({abc:"ABC", yyy:2}));

@JavascriptInterface
public void test(String jsonStr) throws JSONException {
    JSONObject json = new JSONObject(jsonStr);
    String abc = json.getString("abc");
    int yyy = json.getInt("yyy");
}

Java から JavaScript の呼び出し

ドキュメントの通り以下で呼び出せます。

webview.loadUrl("javascript:スクリプト");

が、JS->Java->JSと呼び出すと例外になります。

02-13 04:35:39.382: W/webview(3503): java.lang.Throwable: Warning: 
A WebView method was called on thread 'WebViewCoreThread'.
All WebView methods must be called on the UI thread.
Future versions of WebView may not support use on other threads.

JavaScript は WebView のスレッドで走っているので UIスレッドから呼べ、 と言うことらしいです。

つまり、JavaScriptから呼び出されたメソッドからコールバックしようとする場合、 HandlerかAsyncTaskを経由する必要があるようです。

Java から JavaScript オブジェクトの生成

どうも無理そうです。
JavaScript自体がJavaで実装されて無いと思われるので難しいのでしょう。

XMLやJSONは文字列で渡して JavaScript 側でパーズしてもらう事になりそうです。

動かしてみる

完成した XMLHttpRequestXS を jQuery.ajax() で実行してみます。

HTML:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <script type="text/javascript" src="XMLHttpRequestXS.js" ></script>
    <script type="text/javascript" src="jquery-1.11.0.js" ></script>
    <script type="text/javascript">
jQuery.support.cors = true; // クロスドメインをjQueryでするために必要。

function testJqueryAsync() {
    console.log("testJqueryAsync");
    $.ajax({
        type: "GET", url: "http://www.redmine.org/issues.json",
        dataType: "json",
        success: function(data){
            var issues = data.issues;
            var $table = $("#table");
            for (var i=0; i<issues.length;i++) {
                $table.append($("<tr><td>"+issues[i].id+"</td><td>"+issues[i].subject+"</td></tr>"));
            }
        },
        error: function(xhr, status, error){
            alert("error:"+status+":"+error);
        },
        xhr: function() { // jQueryが使うXHRの差替用API
            return new XMLHttpRequestXS();
        }
    });
}
    </script>
</head>
<body onload="testJqueryAsync()">
    <h3>Ajax result from http://www.redmine.org/issues.json</h3>
    <table id="table" width="100%" border="1" >
        <tr><th>ID</th><th>Subject</th></tr>
    </table>
</body>
</html>

jQuery には XMLHttpRequest 差替え用の API が最初から付いてました。 さすが jQuery です。

実行結果:

外部サイトからちゃんとデータを取って来ています。

所感

一見単純そうに見えてやってみると以外に泥沼w
iPhoneとの共通化は表面を JavaScript の API にしてスタブを2種類用意する感じでしょうか。

課題も多いですが Andorid/iPhone のコード共通化やWebからアプリが更新できるメリットは大きいですね。

PhoneGap や Titanium を使う手もありますが HTML5 の機能拡張が著しいので足りない機能だけ Native で実装すると言う方がフレームワークの縛りが無い分楽かもしれません。

サンプルのソースコード一式は以下のSVNにあります。
注意:XMLHttpRequestXSはXSSのセキュリティホールを持ちます。技術検証以上の利用はしないで下さい。


2013/10/30

AndroidとPCのBluetooth接続のサンプル

そういえば Bluetooth ってやったとこが無いので Android と PC の接続を試してみることにした。

Bluetooth プログラミングの基礎知識

これが分かってなくてちょっと苦労しました。

  • 各Bluetooth機器は固有のMACアドレスを持っている。
    • 6byte。NICのMACアドレスとは別採番。
  • Bluetooth機器内の各サービスは固有のUUIDを持っている。
    • 規定のUUID一覧
    • 独自プロトコルの場合はツールで生成したUUIDで良い。
      • サーバとクライアントが認識していれば良いので衝突とかは考えなくてよい。
  • Bluetoothは非公開にできる。
    • PCやAndroidはデフォルト非公開になっている。
    • 非公開で有ってもペアリング済みなら接続できる。
    • ペアリング未でも公開されていれば検索して接続できる。

android.bluetooth と JSR82

Java で Bluetooth を扱う場合には JSR82 という仕様が存在するのですが Android は独自の API を使います。

つまり、PC と Ancdoid で API を使い分ける必要があると言うことです。orz
#まぁUSBの時はJavaですら無かった訳ですが。

AndroidのAPIはいつもお世話になる「勝手に翻訳」さんのサイトに有ります。

JSR82 の使い方は整理されているところが見つからずexampleを見ながら対応しました。
JSR82 のオープンな実装は以下のプロジェクトが存在します。

現時点(2013/10)での正式リリース版は Win7/x64 に対応していません。
私は bluecore-2.1.1-SNAPSHOT.jar を使いました。

jar の中には DLL とかも入っています。

サーバ側の実装

PCをサーバ側とします。
このクラス1つだけで JSR82 の実装です。 ドキュメントが見つからず example を頼りに調べたので不正確かもしれません。

RfcommServer.java

  • 重要なのは Connecror.open() だけで後の作りはほぼソケットを使ったサーバと同じです。
  • 引数のURL
    • プロトコル:ベースのプロトコルを指定。RFCOMMの場合は「btspp:」。一覧表はどこに有るか不明。
    • MACアドレス:サーバ側は「localhost」に固定。
    • UUID:適当にツールで生成します。
  • サービスをデバイスに登録して置くとクライアントがサービス一覧でUUIDを取得できます。
    • Androidはペアリング済みのデバイスで無いとサービス(UUID)を取得できませんでした。
    • 登録しなくでも接続は可能な様子。
  • 一応、スレッドで同時接続可能にしてあるが実際にできるかは未確認です。

package org.kotemaru.sample.bluetooth;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;

import javax.bluetooth.LocalDevice;
import javax.bluetooth.ServiceRecord;
import javax.microedition.io.Connector;
import javax.microedition.io.StreamConnection;
import javax.microedition.io.StreamConnectionNotifier;

/**
 * 英大文字変換エコーバック Bluetooth サーバ。
 */
public class RfcommServer {
    /**
     * UUIDは独自プロトコルのサービスの場合は固有に生成する。
     * - 各種ツールで生成する。(ほぼ乱数)
     * - 注:このまま使わないように。
     */
    static final String serverUUID = "11111111111111111111111111111123";

    private StreamConnectionNotifier server = null;

    public RfcommServer() throws IOException {
        // RFCOMMベースのサーバの開始。
        // - btspp:は PRCOMM 用なのでベースプロトコルによって変わる。
        server = (StreamConnectionNotifier) Connector.open(
                "btspp://localhost:" + serverUUID,
                Connector.READ_WRITE, true
        );
        // ローカルデバイスにサービスを登録。必須ではない。
        ServiceRecord record = LocalDevice.getLocalDevice().getRecord(server);
        LocalDevice.getLocalDevice().updateRecord(record);
    }

    /**
     * クライアントからの接続待ち。
     * @return 接続されたたセッションを返す。
     */
    public Session accept() throws IOException {
        log("Accept");
        StreamConnection channel = server.acceptAndOpen();
        log("Connect");
        return new Session(channel);
    }
    public void dispose() {
        log("Dispose");
        if (server  != null) try {server.close();} catch (Exception e) {/*ignore*/}
    }

    /**
     * セッション。
     * - 並列にセッションを晴れるかは試していない。
     * - 基本的に Socket と同じ。
     */
    static class Session implements Runnable {
        private StreamConnection channel = null;
        private InputStream btIn = null;
        private OutputStream btOut = null;

        public Session(StreamConnection channel) throws IOException {
            this.channel = channel;
            this.btIn = channel.openInputStream();
            this.btOut = channel.openOutputStream();
        }

        /**
         * 英小文字の受信データを英大文字にしてエコーバックする。
         * - 入力が空なら終了。
         */
        public void run() {
            try {
                byte[] buff = new byte[512];
                int n = 0;
                while ((n = btIn.read(buff)) > 0) {
                    String data = new String(buff, 0, n);
                    log("Receive:"+data);
                    btOut.write(data.toUpperCase().getBytes());
                    btOut.flush();
                }
            } catch (Throwable t) {
                t.printStackTrace();
            } finally {
                close();
            }
        }
        public void close() {
            log("Session Close");
            if (btIn    != null) try {btIn.close();} catch (Exception e) {/*ignore*/}
            if (btOut   != null) try {btOut.close();} catch (Exception e) {/*ignore*/}
            if (channel != null) try {channel.close();} catch (Exception e) {/*ignore*/}
        }
    }

    //------------------------------------------------------
    public static void main(String[] args) throws Exception {
        RfcommServer server = new RfcommServer();
        while (true) {
            Session session = server.accept();
            new Thread(session).start();
        }
        //server.dispose();
    }
    private static void log(String msg) {
        System.out.println("["+(new Date()) + "] " + msg);
    }
}

クライアント側の実装

Androidがクライアント側になります。
今回はペアリング済みのデバイスとの接続なのでデバイスの検索はしません。
検索はBluetooth実装の肝だったりするのですが以下の理由からはしょりました。

  • アプリで実装しても設定画面のペアリング処理とほぼ同じになる。
  • AndroidからPCを検索するにはPC側を一時的に公開設定にする必要があり接続毎は非実用的。
    • PCを公開しっ放しはセキュリティ上の問題から不可。

ManifestAndroid.xml

デフォルトにパーミッションを追加しただけです。

<uses-permission android:name="android.permission.BLUETOOTH"></uses-permission>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"></uses-permission>

ADMINは今回は使っていませんがデバイスの検索で必要になるので入れといた方が良いです。

MainActivity.java

  • Blurtooth の機能は BluetoothTask.java に実装しているので BluetoothTask を呼んでいる所以外は普通のActivityです。
package org.kotemaru.android.sample.bluetooth;

import java.util.Set;

import android.os.Bundle;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.bluetooth.BluetoothDevice;
import android.content.DialogInterface;
import android.content.Intent;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.EditText;

public class MainActivity extends Activity {
    private final static int DEVICES_DIALOG = 1;
    private final static int ERROR_DIALOG = 2;

    private BluetoothTask bluetoothTask = new BluetoothTask(this);

    private ProgressDialog waitDialog;
    private EditText editText1;
    private EditText editText2;
    private String errorMessage = "";

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

        editText1 = (EditText) findViewById(R.id.editText1);
        editText2 = (EditText) findViewById(R.id.editText2);

        Button sendBtn = (Button) findViewById(R.id.sendBtn);
        sendBtn.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                String msg = editText1.getText().toString();
                bluetoothTask.doSend(msg);
            }
        });
        Button resetBtn = (Button) findViewById(R.id.resetBtn);
        resetBtn.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                restart();
            }
        });
    }

    @SuppressWarnings("deprecation")
    @Override
    protected void onResume() {
        super.onResume();
        // Bluetooth初期化
        bluetoothTask.init();
        // ペアリング済みデバイスの一覧を表示してユーザに選ばせる。
        showDialog(DEVICES_DIALOG);
    }

    @Override
    protected void onDestroy() {
        bluetoothTask.doClose();
        super.onDestroy();
    }

    public void doSetResultText(String text) {
        editText2.setText(text);
    }

    protected void restart() {
        Intent intent = this.getIntent();
        this.finish();
        this.startActivity(intent);
    }

    //----------------------------------------------------------------
    // 以下、ダイアログ関連
    @Override
    protected Dialog onCreateDialog(int id) {
        if (id == DEVICES_DIALOG) return createDevicesDialog();
        if (id == ERROR_DIALOG) return createErrorDialog();
        return null;
    }
    @SuppressWarnings("deprecation")
    @Override
    protected void onPrepareDialog(int id, Dialog dialog) {
        if (id == ERROR_DIALOG) {
            ((AlertDialog) dialog).setMessage(errorMessage);
        }
        super.onPrepareDialog(id, dialog);
    }

    public Dialog createDevicesDialog() {
        AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this);
        alertDialogBuilder.setTitle("Select device");

        // ペアリング済みデバイスをダイアログのリストに設定する。
        Set<BluetoothDevice> pairedDevices = bluetoothTask.getPairedDevices();
        final BluetoothDevice[] devices = pairedDevices.toArray(new BluetoothDevice[0]);
        String[] items = new String[devices.length];
        for (int i=0;i<devices.length;i++) {
            items[i] = devices[i].getName();
        }

        alertDialogBuilder.setItems(items, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                dialog.dismiss();
                // 選択されたデバイスを通知する。そのまま接続開始。
                bluetoothTask.doConnect(devices[which]);
            }
        });
        alertDialogBuilder.setCancelable(false);
        return alertDialogBuilder.create();
    }

    @SuppressWarnings("deprecation")
    public void errorDialog(String msg) {
        if (this.isFinishing()) return;
        this.errorMessage = msg;
        this.showDialog(ERROR_DIALOG);
    }
    public Dialog createErrorDialog() {
        AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this);
        alertDialogBuilder.setTitle("Error");
        alertDialogBuilder.setMessage("");
        alertDialogBuilder.setPositiveButton("Exit", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                dialog.dismiss();
                finish();
            }
        });
        return alertDialogBuilder.create();
    }

    public void showWaitDialog(String msg) {
        if (waitDialog == null) {
            waitDialog = new ProgressDialog(this);
        }
        waitDialog.setMessage(msg);
        waitDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
        waitDialog.show();
    }
    public void hideWaitDialog() {
        waitDialog.dismiss();
    }
}

BluetoothTask.java

Bluetoothのメイン処理です。

  • デバイス検索を行わないBluetoothの最小構成の手順はこれだけです。
1. bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
2. Set devices = bluetoothAdapter.getBondedDevices();
3. bluetoothDevice = devicesから選択;
4. bluetoothSocket = bluetoothDevice.createRfcommSocketToServiceRecord(APP_UUID);
5. bluetoothSocket.connect();
6. btIn = bluetoothSocket.getInputStream();
7. btOut = bluetoothSocket.getOutputStream();
8. btIn.read()/btOut.write()
9. bluetoothSocket.close();
  • ただし、connect()以降は通信が発生するためすべて非同期処理とする必要があります。
  • Bluetoothの使用許可が無い場合にユーザに許可を求める処理ははしょってます。

package org.kotemaru.android.sample.bluetooth;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Set;
import java.util.UUID;

import android.os.AsyncTask;
import android.os.ParcelUuid;
import android.util.Log;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;

public class BluetoothTask {
    private static final String TAG = "BluetoothTask";

    /**
     * UUIDはサーバと一致している必要がある。
     * - 独自サービスのUUIDはツールで生成する。(ほぼ乱数)
     * - 注:このまま使わないように。
     */
    private static final UUID APP_UUID = UUID.fromString("11111111-1111-1111-1111-111111111123");

    private MainActivity activity;
    private BluetoothAdapter bluetoothAdapter;
    private BluetoothDevice bluetoothDevice = null;
    private BluetoothSocket bluetoothSocket;
    private InputStream btIn;
    private OutputStream btOut;

    public BluetoothTask(MainActivity activity) {
        this.activity = activity;
    }

    /**
     * Bluetoothの初期化。
     */
    public void init() {
        // BTアダプタ取得。取れなければBT未実装デバイス。
        bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (bluetoothAdapter == null) {
            activity.errorDialog("This device is not implement Bluetooth.");
            return;
        }
        // BTが設定で有効になっているかチェック。
        if (!bluetoothAdapter.isEnabled()) {
            // TODO: ユーザに許可を求める処理。
            activity.errorDialog("This device is disabled Bluetooth.");
            return;
        }
    }
    /**
     * @return ペアリング済みのデバイス一覧を返す。デバイス選択ダイアログ用。
     */
    public Set<BluetoothDevice> getPairedDevices() {
        return bluetoothAdapter.getBondedDevices();
    }

    /**
     * 非同期で指定されたデバイスの接続を開始する。
     * - 選択ダイアログから選択されたデバイスを設定される。
     * @param device 選択デバイス
     */
    public void doConnect(BluetoothDevice device) {
        bluetoothDevice = device;
        try {
            bluetoothSocket = bluetoothDevice.createRfcommSocketToServiceRecord(APP_UUID);
            new ConnectTask().execute();
        } catch (IOException e) {
            Log.e(TAG,e.toString(),e);
            activity.errorDialog(e.toString());
        }
    }

    /**
     * 非同期でBluetoothの接続を閉じる。
     */
    public void doClose() {
        new CloseTask().execute();
    }

    /**
     * 非同期でメッセージの送受信を行う。
     * @param msg 送信メッセージ.
     */
    public void doSend(String msg) {
        new SendTask().execute(msg);
    }

    /**
     * Bluetoothと接続を開始する非同期タスク。
     * - 時間がかかる場合があるのでProcessDialogを表示する。
     * - 双方向のストリームを開くところまで。
     */
    private class ConnectTask extends AsyncTask<Void, Void, Object> {
        @Override
        protected void onPreExecute() {
            activity.showWaitDialog("Connect Bluetooth Device.");
        }

        @Override
        protected Object doInBackground(Void... params) {
            try {
                bluetoothSocket.connect();
                btIn = bluetoothSocket.getInputStream();
                btOut = bluetoothSocket.getOutputStream();
            } catch (Throwable t) {
                doClose();
                return t;
            }
            return null;
        }

        @Override
        protected void onPostExecute(Object result) {
            if (result instanceof Throwable) {
                Log.e(TAG,result.toString(),(Throwable)result);
                activity.errorDialog(result.toString());
            } else {
                activity.hideWaitDialog();
            }
        }
    }

    /**
     * Bluetoothと接続を終了する非同期タスク。
     * - 不要かも知れないが念のため非同期にしている。
     */
    private class CloseTask extends AsyncTask<Void, Void, Object> {
        @Override
        protected Object doInBackground(Void... params) {
            try {
                try{btOut.close();}catch(Throwable t){/*ignore*/}
                try{btIn.close();}catch(Throwable t){/*ignore*/}
                bluetoothSocket.close();
            } catch (Throwable t) {
                return t;
            }
            return null;
        }

        @Override
        protected void onPostExecute(Object result) {
            if (result instanceof Throwable) {
                Log.e(TAG,result.toString(),(Throwable)result);
                activity.errorDialog(result.toString());
            }
        }
    }

    /**
     * サーバとメッセージの送受信を行う非同期タスク。
     * - 英小文字の文字列を送ると英大文字で戻ってくる。
     * - 戻ってきた文字列を下段のTextViewに反映する。
     */
    private class SendTask extends AsyncTask<String, Void, Object> {
        @Override
        protected Object doInBackground(String... params) {
            try {
                btOut.write(params[0].getBytes());
                btOut.flush();

                byte[] buff = new byte[512];
                int len = btIn.read(buff); // TODO:ループして読み込み

                return new String(buff, 0, len);
            } catch (Throwable t) {
                doClose();
                return t;
            }
        }

        @Override
        protected void onPostExecute(Object result) {
            if (result instanceof Exception) {
                Log.e(TAG,result.toString(),(Throwable)result);
                activity.errorDialog(result.toString());
            } else {
                // 結果を画面に反映。
                activity.doSetResultText(result.toString());
            }
        }
    }
}

実行

ペアリング

まずPCとAndroidのペアリングが必要です。1回やればOKです。

(1) PCの Bluetoothの設定で「このコンピュータの検出を許可する」に設定します。

  • 注:ペアリング終了後、必ず非公開に戻してください。

(2) AndroidのBluetoothの設定で「デバイス検出」をタップして暫く待つとPCが検出されるのでタップします。

  • U24E は私のPCに設定した名前です。当然、お使いのPCの名前が表示されます。

(3) PCに許可を求めるポップアップが出るのでクリックします。

(4) 確認画面が出るので「次へ」をクリックします。

(5) Androidにダイアログが出ているので「ペアを設定する」をタップします。

  • パスキーは本来(4)の画面と一致します。キャプチャの都合です。

(6) PCとAndroidでそれぞれデバイスが認識されていればOKです。

サーバの起動

RfcommServer.java を eclipse から実行します。 Consoleのログに Accept と出ていればOKです。

クライアント実行

アプリを起動するとペアリング済みデバイスの一覧がでるのでPCをタップします。

上の TextField に英小文字を入れて「Send」をタップすると下の TextField に英大文字でエコーバックされればOKです。

雑感

MACアドレスをIPアドレス、UUIDをポート番号と考えれば殆どソケットと同じです。 RFCOMM以外のプロトコルを使うと色々有そうですけど。

調査中に見つけた高木先生の記事がおもしろかった。

携帯端末の Bluetooth を公開設定にしていると行動追跡されちゃうよ、と言うお話で実際に山手線で調査したもの。

Androidは今回の調査で一時的にしか公開状態に出来ない事が分かったので安心。 ノートPCは設定画面に注意書きが有るけど設定の戻し忘れに注意かな。


2013/10/20

Nexus7でPASMO(Suica)の履歴を読んでみた

先日、財布の上にNexus7を置いたら「ポロリ〜ン」って変な音がしたので何かと思ったら PASMO に反応していた。

Androidは2.3からFelica等の非接触カードに対応しているらしくNexus7にも付いていたわけだ。
音を鳴らしたアプリは分からずじまいだが面白そうなので自分でアプリを書いてみた。

PASMO/Suicaの仕様

  • Felica仕様の詳細は Sonyの「Felicaカードユーザマニュアル抜粋版」と言うPDFをDLして読んでください。
    • Felica技術情報のページ
    • APIで使うのはこの中の「2.3 アプリケーション層」のコマンドパケットです。
    • Pollingまでは終わった状態からAndroidアプリは始まります。
    • 普通使うのは Read/Write Without Encryption くらいですが引数の指定の仕方が結構ややこしいです。
    • ざっくりとした解説はこちらのページにあります。
  • Felicaの用語
    • サービスコード:
      • 1枚のカードに複数のサービスを同居させる為にサービスに割り当てられた2byteのコード。
      • コードの管理者は良く分かりません。Sony?
    • ブロック:
      • サービスの扱うデータブロックの事で16byte固定です。
      • 何ブロック使えるかはサービス毎の割り当てにより決まるようです。
      • ブロックの中の仕様はサービス毎に仕様が定義されます。
  • PASMO/Suica の仕様は非公開ですが有志で解析された情報があるのでそれを参考にします。
    • felicalibのWiki
    • ここから履歴のサービスコードが 0x090f とわかりました。
    • 履歴の保存フォーマットもここを参考にしています。

AndroidManifext.xml

  • NFC の uses-feature と uses-permission を宣言します。
  • intent-filter でカードを認識したら Activity を起動するようにします。

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

    <uses-sdk android:minSdkVersion="14" android:targetSdkVersion="17" />

    <uses-feature android:name="android.hardware.nfc" android:required="true" />    ←追加
    <uses-permission android:name="android.permission.NFC" />                       ←追加

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name="org.kotemaru.android.sample.nfc.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.nfc.action.TAG_DISCOVERED" />         ←変更
                <category android:name="android.intent.category.DEFAULT" />         ←変更
            </intent-filter>

            <meta-data      ←追加
                android:name="android.nfc.action.TAG_DISCOVERED"
                android:resource="@xml/nfc_filter" />
        </activity>
    </application>
</manifest>

res/xml/nfc_filter.xml

  • 使いたいカードのパッケージを設定します。
  • 今回はFelicaだけなので NfcF です。

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2" >
  <tech-list>
    <tech>android.nfc.tech.NfcF</tech>
  </tech-list>
</resources>

MainActivity.java

  • IntentからカードIDを取得してbyte配列のFelicaコマンドを送信します。
  • byte配列のレスポンスが帰るので解析すれば終わりです。
  • 履歴の保存レコード数は不明ですが私のPASMOでは最新15件しかありませんでした。
    • 1回の要求で取得できるレコード数は「製品により異なります」とマニュアルに書いてありました。
  • 注意事項:byte配列はリトル/ビッグ エンディアンが混在しています。仕様を良く確認してください。
package org.kotemaru.android.sample.nfc;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.NfcF;
import android.os.Bundle;
import android.app.Activity;
import android.content.Intent;
import android.util.Log;
import android.widget.TextView;

public class MainActivity extends Activity {
    private static final String TAG = "NFCSample";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        TextView textView1 = (TextView) this.findViewById(R.id.textView1);

        // カードID取得。Activityはカード認識時起動に設定しているのでここで取れる。
        byte[] felicaIDm = new byte[]{0};
        Intent intent = getIntent();
        Tag tag = (Tag) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
        if (tag != null) {
            felicaIDm = tag.getId();
        }


        NfcF nfc = NfcF.get(tag);
        try {
            nfc.connect();
            byte[] req = readWithoutEncryption(felicaIDm, 10);
            Log.d(TAG, "req:"+toHex(req));
            // カードにリクエスト送信
            byte[] res = nfc.transceive(req);
            Log.d(TAG, "res:"+toHex(res));
            nfc.close();
            // 結果を文字列に変換して表示
            textView1.setText(parse(res));
        } catch (Exception e) {
            Log.e(TAG, e.getMessage() , e);
            textView1.setText(e.toString());
        }
    }

    /**
     * 履歴読み込みFelicaコマンドの取得。
     * - Sonyの「Felicaユーザマニュアル抜粋」の仕様から。
     * - サービスコードは http://sourceforge.jp/projects/felicalib/wiki/suica の情報から
     * - 取得できる履歴数の上限は「製品により異なります」。
     * @param idm カードのID
     * @param size 取得する履歴の数
     * @return Felicaコマンド
     * @throws IOException
     */
    private byte[] readWithoutEncryption(byte[] idm, int size)
            throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream(100);

        bout.write(0);           // データ長バイトのダミー
        bout.write(0x06);        // Felicaコマンド「Read Without Encryption」
        bout.write(idm);         // カードID 8byte
        bout.write(1);           // サービスコードリストの長さ(以下2バイトがこの数分繰り返す)
        bout.write(0x0f);        // 履歴のサービスコード下位バイト
        bout.write(0x09);        // 履歴のサービスコード上位バイト
        bout.write(size);        // ブロック数
        for (int i = 0; i < size; i++) {
            bout.write(0x80);    // ブロックエレメント上位バイト 「Felicaユーザマニュアル抜粋」の4.3項参照
            bout.write(i);       // ブロック番号
        }

        byte[] msg = bout.toByteArray();
        msg[0] = (byte) msg.length; // 先頭1バイトはデータ長
        return msg;
    }

    /**
     * 履歴Felica応答の解析。
     * @param res Felica応答
     * @return 文字列表現
     * @throws Exception
     */
    private String parse(byte[] res) throws Exception {
        // res[0] = データ長
        // res[1] = 0x07
        // res[2〜9] = カードID
        // res[10,11] = エラーコード。0=正常。 
        if (res[10] != 0x00) throw new RuntimeException("Felica error.");

        // res[12] = 応答ブロック数
        // res[13+n*16] = 履歴データ。16byte/ブロックの繰り返し。
        int size = res[12];
        String str = "";
        for (int i = 0; i < size; i++) {
            // 個々の履歴の解析。
            Rireki rireki = Rireki.parse(res, 13 + i * 16);
            str += rireki.toString() +"\n";
        }
        return str;
    }

    private String toHex(byte[] id) {
        StringBuilder sbuf = new StringBuilder();
        for (int i = 0; i < id.length; i++) {
            String hex = "0" + Integer.toString((int) id[i] & 0x0ff, 16);
            if (hex.length() > 2)
                hex = hex.substring(1, 3);
            sbuf.append(" " + i + ":" + hex);
        }
        return sbuf.toString();
    }

}

Rireki.java

  • 履歴のパーザとBeanです。
  • 駅コードから駅名を引くのは大きなテーブルが必要なので今回はパスです。
package org.kotemaru.android.sample.nfc;

import android.util.SparseArray;

/**
 * Pasumo履歴レコード。
 * - 資料:http://sourceforge.jp/projects/felicalib/wiki/suica
 */
public class Rireki {
    public int termId;
    public int procId;
    public int year;
    public int month;
    public int day;
    public String kind;
    public int remain;
    public int seqNo;
    public int reasion;

    public Rireki(){
    }

    public static Rireki parse(byte[] res, int off) {
        Rireki self = new Rireki();
        self.init(res, off);
        return self;
    }

    private void init(byte[] res, int off) {
        this.termId = res[off+0]; //0: 端末種
        this.procId = res[off+1]; //1: 処理
        //2-3: ??
        int mixInt = toInt(res, off, 4,5);
        this.year  = (mixInt >> 9) & 0x07f;
        this.month = (mixInt >> 5) & 0x00f;
        this.day   = mixInt & 0x01f;

        if (isBuppan(this.procId)) {
            this.kind = "物販";
        } else if (isBus(this.procId)) {
            this.kind = "バス";
        } else {
            this.kind = res[off+6] < 0x80 ? "JR" : "公営/私鉄" ;
        }
        this.remain  = toInt(res, off, 11,10); //10-11: 残高 (little endian)
        this.seqNo   = toInt(res, off, 12,13,14); //12-14: 連番
        this.reasion = res[off+15]; //15: リージョン 
    }

    private int toInt(byte[] res, int off, int... idx) {
        int num = 0;
        for (int i=0; i<idx.length; i++) {
            num = num << 8;
            num += ((int)res[off+idx[i]]) & 0x0ff;
        }
        return num;
    }
    private boolean isBuppan(int procId) {
        return procId == 70 || procId == 73 || procId == 74 
                || procId == 75 || procId == 198 || procId == 203;
    }
    private boolean isBus(int procId) {
        return procId == 13|| procId == 15|| procId ==  31|| procId == 35;
    }

    public String toString() {
        String str = seqNo
                +","+TERM_MAP.get(termId)
                +","+ PROC_MAP.get(procId)
                +","+kind
                +","+year+"/"+month+"/"+day
                +",残:"+remain+"円";
        return str;
    }

    public static final SparseArray<String> TERM_MAP = new SparseArray<String>();
    public static final SparseArray<String> PROC_MAP = new SparseArray<String>();
    static {
        TERM_MAP.put(3 , "精算機");
        TERM_MAP.put(4 , "携帯型端末");
        TERM_MAP.put(5 , "車載端末");
        TERM_MAP.put(7 , "券売機");
        TERM_MAP.put(8 , "券売機");
        TERM_MAP.put(9 , "入金機");
        TERM_MAP.put(18 , "券売機");
        TERM_MAP.put(20 , "券売機等");
        TERM_MAP.put(21 , "券売機等");
        TERM_MAP.put(22 , "改札機");
        TERM_MAP.put(23 , "簡易改札機");
        TERM_MAP.put(24 , "窓口端末");
        TERM_MAP.put(25 , "窓口端末");
        TERM_MAP.put(26 , "改札端末");
        TERM_MAP.put(27 , "携帯電話");
        TERM_MAP.put(28 , "乗継精算機");
        TERM_MAP.put(29 , "連絡改札機");
        TERM_MAP.put(31 , "簡易入金機");
        TERM_MAP.put(70 , "VIEW ALTTE");
        TERM_MAP.put(72 , "VIEW ALTTE");
        TERM_MAP.put(199 , "物販端末");
        TERM_MAP.put(200 , "自販機");

        PROC_MAP.put(1 , "運賃支払(改札出場)");
        PROC_MAP.put(2 , "チャージ");
        PROC_MAP.put(3 , "券購(磁気券購入)");
        PROC_MAP.put(4 , "精算");
        PROC_MAP.put(5 , "精算 (入場精算)");
        PROC_MAP.put(6 , "窓出 (改札窓口処理)");
        PROC_MAP.put(7 , "新規 (新規発行)");
        PROC_MAP.put(8 , "控除 (窓口控除)");
        PROC_MAP.put(13 , "バス (PiTaPa系)");
        PROC_MAP.put(15 , "バス (IruCa系)");
        PROC_MAP.put(17 , "再発 (再発行処理)");
        PROC_MAP.put(19 , "支払 (新幹線利用)");
        PROC_MAP.put(20 , "入A (入場時オートチャージ)");
        PROC_MAP.put(21 , "出A (出場時オートチャージ)");
        PROC_MAP.put(31 , "入金 (バスチャージ)");
        PROC_MAP.put(35 , "券購 (バス路面電車企画券購入)");
        PROC_MAP.put(70 , "物販");
        PROC_MAP.put(72 , "特典 (特典チャージ)");
        PROC_MAP.put(73 , "入金 (レジ入金)");
        PROC_MAP.put(74 , "物販取消");
        PROC_MAP.put(75 , "入物 (入場物販)");
        PROC_MAP.put(198 , "物現 (現金併用物販)");
        PROC_MAP.put(203 , "入物 (入場現金併用物販)");
        PROC_MAP.put(132 , "精算 (他社精算)");
        PROC_MAP.put(133 , "精算 (他社入場精算)");
    }
}

実行結果

  • 実行前に「無線とネットワーク」の設定で NFC を有効にする必要があります。
  • とりあえず過去10件分の履歴の表示に成功しました。

雑感

データの解析が面倒でしたが思ったより感単にアクセスできました。
白カードを購入すれば書き込みも出来るようなので簡易のカード鍵とかは簡単に作れそうです。

このサンプルのSVN:


2013/10/13

AndroidのPreference使ってみた

Androidで設定画面を作ろうと思い Google 先生に聞いたとおり PreferenceActivity を使ったら deprecated だって起こられた (;_;)

てか、Android は deprecated 多すぎ。 ググって出てきたコードの半分ぐらい引っかかる気がする。 しかも、代替手段とかすぐに分かるようになって無いし。

そんな訳でさらにググって PreferenceFragment を使わないといけない事が分かった。

以下、最小限のテンプレ。

public class SimplePrefActivity extends Activity {
    private SimplePrefFragment fragment;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        fragment = new SimplePrefFragment();
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, fragment).commit();
    }

    public static class SimplePrefFragment extends PreferenceFragment
            implements OnSharedPreferenceChangeListener
    {
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            addPreferencesFromResource(R.xml.my_pref); // => res/xml/my_pref.xml
        }

        @Override
        public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
            // 変更通知処理
        }
    }
}
  • リソースは従来と同じでAndroid XMLファイルから Preference を選択して専用エディタで作る。
  • PreferenceFragment は static な内部クラスにしないといけないそうです。

リソース書いてみて分かったのだが記述できる内容がかなりしょぼく Checkbox,List選択,TextField しか使えない。

Radio とかは Preferenceクラスを拡張すればできるようだけど せっかく XML化して設定画面の UI を統一してるのにカスタマイズしてよいの?

さらに List選択,TextField の現在の設定値がタップしないと分からないようなっている。
流石にこれは有り得ないと思うので現在値を表示させる汎用のカスタマイズをして以下のコードに落ち着いた。

public class PrefActivity extends Activity {
    private PrefFragment fragment;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        fragment = new PrefFragment();
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, fragment).commit();
    }
    public void setChanged(boolean b) {
        Intent intent = new Intent();
        // 設定変更があったことを呼び出し元に返す。
        setResult(b ? RESULT_OK : RESULT_CANCELED, intent);
    }

    public static class PrefFragment extends PreferenceFragment
            implements OnSharedPreferenceChangeListener
    {
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            addPreferencesFromResource(R.xml.adkterm_pref); // res/xml/adkterm_pref.xml
        }

        @Override
        public void onResume() {
            super.onResume();
            resetSummary();
            getPreferenceScreen().getSharedPreferences()
                    .registerOnSharedPreferenceChangeListener(this);
        }

        @Override
        public void onPause() {
            super.onPause();
            getPreferenceScreen().getSharedPreferences()
                    .unregisterOnSharedPreferenceChangeListener(this);
        }

        @Override
        public void onSharedPreferenceChanged(SharedPreferences paramSharedPreferences, String paramString) {
            resetSummary();
            ((PrefActivity)getActivity()).setChanged(true);
        }

        // CheckBoxを除く項目のSummaryに現在値を設定する。
        public void resetSummary() {
            SharedPreferences sharedPrefs = getPreferenceManager().getSharedPreferences();
            PreferenceScreen screen = this.getPreferenceScreen();
            for (int i = 0; i < screen.getPreferenceCount(); i++) {
                Preference pref = screen.getPreference(i);
                if (pref instanceof CheckBoxPreference) continue;
                String key = pref.getKey();
                String val = sharedPrefs.getString(key, "");
                pref.setSummary(val);
            }
        }
    }
}

リソースXMLの例はこんな感じ。

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >
    <ListPreference
        android:defaultValue="Landscape"
        android:entries="@array/direction_list"
        android:entryValues="@array/direction_list"
        android:key="orientation"
        android:title="Orientation" />
    <CheckBoxPreference
        android:defaultValue="true"
        android:key="keybord"
        android:selectable="true"
        android:title="Keybord" />
    <EditTextPreference
        android:defaultValue="16"
        android:dialogTitle="Font size (px)"
        android:key="fontsize"
        android:inputType="number"
        android:maxLength="2"
        android:title="Font size" />
    <EditTextPreference
        android:defaultValue="300"
        android:key="logsize"
        android:inputType="number"
        android:maxLength="3"
        android:title="Log size" android:dialogTitle="Log size (lines)"/>
</PreferenceScreen>

実行するとこうなります。(下段に小さく表示されるのが現在値)

設定参照側コードの例はこんな感じです。

public class Config  {
    public final static String K_KEYBORD = "keybord";
    public final static String K_ORIENT = "orientation";
    public final static String K_FONTSIZE = "fontsize";
    public final static String K_LOGSIZE = "logsize";
    public final static String V_LANDSCAPE = "Landscape";
    public final static String V_PORTRAIT = "Portrait";

    private static SharedPreferences sharedPrefs;

    public static void init(Context context) {
        sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
    }

    public static Boolean getKeybord() {
        return sharedPrefs.getBoolean(K_KEYBORD, true);
    }
    public static String getOrientation() {
        return sharedPrefs.getString(K_ORIENT, V_LANDSCAPE);
    }
    public static int getFontsize() {
        return Integer.parseInt(sharedPrefs.getString(K_FONTSIZE, "16"));
    }
    public static int getLogsize() {
        return Integer.parseInt(sharedPrefs.getString(K_LOGSIZE, "300"));
    }
}
  • getする型を間違えると ClassCastException が起こります。
    • CheckBox のキーに getString() とかすると。
  • どのキーがどの型を返すのかは Preference のクラスをチェックするしかありません。
    • 上記の PrefFragment.resetSummary()参照

所感

設定内容をストレージに自動保存してくれたりするのはよいけど全体的に詰めが甘い感じ。
せめてラジオボタンとスライダーくらい標準で欲しい。

キー文字列の定義や設定値の参照クラスとかもXMLから自動生成して欲しい気がする。


2013/10/06

AndroidとPCのUSB通信のサンプル

Android で USB 通信を行う為の API が有ったので試してみた。

USB の API は ADK(Accessory Development Kit)と呼ばれている物。

Android がホストになる場合とUSB機器になる場合があるが今回試したのは後者の方で Android が PC の USBデバイスとして認識されるケース。

Android側

API の選択

ADK は android.hardware.usb と com.android.future.usb の2つがある。 この2つは基本的に同じなのだけど後者は Android/2.3.4 向けの互換用。

違いは基本インスタンスの取得方法だけ。

android.hardware.usb:

UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE);
UsbAccessory accessory = (UsbAccessory) intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY);

com.android.future.usb:

UsbManager manager = UsbManager.getInstance(this);
UsbAccessory accessory = UsbManager.getAccessory(intent);

今回は android.hardware.usb を使用する。

AndroidManufest.xml の設定

USB 関連の設定を追加して置く。

AndroidManufest.xml:

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

    <uses-sdk android:minSdkVersion="12" android:targetSdkVersion="17" />

    <application android:allowBackup="true" android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" android:theme="@style/AppTheme">

        <uses-feature android:name="android.hardware.usb.accessory" />

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

                <intent-filter>
                    <action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
                </intent-filter>
                <meta-data android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
                    android:resource="@xml/accessory_filter" />
            </intent-filter>
        </activity>
    </application>

</manifest>

res/xml/accessory_filter.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <usb-accessory
        manufacturer="kotemaru.org"
        model="AdkSample"
        version="1.0" />
</resources>

ソース

MainAcrivity.java:

  • UsbReceiverとUsbDriverを呼んでいる所以外は普通のActivity。
package org.kotemaru.android.usbsample;

import java.io.IOException;

import android.os.Bundle;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Intent;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.EditText;

public class MainActivity extends Activity {

    private static final String ACTION_USB_PERMISSION = "org.kotemaru.android.usbsample.USB_PERMISSION";

    private UsbDriver usbDriver;
    private UsbReceiver usbReceiver;
    private EditText editText1;
    private EditText editText2;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        usbDriver = new UsbDriver(this);
        usbReceiver = UsbReceiver.init(this, ACTION_USB_PERMISSION, usbDriver);
        
        editText1 = (EditText) findViewById(R.id.editText1);
        editText2 = (EditText) findViewById(R.id.editText2);

        Button sendBtn = (Button) findViewById(R.id.sendBtn);
        sendBtn.setOnClickListener(new OnClickListener(){
            @Override public void onClick(View v) {
                String msg = editText1.getText().toString();
                try {
                    usbDriver.send(msg);
                    String rmsg = usbDriver.receive();
                    editText2.setText(rmsg);
                } catch (IOException e) {
                    errorDialog(e.getMessage());
                }
            }
        });
        Button resetBtn = (Button) findViewById(R.id.resetBtn);
        resetBtn.setOnClickListener(new OnClickListener(){
            @Override public void onClick(View v) {
                restart();
            }
        });
    }

    @Override
    public void onResume() {
        super.onResume();
        usbReceiver.resume();
    }

    @Override
    public void onPause() {
        super.onPause();
        usbReceiver.close();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        usbReceiver.destroy();
    }

    protected void restart() {
        Intent intent = this.getIntent();
        this.finish();
        this.startActivity(intent);
    }
    public void errorDialog(String message) {
        AlertDialog.Builder dialog = new AlertDialog.Builder(this);
        dialog.setTitle("Error!");
        dialog.setMessage(message);
        dialog.show();
    }
}

UsbReceiver.java:

  • USB の ATTACH/DETTACH イベントを受け取る為の Receiver。
  • pause/resume の処理が有るため若干ややこしくなっている.
package org.kotemaru.android.usbsample;

import android.app.Activity;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.hardware.usb.UsbAccessory;
import android.hardware.usb.UsbManager;
import android.util.Log;

public class UsbReceiver extends BroadcastReceiver {
    private static final String TAG = "UsbReceiver";

    private Activity activity;
    private Driver driver;
    private final String action_usb_permission;

    private UsbManager usbManager;
    private UsbAccessory activeAccessory;

    private PendingIntent permissionIntent;
    private boolean permissionRequestPending = false;

    // I/O処理を分離する為のインターフェース。
    public interface Driver {
        public void openAccessory(UsbAccessory accessory);
        public void closeAccessory(UsbAccessory accessory);
    }

    public static UsbReceiver init(Activity activity, String permissionName, Driver driver) {
        UsbReceiver receiver = new UsbReceiver(activity, permissionName, driver);

        /* receiver */
        IntentFilter filter = new IntentFilter();
        filter.addAction(permissionName);
        filter.addAction(UsbManager.ACTION_USB_ACCESSORY_DETACHED);
        activity.registerReceiver(receiver, filter);

        return receiver;
    }

    public UsbReceiver(Activity activity, String permissionName, Driver driver) {
        super();
        this.activity = activity;
        this.action_usb_permission = permissionName;
        this.driver = driver;

        this.usbManager = (UsbManager) activity.getSystemService(Context.USB_SERVICE);
        this.permissionIntent =
                PendingIntent.getBroadcast(activity, 0, new Intent(permissionName), 0);
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (action_usb_permission.equals(action)) {
            open(intent);
        } else if (UsbManager.ACTION_USB_ACCESSORY_DETACHED.equals(action)) {
            close(intent);
        }
    }

    private synchronized void open(Intent intent) {
        UsbAccessory accessory = (UsbAccessory) intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY);
        if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
            driver.openAccessory(accessory);
            activeAccessory = accessory;
        } else {
            Log.d(TAG, "permission denied for accessory " + accessory);
        }
        permissionRequestPending = false;
    }

    private synchronized void close(Intent intent) {
        UsbAccessory accessory = (UsbAccessory) intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY);
        if (accessory != null && accessory.equals(activeAccessory)) {
            close();
        }
    }

    public void resume() {
        UsbAccessory[] accessories = usbManager.getAccessoryList();
        UsbAccessory accessory = (accessories == null ? null : accessories[0]);
        if (accessory != null) {
            if (usbManager.hasPermission(accessory)) {
                driver.openAccessory(accessory);
            } else {
                synchronized (this) {
                    if (!permissionRequestPending) {
                        usbManager.requestPermission(accessory, permissionIntent);
                        permissionRequestPending = true;
                    }
                }
            }
        } else {
            Log.d(TAG, "accessory is null");
        }
    }

    public synchronized void close() {
        if (activeAccessory != null) {
            driver.closeAccessory(activeAccessory);
            activeAccessory = null;
        }
    }
    public void destroy() {
        activity.unregisterReceiver(this);
    }
}

UsbDriver.java:

  • USBへのI/O処理。
  • USB接続はFileDescriptor扱いなのでほぼ普通の java.io と変わらない。
package org.kotemaru.android.usbsample;

import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

import android.content.Context;
import android.hardware.usb.UsbAccessory;
import android.hardware.usb.UsbManager;
import android.os.ParcelFileDescriptor;
import android.util.Log;

public class UsbDriver implements UsbReceiver.Driver {
    private static final String TAG = "UsbDriver";

    private UsbManager usbManager;

    private ParcelFileDescriptor fileDescriptor;
    private FileInputStream usbIn;
    private FileOutputStream usbOut;

    public UsbDriver(MainActivity activity) {
        this.usbManager = (UsbManager) activity.getSystemService(Context.USB_SERVICE);
    }

    @Override
    public void openAccessory(UsbAccessory accessory) {
        fileDescriptor = usbManager.openAccessory(accessory);

        if (fileDescriptor != null) {
            FileDescriptor fd = fileDescriptor.getFileDescriptor();
            usbIn = new FileInputStream(fd);
            usbOut = new FileOutputStream(fd);
        } else {
            Log.d(TAG, "accessory open fail");
        }
    }

    @Override
    public void closeAccessory(UsbAccessory accessory) {
        try {
            if (fileDescriptor != null) {
                fileDescriptor.close();
            }
        } catch (IOException e) {
            // ignore.
        } finally {
            fileDescriptor = null;
        }
    }

    public void send(String msg) throws IOException {
        usbOut.write(msg.getBytes("UTF-8"));
        usbOut.flush();
    }
    public String receive() throws IOException {
        byte[] buff = new byte[1024];
        int len = usbIn.read(buff);
        return new String(buff,0,len,"UTF-8");
    }
}

PC 側

PC 側は Ubuntu,FreeBSD,RaspberryPi で接続を確認している。 libusb を使用するので Windows でも繋がるはず。

libusb の準備

linux系の場合は libusb-1.0 をインストールする。FreeBSD はOS組込なので不要。

$ sudo apt-get install libusb-1.0

libusb で ADK のプロトコルを実装したライブラリ AOA をこちらからお借りしました。 (若干修正が入っています。)

ソース

受け取った文字列を大文字に変換して送り返すだけです。 言語は C++ です。

AdkEcho.cpp:

/**
Android USB connection sample.
@Author kotemru.org
@Licence apache/2.0
*/

#include <stdlib.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <string.h>

#include "AOA/AOA.h"

// USB Connector opend.
AOA acc("kotemaru.org",
        "AdkSample",
        "Sample for ADK",
        "1.0",
        "http://blog.kotemaru.org/androidUSBSample",
        "000000000000001") ;

/**
 * Disconnect USB innterrupt aborted.
 */
void signal_callback_handler(int signum)
{
    fprintf(stderr, "\ninterrupt %d\n",signum);
    acc.disconnect();
    exit(0);
}

static void error(char *msg, int rc) {
    fprintf(stderr,"Error(%d,%s): %s\n",rc,strerror(errno),msg);
    acc.disconnect();
    exit(0);
}

int main(int argc, char *argv[])
{
    signal(SIGINT, signal_callback_handler);
    signal(SIGTERM, signal_callback_handler);

    unsigned char buff[1024];

    acc.connect(100);
    // Echo back.
    while (1) {
        int len = acc.read(buff, sizeof(buff), 1000000);
        if (len < 0) error("acc.read",len);
        buff[len+1] = '\0';
        printf("USB>%s\n", buff);
        for (int i=0; i<len; i++) buff[i] = buff[i] - 0x20;
        acc.write(buff, len, 1000);
    }
}

実行結果

先に Android 側アプリを起動して置きます。

USBをPCに接続してから PC 側のプログラムを起動します。 (※root権限が必要です。)

$ sudo ./AdkEcho
VID:18D1, PID:2D01 Class:00
already in accessory mode.
bNumInterfaces: 2
bNumEndpoints: 2
 bEndpointAddress: 81, bmAttributes:02
 bEndpointAddress: 02, bmAttributes:02
VID:18D1, PID:2D01

Android 側にダイアログが出るので応答します。

接続したら英小文字の文書を入力して「Send」をタップします

英大文字が帰ってきたら成功です。

所感

正直、ネットワークで無くUSBでPCと繋げたいケースと言うのが余り思い付かない。

今のところ、RaspberryPiのTTY端末くらい。

何か面白いアイデアが有ったら教えて下さい。

ソースのSVNは以下に有ります。

  • https://kotemaru.googlecode.com/svn/trunk/androidUSBSample

2013/09/23

AndroidのソフトキーボードはonKey()を呼ばない。

Android で端末アプリを作ろうとしてえらいはまった。

キーボード入力を普通に OnKeyListener.onKey() で拾うプログラムを書いてエミュレータで動作確認後に 実機で動かしてみたら全然動かない。
キーを押しても何も反応しないのだが何故かリターンキーは拾える。

理由がさっぱり分からず数時間を費して OnKeyListener クラスの説明文に気が付いた。

View.OnKeyListener
Class Overview

Interface definition for a callback to be invoked when a hardware key event is dispatched to this view. The callback will be invoked before the key event is given to the view. This is only useful for hardware keyboards; a software input method has no obligation to trigger this listener.

orz...
英語読めないってだめね、完全にスルーしてたよ。
なんでこんな仕様なのかは後で分かったが...

しかし、個別のキー入力が拾えないと端末アプリにならないので対策を調べてみた。
結論から言うとソフトキーボードを自前で実装すればOKよ、と言うことらしい。

ソフトキーボードなんてどうやって実装するかと言うとSDKにちゃんとサンプルが用意されてた。

  • android-sdk/samples/android-17/SofKeyboard

具体的にはXMLでキーボードの配列を書いて Keybord クラスにリスースIDを渡してわれば終り。
以外に簡単だった。

KeyboardView keyboardView = new KeyboardView(context, null);
keyboard = new Keyboard(context, R.xml.keymap); // -> res/xml/keymap.xml
keyboardView.setKeyboard(keyboard);
// keyboardViewを適当に配置。

せっかくなので自分好みのキーマップを作ってみた。(US配列風)

あ、ESC忘れた...

これ作って気が付いたのだが Ctrl キーとかは標準のソフトキーボードではサポートされないので 端末アプリで使う場合には結局自前で実装せざる得なかった訳だ。 理屈は分かったけどもう少し分かり易いようにしておいて欲しいよ。


2013/09/14

Androidの非同期処理を簡単にする実験

Androidでは通信等の時間のかかる処理はUIスレッドでは無く別スレッドで行えと言われる。
しかし、通信結果を別スレッドから画面に反映すると UIスレッドで実行しろと怒られる。

どないせいっちゅーねん! (ノ`Д)ノ彡 ┻━┻∴

対策として AsyncTask が用意されているが使い方は結構めんどくさい。

なんとか非同期処理を簡単にする方法は無いかと思いアノテーションと AsyncTask を組み合わせる方法で試してみた。

テスト実装してみたアプリは入力されたURLをWebから取得してテキスト表示するだけの単純な物。

こんな感じ。

ソースコード

MainActivity.java:
  • Activity はいたって普通。

package org.kotemaru.android.logicasync.sample;

import android.os.Bundle;
import android.app.Activity;
import android.app.AlertDialog;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;

public class MainActivity extends Activity implements UIAction {

    private MyLogic logic = new MyLogic(this);

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

        final EditText textUrl = (EditText)findViewById(R.id.text_url);
        Button btnGo = (Button)findViewById(R.id.btn_go);
        btnGo.setOnClickListener(new OnClickListener() {
            @Override   public void onClick(View btn) {
                String url = textUrl.getText().toString();
                logic.async.doGetHtml(url);  // <=Webへ通信を非同期実行
            }
        });
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
        logic.async.close();
    }

    @Override
    public void updateView(String html) {
        TextView  textHtml = (TextView)findViewById(R.id.text_html);
        textHtml.setText(html);
    }

    @Override
    public void errorDialog(String message) {
        AlertDialog.Builder dialog = new AlertDialog.Builder(this);
        dialog.setTitle("Error!");
        dialog.setMessage(message);
        dialog.show();
    }
}


UIAction.java:
  • ロジックとActivityを明確に分離したかったのでインターフェース化。

package org.kotemaru.android.logicasync.sample;
public interface UIAction {
    void updateView(String html);
    void errorDialog(String message);
}


MyLogic.java:
  • アノテーションを使うロジック部分。
  • @Task() の指定されたメソッドが非同期実行用。
    • MyLogicAsync.java にスタブが自動生成される。
  • @Task("UI") は UIスレッドで実行される事を意味する。

package org.kotemaru.android.logicasync.sample;

import java.io.Serializable;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicResponseHandler;
import org.apache.http.impl.client.DefaultHttpClient;
import org.kotemaru.android.logicasync.annotation.Logic;
import org.kotemaru.android.logicasync.annotation.Task;

@Logic
public class MyLogic implements Serializable {
    private static final long serialVersionUID = 1L;

    public MyLogicAsync async = new MyLogicAsync(this);
    private UIAction uiAction;

    public MyLogic(UIAction uiAction) {
        this.uiAction = uiAction;
    }

    @Task
    public void doGetHtml(String url) {
        // HTTPリクエストを行う処理。
        DefaultHttpClient httpClient = new DefaultHttpClient();
        HttpGet request = new HttpGet(url);
        try {
            String html = httpClient.execute(request, new BasicResponseHandler());
            async.doGetHtmlFinish(html); // <= 結果反映の非同期実行。このメソッド終了後実行される。
        } catch (Exception e) {
            async.doGetHtmlError(e); // <= エラー表示の非同期実行。このメソッド終了後実行される。
        } finally {
            httpClient.getConnectionManager().shutdown();
        }
    }

    @Task("UI")
    public void doGetHtmlFinish(String html) {
        // 通信結果反映処理。
        uiAction.updateView(html);
    }

    @Task("UI")
    public void doGetHtmlError(Exception e) {
        uiAction.errorDialog(e.getMessage());
    }
}


MyLogicAsync.java:
  • アノテーション プロセッサによって自動生成されたソース。
  • MyLogicクラスのメソッドを非同期実行する。

//  Generated stub.
package org.kotemaru.android.logicasync.sample;
import org.kotemaru.android.logicasync.TaskThread;
import org.kotemaru.android.logicasync.Task;

import android.util.Log;

public class MyLogicAsync implements java.io.Serializable
{
    private static final long serialVersionUID = 1L;
    private static final String TAG = "LogicAsync";

    private final TaskThread thread = new TaskThread();
    private final MyLogic origin;

    public MyLogicAsync( MyLogic origin ) {
        this.origin = origin;
    }
    public final void close() {
        thread.stop();
    }

    public void doGetHtml(final java.lang.String url) {
        Task task = new Task(){
            private static final long serialVersionUID = 1L;
            @Override public void run() {
                origin.doGetHtml(url);
            }
        };
        thread.addTask(task);
    }

    public void doGetHtmlError(final java.lang.Exception e) {
        Task task = new Task(){
            private static final long serialVersionUID = 1L;
            @Override public void run() {
                origin.doGetHtmlError(e);
            }
        };
        task.setThreadType(Task.UI);
        thread.addTask(task);
    }

    public void doGetHtmlFinish(final java.lang.String html) {
        Task task = new Task(){
            private static final long serialVersionUID = 1L;
            @Override public void run() {
                origin.doGetHtmlFinish(html);
            }
        };
        task.setThreadType(Task.UI);
        thread.addTask(task);
    }
}

まとめ

かなりすっきり記述できている気がする。

メリット:

  • Javaの言語仕様から逸脱していないので eclipse の「宣言を開く」等でソースが追える。
  • ロジックとビューの分離がしやすい。

デメリット:

  • アノテーション プロセッサの設定がちょっとめんどくさい。

あと、リトライ処理ぐらい有れば充分、軽量フレームワークとして使えそう。

ダウンロード

eclipseのプロジェクトです。

サンプルのコンパイル時に NullPointerException となるときは eclipse を再起動して下さい。


2013/09/08

Nexus7を外部モニタ化 (3)

前の記事で 野良ビルドがインストールできなかった件は単純に署名してなかったから。 オレオレ証明で署名したら実機にインストールできたよ。

野良ビルドって無署名の事だと思い込んでた。orz

野良ビルドのAPKはこちらに置いておきます。

当然ながら無保証、無責任です。各位、自己責任でお使い下さい。

Nexus7でしか確認してませんが、4.1以降のOSなら動作すると思います。


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

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿【android】

リンク

アーカイブ