AndroidのWebViewでは、addJavascriptInterfaceという、JavaオブジェクトをWebViewに埋め込む機能があります。これにより、WebView内で表示しているWebページに埋め込まれたJavascriptから、当該Javaオブジェクトで定義されたメソッドを呼び出せるようになります。
今回は、WebViewでのHTTP通信にOkHttpを利用した時、WebViewのコールバックメソッドはどう呼ばれるか軽く調べてみました。
読むのがめんどくさい方は最後の「まとめ」だけを読んでいただいてもよいです。
WebViewを使用するActivityの実装
WebViewを使用するActivityのソースコードはこんな感じです。
サンプルコードがお粗末なのはご愛嬌です。
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のエンドポイントにアクセスされることを前提としています。
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エンドポイントアクセスした際の通信フローをまとめると、以下のような感じになります。
- root
- "submitFormByJs"
- "getJsBySrc"
- "doHttpReqFromJsCode.js"
- "fromXMLHttpReq"
- "onReceivedXHRResAndSetLocationHref"
1. root
HTTPステータス302で"submitFormByJs"へリダイレクト
2. "submitFormByJs"
<form>タグにより、"getJsBySrc"エンドポイントへのPOSTリクエストを送出するhtmlを応答。
WebViewがこのHTMLを解釈するときに、上記POSTリクエストが実行される。
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を応答。
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通信によるリダイレクトをさせる。
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実行によりアクセスされる。
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通信が成功したときアクセスされる。
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アドレスです。
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出力結果は以下。
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を作りましょう)