TypeScript (Angular) で実装されたサイトを Android WebView(Kotlin) で動かす方法

# はじめに

タイトルの通りなのですが、当初 TWA (Trusted Web Activity) を使って動かしていましたが、
**「○○をタップしたときに××を表示したい」**
という要件が出てきました。

調べてみたところ TWA では実装者側でハンドリングできることがほぼ無く…
（できるのは [起動時に表示するページぐらい](https://qiita.com/koichi-ozaki/items/a67db93484fed9b570fa) で、あとはまるっと Chrome さんにおまかせ！ といった感じだった）

 [WebView](https://developer.android.com/reference/android/webkit/WebView) を使って動かすことになりました。

この記事では TypeScript で実装されたサイト (Angular フレームワークが使われている) を、WebView でひととおりの機能が動くようになるまでの道のりをまとめようと思います。

**※ TWA でクリックイベントなどをハンドリングする方法があれば教えていただきたいです！**

# 環境
macOS Mojave - 10.14.6
Android Studio - 3.5
Kotlin - 1.3.31

# 方法

## ソースコード

まずはソースコードです。

[Kotlin Android Extensions](https://kotlinlang.org/docs/tutorials/android-plugin.html) を使っています。
（Kotlin Android Extensions とは、 layout ファイルに Activity を定義することで findViewById をやらなくてよくなる便利な Plugin です。）

### Activity

```kotlin:WebViewActivity.kt

class WebViewActivity : AppCompatActivity() {

    // <input type="file"> を反応させるために使用します
    private val INPUT_FILE_REQUEST_CODE = 101
    private var mFilePathCallback: ValueCallback<Array<Uri>>? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_webview)

        // WebView の設定
        webView.apply {
            settings.apply {
                javaScriptEnabled = true
                builtInZoomControls = true
                displayZoomControls = false
                domStorageEnabled = true
                allowFileAccess = true
                allowContentAccess = true
                allowFileAccessFromFileURLs = true
                javaScriptCanOpenWindowsAutomatically = true
                databaseEnabled = true
                loadsImagesAutomatically = true
                loadWithOverviewMode = true
                useWideViewPort = true
                isFocusableInTouchMode = true
                setSupportMultipleWindows(true)
                setAppCacheEnabled(true)
                setSupportZoom(true)

                userAgentString += " TEST_USER_AGENT/" + BuildConfig.VERSION_NAME

                setOnKeyListener { view, keyCode, keyEvent ->
                    if (keyEvent.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack() == true) {
                        webView.goBack()
                        return@setOnKeyListener true
                    }
                    return@setOnKeyListener false
                }

                loadUrl("TypeScriptで実装されたサイトのURL")
            }
        }
        webView.webViewClient = CustomWebViewClient(this)
        webView.webChromeClient = CustomWebChromeClient(this)

        // Pull to Refresh したときに実行させる処理です
        swipeRefreshLayout.setOnRefreshListener {
            webView.reload()
            swipeRefreshLayout.isRefreshing = false
        }
    }

    // <input type="file"> をタップしてファイル選択後に実行される処理です
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == INPUT_FILE_REQUEST_CODE) {
            if (mFilePathCallback == null) {
                super.onActivityResult(requestCode, resultCode, data)
                return
            }
            var results: Array<Uri>? = null

            if (resultCode == Activity.RESULT_OK) {
                val dataString = data?.getDataString()
                if (dataString != null) {
                    results = arrayOf(Uri.parse(dataString))
                }
            }
            mFilePathCallback?.onReceiveValue(results)
            mFilePathCallback = null
        }
    }

    // WebViewClient のカスタムクラス
    private class CustomWebViewClient internal constructor(private val activity: WebViewActivity) :
        WebViewClient() {

        // shouldOverrideUrlLoading() では反応しません
        override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
            super.doUpdateVisitedHistory(view, url, isReload)

            Log.d("TEST", "activity.webView.url:" + activity.webView.url)

            // *** ここでクリックイベントを補足して任意の処理を実行させる *** //
        }
    }

    // WebChromeClient のカスタムクラス
    private class CustomWebChromeClient internal constructor(private val activity: WebViewActivity) :
        WebChromeClient() {

        // JavaScript を動かすために必要です
        override fun onJsAlert(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            return false
        }

        // JavaScript を動かすために必要です
        override fun onJsConfirm(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            view ?: return true

            MaterialDialog(view.context).show {
                title(text = message)
                positiveButton {
                    result?.confirm()
                }
                negativeButton {
                    result?.cancel()
                }
                onCancel {
                    result?.cancel()
                }
            }
            return true
        }

        // JavaScript を動かすために必要です
        override fun onJsPrompt(
            view: WebView?,
            url: String?,
            message: String?,
            defaultValue: String?,
            result: JsPromptResult?
        ): Boolean {
            return false
        }

        // <input type="file"> を反応させるために必要です
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            if (activity.mFilePathCallback != null) {
                activity.mFilePathCallback?.onReceiveValue(null)
            }
            activity.mFilePathCallback = filePathCallback

            val intent = Intent(Intent.ACTION_GET_CONTENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            intent.type = "image/*"

            activity.startActivityForResult(
                intent,
                activity.INPUT_FILE_REQUEST_CODE
            )
            return true
        }

        // <a target="_blank"> を反応させるために必要です
        override fun onCreateWindow(
            view: WebView?,
            isDialog: Boolean,
            isUserGesture: Boolean,
            resultMsg: Message?
        ): Boolean {

            view ?: return false

            val href = view.handler.obtainMessage()
            view.requestFocusNodeHref(href)
            val url = href.data.getString("url")

            view.stopLoading()
            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
            activity.startActivity(browserIntent)
            return true
        }

        // console.log を Logcat に表示させます
        override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
            if (consoleMessage != null) {
                Log.d(
                    "TEST",
                    "[" + consoleMessage.messageLevel() + "] " + consoleMessage.message() + " - " + consoleMessage.sourceId() + "：" + consoleMessage.lineNumber() + "行目"
                )
            }
            return super.onConsoleMessage(consoleMessage)
        }
    }
}
```

### layout

つづいてレイアウトファイルです。

Pull to Refresh させるために `SwipeRefreshLayout` 内に WebView を設置しています。

```xml:activity_webview.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/frameLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WebViewActivity">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <WebView
            android:id="@+id/webView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
```

## なにをやっているか見ていく

### WebView の設定をする

```kotlin
        webView.apply {
            settings.apply {
                javaScriptEnabled = true
                builtInZoomControls = true
                displayZoomControls = false
                domStorageEnabled = true
                allowFileAccess = true
                allowContentAccess = true
                allowFileAccessFromFileURLs = true
                javaScriptCanOpenWindowsAutomatically = true
                databaseEnabled = true
                loadsImagesAutomatically = true
                loadWithOverviewMode = true
                useWideViewPort = true
                isFocusableInTouchMode = true
                setSupportMultipleWindows(true)
                setAppCacheEnabled(true)
                setSupportZoom(true)

                userAgentString += " TEST_USER_AGENT/" + BuildConfig.VERSION_NAME

                setOnKeyListener { view, keyCode, keyEvent ->
                    if (keyEvent.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack() == true) {
                        webView.goBack()
                        return@setOnKeyListener true
                    }
                    return@setOnKeyListener false
                }

                loadUrl("TypeScriptで実装されたサイトのURL")
            }
        }
        webView.webViewClient = CustomWebViewClient(this)
        webView.webChromeClient = CustomWebChromeClient(this)
```

JavaScript、Storage などを許可、UserAgent の設定、カスタムしたWebViewClient、WebChromeClient のセットをします。

各設定項目は以下のとおりです。

| 項目 | 内容 |
|:-----------------|:------------------|
| javaScriptEnabled | JavaScriptの実行を有効にするか |
| builtInZoomControls | ズームを使用するか |
| displayZoomControls | ズームを使用するときに、WebViewにズームコントロールを表示するか |
| domStorageEnabled | DOMストレージを有効にするか |
| allowFileAccess | WebView内のファイルアクセスを有効にするか |
| allowContentAccess | WebView内のコンテンツURLアクセスを有効にするか |
| allowFileAccessFromFileURLs | ファイルスキームURLのコンテキストで実行されているJavaScriptが他のファイルスキームURLのコンテンツにアクセスできるようにするか<br>（意味がよくわかっていない…） |
| javaScriptCanOpenWindowsAutomatically | JavaScriptでウィンドウを自動的に開くか |
| databaseEnabled | データベースストレージを有効にするか |
| loadsImagesAutomatically | WebViewが画像リソースをロードするか |
| loadWithOverviewMode | 画面幅に収まるようにロードするか |
| useWideViewPort | HTMLメタタグの viewport を有効にするか |
| isFocusableInTouchMode | 画面をタッチしたときにフォーカスあてるか？ |
| setSupportMultipleWindow | 複数のウィンドウをサポートするか |
| setAppCacheEnabled | キャッシュを有効にするか |
| setSupportZoom | ズームを有効にするか |

[公式ドキュメント WebViewSettings](https://developer.android.com/reference/android/webkit/WebSettings)

WebView で設定できる項目がこんなにあるとは知りませんでした。。
中にはデフォルトが true でわざわざ設定しなくてもよかったものが含まれてましたね。。

UserAgent はコードを見たまんまですが、`userAgentString += 〜` で設定しています。

`setOnKeyListener〜` をおこなっているのは、端末の戻るボタンをタップした時に WebView 内のページをひとつ前のページに遷移させるためです。

後述する [WebViewClient のカスタムクラス](#WebViewClient-のカスタムクラス)、WebChromeClient のカスタムクラスをセットして WebView の設定まわりはおしまいです。

### Pull to Refresh できるようにする

```kotlin
        swipeRefreshLayout.setOnRefreshListener {
            webView.reload()
            swipeRefreshLayout.isRefreshing = false
        }
```

```xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/frameLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WebViewActivity">

    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        android:id="@+id/swipeRefreshLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <WebView
            android:id="@+id/webView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
```

Pull to Refresh がなくても問題なく動作するのですが、実装してあげたほうが親切です。

`swipeRefreshLayout.isRefreshing = false` これがないと Pull to Refresh の
![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/b68b52a7-fb68-7c9d-b1d5-a34a58119590.png)
コイツが表示されたままになってしまうのでご注意を。

ぼくは FrameLayout 配下に SwipeRefreshLayout と WebView を配置してますが、「FrameLayoutいる？」と思ったので
試しに FrameLayout を削除して動かしてみましたがちゃんと動きました。

View の構造は浅い方が良いため FrameLayout は削除して良いかもしれません。

### WebViewClient のカスタムクラス

#### タップイベントをハンドリングする

```kotlin
    // WebViewClient のカスタムクラス
    private class CustomWebViewClient internal constructor(private val activity: WebViewActivity) :
        WebViewClient() {

        // shouldOverrideUrlLoading() では反応しません
        override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
            super.doUpdateVisitedHistory(view, url, isReload)

            Log.d("TEST", "activity.webView.url:" + activity.webView.url)

            // *** ここでクリックイベントを補足して任意の処理を実行させる *** //
        }
    }
```

ページ遷移時に submit が発生しないため、 `shouldOverrideUrlLoading()` ではハンドリングできませんでした。

代わりに `doUpdateVisitedHistory()` を使用します。

### WebChromeClient のカスタムクラス

#### JavaScript が動くようにする

```kotlin
        // JavaScript を動かすために必要です
        override fun onJsAlert(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            return false
        }

        // JavaScript を動かすために必要です
        override fun onJsConfirm(
            view: WebView?,
            url: String?,
            message: String?,
            result: JsResult?
        ): Boolean {
            view ?: return true

            MaterialDialog(view.context).show {
                title(text = message)
                positiveButton {
                    result?.confirm()
                }
                negativeButton {
                    result?.cancel()
                }
                onCancel {
                    result?.cancel()
                }
            }
            return true
        }

        // JavaScript を動かすために必要です
        override fun onJsPrompt(
            view: WebView?,
            url: String?,
            message: String?,
            defaultValue: String?,
            result: JsPromptResult?
        ): Boolean {
            return false
        }
```

JavaScript の alert()、confirm()、prompt() を動かすための設定になります。

`onJsConfirm()` 内の `MaterialDialog〜` の処理がないと

<img width="320" alt="スクリーンショット 2019-09-09 15.20.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34926/0a393260-6b53-7039-fea6-f544eea9bab9.png">

上記画像のような 「〜のページ:」 というタイトルが表示されてしまうため独自に表示しています。

ダイアログの表示には [Material Dialogs](https://github.com/afollestad/material-dialogs) を使用しています。

#### `<input type="file">` が反応するようにする

```kotlin:WebViewActivity.kt
    // <input type="file"> を反応させるために使用します
    private val INPUT_FILE_REQUEST_CODE = 101
    private var mFilePathCallback: ValueCallback<Array<Uri>>? = null

    // <input type="file"> をタップしてファイル選択後に実行される処理です
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == INPUT_FILE_REQUEST_CODE) {
            if (mFilePathCallback == null) {
                super.onActivityResult(requestCode, resultCode, data)
                return
            }
            var results: Array<Uri>? = null

            if (resultCode == Activity.RESULT_OK) {
                val dataString = data?.getDataString()
                if (dataString != null) {
                    results = arrayOf(Uri.parse(dataString))
                }
            }
            mFilePathCallback?.onReceiveValue(results)
            mFilePathCallback = null
        }
    }
```

```kotlin
        // <input type="file"> を反応させるために必要です
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            if (activity.mFilePathCallback != null) {
                activity.mFilePathCallback?.onReceiveValue(null)
            }
            activity.mFilePathCallback = filePathCallback

            val intent = Intent(Intent.ACTION_GET_CONTENT)
            intent.addCategory(Intent.CATEGORY_OPENABLE)
            intent.type = "image/*"

            activity.startActivityForResult(
                intent,
                activity.INPUT_FILE_REQUEST_CODE
            )
            return true
        }
```

`<input type="file">` のボタンがタップされると `onShowFileChooser()` が呼び出されます。

```
1. Activity に <input type="file"> のコールバック変数を定義する
2. `onShowFileChooser()` の引数にタップしたときにコールバックオブジェクトが渡ってくるので 1 で定義した変数にセットする
3. "image/*" を指定して ACTION_GET_CONTENT インテントを `startActivityForResult()` で呼び出す
4. onActivityResult() 内で選択されたファイルを取得しコールバック変数に渡して処理する
```

こんな感じだと思ってますが、正直このあたりの処理はよくわかってないです。。

#### `<a target="_blank">` が反応するようにする

```kotlin
        // <a target="_blank"> を反応させるために必要です
        override fun onCreateWindow(
            view: WebView?,
            isDialog: Boolean,
            isUserGesture: Boolean,
            resultMsg: Message?
        ): Boolean {

            view ?: return false

            val href = view.handler.obtainMessage()
            view.requestFocusNodeHref(href)
            val url = href.data.getString("url")

            view.stopLoading()
            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
            activity.startActivity(browserIntent)
            return true
        }
```

`<a target="_blank">` のリンクがタップされると `onCreateWindow()` が呼び出されます。
タップされたリンクの URL を取得し標準ブラウザで表示することができます。

ブラウザを起動させずに WebView 内で表示させるには WebView の `loadUrl()` に取得した URL を渡せば良いです。

#### console.log を Logcat に表示させる

```kotlin
        // console.log を Logcat に表示させます
        override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
            if (consoleMessage != null) {
                Log.d(
                    "TEST",
                    "[" + consoleMessage.messageLevel() + "] " + consoleMessage.message() + " - " + consoleMessage.sourceId() + "：" + consoleMessage.lineNumber() + "行目"
                )
            }
            return super.onConsoleMessage(consoleMessage)
        }
```

`onConsoleMessage()` を継承して実装します。

↓ こんな感じで表示されます。

```
2019-09-09 16:25:54.312 14024-14024/android.app.package-name D/TEST: [WARNING] The key "viewport-fit" is not recognized and ignored. - TypeScriptで実装されたサイトのURL：10行目
2019-09-09 16:25:55.285 14024-14024/android.app.package-name D/TEST: [LOG] NGSW: Checked Update - TypeScriptで実装されたサイトのURL main.17d48fcf96a1908d9c7b.js：1行目
2019-09-09 16:25:55.536 14024-14024/android.app.package-name D/TEST: [LOG] AppModule boostraped! - TypeScriptで実装されたサイトのURL main.17d48fcf96a1908d9c7b.js：1行目
```

# おわりに

Android の WebView は 「扱いにくい」 「表示が遅い」 といったイメージがあり、WebView で表示させるなら [Chrome Custom Tabs](https://developer.chrome.com/multidevice/android/customtabs) を使うようにしていました。

ただ冒頭でも書いたとおりカスタマイズがあまりできません。

Chrome Custom Tabs の実装は難しくないし画面表示も早いので Web ページを表示させたいだけなら Chrome Custom Tabs で良いと思います。

WebView でもいろんな設定を施してあげるとそれなりに動くようになると知ることができました。
（それでも Chrome と動きが違う箇所は出てきちゃいますが。。）

# 参考
* [AndroidでWebViewを使用する時に押さえておきたいポイント5選](https://re-engines.com/2019/02/21/android-webview-tips/)
* [AndroidでWebViewを使用するときに気をつけたいポイント追加4選!](https://re-engines.com/2019/03/27/android-webview-tips-2/)
* [もう出ないなんて言わせないWebViewでファイルアップロードするダイアログの表示](https://qiita.com/futabooo/items/4706a0767c8ed2330752)
* [WebViewを作ってサイトを表示する](http://kimagureneet.hatenablog.com/entry/2019/04/09/222845)
* [Kotlin Android Extensions](https://kotlinlang.org/docs/tutorials/android-plugin.html)