アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

Android アプリの WebView 連携

January 26, 2014開発Android, WebView

この前 iOS アプリの UIWebView 連携という記事を書いたが、その対となるものとして Android アプリにおける WebView 連携についてまとめてみる。

はじめに

Android SDK は WebView という Web ブラウザ コントロールを提供している。これは iOS における UIWebView に相当し、ページ表示とアプリ連携をサポートしている。本記事では iOS の UIWebView 連携で実装したサンプルを Android に移植しながら WebView 連携の実装手順や考察をまとめる。

ローカル HTML と assets

Android アプリ内にローカル HTML を組み込む場合、assets を利用することになる。Eclipse + ADT を利用した Android プロジェクトの場合、その直下に assets というディレクトリが用意されている。ここに配置したものはフォルダ階層を維持した状態でアプリから参照できる。

assets

assets 内の HTML を WebView へ表示する場合は WebView.loadUrlfile:///android_asset/ から始まるパスを指定すればよい。assets 直下の html という名前のフォルダ内にある basic.html というファイルが対象なら以下のようになる。

// WebView mWebView が定義されているものとする
this.mWebView.loadUrl( "file:///android_asset/html/basic.html" );

assets にはどのようなファイルも配置可能だが、未圧縮ファイルの場合は 1 〜 2MB ほどの容量制限 (上限は端末依存) がある。ここは小さなファイルを置く場所なので大きくなりそうなら res/raw を利用したほうがよいだろう。

ローカル HTML の構成物に大きな画像や音声ファイル、Web フォントなどが含まれる場合はリンク指定を Web にして動的にダウンロードされるようにするなどして容量制限を回避する必要あり。ローカル HTML 全体を ZIP 圧縮したものを組み込んで動的に展開してもよいだろう。

assets は配置されたファイルをそのまま組み込むため意図せぬファイルが含まれぬよう注意する。例えば Mac の .DS_Store や Windows の thumb.db、Git リポジトリの設定ファイル (ローカル HTML だけ Git submodule で組み込んでいるとやりがち) など。これらが組み込まれるとアプリのサイズ増大やセキュリティ的な問題 (開発環境の情報などが漏れる) を引き起こす可能性がある。

組み込み対象をフィルタリングできればよいのだが Eclipse + ADT にはそういう機能が用意されていないらしく自衛するしかなさそうだ。

アプリから WebView 上の JavaScript 関数を実行する

アプリから WebView の JavaScript 関数を実行してみる。まず HTML に以下の <div> を定義。アプリから指定されたテキストを受け取りここへ表示したい。

<div class="from-app-text"></div>

次に HTML から読み込まれた JavaScript 上で以下の関数を定義。関数がグローバルに定義されていることが重要で名前については重複しなければ何でもよい。たとえばプロジェクト名を接頭語として付与してもよいだろう。この関数を実行すると引数に指定されたテキストが前述の <div> へ表示されるようにしておく。

window.webViewCallback = function( text ) {
    $( ".from-app-text" ).text( text );
};

アプリ側からこの関数を実行する処理は以下。

/**
 * WebView 上に読み込まれた JavaScript の関数を実行します。
 *
 * @param param JavaScript の関数へ指定するパラメータ。
 */
private void executeJavaScriptFunction( String param ) {
    final String script = "javascript:window.webViewCallback('%s');";
    this.mWebView.loadUrl( String.format( script, param ) );
}

Android の WebView には iOS の UIWebView - stringByEvaluatingJavaScriptFromString 相当のメソッドはない。かわりに javascript: から始まる JavaScript を記述してそれを WebView.loadUrl へ指定することでスクリプトが実行される。

WebView 上で JavaScript を有効にする場合は WebView.getSettings で取得した WebViewSettings に対し setJavaScriptEnabled を呼び出す必要があるのだけど、そうすると以下のコンパイラ警告がでる。

Using setJavaScriptEnabled can introduce XSS vulnerabilities into you application, review carefully.

これは JavaScript を有効にすることでアプリに XSS の脆弱性を含んでしまう可能性がありますよ、というもの。リスクを承知したうえで警告を抑止する場合は WebViewSettings.setJavaScriptEnabled を呼び出すメソッドか、それを含むクラスに以下のアノテーションを追加する。

@SuppressLint( "SetJavaScriptEnabled" )

たとえローカル HTML であっても Web 側のリソースを参照する場合は危険と隣合わせであることは意識しておきたい。後述する Google Maps API 連携のように Web サービスを参照するならサイトが乗っ取られて API の中身が入れ替わってアプリ側へのコールバックが悪用される危険性もある。そのためアプリ側のインターフェースは非公開としておくとかコールバックのパラメータ妥当性を検証するといった対策をしておくのがよいだろう。

WebView 上の JavaScript からアプリへコールバックする

WebView 上の JavaScript からアプリ側へコールバックする処理を実装してみる。二通りの方法があるので両方紹介しておくが、サンプルでは location.href の方だけ採用している。

JavaScriptInterface

WebView 上からアプリ側へコールバックする一般的な方法は JavascriptInterface になるだろう。これはアプリ側で実装したクラスを WebView 上の JavaScript Object として公開する機能である。例えば以下のようなクラスを定義する。

/**
 * WebView 上の JavaScript に公開するインターフェースです。
 */
class MyJavaScriptInterface {
    /**
     * アラートを表示します。
     *
     * @param message メッセージ。
     */
    @JavascriptInterface
    public void showAlert( String message ) {
        // アラート表示処理 ...
    }
}

このクラスのインスタンスを生成して WebView.addJavascriptInterface の第一引数へ指定することで、第二引数の名称をもったオブジェクトとして WebView 上の JavaScript へ公開される。

MyJavaScriptInterface obj = new MyJavaScriptInterface();
this.mWebView.addJavascriptInterface( obj, "appJsInterface" );

JavaScript 側からの操作は以下。

appJsInterface.showAlert( "message" );

パラメータの型は自動的にマーシャリングされる。StringBoolean はそのまま、Number なら IntegerDoubleFloat などへ対応づけられる。

JavaScriptInterface 利用上の注意点。これを実装したクラスだけで処理が完結することは考えにくい。そのため ActivityContext として参照させたり Listener 経由でコールバック機構を設けたりすることになるだろう。型の自動解決や JasScript のオブジェクトとして操作可能なことから非常に便利な機能ではあるけれど想定外のメソッド呼び出しやフィールド参照が実行される脆弱性がある。

Android 4.2 以降ではこの問題への対策として JavascriptInterface アノテーションが指定された public メソッドのみを許可するための制限が追加された。とはいえ Android 4.2 未満では利用できないし WebView 連携するページを iOS と共通にする際にコールバック部分を Android バージョンで分岐するのもイマイチ。というわけで次項では JavaScriptInterface 以外の方法で連携する方法を紹介する。

location.href によるコールバック

iOS アプリの UIWebView 連携で紹介した方法を Android に移植してみる。JavaScript 側の処理は iOS と共通である。

location.href = "app-callback://test";

location.href にリクエストを指定するとその内容にしたがって Web ブラウザーはページ遷移する。今回はコールバックが目的なのでリクエストとなる URL のスキーム部分に独自の文字列を指定する。スキームの名称は RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax に定義された文字の範囲であればなんでもよい。今回は app-callback としているが、アプリのプロジェクト名を含めてユニークなものとするのが好ましい。

アプリ側でページ遷移をハンドリングするには WebViewClient.shouldOverrideUrlLoading を実装して対象となる WebView へ指定する。

/**
 * 指定された URL が WebView からのコールバックであることを調べ、対応する処理を実行します。
 *
 * @param url URL。
 *
 * @return コールバックだった場合は true。
 */
private boolean checkCallbackUrl( String url ) {
    final String CallbacScheme = "app-callback://";
    if( !url.startsWith( CallbacScheme ) ) { return false; }

    String message = url.substring( CallbacScheme.length() );
    new AlertDialog.Builder( this )
        .setTitle( R.string.text_from_webview )
        .setMessage( message )
        .setPositiveButton( "OK", null )
        .show();

    return true;
}

/**
 * WebView を初期化します。
 */
@SuppressLint( "SetJavaScriptEnabled" )
private void initWebView() {
    this.mWebView = ( WebView )this.findViewById( R.id.webViewBasicCoordination );
    this.mWebView.getSettings().setJavaScriptEnabled( true );
    this.mWebView.setWebViewClient( new WebViewClient() {
        @Override
        public boolean shouldOverrideUrlLoading( WebView view, String url ) {
            return checkCallbackUrl( url );
        }
    } );

    this.mWebView.loadUrl( "file:///android_asset/html/basic.html" );
}

WebViewClient.shouldOverrideUrlLoading には遷移先となる URL 文字列が指定されるため、コールバック用スキームであることを判定する。コールバック用なら独自処理。shouldOverrideUrlLoading の戻り値は独自処理を実行してページ遷移をキャンセルするなら true、WebView 既定の処理にまかせる場合は false を返す。コールバック処理ならページ遷移は不要なので true にしておく。

ここまでの処理を利用して簡単なサンプルを実装してみた。表示しているローカル HTML は iOS のサンプルへ組み込んでいたものから一切、手を加えていない。

基本的な連携

画面上部の EditText に文字を入力してキーボードを Done で閉じると JavaScript が実行され、HTML 側のテキストが更新される。HTML 上の青い領域をタップするとアプリ側へのコールバックが実行され JavaScript 側で定義されたテキストを AlertDialog で表示。

Google Maps API と連携してみる

iOS 版のサンプルで Google Maps API 連携してみたが、ローカル HTML 部分はそのままにアプリ側の処理を Android に移植してみる。まず JavaScript への住所指定については iOS とほぼ変わらない。単に WebView.loadUrl でスクリプト実行するだけである。

/**
 * マップの中央位置を指定された住所の位置へ移動させます。
 *
 * @param address 住所。ジオコーディングによって検索されます。
 */
private void moveToMapCenter( String address ) {
    final String region = this.getString( R.string.google_map_rgion );
    final String script = "javascript:window.webViewCallbackSearchAddress('%s','%s');";
    this.mWebView.loadUrl( String.format( script, address, region ) );
}

次に WebView 上の JavaScript からのコールバックだが、これも大まかな処理の流れは一緒。ページ遷移をアプリ側で検出して URL がコールバックであることを判定した後にパラメータを解析する。以下の checkCallbackUrlWebViewClient.shouldOverrideUrlLoading から呼び出される。

/**
 * 指定された URL が WebView からのコールバックであることを調べ、対応する処理を実行します。
 *
 * @param url URL。
 *
 * @return コールバックだった場合は true。
 */
private boolean checkCallbackUrl( String url ) {
    final String CallbacScheme = "app-callback://map";
    if( !url.startsWith( CallbacScheme ) ) { return false; }

    Map< String, String > params = this.parseUrlParameters( url );
    String address   = params.get( "address" );
    String latitude  = params.get( "lat" );
    String longitude = params.get( "lng" );
    Log.d( "HostWebView", String.format( "address = %s, latitude = %s, longitude = %s", address, latitude, longitude ) );

    if( address != null ) {
        this.mAddressTextView.setText( address );
    }

    return true;
}

/**
 * URL におけるパラメータ部分を解析してディクショナリ化します。
 *
 * @param url URL。
 *
 * @return 解析結果。
 */
private Map< String, String > parseUrlParameters( String url ) {
    Map< String, String > result = new HashMap< String, String >();
    int                   index = url.indexOf( "?" );
    if( index == -1 ) { return result; }

    String[] params = url.substring( index + 1 ).split( "&" );
    for( String param : params ) {
        String[] keyValuePair = param.split( "=" );
        if( keyValuePair.length >= 2 ) {
            try {
                String value = URLDecoder.decode( keyValuePair[ 1 ], "utf-8" );
                result.put( keyValuePair[ 0 ], value );

            } catch( UnsupportedEncodingException e ) {
                e.printStackTrace();
            }
        }
    }

    return result;
}

Android 上でもバッチリ動作している。このスクリーンショットではハチ公で検索して渋谷駅近辺が表示されている。ところでハチ公は Google Maps API 的にも渋谷なのだろうか?

Google Maps API 連携

サンプル プログラム

今回の内容を実装したサンプル プログラムのプロジェクト一式を GitHub にて公開した。開発は Eclipse Kepler Service Release 1 + ADT 22.3、動作確認には Xperia NX (SO-02D) を使用。ライセンスは The MIT License (MIT)。fork、改変などはご自由にどうぞ