ElixirDesktopで作るスマホアプリ Part 5 ネイティブブリッジを使って特定のURLをデフォルトブラウザで開く

# はじめに

この記事はElixirアドベントカレンダー2024のシリーズ２、8日目の記事です

https://qiita.com/advent-calendar/2024/elixir

WebViewで表示しているWebアプリからネイティブブリッジを通してiOS、Androidのアプリから別のアプリ（ブラウザ）を呼び出す機能を実装していきます

今回の作業ブランチを作成します

```
git checkout -b feature/naive_bridge
```

# ネイティブブリッジとは

> Native BridgeとはWebViewとネイティブアプリとの連携を表す言葉で、Webからネイティブアプリのメソッドを呼び出したり、ネイティブアプリから表示しているWebページのJavascriptのメソッドを呼び出すことができます。 
by https://engineering.mercari.com/blog/entry/20210814-6694cc7502/

ElixirDesktopはPhoenixのWebアプリケーションと各OSのアプリでWebViewを起動するネイティブ部分に分かれており

Webアプリケーション側から特定のJavaScript関数を実行し、
ネイティブのWebView側で設定した特定の関数の実行をトリガーとして
外部のブラウザを開く、別のアプリを開く、各種センサーデータの取得、課金機能などが実行できます

```
Web app -> JS Handler -> OSネイティブ機能
```

# 各OSのブリッジ

各OSのブリッジについて解説します

## iOS WKScriptMessageHandler

iOSではWKScriptMessageHandlerを使用します

https://developer.apple.com/documentation/webkit/wkscriptmessagehandler


WKScriptMessageHandlerを継承したクラス作成し、
`func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)`のメソッドを実装します

そして、`init`関数内で`WKWebViewConfiguration`の`userContentController`を引数にしてWebViewを作成することで、WebViewで表示しているWebアプリのJavaScriptから`window.webkit.messageHandlers.[メソッド名].postMessage(ペイロード)`で呼び出すことが出来ます

```WebView.swift
final class WebView: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
    var webview: WKWebView
    
    override init() {
        ...
        let configuration = WKWebViewConfiguration()        
        configuration.userContentController.add(self, name: [メソッド名])
        webview = WKWebView(frame: CGRect.zero, configuration: configuration)
        ...
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        switch message.name {
        case "[メソッド名]":
          //  ブラウザを開くなどネイティブ機能を実行する処理を追加
          //  callbackを行いたい場合は evaluateJavaScriptで行う
          //  ペイロード等は message.bodyで取得できる
        default: // 設定していないメソッド名を実行された時の処理
            assertionFailure("Received invalid message: \(message.name)")
        }
    }

}
```

## Android 

AndroidではWebAppInterfaceを使用します

https://developer.android.com/develop/ui/views/layout/webapps/webview?hl=ja#BindingJavaScript

MainActivityクラスの外にWebAppInterfaceクラスを作成し、`@JavascriptInterface`アノテーションを付けた関数を作ることで、WebViewで表示しているWebアプリケーションから`Android.method_name`で呼ぶことが出来ます

```MainActivity.kt
class WebAppInterface(private val mContext: Context) {

    @JavascriptInterface
    fun method_name (payload) {
        // ネイティブ処理をここに書く
        // コールバックが欲しい場合は
        // wevbiew.loadUrl("javascript:実行するJSコード")
    }
}
```

onCreate内でWebAppInterfaceインスタンスを作成し、WebViewにインターフェイスを追加して完了です

```MainActivity.kt
class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val webView: WebView = findViewById(R.id.webview)
        val webInterface = WebAppInterface(this@MainActivity)
        webView.addJavascriptInterface(webAppInterface, "Android")
        ...
    }
}
```

# 実装


iOS,Androidで実装方法がわかったので外部のURLをクリックした際にOSのデフォルトブラウザで開くようにします

## iOS側

desktop_setupのテンプレートで作成した場合すでに実装済みですので該当箇所のハイライトを行います


`super.init()`実行後に以下を追加します
```
configuration.userContentController.add(self, name: [関数名])
```

今回は`openSafari`となり、別の関数を追加したい場合は同様のコードを下に追加していきます

```diff_swift:native/ios/Trarecord/WebView.swift:L18
    override init() {
        // Enable javascript in WKWebView to interact with the web app
        let preferences = WKPreferences()
        let page = WKWebpagePreferences()
        page.allowsContentJavaScript = true
        
        let configuration = WKWebViewConfiguration()
        configuration.limitsNavigationsToAppBoundDomains = true
        configuration.preferences = preferences
        configuration.defaultWebpagePreferences = page
        
        webview = WKWebView(frame: CGRect.zero, configuration: configuration)
        webview.allowsBackForwardNavigationGestures = true
        webview.scrollView.isScrollEnabled = true
        
        super.init()
        
+       configuration.userContentController.add(self, name: "openSafari")
        ....
    }

```

userController関数に`openSafari`が呼ばれた時の処理を追加します

`canOpenURL`で開くことができるURLかチェックを行い、問題なければ`open`でデフォルトに指定しているブラウザでURLを開きます


```diff_swift:native/ios/Trarecord/WebView.swift:L89
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        switch message.name {
+       case "openSafari":
+           let url = URL(string:message.body as! String)
+           if( UIApplication.shared.canOpenURL(url!) ) {
+             UIApplication.shared.open(url!)
+           }
        case "error":
            // You should actually handle the error :)
            let error = (message.body as? [String: Any])?["message"] as? String ?? "unknown"
            assertionFailure("JavaScript error: \(error)")
        default:
            assertionFailure("Received invalid message: \(message.name)")
        }
    }
```

## Android

Android,デスクトップアプリの場合は特に手を加えること無く開けるので特にやることはありません

## Phoenix

Webアプリケーション側の実装を行います

今回はLiveViewとJavaScript間で相互に関数の実行やデータのやり取りができる JS Hooksを使ってネイティブブリッジを作っていきます

最初にhooksフォルダをassets配下に作ります
```
mkdir assets/hooks
```

ネイティブブリッジに`open_safari`を追加します。ネイティブブリッジを使うのはiOSだけなので、iOSだけ正常に終了して、他のOSだと例外を握りつぶして何も起こらないようにします。

Android,iOSで分ける場合は、UAを見るかAndroidがundefかどうかで切り分けます

```assets/js/hooks/NativeBridge.js
const NativeBridge = {
  mounted() {
    this.handleEvent("open_safari", ({ url }) => {
      try {
        window.webkit.messageHandlers.openSafari.postMessage(url);
      } catch {}
    });
  },
};

export default NativeBridge;
```

ブリッジが出来たのでindex.jsでまとめます

```assets/js/hooks/index.js
import NativeBridge from "./NativeBridge";

export default Hooks = {
  NativeBridge: NativeBridge,
};
```

JS Hookが出来たのでapp.jsで読み込みます

```diff_javascript:assets/js/app.js
import "phoenix_html";
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import topbar from "../vendor/topbar";
+ import Hooks from "./hooks";

let csrfToken = document
  .querySelector("meta[name='csrf-token']")
  .getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
+ hooks: Hooks,
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
});
```

JS Hooksが出来たので実行する箇所で読み込み、イベントトリガーを追加します


JS Hooksを読み込む箇所はid属性をつける必要があります
`phx-click="イベント名"`で`handle_event/3`関数で待ち受けているイベントを発火させます
`phx-value-[パラメーター名]={パラメーター}`で`handle_event/3`関数の第2引数に値を渡せます
```diff_elixir:lib/trarecord_web/live/onboarding_live/index.ex:L4
  def render(assigns) do
    ~H"""
    <div
      class="flex justify-center w-screen h-screen bg-local bg-contain bg-center bg-no-repeat"
      style={"background-image: url(#{@page_data.image}); background-color: #{@page_data.bg}"}
    >
    ...
-   <p id="freepik" class="absolute bottom-2 right-4 text-sm">
+   <p id="freepik" class="absolute bottom-2 right-4 text-sm" phx-hook="NativeBridge">   
      <a
        href={@page_data.link}
        class="underline text-blue-600"
        target="_blank"
+       phx-click="open_safari"
+       phx-value-url={@page_data.link}
      >
        Image by storyset on Freepik
      </a>
    </p>
    """
  end
```

`push_event/3`関数でJS Hookの`this.handleEvent`で実装しているイベントを発火させます
第3引数は渡す値がなくても必須で`%{}`(空のマップ）を渡します

```diff_elixir:lib/trarecord_web/live/onboarding_live/index.ex:L49
  def handle_event("finish", _params, socket) do
    {:noreply, push_navigate(socket, to: ~p"/")}
  end

+ def handle_event("open_safari", %{"url" => url}, socket) do
+   socket
+   |> push_event("open_safari", %{url: url})
+   |> then(&{:noreply, &1})
+ end
```

# 動作確認

iOSで問題なくデフォルトブラウザでURLを開くことが出来ました
またAndroid、デスクトップアプリでURLをブラウザで開けることが確認できました

![e1fdaa449336cbfced89f2714db3261c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/723a7d75-16d0-3df6-d218-6f42a51020a7.gif)

今回はネイティブブリッジのテストはLiveviewの範囲外になるため、テストは書けませんが`playwright-elixir`を使えば書けるかもしれません

https://github.com/mechanical-orchard/playwright-elixir

https://qiita.com/RyoWakabayashi/items/ca2924b652ec358008d7

# 最後に

今回はネイティブブリッジの解説と実装JS Hookについて解説しました
次は行きたいスポットをグループ分けするフォルダのCRUD画面とリレーションに付いて解説します

本記事は以上になりますありがとうございました

# 参考サイト
https://developer.apple.com/documentation/webkit/wkscriptmessagehandler
https://qiita.com/the_haigo/items/aed1cc6b57d596dc0dc1
https://smile-jsp.hateblo.jp/entry/android/webview-js-to-native-code
https://engineering.mercari.com/blog/entry/20210814-6694cc7502/
https://qiita.com/atsuto/items/1acb87ce9f0512cf8641
https://github.com/mechanical-orchard/playwright-elixir
https://qiita.com/RyoWakabayashi/items/ca2924b652ec358008d7