Expo WebView + Phoenixでスマホアプリを作る

# はじめに
本記事はExpoのWebviewを通してPhoenixのWebアプリケーションを表示する Webviewアプリの作成方法を紹介します

ElixirImp 2024-01-17のこの発表の補足資料です

https://speakerdeck.com/thehaigo/elixirdesumahokai-fa-noxian-zai

# create Phoenix Project 
通常通りにPhoenixプロジェクトを作成し、認証機能を作成します
```
mkdir trarecord
cd trarecord
mix phx.new trarecord
mv trarecord phoenix 
cd phoenix
mix ecto.create
```

```
mix phx.gen.auth Accounts User users
mix deps.get
mix ecto.migrate
```

```
iex -s mix phx.server
```

# Create Expo(ReactNative) Project
Expoプロジェクトを作成していきます

https://docs.expo.dev/

```
npx create-expo-app trarecod
mv trarecord expo
cd expo
```
```
npx expo install react-native-webview
npx expo install @react-native-async-storage/async-storage
npx expo install expo-splash-screen
```

```
npm run ios
```

# Expo Login Logic
認証周りのロジックについて解説します

### 起動時
- expo アプリを起動
- expo スプラッシュ画像を表示
- expo 認証トークンの有無のチェック
### トークンがある場合
- expo Authorization headerに トークンを入れて phoenixのトップページを開く
- phoenix plug require_verify_headerで有効なトークンをチェック
- phoenix current_userをアサイン
- phoenix 新たに生成したトークンをsessionに入れる
- phoenix JS Hookを通して loginイベントを発火
- expo 新しく発行されたトークンを端末に保存する
- phoenix JS HookからpushEvent loginを発火
- phoenix ログイン後ページ users/settignsへリダイレクト
### トークンがない場合
- expo phoenixのトップページを開く
- phoenix sessionをチェックし user_tokenが無いのを確認
- pohenix JS Hookを通してExpo側にlogoutイベントを発火
- expo 期限切れのトークンを保持していた場合は破棄する
- phoenix JS HookからpushEvent logoutを発火
- phoenix ログインページへリダイレクト
### ログイン/登録時の処理
- phoenix ログイン/登録ページからログイン
- phoenix UserAuth.user_loginを実行
- phoenix current_userをアサイン
- phoenix sessionにuser_tokenを入れる
- phoenix JS Hookを通して loginイベントを発火
- expo 新しく発行されたトークンを端末に保存する
- phoenix JS HookからpushEvent loginを発火
- phoenix ログイン後ページ users/settignsへリダイレクト

# hooksを通してネイティブ側の関数を実行する方法
送信側
`window.ReactNativeWebView.postMessage`を使用する
```js
Hooks.Bridge = {
  mounted() {
    this.handleEvent("sendWebView", ({ message }) => {
      const json = JSON.stringify(message);
      if (window.ReactNativeWebView) {
        window.ReactNativeWebView.postMessage(json);
      }
    });
  },
};
```

```elixir
push_event(socket, "sendWebView", %{message: %{action: "login", token: "token"}}
```

受信側
webviewのオプション`onMessage`で待ち受けし、
`event.nativeEvent.data`で受信メッセージにアクセス
```jsx
<WebView
  source={{ uri: host }}
  onMessage={async (event) => {
    const data = JSON.parse(event.nativeEvent.data);
    if (data.action === "login") {
      await AsyncStorage.setItem("token", data.token);
      setToken(data.token);
    }
  }}
/>
```

# Expo実装
それではExpo側の実装になります

## 接続先をコンポーネント化
webviewの接続先を一括で変更できるようにコンポーネント化しておきます
```Host.js
const host = "http://localhost:4000";
export default host;
```

# App.js スプラッシュ画像の表示
起動時のエントリーポイントです
ここではスプラッシュ画像を３秒表示して
トークン有無をチェックする`Navigator`コンポーネントをレンダリングしています

```App.js
import { useEffect } from "react";
import Navigator from "./Navigator";
import * as SplashScreen from "expo-splash-screen";

SplashScreen.preventAutoHideAsync();

export default function App() {
  useEffect(() => {
    setTimeout(() => {
      SplashScreen.hideAsync();
    }, 3000);
  });

  return <Navigator />;
}
```

# AuthContextの作成
トークンの保持と、更新検知をしたいので Contextを作成します

```AuthContext.js
import { createContext } from "react";
export const AuthContext = createContext();
```

# Navigatorの作成
トークンチェックと
メインスクリーンと認証スクリーンを切り替えるコンポーネントを作成します



今回は実装しませんが
ReactNavigationのStackを使ってネイティブの地図を表示するScreenとWebView間を移動させることも可能です

https://reactnavigation.org/

https://github.com/react-native-maps/react-native-maps

```jsx:Navigator.js
import React, { useEffect, useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import AuthScreen from "./screens/AuthScreen";
import MainScreen from "./screens/MainScreen";
import { AuthContext } from "./AuthContext";

const Navigator = () => {
  const [token, setToken] = useState(null);
  const value = {
    token,
    setToken,
  };

  useEffect(() => {
    const getToken = async () => {
      const token = await AsyncStorage.getItem("token");
      setToken(token);
    };
    getToken();
  }, []);

  const switcher = () => {
    if (token === null) {
      return <AuthScreen />;
    }
    return <MainScreen />;
  };
  return (
    <AuthContext.Provider value={value}>{switcher()}</AuthContext.Provider>
  );
};

export default Navigator;
```

## 認証スクリーン
トークンがなかった時に表示される`AuthScreen`を作成します
`AuthContext`からログイン後のトークン更新関数を取得しています
`SafeAreaView`を使用して端末差によるデザイン崩れを防いでいます
`WevView`は`source`で表示するページを指定し、`onMessage`でPhoenix側からのイベントを待ち受けています

```jsx:screens/AuthScreen.js
import React, { useContext } from "react";
import { StyleSheet, View, SafeAreaView } from "react-native";
import { StatusBar } from "expo-status-bar";
import { WebView } from "react-native-webview";
import AsyncStorage from "@react-native-async-storage/async-storage";

import { AuthContext } from "../AuthContext";
import host from "../Host";

const AuthScreen = () => {
  const { setToken } = useContext(AuthContext);
  return (
    <SafeAreaView style={styles.container}>
      <StatusBar style="auto" />
      <View style={styles.webviewArea}>
        <WebView
          source={{ uri: host }}
          onMessage={async (event) => {
            const data = JSON.parse(event.nativeEvent.data);
            if (data.action === "login") {
              await AsyncStorage.setItem("token", data.token);
              setToken(data.token);
            }
            if (data.action === "logout") {
              await AsyncStorage.removeItem("token");
              setToken(null);
            }
          }}
        />
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
  },
  webviewArea: {
    height: "100%",
  },
});

export default AuthScreen;
```

## メインスクリーン
トークンがあった時に表示される`MainScreen`を作成します
`AuthContext`からトークンとログアウト後のトークン更新関数を取得しています
`WebView`は`source`オプションにURLに加え`Authorization Header`を追加しトークンを入れています
`onMessage`オプションにはexpoから別のネイティブ機能を実行するテストとしてブラウザを開くイベントを追加しています
```jsx:screens/MainScreen.js
import React, { useContext } from "react";
import { StyleSheet, View, SafeAreaView, Linking } from "react-native";
import { StatusBar } from "expo-status-bar";
import { WebView } from "react-native-webview";
import AsyncStorage from "@react-native-async-storage/async-storage";

import { AuthContext } from "../AuthContext";
import host from "../Host";

const MainScreen = () => {
  const { token, setToken } = useContext(AuthContext);
  return (
    <SafeAreaView style={styles.container}>
      <StatusBar style="auto" />
      <View style={styles.webviewArea}>
        <WebView
          source={{
            uri: `${host}`,
            headers: {
              Authorization: `Bearer ${token}`,
            },
          }}
          onMessage={async (event) => {
            const data = JSON.parse(event.nativeEvent.data);
            if (data.action === "logout") {
              await AsyncStorage.removeItem("token");
              setToken(null);
            }
            if (data.action === "open") {
              Linking.openURL(data.url);
            }
          }}
        />
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
  },
  webviewArea: {
    height: "100%",
  },
});

export default MainScreen;
```

Expo側は以上になります

# Phoenix実装
それではPhoenix側の実装に入ります

## JS Hookの作成
Expo側の関数を実行するためのBridge Hookを作成します
Web側から実行された際にエラーにならないように`ReactNativeWebView`の有無をチェックしています
`pushEvent`を`message`の`action`名が`login`と`logout`の時だけリダイレクト処理を発火するようにしています
```assets/js/hook.js
const Hooks = {};

Hooks.Bridge = {
  mounted() {
    this.handleEvent("sendWebView", ({ message }) => {
      const json = JSON.stringify(message);
      if (window.ReactNativeWebView) {
        window.ReactNativeWebView.postMessage(json);
      }
      if (["login", "logout"].includes(message.action)) {
        this.pushEvent(message.action, {});
      }
    });
  },
};

export default Hooks;
```

app.jsでhooksをセットします

```assets/js/app.js
import Hooks from "./hook";

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks,
  params: { _csrf_token: csrfToken },
});
```

# トップページの作成
セッションのトークンの有無をチェックして、
ネイティブ側イベントの発火とリダイレクト処理を行う`LiveView`を作成します

mountとhandle_eventに処理を分けているのは、mount時にredirectを行うとJS Hookが実行されずにリダイレクトされてしまうので
`LiveView push_event-> JS Hook pushEvent -> LiveView handle_event`
というルートを辿っています

```lib/trarecord_web/live/page_live.ex
defmodule TrarecordWeb.PageLive do
  use TrarecordWeb, :live_view

  def render(assigns) do
    ~H"""
    <div id="main" phx-hook="Bridge"></div>
    """
  end

  def mount(_params, %{"user_token" => token}, socket) do
    socket
    |> push_event("sendWebView", %{message: %{action: "login", token: Base.encode64(token)}})
    |> then(&{:ok, &1})
  end

  def mount(_params, _session, socket) do
    socket
    |> push_event("sendWebView", %{message: %{action: "logout"}})
    |> then(&{:ok, &1})
  end

  def handle_event("login", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/users/settings")}
  end

  def handle_event("logout", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/users/log_in")}
  end
end
```
liveviewが作成できたら routerの差し替えをします

```diff_elixir:lib/trarecord_web/router.ex
  scope "/", TrarecordWeb do
    pipe_through :browser

-   get "/", PageController, :home
+   live "/", PageLive
  end
```

## トップページアクセス時のトークンチェックを行うPlug作成
トップページにアクセスした際に Authorization Headerをチェックして
新規トークンの発行とセッションに入れる処理を行う`plug`を作成します
Authorization Headerの値をバイナリパターンマッチでトークン部分のみをtokenにマッチさせて
デコードしたトークンでユーザーを取得します

そのあとは`login_user`と同じ処理を行っています
```lib/trarecord_web/user_auth.ex
  def require_verify_header(conn, _opts) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         user when is_struct(user) <-
           token
           |> Base.decode64!()
           |> Accounts.get_user_by_session_token() do

      token = Accounts.generate_user_session_token(user)
      conn
      |> assign(:current_user, user)
      |> put_token_in_session(token)
    else
      _error ->
        conn
    end
  end
```

plugを作成したら routerのpipe_throughに追加します
```diff_elixir:lib/trarecord_web/router.ex
  scope "/", TrarecordWeb do
-   pipe_through :browser
+   pipe_through [:browser, :require_verify_header]

    live "/", PageLive
  end
```

# おまけのネイティブ機能実行
```lib/trarecord_web/live/user_settings_live.ex
defmodule TrarecordWeb.UserSettingsLive do
  use TrarecordWeb, :live_view

  alias Trarecord.Accounts

  def render(assigns) do
    ~H"""
    <.button phx-click="open_safari" id="open" phx-hook="Bridge">open googlemap</.button>
    ...
    """
  end

  def handle_event("open_safari", _params, socket) do
    {:noreply,
     push_event(socket, "sendWebView", %{
       message: %{action: "open", url: "https://www.google.co.jp/maps/"}
     })}
  end
end
```
受け側
```jsx:screens/MainScreen.js
import { StyleSheet, View, SafeAreaView, Linking } from "react-native";

<WebView
  source={{
    uri: `${host}`,
    headers: {
      Authorization: `Bearer ${token}`,
    },
  }}
  onMessage={async (event) => {
    const data = JSON.parse(event.nativeEvent.data);
    if (data.action === "open") {
      Linking.openURL(data.url);
    }
  }}
/>
```

今回は使用していませんが、Native -> Phoenixへの発火は`injectJavaScript`を使うそうです

https://zenn.dev/erukiti/articles/react-native-webview-comm

# DEMO
![f706ae500c84403dc8d4f48d85150764.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/3bed124b-9fba-d133-a938-e225f5adce31.gif)

# 最後に

Reactで一切画面を作らず Phoenixの画面のみで認証機能付きのアプリケーションを作成し、
ネイティブ機能の実行の仕方について解説しました

オンライン状態で使う通常のアプリであれば、この方法でだいぶ楽にアプリが作れて、リリース等もだいぶ楽にできるかと思います

WebViewのみしか使っていない場合はリジェクト要因になりかねないので、ネイティブ機能をうまく組み込んで審査をパスできるように頑張ってみてください

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