0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

小ネタ: WebView のすべてのリクエストに追加のヘッダーをつけるには

0
Posted at

Android の WebView でリクエストに任意のヘッダーをつけたいことがあると思います。(正直ないと思います)

そういうときは WebView#loadUrl の引数の additionalHttpHeaders に指定するわけですが、

このヘッダーはそのロード対象のドキュメントにしか追加されないので、ドキュメント内のスクリプトや画像などのリソースのサブリクエストには追加されません!

したがって、仮に特別なヘッダーがないと閲覧できないようなアクセス制限されたリソースがあると困りそうです。

とはいえ、そのようなページは通常の Web ブラウザでも閲覧できないので、一般的な用途で問題になることはないと思いますが、アプリの WebView 用にページを用意しているユースケースでごくまれにあるかもしれません。(正直ないと思います)

サンプル

このようなよくある WebView を使ったサンプルコードを用意しました:

MyWebView.kt
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun MyWebView(
    url: String,
    modifier: Modifier = Modifier,
    additionalHttpHeaders: Map<String, String> = emptyMap(),
) {
    AndroidView(
        factory = { context ->
            WebView(context).apply {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT,
                )
                webViewClient = object : WebViewClient() {
                    override fun shouldOverrideUrlLoading(
                        view: WebView,
                        request: WebResourceRequest,
                    ): Boolean {
                        return false
                    }
                }
                settings.javaScriptEnabled = true
                settings.domStorageEnabled = true
                loadUrl(url, additionalHttpHeaders)
            }
        },
        modifier = modifier,
    )
}

Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colorScheme.background,
) {
    MyWebView(
        url = "https://www.google.co.jp/",
    )
}

こういう結果になるのは想像通りですね:

WebView.loadUrl に指定した場合

ここで WebView#loadUrl の引数の additionalHttpHeaders に指定することで、追加のヘッダーを指定してみることにします:

MyWebView(
    url = "https://postman-echo.com/get",
    additionalHttpHeaders = mapOf(
        "X-My-App-Header" to "Value",
    ),
)

ヘッダーをダンプするために、https://postman-echo.com/get を取得するようにしています。

リクエストヘッダーを確認すると、ナビゲーションするページのリクエストに指定したヘッダーが追加されていることがわかります:

ページ遷移後

ためしに https://postman-echo.com/get へのリンクだけを置いたページを表示し、遷移で挙動が変わるかを確認してみます:

→

ページからの遷移時には付加されていないことがわかります。

これについては、WebViewClient#shouldOverrideUrlLoading をオーバーライドしている箇所で明示的にヘッダーを追加し読み込みをすることで解決します:

  AndroidView(
      factory = { context ->
          WebView(context).apply {
              layoutParams = ViewGroup.LayoutParams(
                  ViewGroup.LayoutParams.MATCH_PARENT,
                  ViewGroup.LayoutParams.MATCH_PARENT,
              )
              webViewClient = object : WebViewClient() {
                  override fun shouldOverrideUrlLoading(
                      view: WebView,
                      request: WebResourceRequest,
                  ): Boolean {
-                     return false
+                     // iframe 内の遷移まで loadUrl するとメインフレームに昇格してしまうので、
+                     // サブフレームは WebView に任せる
+                     // 本当は mailto: や tel: などの非 HTTP(S) スキームは loadUrl に渡すと失敗するので、そこのハンドリングも必要
+                     if (!request.isForMainFrame) return false
+                     // リンク遷移などのナビゲーションにも常に追加ヘッダーを付ける
+                     view.loadUrl(request.url.toString(), additionalHttpHeaders)
+                     return true
                  }
              }
              settings.javaScriptEnabled = true
              settings.domStorageEnabled = true
              loadUrl(url, additionalHttpHeaders)
          }
      },
      modifier = modifier,
  )

サブリクエストにヘッダーを付与する

さて、ページ遷移も含め、対象のドキュメントのリクエストには追加のヘッダーを乗せることができました。

しかしながら、ページ内の画像やスクリプトのサブリクエストはどうでしょうか? テストサーバーを立ててリクエストをログで確認できるようにしてみます。

server.py
import http.server

class MyHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        print("\n--- Request Headers ---")
        print(self.headers)
        super().do_GET()
        print("-----------------------\n")

if __name__ == "__main__":
    http.server.test(HandlerClass=MyHandler, port=8888)
index.html
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title></title>
	</head>
	<body>
		<main>
			<p><img src="/image.png" /></p>
			<p><a href="https://postman-echo.com/get">https://postman-echo.com/get</a></p>
		</main>
	</body>
</html>

ログを確認してみます。
最初のドキュメントのリクエストには付加されていますが、後続の画像のリクエストにはなさそうです:

before

無理やりつけてみる

これを解決するためにはどうすればいいかというと、WebViewClient#shouldInterceptRequest でリクエストを無理やり肩代わりすることができそうです:

したがって、WebViewClient を継承した MyWebViewClient をつくり、shouldInterceptRequest で介入してみます:

MyWebViewClient.kt
class MyWebViewClient(
    private val httpClient: OkHttpClient = OkHttpClient.Builder()
        // WebView と Cookie ストアを共有し、ログインセッションなどを引き継ぐ
        .cookieJar(WebViewCookieJar())
        .build(),
    private val additionalHttpHeaders: Map<String, String>,
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest,
    ): Boolean {
        // iframe 内の遷移まで loadUrl するとメインフレームに昇格してしまうので、
        // サブフレームは WebView に任せる
        // 本当は mailto: や tel: などの非 HTTP(S) スキームは loadUrl に渡すと失敗するので、そこのハンドリングも必要
        if (!request.isForMainFrame) return false
        // リンク遷移などのナビゲーションにも常に追加ヘッダーを付ける
        view.loadUrl(request.url.toString(), additionalHttpHeaders)
        return true
    }

    override fun shouldInterceptRequest(
        view: WebView?,
        request: WebResourceRequest?,
    ): WebResourceResponse? {
        val url = request?.url?.toString() ?: return null
        // WebView はリクエストボディを渡してくれないため、GET 以外を OkHttp で再現すると
        // フォーム送信などが壊れる。GET 以外は WebView 自身に処理させる。
        if (request.method != "GET") return null

        val okHttpRequest: Request = Request.Builder()
            .url(url)
            .apply {
                // User-Agent や Accept など WebView が付けた元のヘッダーを引き継ぐ。
                // ただし Accept-Encoding を自前で付けると OkHttp の透過的な gzip 解凍が無効になり、
                // 圧縮されたままのボディを WebView に渡してしまうので除外する。
                request.requestHeaders
                    .filterKeys { !it.equals("Accept-Encoding", ignoreCase = true) }
                    .forEach { (name, value) -> addHeader(name, value) }
                additionalHttpHeaders.forEach { (name, value) -> header(name, value) }
            }
            .build()

        return try {
            val response = httpClient.newCall(okHttpRequest).execute()
            // OkHttp は通常リダイレクトを追従するが、Location 無しなどで 3xx が残ることがある。
            // WebResourceResponse は 3xx を受け付けず例外を投げるので、その場合は WebView に任せる
            if (response.code in 300..399) {
                response.close()
                return null
            }
            response.toWebResourceResponse()
        } catch (e: IOException) {
            null
        }
    }

    private fun Response.toWebResourceResponse(): WebResourceResponse {
        val contentType = body?.contentType()
        return WebResourceResponse(
            // WebResourceResponse の mimeType に "; charset=..." が混ざると描画に失敗することがあるので
            // type/subtype だけを渡す
            contentType?.let { "${it.type}/${it.subtype}" },
            contentType?.charset()?.name(),
            code,
            // reasonPhrase が空だと WebResourceResponse が IllegalArgumentException を投げる。
            // HTTP/2 では message が空になるためフォールバックを入れる
            message.ifEmpty { "OK" },
            headers.toMap(),
            body?.byteStream(),
        )
    }
}
WebViewCookieJar.kt
class WebViewCookieJar(
    private val cookieManager: CookieManager = CookieManager.getInstance(),
) : CookieJar {

    override fun loadForRequest(url: HttpUrl): List<Cookie> {
        val cookieHeader = cookieManager.getCookie(url.toString()) ?: return emptyList()
        return cookieHeader
            .split(';')
            .mapNotNull { Cookie.parse(url, it.trim()) }
    }

    override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
        cookies.forEach { cookieManager.setCookie(url.toString(), it.toString()) }
    }
}

ここではカスタムヘッダーを載せて OkHttp を使いリクエストをし、その結果を返してあげるようにしてみました。

Claude くんにレビューしてもらったところ「ここも! ここも!」と言われて、ハイハイ……と直していったら、こんなにコードが膨らんだことからも、考慮する点がかなりあることがわかります。

さて、結果ですが、リクエストヘッダーをみると付加されていることがわかります:

after

自分で OkHttp を使用してリクエストを行うことで、自前のヘッダー処理を経由したリクエスト結果を返すようにしてあげるという力技なやりかたですね。他方、OkHttp の Interceptor などのレールの上に乗ることができるという意味でもありますが……。

しかし……

しかしながら、この例では POST に対応できてないとか、Cookie など本来 WebView が管理してくれるものまで自前で面倒を見ることになります。正直なところこれを見て積極的に採用したくはなくなっているでしょう!

できなくはないものの、WebView の簡単さが失われ、将来的な負債のタネにもなろうかと思うので、ここまでのユースケースの場合は WebView 自体を利用すべきか再考できるとよいかと思います。

以上です!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?