ElixirDesktopでSkyWayを使用したビデオ通話アプリを作る

# ElixirDesktopでSkyWayを使用したビデオ通話アプリを作る

SkyWay社の**JavaScript SDK**のチュートリアルをLiveViewで実行し、ビデオ通話ページを実装する方法をまとめました。

当記事の開発環境
```
Arch Linux      6.7.4-arch1-1
elixir          1.15.7-otp-25 
erlang          25.0.4 
```

目次

- [ElixirDesktopでSkyWayを使用したビデオ通話アプリを作る](#elixirdesktopでskywayを使用したビデオ通話アプリを作る)
  - [用語](#用語)
  - [SkyWayを利用したビデオ通話の手順](#skywayを利用したビデオ通話の手順)
  - [SkyWayを利用するための事前準備](#skywayを利用するための事前準備)
  - [Phoenixプロジェクトを作成する](#phoenixプロジェクトを作成する)
  - [LiveViewモジュールを作成する](#liveviewモジュールを作成する)
  - [Hookを作成する その1](#hookを作成する-その1)
  - [Hookを作成する その2](#hookを作成する-その2)
  - [実行](#実行)
- [Androidアプリ化する](#androidアプリ化する)
  - [Roomへの接続について](#roomへの接続について)
  - [Memberの名前を設定する](#memberの名前を設定する)

## 用語

各用語の意味は次のようになります。

* **SkyWayAuthToken** - SkyWayを利用するために必要なJWT(JSON Web Token)形式のトークンです
* **Context** - SkyWayAuthTokenを基に生成され、Roomを作成します
* **Stream** - カメラやオーディオのメディアストリームのことを指します
* **Room** - 通話を行うグループの単位です
* **Member** - Roomに参加している人を指します
* **Publish** - RoomにStreamを公開することです
* **Publication** - Roomに公開したSreamに対応するリソースです

詳細はこちら

https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/overview/

## SkyWayを利用したビデオ通話の手順

ビデオ通話の手順は以下の通りになります。

1. SkyWayAuthTokenを取得するか、生成する
2. SkyWayのメソッドを使用してカメラやオーディオのStreamを取得する
3. SkyWayAuthTokenを使用してContextを生成する
4. 生成したContextを使用して、指定した名前のRoomを作成するか、検索して取得する
5. Roomに接続する
6. StreamをRoomに公開する（これ以降、この公開されたStreamはPublicationから参照する）
7. Roomに公開された他のMemberのPublicationからStreamを取得する
8. 取得したStreamから各種メディアをアタッチする
9. ビデオ通話が可能になる

## SkyWayを利用するための事前準備

SkyWayのビデオ通話を利用するには、<font color="red">アプリケーションID</font>と<font color="red">シークレットID</font>が必要になります。

アプリケーションIDとシークレットIDは、以下の手順で生成することができます。

1. SkyWayのアカウントを作成する
2. SkyWayのプロジェクトを作成する
3. SKyWayのアプリケーションを作成する
4. アプリケーションIDとシークレットIDをメモする
5. 上記のキーを`config.ex`に記述する

![プロジェクトを作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3455723/6cc4f252-dc22-ac4a-5fda-3162319b8fe6.png)
![アプリケーションを作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3455723/e249e2d0-104b-2802-eca8-4dbc135f8824.png)
![ID.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3455723/9b21e225-ecc0-780e-c69d-9998ce7abe5c.png)

## Phoenixプロジェクトを作成する

Phoenixプロジェクトを作成します。

```shell
mix phx.new tuto_skyway
```

WebアプリケーションでSkyWayを利用するためには、<font color="red">`skyway-sdk`</font>というライブラリが必要になります。
また<font>**`@skyway-sdk/room`**</font>と`@skyway-sdk/core`の2種類がありますが、ここでは前者をインストールします。

```shell
npm install --save --prefix assets \
@skyway-sdk/room
``````

`config.ex`にアプリケーションIDとシークレットIDを記述します。

```elixir:config/config.ex
# Skywayのキー
config :tuto_skyway, :key,
  app_id: "アプリケーションIDの中身",
  secret: "シークレットの中身"
```

## LiveViewモジュールを作成する

LiveViewモジュールを作成します。

まず`live/home_live/intercom.ex`を作成を作成します。

```elixir:live/home_live/intercom.ex
defmodule TutoSkywayWeb.HomeLive.Intercom do
  use TutoSkywayWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  def handle_params(params, _uri, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :home, _params) do
    socket
  end

  # Hookにキーを渡す
  def handle_event("getKey", %{}, socket) do
    app_id = Application.get_env(:tuto_skyway, :key)[:app_id]
    secret = Application.get_env(:tuto_skyway, :key)[:secret]

    {:reply, %{app_id: app_id, secret: secret}, socket}
  end
end
```

次に`live/home_live/intercom.html.heex`を作成してください。

```heex:live/home_live/intercom.html.heex
<div id="skyway-hook" phx-hook="SkywayHook" />
<p>Member ID: <span id="my-id"></span></p>
<p>Member Name: <span id="my-name"></span></p>
<div>
  channel name: <input id="channel-name" type="text" />
  <button id="join">join</button>
</div>
<p>Channel ID: <span id="channel-id"></span></p>
<video id="local-video" width="400px" muted playsinline></video>
<div id="button-area"></div>
<div id="remote-media-area"></div>
```

`router.ex`を書き換えます

```elixir:router.ex
    # get "/", PageController, :home
    live "/", HomeLive.Intercom, :home
```

## Hookを作成する その1

Skywayの処理を実行するHookを作成します。ここでは`SkywayHook`とします。

次のファイルを作成してください。

* assets/js/hooks.js
* assets/js/hooks/skywayHook.js

`hooks.js`には以下のように記述します。

```javascript:assets/js/hooks.js
import SkywayHook from "./hooks/skywayHook";

const Hooks = {
  SkywayHook: SkywayHook
}

export default Hooks;
```

`app.js`にHooksを追加します。

```javascript:assets/js/app.js
import Hooks from "./hooks"
// ...
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
```

## Hookを作成する その2

`skyWayHook.js`にSkyWayの処理を記述します

```javascript:assets/js/hooks/skywayHook.js
// 各種モジュールをインポート
import {
  nowInSec,
  SkyWayAuthToken,
  SkyWayContext,
  SkyWayRoom,
  SkyWayStreamFactory,
  uuidV4,
} from '@skyway-sdk/room';

const SkywayHook = {
  mounted() {
    // 下記を記述
  }
}

export default SkywayHook;
```

`mounted()`に以下を追加

```javascript:assets/js/hooks/skywayHook.js
  mounted() {
    let appId;
    let secret;

    this.pushEvent("getKey", {}, (reply, _ref) => {
      appId = reply.app_id;
      secret = reply.secret;

      // トークンの作成
      const token =   new SkyWayAuthToken({
        jti: uuidV4(),
        iat: nowInSec(),
        exp: nowInSec() + 60 * 60 * 24,
        scope: {
          app: {
            id: appId,
            turn: true,
            actions: ['read'],
            channels: [
              {
                id: '*',
                name: '*',
                actions: ['write'],
                members: [
                  {
                    id: '*',
                    name: '*',
                    actions: ['write'],
                    publication: {
                      actions: ['write'],
                    },
                    subscription: {
                      actions: ['write'],
                    },
                  },
                ],
                sfuBots: [
                  {
                    actions: ['write'],
                    forwardings: [
                      {
                        actions: ['write'],
                      },
                    ],
                  },
                ],
              },
            ],
          },
        },
      }).encode(secret);

      (async () => {
        const localVideo = document.getElementById('local-video');
        const buttonArea = document.getElementById('button-area');
        const remoteMediaArea = document.getElementById('remote-media-area');
        const channelNameInput = document.getElementById('channel-name');
      
        const myId = document.getElementById('my-id');
        const myName = document.getElementById('my-name');
        const joinButton = document.getElementById('join');

        // オーディオとカメラを取得する
        const { audio, video } =
          await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
        video.attach(localVideo);
        await localVideo.play();
      
        joinButton.onclick = async () => {
          if (channelNameInput.value === '') return;
      
          // トークンを元にContextを作成する(SkyWayの各種機能を利用する)
          const context = await SkyWayContext.Create(token);
          
          // Contextを使用して、Room名をRoomを取得、または生成する 
          const channel = await SkyWayRoom.FindOrCreate(context, {
            type: 'p2p',
            name: channelNameInput.value,
          });

          // Roomに入室する 
          const me = await channel.join();

          myId.textContent = me.id;
          myName.textContent = me.name;
      
          // Streamを、Roomにpublication(公開)する
          await me.publish(audio);
          await me.publish(video);

          
          const subscribeAndAttach = (publication) => {
            if (publication.publisher.id === me.id) return;
      
            const subscribeButton = document.createElement('button');
            // publicationしたユーザのIDとメディアストリームの種類を表示
            subscribeButton.textContent = `${publication.publisher.id}: ${publication.contentType}`;
            buttonArea.appendChild(subscribeButton);
      
            subscribeButton.onclick = async () => {
              // publicationのIDを指定して、メディアストリームを取得する
              const { stream } = await me.subscribe(publication.id);
      
              switch (stream.contentType) {
                case 'video':
                  {
                    const elm = document.createElement('video');
                    elm.playsInline = true;
                    elm.autoplay = true;
                    stream.attach(elm);
                    remoteMediaArea.appendChild(elm);
                  }
                  break;
                case 'audio':
                  {
                    const elm = document.createElement('audio');
                    elm.controls = true;
                    elm.autoplay = true;
                    stream.attach(elm);
                    remoteMediaArea.appendChild(elm);
                  }
                  break;
              }
            };
          };
      
          // 入室時に、入室済みの利用者ごとに処理を行う。
          channel.publications.forEach(subscribeAndAttach);
          // 入室後に、入室が行われると処理を行う。
          channel.onStreamPublished.add((e) => subscribeAndAttach(e.publication));
        };
      })();
    });
  }
```

## 実行

ページがロードされるとカメラとマイクの権限が要求されます。
許可をした後、`channel name`の右に適当なRoom名(半角英数)を入力してください。

Roomが生成されると、`Member ID:`の右に自身MemberのIDが表示されます。
IDが表示されたあとは、すでにRoomに入室している、もしくは後から入室したMemberのpublicationへの処理が行われます。

画面の下部に、RoomへPublishされたStreamに対応するButton要素が追加されます。
これを押すことで他のMemberのビデオやオーディオを再生することができます。

# Androidアプリ化する

ElixirDesktop化とAndroidアプリ化の方法は省略します。

SkyWayによるビデオ通話を行うために必要な、追加の権限等についての記述は以下の通りになります。

```kotlin:AndroidManifest.xml
+    <uses-feature
+        android:name="android.hardware.camera"
+        android:required="false" />

+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
```

`MainActivity.kt`の`onCreate()`に以下を追加

```kotlin:MainActivity.kt
class MainActivity : Activity() {
    private lateinit var binding: ActivityMainBinding
+    private val MY_PERMISSIONS_REQUEST_CAMERA = 0
+    private val MY_PERMISSIONS_REQUEST_RECORD_AUDIO = 1
+    private val MY_PERMISSIONS_REQUEST_MODIFY_AUDIO_SETTINGS = 2
+    private val MY_PERMISSIONS_REQUEST_ACCESS_NETWORK_STATE = 3
+    private val MY_PERMISSIONS_REQUEST_ACCESS_WIFI_STATE = 4

    override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
        // 省略
            bridge = Bridge(applicationContext, binding.browser)
        }

        // 以下を追加
+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
+            != PackageManager.PERMISSION_GRANTED) {
+            ActivityCompat.requestPermissions(this,
+                arrayOf(Manifest.permission.CAMERA),
+                MY_PERMISSIONS_REQUEST_CAMERA)
+        }

+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+            != PackageManager.PERMISSION_GRANTED) {
+            ActivityCompat.requestPermissions(this,
+                arrayOf(Manifest.permission.RECORD_AUDIO),
+                MY_PERMISSIONS_REQUEST_RECORD_AUDIO)
+        }

+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.MODIFY_AUDIO_SETTINGS)
+            != PackageManager.PERMISSION_GRANTED) {
+            ActivityCompat.requestPermissions(this,
+                arrayOf(Manifest.permission.MODIFY_AUDIO_SETTINGS),
+                MY_PERMISSIONS_REQUEST_MODIFY_AUDIO_SETTINGS)
+        }

+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_NETWORK_STATE)
+            != PackageManager.PERMISSION_GRANTED) {
+            ActivityCompat.requestPermissions(this,
+                arrayOf(Manifest.permission.ACCESS_NETWORK_STATE),
+                MY_PERMISSIONS_REQUEST_ACCESS_NETWORK_STATE)
+        }

+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_WIFI_STATE)
+            != PackageManager.PERMISSION_GRANTED) {
+            ActivityCompat.requestPermissions(this,
+                arrayOf(Manifest.permission.ACCESS_WIFI_STATE),
+                MY_PERMISSIONS_REQUEST_ACCESS_WIFI_STATE)
+        }

+        if (Build.VERSION.SDK_INT >= 23) {
+            if (androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ||
+                androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ||
+                androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.ACCESS_NETWORK_STATE) != PackageManager.PERMISSION_GRANTED ||
+                androidx.core.app.ActivityCompat.checkSelfPermission(applicationContext, android.Manifest.permission.ACCESS_WIFI_STATE) != PackageManager.PERMISSION_GRANTED) {
+                ActivityCompat.requestPermissions(applicationContext as Activity, arrayOf(android.Manifest.permission.CAMERA, android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.ACCESS_NETWORK_STATE, android.Manifest.permission.ACCESS_WIFI_STATE), 12345)
+            }
+        }
```

`Bridge.kt`の`setWebView()`内に以下を追加

```kotlin:Bridge.kt
class Bridge(private val applicationContext : Context, private var webview : WebView) {
    // 省略
    init {
        // 省略
        setWebView(webview)

        // 以下を追加
        webview.settings.javaScriptEnabled = true
        webview.settings.allowFileAccess = true
        webview.settings.domStorageEnabled = true
        webview.settings.javaScriptCanOpenWindowsAutomatically = true
        webview.settings.mediaPlaybackRequiresUserGesture = false
        webview.webChromeClient = object : WebChromeClient() {
            override fun onPermissionRequest(request: PermissionRequest) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                    request.grant(request.resources)
                }
            }
        }
```

## Roomへの接続について
[前述のコード](#hookを作成する-その2)では、Roomを生成または取得する際に名前を指定しています。

Roomは生成する際に、**id**と**name**は次のように設定されます。

* id - 自動で設定される。(`8桁-4桁-4桁-4桁-8桁`)
* name - 指定がない場合、自動で設定される。(同上)

しかし、Roomのnameを設定するとセキュリティ上の問題が生じます。
具体的には、「<font color="red">Roomの名前をさえ知っていればどんなRoomにも接続できる</font>」という問題です。

これは、Roomを取得する際に<font color="red">nameだけで検索することができてしまう</font>ためです。

Roomに接続できれば、他のMemberのStreamを許可なく取得することが可能になります。

そのため、Roomを生成する際は**nameを指定せず**、**idで取得する**のが最善の方法と考えられます。

```javascript:assets/js/hooks/skywayHook.js
// const channel = await SkyWayRoom.FindOrCreate(context, {
//   type: 'p2p',
//   name: channelNameInput.value,
// });

// 生成のみ 
const channel = await SkyWayRoom.Create(context, {
  type: 'p2p',
});
```

SkyWayのドキュメント読むと、Roomに接続できるユーザーを制限する機能は提供されていないようです。

そのため、ChannelのID(`channel.id`)を他者が取得できないように、安全に他のMemberに渡すことが重要となります。

## Memberの名前を設定する

RoomにJoinする際、自身のMemberのnameを指定するには`join()`メソッドに、次のようにオプションを渡します。

```javascript:assets/js/hooks/skywayHook.js
const me = await channel.join({name: 'Takashi'});

// me.nameで参照できる
```

このnameは、Room内で一意の文字列になります。ただし、全角は使用できません。