THETAプラグインのWeb UIの実装方法【THETA プラグイン開発】

こんにちは、リコーの@3215です。

[RICOH THETA V](https://theta360.com/ja/about/theta/v.html)はAndroidベースのOSで動いているので、Androidアプリをインストールすることで機能を拡張することができます。
このアプリをTHETAプラグインと呼んでおり、[開発・公開する仕組み](https://pluginstore.theta360.com/)も提供しています。

この記事ではTHETAプラグインのWeb UIの実装方法を紹介します。

THETAプラグインをご存じない方は[こちら](https://api.ricoh/products/theta-plugin/)をご覧ください。
THETAプラグインを開発してみたいという方はまずは以下の記事をご覧ください。

- [THETA プラグインパートナープログラムはじめました](https://qiita.com/mrtakada/items/8f3bbf8d283c26899473)
- [【THETA プラグイン開発】プラグインパートナープログラムに登録してみよう](https://qiita.com/mShiiina/items/55d98f366e650ca42251)
- [【THETA プラグイン開発】THETAを開発者モードにする手順](https://qiita.com/ueue/items/7c4027db3af463956e26)

興味を持たれた方は[twitter](https://twitter.com/thetaplugin)のフォローと[THETAプラグイン開発コミュニティ(slack)](https://join.slack.com/t/thetadeveloper/shared_invite/enQtNzcxODI2MTk2ODg0LWY5YmU2ZTIxNGYwNzlmNWZjMWMyMWFlMzY3ZmQ3NGU3ZjZiZjI0Njk4MGIxMDU5NDJiMzQ4Y2FkNDEzYmFlNGU)への参加もよろしくおねがいします｡

## Web UIとは

THETAプラグインの機能によっては、撮影時のカメラ設定や連携する外部サービスのアカウントなどを、ユーザーが設定できるようにしておきたい場合も多いと思います。

しかしRICOH THETA Vには画面がないので、通常のAndroidアプリのようなレイアウトや画面遷移を使うことができません。
そこでTHETAプラグインのDeveloper Guideには、[Webサーバーを立てることでWeb UIを提供できる](https://api.ricoh/docs/theta-plugin/how-to-use/#using-a-web-server)と記載されています。

公式プラグイン（[クラウドアップロード](https://pluginstore.theta360.com/plugins/com.theta360.cloudupload/)、[無線ライブストリーミング](https://pluginstore.theta360.com/plugins/com.theta360.cloudstreaming/)、[自動顔ぼかしβ版](https://pluginstore.theta360.com/plugins/com.theta360.automaticfaceblur/)）でもこの方法で設定画面が提供されています。
プラグイン起動中であれば、スマートフォンの[RICOH THETAアプリ](https://theta360.com/ja/about/application/basic.html)のカメラ設定からWeb UIにアクセスすることができます。URLを直接入力すればブラウザからアクセスすることも可能です。

![official_plugins.png](https://qiita-image-store.s3.amazonaws.com/0/265699/ba7d0a19-2b4e-00d7-b6ff-a08c95559be7.png)

今回は簡単なWeb UIを実装する方法を紹介します。

既にある程度実装のイメージがついている方は、[公式プラグインのソースコード](https://github.com/search?q=topic%3Atheta-plugin+org%3Aricohapi&type=Repositories)を参考に実装いただくのも良いかと思います。

## THETA Plug-in SDKのプロジェクトをインポートする

まずは以下の記事を参考にしてPlug-in SDKのプロジェクトをAndroid Studioにインポートし、動作することを確認してください。

[【THETA プラグイン開発】THETAでRICOH THETA Plug-in SDKを動かす方法](https://qiita.com/yokazuya/items/cda20420ee0b1b58e03f)

package名やプラグインの表示名は必要に応じて変更しておいてください。
プラグインの表示名は`res/values/strings.xml`の`app_name`で変更できます。

## THETAのWi-FiをUSB接続中も有効にしておく

Web UIを確認するためには、Wi-Fi経由でTHETAにアクセスする必要があります。
しかしTHETAはそのままの状態ではUSBを接続するとWi-Fiが無効になってしまうので、Android Studioでプラグインを実行しながらWeb UIを確認することができずとても不便です。

そこで、[THETAのWi-FiをUSB接続中に有効化する方法](https://qiita.com/ueue/items/ecb5032c923531a45408#theta%E3%81%AEwi-fi%E3%82%92usb%E6%8E%A5%E7%B6%9A%E4%B8%AD%E3%81%AB%E6%9C%89%E5%8A%B9%E5%8C%96%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95)でUSB接続中でもWi-Fiが使えるようにしておきます。

## Web UIを有効にする

[Developer Guide](https://api.ricoh/docs/theta-plugin/how-to-use/#using-a-web-server)に記載の手順で、スマートフォンのRICOH THETAアプリのプラグイン画面からWeb UIを開くことができるようにしておきます。

まず、[右クリック]->[新規]->[フォルダー]->[Assetsフォルダー]から、`assets`フォルダーを作成します。

![create_assets.png](https://qiita-image-store.s3.amazonaws.com/0/265699/8f00ee4d-b270-ffed-891d-7b03a5315f4c.png)

フォルダーが作成されたら、settings.jsonファイルを作成します。
settings.jsonには以下を記載します。

```json
{
  "webServer": true
}
```

スマートフォンとTHETAをWi-Fi接続してAndroid Studioでプロジェクトを実行し、RICOH THETAアプリのプラグイン画面で以下のように「プラグイン起動中」の右がリンク付きの表示になっていればOKです。

![theta_app.png](https://qiita-image-store.s3.amazonaws.com/0/265699/12519fbf-9adc-efa4-9d4e-f8671f05139f.png)

## HTMLファイルを作成し、assetsに配置する

「Hello World!」と表示されるだけの`index.html`を作成し、`assets`フォルダーの下に作成します。

```html
<html>
  <head>
    <title>Web UI Simple</title>
  </head>
  <body>
    <h1>
      Hello World!
    </h1>
  </body>
</html>
```

## Webサーバーを実装する

今回は[NanoHttpd](https://github.com/NanoHttpd/nanohttpd)というライブラリを利用してWebサーバーを実装します。
`app`の`build.gradle`の`dependencies`に以下を追加します。

```
implementation 'org.nanohttpd:nanohttpd-webserver:2.3.1'
```

`NanoHTTPD`を継承したクラスを作成し、必要に応じてメソッドをオーバーライドして実装していきます。
最低限、以下2点を実装します。

- コンストラクタで`port`に`8888`を指定する
- `serve()`メソッドをオーバーライドして受け取ったリクエストに対する処理を実装する

リクエストによらず常に`index.html`を返すだけのWebサーバーを、`MainActivity`で立てるのであれば以下のような感じです。

```java

public class MainActivity extends PluginActivity {

  private Context context;
  private WebServer webServer;
  .....

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    .....

    this.context = getApplicationContext();
    this.webServer = new WebServer(this.context);
    try {
      this.webServer.start();
    } catch (IOException e) {
      e.printStackTrace();
    }
    .....
  
  }
  .....

  protected void onDestroy() {
    super.onDestroy();
    if (this.webServer != null) {
      this.webServer.stop();
    }
  }
  .....

  private class WebServer extends NanoHTTPD {

    private static final int PORT = 8888;
    private Context context;

    public WebServer(Context context) {
      super(PORT);
      this.context = context;
    }

    @Override
    public Response serve(IHTTPSession session) {
      AssetManager assetManager = context.getAssets();
      InputStream is = null;
      try {
        is = assetManager.open("index.html");  // このInputStreamはNanoHTTPD側でcloseしてくれる
        return newChunkedResponse(Status.OK, "text/html", is);
      } catch (IOException e ) {
        e.printStackTrace();
        return newFixedLengthResponse(Status.INTERNAL_ERROR, "text/plain", e.getMessage());
      }
    }

  }

}
```

プラグインを起動し、Web UIにアクセスすると`index.html`の内容が表示されます。

<img width="169" alt="hello_world.png" src="https://qiita-image-store.s3.amazonaws.com/0/265699/e2876215-e2a6-0735-2763-78932264c683.png">

## Web UIからLEDランプの色を設定できるようにする

ここまででWeb UIの基本的な実装はできているので、あとは`index.html`やWebサーバーの実装を拡張していくだけです。
今回はTHETAのLEDランプの色をWeb UIから設定できるようにしてみました。

<img width="169" alt="led_color_selector.png" src="https://qiita-image-store.s3.amazonaws.com/0/265699/d8c5fdea-dfc6-52c5-b975-722ec05035a7.png">

サンプルコードはこの記事の[最後](#サンプルコード)にあります。
※エラー処理は省略しているので、流用される際はご注意ください。

実装の概要は以下になります。

- 設定画面を`form`として実装
- `select`パーツで青（デフォルト）、赤、白を選択可能
- `OK`ボタン押下でLEDランプの色を変更（LEDランプ色変更の方法は[こちら](https://qiita.com/meronpan/items/fde7d66bed7f31f9e193#%E3%83%A9%E3%83%B3%E3%83%97%E3%81%AE%E5%9F%BA%E6%9C%AC%E7%9A%84%E3%81%AA%E4%BD%BF%E3%81%84%E6%96%B9)を参照）
- LEDランプの色は[`SharedPreferences`](https://developer.android.com/reference/android/content/SharedPreferences)に保存
- 現在選択中の色を初期値として表示するためテンプレートエンジン（[Mustache](https://github.com/samskivert/jmustache)）を利用


## まとめ

Web UIの基本的な実装方法を紹介し、THETAのLEDランプの色をTHETAプラグインのWeb UIから変更できるようにしてみました。

ただし、今回のサンプルは最低限の実装しかしていないため、かなり見づらいUIになっています。

スマートフォンから見やすい画面にするためには、cssを適用する必要があります。
JavaScriptを使うことで、より複雑なWeb UIを作ることも可能です。

また、設定値が多くなるようであれば`SharedPreferences`でなく`SQLite`を使った方が良いでしょう。

これらの方法を知りたい場合は、[公式プラグインのソースコード](https://github.com/search?q=topic%3Atheta-plugin+org%3Aricohapi&type=Repositories)を参考にしてみてください。


THETAプラグイン開発に興味を持たれた方はぜひパートナープログラムにご登録ください！

なお、登録時に申請したシリアルナンバーのTHETAについてはメーカーサポート対象外になりますので、ご注意ください。

- パートナープログラムの詳細は[こちら](https://api.ricoh/products/theta-plugin/)
- 登録フォームは[こちら](https://www8.webcas.net/db/pub/ricoh/thetaplugin/create/input)


## サンプルコード

```MainActivity.java

package com.theta360.pluginapplication;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.AssetManager;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.view.KeyEvent;
import com.samskivert.mustache.Mustache;
import com.theta360.pluginapplication.task.TakePictureTask;
import com.theta360.pluginapplication.task.TakePictureTask.Callback;
import com.theta360.pluginlibrary.activity.PluginActivity;
import com.theta360.pluginlibrary.callback.KeyCallback;
import com.theta360.pluginlibrary.receiver.KeyReceiver;
import com.theta360.pluginlibrary.values.LedColor;
import com.theta360.pluginlibrary.values.LedTarget;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.Response.Status;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MainActivity extends PluginActivity {

  private Context context;
  private WebServer webServer;
  private static final String PREFERENCE_KEY_COLOR = "color";

  private TakePictureTask.Callback mTakePictureTaskCallback = new Callback() {
    @Override
    public void onTakePicture(String fileUrl) {

    }
  };

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    this.context = getApplicationContext();
    notificationLedBlink(LedTarget.LED3, this.loadLedColor(), 1000);
    this.webServer = new WebServer(this.context);
    try {
      this.webServer.start();
    } catch (IOException e) {
      e.printStackTrace();
    }

    // Set enable to close by pluginlibrary, If you set false, please call close() after finishing your end processing.
    setAutoClose(true);
    // Set a callback when a button operation event is acquired.
    setKeyCallback(new KeyCallback() {
      @Override
      public void onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyReceiver.KEYCODE_CAMERA) {
          /*
           * To take a static picture, use the takePicture method.
           * You can receive a fileUrl of the static picture in the callback.
           */
          new TakePictureTask(mTakePictureTaskCallback).execute();
        }
      }

      @Override
      public void onKeyUp(int keyCode, KeyEvent event) {
        /**
         * You can control the LED of the camera.
         * It is possible to change the way of lighting, the cycle of blinking, the color of light emission.
         * Light emitting color can be changed only LED3.
         */
//         notificationLedBlink(LedTarget.LED3, LedColor.BLUE, 1000);
      }

      @Override
      public void onKeyLongPress(int keyCode, KeyEvent event) {

      }
    });
  }

  @Override
  protected void onResume() {
    super.onResume();
  }

  @Override
  protected void onPause() {
    // Do end processing
    close();

    super.onPause();
  }

  protected void onDestroy() {
    super.onDestroy();
    if (this.webServer != null) {
      this.webServer.stop();
    }
  }

  private void saveLedColor(LedColor ledColor) {
    SharedPreferences data = PreferenceManager.getDefaultSharedPreferences(context);
    SharedPreferences.Editor editor = data.edit();
    editor.putString(PREFERENCE_KEY_COLOR, ledColor.toString());
    editor.apply();
  }

  private LedColor loadLedColor() {
    SharedPreferences data = PreferenceManager.getDefaultSharedPreferences(context);
    String savedColor = data.getString(PREFERENCE_KEY_COLOR, LedColor.BLUE.toString());
    return LedColor.getValue(savedColor);
  }

  private class WebServer extends NanoHTTPD {

    private static final int PORT = 8888;
    private Context context;
    private static final String INDEX_TEMPLATE_FILE_NAME = "index_template.html";
    private static final String INDEX_OUTPUT_FILE_NAME = "index_out.html";
    private static final String HTML_SELECTOR_ID_COLOR = "color";

    public WebServer(Context context) {
      super(PORT);
      this.context = context;
    }

    @Override
    public Response serve(IHTTPSession session) {
      Method method = session.getMethod();
      String uri = session.getUri();
      switch (method) {
        case GET:
          return this.serveFile(uri);
        case POST:
          Map<String, List<String>> parameters = this.parseBodyParameters(session);
          this.updatePreferences(uri, parameters);
          return this.serveFile(uri);
        default:
          return newFixedLengthResponse(Status.METHOD_NOT_ALLOWED, "text/plain",
              "Method [" + method + "] is not allowed.");
      }
    }

    private Response serveFile(String uri) {
      switch (uri) {
        case "/":
          return this.newHtmlResponse(this.generateIndexHtmlContext(), INDEX_TEMPLATE_FILE_NAME, INDEX_OUTPUT_FILE_NAME);
        default:
          return newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "URI [" + uri + "] is not found.");
      }
    }

    private Response newHtmlResponse(Map<String, Object> data, String templateFileName, String outFileName) {
      AssetManager assetManager = context.getAssets();
      try(InputStreamReader template = new InputStreamReader(assetManager.open(templateFileName));
          OutputStreamWriter output = new OutputStreamWriter(openFileOutput(outFileName, Context.MODE_PRIVATE))) {
        Mustache.compiler().compile(template).execute(data, output);
        return newChunkedResponse(Status.OK, "text/html", openFileInput(outFileName));
      } catch (IOException e) {
        e.printStackTrace();
        return newFixedLengthResponse(Status.INTERNAL_ERROR, "text/plain", e.getMessage());
      }
    }

    private Map<String, List<String>> parseBodyParameters(IHTTPSession session) {
      Map<String, String> tmpRequestFile = new HashMap<>();
      try {
        session.parseBody(tmpRequestFile);
      } catch (IOException e) {
        e.printStackTrace();
      } catch (ResponseException e) {
        e.printStackTrace();
      }
      return session.getParameters();
    }

    private void updatePreferences(String uri, Map<String, List<String>> parameters) {
      if(parameters == null) return;

      switch (uri) {
        case "/":
          this.updateLedColor(parameters);
          return;
        default:
          return;
      }
    }

    private void updateLedColor(Map<String, List<String>> parameters) {
      if (parameters.get(HTML_SELECTOR_ID_COLOR) == null || parameters.get(HTML_SELECTOR_ID_COLOR).isEmpty()) {
        return;
      }
      String color = parameters.get(HTML_SELECTOR_ID_COLOR).get(0);
      LedColor ledColor = LedColor.getValue(color);
      notificationLedBlink(LedTarget.LED3, ledColor, 1000);
      saveLedColor(ledColor);
    }

    private Map<String, Object> generateIndexHtmlContext() {
      Map<String, Object> context = new HashMap<>();
      context.putAll(this.generateLedColorContext());
      return context;
    }

    private Map<String, Object> generateLedColorContext() {
      Map<String, Object> ledContext = new HashMap<>();
      LedColor ledColor = loadLedColor();
      switch (ledColor) {
        case BLUE:
          ledContext.put("isBlue", true);
          break;
        case RED:
          ledContext.put("isRed", true);
          break;
        case WHITE:
          ledContext.put("isWhite", true);
          break;
        default:
          ledContext.put("isBlue", true);
      }
      return ledContext;
    }

  }

}

```

```index_template.html

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html" charset="UTF-8">
    <title>Web UI Simple</title>
  </head>
  <body>
    <form action="/" method="post" id="form_id">
      <div id="settings_box">
        <h1>
          <label>LED Color</label>
        </h1>
        <p>
          <select id="color" name="color">
            <option value="blue" {{#isBlue}}selected{{/isBlue}}>Blue</option>
            <option value="red" {{#isRed}}selected{{/isRed}}>Red</option>
            <option value="white" {{#isWhite}}selected{{/isWhite}}>White</option>
          </select>
        </p>
      </div>
      <div id="submit_button_box">
        <button id="submit_button" type="submit" name="action" value="send">OK</button>
      </div>
    </form>
  </body>
</html>

```