Androidはワンツーパンチ 三歩進んで二歩下がる

プログラミングやどうでもいい話

Android4.4のWebViewでopenFileChooserが動かない件の対処方法2つ JavascriptInterfaceを使う/Crosswalkを使う

WebViewで表示しているページ上で、ユーザーがボタンを押したらローカルのイメージ一覧を表示するような処理があるとします。
そして、ボタンを押されたイベントをネイティブで受け取る→暗黙的Intentでギャラリーを呼び出しファイルを選択させるということをしようとするとします。


この場合、WebChromeClientを拡張してopenFileChooserメソッド又はonShowFileChooserメソッド(Lollipop以上のバージョンはメソッド名が変わっています)をoverrideして、ネイティブ側でイメージ一覧を表示する方法が考えられますが、Android4.4ではopenFileChooserメソッドが取り除かれていてメソッドが呼び出されないという不具合があります。

Issue 62220 - android - openFileChooser not called when <input type="file"> is clicked on android 4.4 webview - Android Open Source Project - Issue Tracker - Google Project Hosting

2013年からissueに上がっているのですが、公式では対応されていないようです。

WebViewはAndroid4.4でChromiumベースに変わっており、5.0以上は、Androidのプラットフォームとは切り離されてアップデートされるようになりました。これによりアップデートの進まないAndroidプラットフォームでもWebViewのセキュリティ上の脆弱性に素早く対応出来るようになりました。
Android 5.0未満を切り捨てることが出来れば一番いいと思うんですが2016/06現在なかなかそうはいかないです。
WebView for Android - Google Chrome


(ところでAndroid 4.3以前のWebViewについてはGoogleはサポートを終了しているようですが、4.4ってまだサポート対象なのでしょうか?軽いググりではよくわかりませんでした。)

issueやStackOverflowではopenFileChooserが動かない問題に対してだいたい2つの対処方法をおすすめされていて、当エントリでも試したことを書いておきます。
何か間違いを発見したりもっと良い方法を御存知でしたらご教示いただけますととても助かりますm(_ _)m

対処方法
1. openFileChooserでイベントを受け取るのを諦めてJavaScriptInterfaceを利用してWeb側からネイティブのメソッドを呼び出してもらう。
2.Chromiumエンジンで作られているWebViewのバックポートライブラリをアプリに組み込み、AndroidのWebViewの代わりに使用する。(ここではintel製のCrosswalkを使うものとします。)


利点と欠点
1.の方法

利点 欠点
・どのAndroidバージョンでもこの方法で動くはずなので、バージョン毎にコードを分けたりする必要がない。  ・外部ライブラリを使用しないのでアプリサイズが増えない。 ・WebページにAndroidのメソッドを呼び出すようにコーディングする必要があるので、Webページ側のコーディングが出来ない場合はこの方法は利用できない。  ・処理がWebページ側とネイティブ側にまたがっているのでコードを追いづらい。


2.の方法

利点 欠点
・4.4未満のバージョンでもChromiumエンジンの統一されたWebViewを使える。(ライブラリのメンテナンス状況によるがセキュリティ上のメリットも大きい) ・アプリサイズが大きくなる。(サンプルアプリを作ったら44Mを超えてしまいました。)  ・Google公式のサポート対象ではなくサードパーティ製のライブラリなので今後の開発状況が気になる。  ・WebViewをたくさん使うアプリなら導入のメリットがあると思うが、使用箇所が少ない場合は私には1の方が手軽に感じた。


どちらにも良し悪しあるので、プロジェクトによって合う方法を選べばいいと思います。
Multiple APKの機能を利用してAndroid5以降は公式のWebViewを使用してそれ以前のapkは2の方法を使うという手もあります。
開発コストがかかるので場合によりけりですが。



1.の方法の例

色んなところでおすすめされているサイト
Android Kitkat Webview image&nbsp;upload!codemumbai.wordpress.com
↑これを参考にして手元でテストしやすいように書き換えたサンプルです。
なお、サンプルではuploadボタンが付いていますがAndroidネイティブ側ではファイルをアップロードする実装はしていません。

  1. ローカルのassetsフォルダ配下にhtmlファイルを置いておいてWebViewで表示しています。
  2. Browse Galleryボタンを押すと、JavaScriptのメソッドからAndroidネイティブで定義しているメソッドを呼び出します。WebViewのaddJavascriptInterfaceメソッドを用いてバインディングしています。JavascriptInterfaceの使い方はこちらをごらんください。https://developer.android.com/guide/webapps/webview.html#BindingJavaScript
  3. サンプルプロジェクト全体はこちらからご覧になれます。

github.com


file:///android_asset/index.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Website name</title>
    <script type="text/javascript">
        function browsepicture(){
            Android.openGallary();
        }
        function uploadfunction(){
            Android.uploadImage();
        }
        function setFileUri(uri) {
            document.getElementById('lbluri').innerHTML = uri;
        }

    </script>
</head>

<body>

<div data-role="page" id="pageone">
    <div data-role="header">
        <h1>Kitkat WebView</h1>
        <p><a href="https://codemumbai.wordpress.com/android-webview-image-upload-solved/">modified from android-webview-image-upload-solved</a>
        </p>
    </div>

    <div data-role="content">
        <input type="button" value="Browse Gallary" onClick="browsepicture()"/>
        <br>
        <label id="lbluri"></label>
        <br>
        <input type="button" value="Upload" onClick="uploadfunction()"/>
    </div>
</div>

</body>

</html>


MainActivity.java

package sakura_fish.com.exam.kitkatwebview;

import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.widget.Toast;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

import permissions.dispatcher.NeedsPermission;
import permissions.dispatcher.RuntimePermissions;

@RuntimePermissions
public class MainActivity extends AppCompatActivity {
    protected final int SELECT_PICTURE = 1;
    private Context mContext;
    private WebView mWebView;
    private boolean isImageSelected;

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

        mContext = this;
        isImageSelected = false;
        mWebView = (WebView) findViewById(R.id.webview);
        settingWebView();
        mWebView.loadUrl("file:///android_asset/index.html");
    }

    @Override
    protected void onResume() {
        if (mWebView != null) {
            mWebView.onResume();
        }
        super.onResume();
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (mWebView != null) {
            mWebView.onPause();
        }
    }

    @Override
    protected void onDestroy() {
        if (mWebView != null) {
            mWebView.stopLoading();
            mWebView.setWebChromeClient(null);
            mWebView.setWebViewClient(null);
            mWebView.destroy();
            mWebView = null;
        }
        mContext = null;

        super.onDestroy();
    }

    @SuppressLint("SetJavaScriptEnabled")
    protected void settingWebView() {

        mWebView.getSettings().setJavaScriptEnabled(true);
        mWebView.getSettings().setAllowFileAccess(true);
        // ここでバインディング
        mWebView.addJavascriptInterface(new WebAppInterface(), "Android");

        // openFileChooser not called on Android 4.4
//        mWebView.setWebChromeClient(new WebChromeClient() {
//            @SuppressWarnings("unused")
//            public void openFileChooser(ValueCallback<Uri> uploadMsg) {
//                selectImage();
//            }
//
//            @SuppressWarnings("unused")
//            public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType) {
//                selectImage();
//            }
//
//            // For Android 4.1
//            @SuppressWarnings("unused")
//            public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture) {
//                selectImage();
//            }
//
//            // Android 5.0 +
//            @Override
//            public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, FileChooserParams fileChooserParams) {
//                selectImage();
//                return true;
//            }
//        });
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        Log.d(MainActivity.class.getSimpleName(), "requestCode:" + requestCode + " resultCode:" + resultCode);
        if (requestCode == SELECT_PICTURE && resultCode == Activity.RESULT_OK) {
            Uri selectedImage = data.getData();

            setFileUriToWebView(selectedImage.toString());

            InputStream input = null;
            try {
                input = mContext.getContentResolver().openInputStream(data.getData());
                if (input != null) {
                    // create image cache file
                    isImageSelected = true;
                    createPickCache(data.getData());
                }
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
        }
    }

    private void setFileUriToWebView(String uriString) {
        mWebView.loadUrl("javascript:setFileUri('" + "uri : " + uriString + "')");
    }

    void checkStoragePermission() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
            chooseImage();
            return;
        }
        if (ActivityCompat.checkSelfPermission(mContext, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            MainActivityPermissionsDispatcher.chooseImageWithCheck(MainActivity.this);
        } else {
            chooseImage();
        }
    }

    @NeedsPermission({Manifest.permission.READ_EXTERNAL_STORAGE})
    void chooseImage() {
        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
        i.setType("image/*");
        startActivityForResult(Intent.createChooser(i, "File Chooser"), SELECT_PICTURE);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        MainActivityPermissionsDispatcher.onRequestPermissionsResult(MainActivity.this, requestCode, grantResults);
    }

    private boolean createPickCache(@NonNull final Uri uri) {
        try {
            InputStream in = null;
            in = mContext.getContentResolver().openInputStream(uri);
            Bitmap bitmap = BitmapFactory.decodeStream(in);
            in.close();
            if (bitmap == null) {
                Log.e(MainActivity.class.getSimpleName(), "bitmap is null!");
                return false;
            }

            // サーバー送信用に画像を圧縮してテンポラリファイルに保存する
            float maxImageSize = 1500;
            ByteArrayOutputStream stream = new ByteArrayOutputStream();

            try {
                float ratio = Math.min(
                        maxImageSize / bitmap.getWidth(),
                        maxImageSize / bitmap.getHeight());
                int width = Math.round(ratio * bitmap.getWidth());
                int height = Math.round(ratio * bitmap.getHeight());

                Bitmap newBitmap = Bitmap.createScaledBitmap(bitmap, width, height, false);
                newBitmap.compress(Bitmap.CompressFormat.JPEG, 70, stream);

                byte[] byteArray = stream.toByteArray();

                File file = new File(mContext.getCacheDir() + "cache.jpg");
                if (file.exists()) file.delete();
                FileOutputStream fo = null;
                fo = new FileOutputStream(file);
                fo.write(byteArray);
                fo.flush();
                fo.close();
                newBitmap.recycle();
                return true;
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                bitmap.recycle();
            }
            return false;
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return false;
    }

    private void sendImageToServer() {
        File file = new File(mContext.getCacheDir() + "cache.jpg");
        if (!file.exists()) {
            throw new IllegalStateException("No cache file!");
        }

        // TODO ここにサーバーに送信する処理を書く
        Toast.makeText(mContext, "TODO: 画像送信しました!", Toast.LENGTH_LONG).show();
        isImageSelected = false;
        setFileUriToWebView("");
    }

    // JavascriptInterface
    class WebAppInterface {

        @JavascriptInterface
        public void openGallary() {
            checkStoragePermission();
        }

        @JavascriptInterface
        public void uploadImage() {
            if (isImageSelected) {
                sendImageToServer();
            } else {
                Toast.makeText(mContext, "You need to select image!", Toast.LENGTH_LONG).show();
            }
        }
    }
}


2.の方法の例

CROSSWALKとは何ぞや?というところは、公式や参考サイト様をご覧になるのがいいと思います。

crosswalk-project.org

grandbig.github.io


当サンプルでは、CROSSWALKの埋め込みのViewを使います。
WebViewをCROSSWALKのViewに置き換えるイメージです。ファイルの選択はCrossWalkに任せています。

※Attention! こちらのサンプルではファイル選択→画像表示しかしていません。CrossWalkについて詳しい情報は公式サイトなどご覧ください。

サンプルの全体はこちらからご覧になれます。
github.com

/build.gradle

allprojects {
    repositories {
        jcenter()
        maven {
            url 'https://download.01.org/crosswalk/releases/crosswalk/android/maven2'
        }
    }
}

/app/build.gradle

dependencies {
    compile 'org.xwalk:xwalk_core_library:18.48.477.13'
}


file:///android_asset/index.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Website name</title>
</head>

<body>

<div data-role="page" id="pageone">
    <div data-role="header">
        <h1>CrossWalk</h1>
        <p><a href="https://crosswalk-project.org/documentation/android/embedding_crosswalk.html">Android
            embded runtime</a>
        </p>
    </div>

    <div data-role="content">
        <input type="file">
    </div>
</div>

</body>

</html>


MainActivity.java

package sakura_fish.com.exam.crosswalk;

import android.content.ContentResolver;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.webkit.ValueCallback;
import android.widget.ImageView;

import org.xwalk.core.XWalkUIClient;
import org.xwalk.core.XWalkView;

import java.io.FileNotFoundException;
import java.io.IOException;

public class MainActivity extends AppCompatActivity {
    private XWalkView mXWalkView;
    private ImageView mImageView;
    private ValueCallback<Uri> mUploadMessage;

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

        mXWalkView = (XWalkView) findViewById(R.id.xwalkview);
        mImageView = (ImageView) findViewById(R.id.imageview);
        settingXWalkView();
        mXWalkView.load("file:///android_asset/index.html", null);
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (mXWalkView != null) {
            mXWalkView.pauseTimers();
            mXWalkView.onHide();
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (mXWalkView != null) {
            mXWalkView.resumeTimers();
            mXWalkView.onShow();
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (mXWalkView != null) {
            mXWalkView.onDestroy();
        }
    }

    protected void settingXWalkView() {
        mXWalkView.setUIClient(new MyUIClient(mXWalkView));
    }

    class MyUIClient extends XWalkUIClient {
        MyUIClient(XWalkView view) {
            super(view);
        }

        @Override
        public void openFileChooser(XWalkView view, ValueCallback<Uri> uploadFile, String acceptType, String capture) {
            Log.d(MainActivity.class.getSimpleName(), "openFileChooser");
            super.openFileChooser(view, uploadFile, acceptType, capture);
            mUploadMessage = uploadFile;
        }
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        Log.d(MainActivity.class.getSimpleName(), "requestCode:" + requestCode + " resultCode:" + resultCode);

        if (mUploadMessage == null) return;
        Uri result = data == null || resultCode != RESULT_OK ? null : data.getData();
        mUploadMessage.onReceiveValue(result);
        mUploadMessage = null;

        Bitmap bm = null;
        ContentResolver resolver = getContentResolver();
        try {
            bm = MediaStore.Images.Media.getBitmap(resolver, result);
            mImageView.setImageBitmap(bm);
            bm = null;
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

参考サイト様
qiita.com
stackoverflow.com
stackoverflow.com
Embedding Crosswalk in Android Studio – diego.org
iti.hatenablog.jp
blog.asial.co.jp