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

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

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

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

```kotlin: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のエンドポイントにアクセスされることを前提としています。

```haskell: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リクエストが実行される。

```html: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を応答。

```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通信によるリダイレクトをさせる。

```javascript: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実行によりアクセスされる。

```html: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通信が成功したときアクセスされる。

```html: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アドレスです。

```java:タグ「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出力結果は以下。

```java:タグ「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を作りましょう）