Help us understand the problem. What is going on with this article?

OkHttpを使った場合にWebViewのJavascriptInterfaceはどう呼ばれるか軽く調べてみた

AndroidのWebViewでは、addJavascriptInterfaceという、JavaオブジェクトをWebViewに埋め込む機能があります。これにより、WebView内で表示しているWebページに埋め込まれたJavascriptから、当該Javaオブジェクトで定義されたメソッドを呼び出せるようになります。

今回は、WebViewでのHTTP通信にOkHttpを利用した時、WebViewのコールバックメソッドはどう呼ばれるか軽く調べてみました。
読むのがめんどくさい方は最後の「まとめ」だけを読んでいただいてもよいです。

WebViewを使用するActivityの実装

WebViewを使用するActivityのソースコードはこんな感じです。
サンプルコードがお粗末なのはご愛嬌です。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    companion object {
        const val TAG = "WebViewTest_"
        const val TAG_OKHTTP = "OkHttp_WebViewTest"
        const val TAG_TID = "Tid_webView"
        const val JS_INTERFACE_NAME = "Android"
        const val INITIAL_ENDPOINT = "http://${sever_ipAddress}:10000/"
    }

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        initWebView()
    }

    @SuppressLint("JavascriptInterface", "SetJavaScriptEnabled")
    fun initWebView() {
        Log.d(TAG_TID, "MainActivity#initWebView called. tid = " + Thread.currentThread().id)
        binding.webView.apply {
            settings.javaScriptEnabled = true
            addJavascriptInterface(this@MainActivity, JS_INTERFACE_NAME)

            webViewClient = object: WebViewClient() {
                override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
                    Log.d(TAG, "shouldOverrideUrlLoading called. url = " + request?.url.toString())
                    return super.shouldOverrideUrlLoading(view, request)
                }

                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                    Log.d(TAG, "onPageStarted called. url = " + url!!)
                    super.onPageStarted(view, url, favicon)
                }

                override fun onPageFinished(view: WebView?, url: String?) {
                    Log.d(TAG, "onPageFinished called. url = " + url!!)
                    super.onPageFinished(view, url)
                }

                override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
                    Log.d(TAG, "onReceivedError error = " + error!!)
                    super.onReceivedError(view, request, error)
                }

                override fun onLoadResource(view: WebView?, url: String?) {
                    Log.d(TAG, "onLoadResource called. url = " + url!!)
                    super.onLoadResource(view, url)
                }

                override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
                    Log.d(TAG_TID, "shouldInterceptRequest called. tid = " + Thread.currentThread().id)
                    if (!request?.url.toString().endsWith("/favicon.ico")) {
                        Log.d(TAG, "shouldInterceptRequest. url = " + request?.url.toString())
                    }
                    val latch = CountDownLatch(1)

                    var res: InputStream? = null
                    val call = if (request!!.url.path!!.endsWith("/getJsBySrc") or
                                request.url.path!!.endsWith("/doHttpReqFromJsCode.js")
                    ) {
                        createOkHttpClient().newCall(Request.Builder().url(request.url.toString()).method("POST", RequestBody.create(null, "hoge")).build())
                    } else {
                        createOkHttpClient().newCall(Request.Builder().url(request.url.toString()).build())
                    }

                    call.enqueue(object: Callback {
                        override fun onFailure(call: Call, e: IOException) {
                            //Log.d(TAG_OKHTTP, "okhttp Callback#onFailure called. callUrl = " + call.request().url())
                            Log.d(TAG_OKHTTP, "okhttp Callback#onFailure called. error = " + e.message.toString())
                            latch.countDown()
                        }

                        override fun onResponse(call: Call, response: Response) {
                            //Log.d(TAG_OKHTTP, "okhttp Callback#onResponse called. callUrl = " + call.request().url())
                            Log.d(TAG_OKHTTP, "okhttp Callback#onResponse called. resUrl = " + response.request().url())
                            res = response.body()?.byteStream()
                            latch.countDown()
                        }
                    })

                    latch.await()
                    return WebResourceResponse(
                        "text/html", "UTF-8",
                        res
                    )
                }
            }
            loadUrl(INITIAL_ENDPOINT)
        }
    }

    private val cookieStore = HashMap<String, MutableList<Cookie>>()

    fun createOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addNetworkInterceptor { chain ->
                Log.d(TAG_TID, "okhttp intercepted: tid = " + Thread.currentThread().id)
                Log.d(TAG_OKHTTP, "okhttp intercepted: " + chain.request().url().toString())
                chain.proceed(chain.request())
            }
            //.connectTimeout(1, TimeUnit.MILLISECONDS)
            .cookieJar(object: CookieJar {
                override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) {
                    cookieStore[url.host()] = cookies
                }

                override fun loadForRequest(url: HttpUrl): MutableList<Cookie> {
                    val cookies = cookieStore[url.host()]
                    return cookies ?: ArrayList()
                }
            })
            .build()
    }

    @JavascriptInterface
    fun showToast(str: String) {
        Toast.makeText(this, str, Toast.LENGTH_LONG).show()
    }
}

ポイントは以下。

  • MainActivityをJavascriptInterfaceとして「Android」という名前で追加
  • showToastというメソッドをWebView内からJavascriptにて実行できるよう定義
  • 各コールバックが呼ばれるタイミングでLogを出力し、どのようにHTTP通信が制御されているのか確認

サーバ側の実装

サーバ側の実装は以下です。今回はクライアントからrootのエンドポイントにアクセスされることを前提としています。

app/Main.hs
  1 {-# LANGUAGE OverloadedStrings #-}                                                                                                                                                                                                        
  2 module Main where
  3 
  4 import Network.Wai.Middleware.Static
  5 import Network.HTTP.Types.Status
  6 import Web.Spock
  7 import Web.Spock.Config
  8 
  9 import Control.Monad.Trans
 10 import qualified Data.Text as T
 11 
 12 data MySession = MySession { msUserId :: Maybe String }
 13 
 14 main :: IO ()
 15 main = do
 16   spockCfg <- defaultSpockCfg (MySession Nothing) PCNoDatabase ()
 17   runSpock 10000 (spock spockCfg app)
 18 
 19 app :: SpockM () MySession () ()
 20 app = do
 21   middleware $ staticPolicy (addBase "static")
 22   get root $
 23       (modifySession $ \sess -> sess { msUserId = Just "dummy" }) >> redirect "http://${server_ipAddress}:10000/submitFormByJs"
 24   get ("submitFormByJs") $ do
 25       sess <- readSession
 26       case msUserId sess of
 27         Nothing -> setStatus status401 >> text "No session"
 28         Just _ -> (liftIO . readFile $ "static/submitFormByJs.html") >>= html . T.pack
 29   post ("getJsBySrc") $
 30       (liftIO . readFile $ "static/getJsBySrc.html") >>= html . T.pack
 31   post ("doHttpReqFromJsCode.js") $
 32       file "application/javascript" "static/doHttpReqFromJsCode.js"
 33   get ("fromXMLHttpReq") $
 34       (liftIO . readFile $ "static/fromXMLHttpReq.html") >>= html . T.pack
 35   get ("onReceivedXHRResAndSetLocationHref") $
 36       (liftIO . readFile $ "static/onReceivedXHRResAndSetLocationHref.html") >>= html . T.pack
 37   get ("favicon.ico") $
 38       file "image/png" "favicon.ico"
~                                                    

通信フローまとめ

上記のWebサーバのrootエンドポイントアクセスした際の通信フローをまとめると、以下のような感じになります。

  1. root
  2. "submitFormByJs"
  3. "getJsBySrc"
  4. "doHttpReqFromJsCode.js"
  5. "fromXMLHttpReq"
  6. "onReceivedXHRResAndSetLocationHref"

1. root

HTTPステータス302で"submitFormByJs"へリダイレクト

2. "submitFormByJs"

<form>タグにより、"getJsBySrc"エンドポイントへのPOSTリクエストを送出するhtmlを応答。
WebViewがこのHTMLを解釈するときに、上記POSTリクエストが実行される。

submitFormByJs.html
  1 <html>
  2 <body>                                                                                                                                                                                                                                    
  3    <script type="text/javascript">
  4      const ua = window.navigator.userAgent
  5      if (ua.includes("Android")) {
  6        Android.showToast("A post request is going to be sent from a <form> tag.");
  7      }
  8    </script>
  9    <script type="text/javascript">
 10         function doPost() {
 11             document.form1.method = "post";
 12             document.form1.submit();
 13         }
 14     </script>
 15     <form name="form1"  action="http://${server_ip}:10000/getJsBySrc" method="post">
 16     </form>
 17 <h1>submitFormByJs</h1>
 18    <script type="text/javascript">
 19       doPost();
 20    </script>
 21 
 22 </body>
 23 </html>
~               

3. "getJsBySrc"

<script>タグの実行により、エンドポイント"doHttpReqFromJsCode"へのGETリクエストを行うhtmlを応答。

getJsBySrc.html
 1 <html>                                                                                                                                                                                                                                    
  2 <body> 
  3 <h1>GetJSBySrc</h1>                                                                                                
  4    <!-- <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script> -->               
  5   <script type="text/javascript">                                                                                  
  6     const ua = window.navigator.userAgent                                                                          
  7     if (ua.includes("Android")) {                                                                                  
  8       Android.showToast("A javascript file is going to be loaded by <script ... src=...>");                        
  9     }
 10   </script>
 11   <script type="text/javascript" src="http://${server_ip}:10000/doHttpReqFromJsCode.js"></script>               
 12 </body>
 13 </html>                                                                                                                                   

4. "doHttpReqFromJsCode.js"

XMLHttpReqeustにより、エンドポイント"fromXMLHttpReq"へHTTP通信を行うJavascriptを応答。
上記通信が成功したときに、location.hrefによりエンドポイント"onReceivedXHRResAndSetLocationHref"へGET通信によるリダイレクトをさせる。

doHttpReqFromJsCode.js
  1 const xhr = new XMLHttpRequest();                                                                                                                                                                                                         
  2  
  3 xhr.open('POST', 'http://${server_ip}:10000/fromXMLHttpReq.html');                                              
  4 xhr.send();
  5  
  6 xhr.onreadystatechange = function() {
  7     if(xhr.readyState === 4 && xhr.status === 200) {
  8       const ua = window.navigator.userAgent
  9       if (ua.includes("Android")) {
 10         Android.showToast("doHttpReqFromJsCode.js"); 
 11       }
 12       location.href="http://${server_ip}:10000/onReceivedXHRResAndSetLocationHref"
 13     }
 14 }

5. "fromXMLHttpReq"

doHttpReqFromJsCode.js内の、XMLHttpRequest実行によりアクセスされる。

fromXMLHttpReq.html
  1 <html>                                                                                                                                                                                                                                    
  2 <body>
  3 <h1>XMLHttpRequest</h1>
  4 <script type="text/javascript">
  5   const ua = window.navigator.userAgent
  6   if (ua.includes("Android")) {
  7     Android.showToast("XMLHttpRequest succeeded. location.href is going to be modified.");
  8   }
  9 </script>
 10 </body>
 11 </html>

6. ""onReceivedXHRResAndSetLocationHref"

doHttpReqFromJsCode.jsによるXMLHttpRequest通信が成功したときアクセスされる。

onReceivedXHRResAndSetLocationHref.html
  1 <html>                                                                                                                                                                                                                                    
  2 <body>
  3 <script type="text/javascript">
  4   const ua = window.navigator.userAgent
  5   if (ua.includes("Android")) {
  6     Android.showToast("location.href has been modified.");
  7   }
  8 </script>
  9 <h1>Received XMLHttpRequest's response</h1>
 10 </body>
 11 </html>

ログ出力結果

WebViewでloadUrl("http://${server_ip}:10000/")した際のログ出力は以下です。
※192.168.100.151は筆者のローカルマシンのIPアドレスです。

タグ「WebViewTest_」でフィルター
  1 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/                                                                                                                                                               
  2 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/
  3 D/WebViewTest_: JavascriptInterface method called. str = A post request is going to be sent from a <form> tag.
  4 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/getJsBySrc
  5 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/
  6 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/getJsBySrc
  7 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/doHttpReqFromJsCode.js
  8 D/WebViewTest_: JavascriptInterface method called. str = A javascript file is going to be loaded by <script ... src=...>
  9 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/fromXMLHttpReq.html
 10 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/getJsBySrc
 11 D/WebViewTest_: JavascriptInterface method called. str = doHttpReqFromJsCode.js
 12 D/WebViewTest_: shouldOverrideUrlLoading called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref
 13 D/WebViewTest_: onPageStarted called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref
 14 D/WebViewTest_: shouldInterceptRequest. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref
 15 D/WebViewTest_: JavascriptInterface method called. str = location.href has been modified.
 16 D/WebViewTest_: onPageFinished called. url = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref

OkHttp通信の箇所のLog出力結果は以下。

タグ「OkHttp_WebViewTest」でフィルター
 18 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/
 19 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/submitFormByJs
 20 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/submitFormByJs
 21 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/getJsBySrc
 22 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico
 23 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/getJsBySrc
 24 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico
 25 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/doHttpReqFromJsCode.js
 26 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/doHttpReqFromJsCode.js
 27 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/fromXMLHttpReq.html
 28 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/fromXMLHttpReq.html
 29 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico
 30 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico
 31 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref
 32 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/onReceivedXHRResAndSetLocationHref 
 33 D/OkHttp_WebViewTest: okhttp intercepted: http://192.168.100.151:10000/favicon.ico
 34 D/OkHttp_WebViewTest: okhttp Callback#onResponse called. resUrl = http://192.168.100.151:10000/favicon.ico

まとめ

上記結果をまとめてみます。(あくまで筆者のローカル環境で検証した結果のため、挙動に差異が出る可能性あり。)

WebViewのshouldInterceptRequestが呼ばれるパターン

  • HTML内の<form>タグで発生するHTTP通信
  • HTML内の<script type="text/javascript" src="...">で発生するHTTP通信
  • HTTP通信によるJavascript取得(GETリクエストで"xxx.js"にアクセス)
  • JavascriptでのXMLHttpRequestでのHTTP通信
  • HTML内でのlocation.href変更によるリダイレクト

HTTPステータス302によるリダイレクトではWebViewのshouldInterceptRequestは呼ばれなかった。
OkHttpは(デフォルトでは)HTTPステータス302リダイレクトをよしなにハンドリングして、レスポンスを取得するため、rootエンドポイントからのリダイレクト先"submitFormByJs"に対して、WebViewのshouldInterceptRequestは上記の実装では呼ばれない。

JavascriptInterfaceメソッドが呼ばれるパターン

  • HTML内の<form>タグで発生するHTTP通信
  • HTML内の<script type="text/javascript" src="...">で発生するHTTP通信
  • HTTP通信によるJavascript取得(GETリクエストで"xxx.js"にアクセス)
  • JavascriptでのXMLHttpRequestでのHTTP通信
  • HTML内でのlocation.href変更によるリダイレクト

上記の実装では、XMLHttpRequest通信でアクセスする"fromXMLHttpReq"でのJavascriptInterfaceメソッド呼び出しは実行されなかった。レスポンスをWebViewで表示しようとすれば呼ばれるかもしれませんが、時間なかったので調べてません。

その他JavascriptInterface呼び出しについて思うこと

・Android Developers公式サイトにも記載の通り、Javascriptを利用したWebView <-> Native間のデータ連携はセキュリティリスクが伴うので、なるべくやりたくない。

・上記の通り、リダイレクトが絡むJavascriptInterfaceの実行制御はトリッキーな実装になりがちなので、既存のWebサービスがあるからといって、WebAPIの作成を怠って、安易にJavascriptInterfaceを採用するべきではないでしょう。WebViewの主目的はWebコンテンツを表示することのはずです。WebAPI代わりに使ったら、無駄なHTML評価や画像取得とか色々無駄な処理が走っちゃいますね。無駄な処理を省くには結局、スクラッチレベルでHTMLを解析し、必要な処理だけ通信を行う処理を書く必要がありそうです(極力、スマホアプリで使用する専用のWebAPIを作りましょう)。

・WebViewは文字通りViewの一種なので、単純なWebAPIのように、任意のタイミングでHTTP通信実行とか、複数画面にまたがるデータ保持とか難しい。AndroidシステムのWindowにアタッチした状態でないと、WebViewによるHTMLの評価は走らないのではないでしょうか(よく知りませんが)。AndroidコンポーネントであるServiceクラスにWebViewを定義してHTMLの評価ができるみたいな情報もありますが、できたとしてもバグが出そうですね。商用のアプリでは採用すべきではないでしょう。(スマホアプリで使用する専用のWebAPIを作りましょう)

・JavascriptInterfaceの使用は、基本、既存のシステム設計上止むを得ない場合のみに、セキュリティを十分に考慮した上で使用するに留めた方が良い(スマホアプリで使用する専用のWebAPIを作りましょう)

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away