RICOH THETA V で、無限に録画を続けられるPluginを作ってみる

# この記事について
[前回](https://qiita.com/nara256/items/6fb46a9b5636e932d409)の続きです。
THETAで撮影した動画を、ローカルに溜め込んでいくのではなく自動的にクラウドストレージにアップしてくれるような Plugin を作ってみます。

# 動画を自動的にクラウドストレージに保存する Plugin を作る

## THETAの不便なところ
THETAは、撮影した動画・静止画をスマホやPCに取り出すための専用アプリが用意されています。
逆に言うと、そのアプリを使わないとファイルを取り出せず、内部のストレージに溜まりっぱなしになってしまうのがちょっと不便だったりします。
せっかく WiFi で接続できるのだから、撮影した動画をネットワーク経由で自動的に外部ストレージに保存しながら録画できたほうが便利だろう、と考えました。副次的な効果で、録画しながらクラウドストレージデータ保存することによって、内部ストレージを消費せずにほぼ無限に録画を続けられるようになります。

ということで、そんな Plugin を実装してみます。

## 事前準備（前提・想定）
- THETA V を使用します。
- [前回](https://qiita.com/nara256/items/6fb46a9b5636e932d409) と同様に、カメラ撮影の plugin のサンプルである https://github.com/ricohapi/theta-plugin-camera-api-sample をベースに、ちょっと内部をいじります。
- LTEの回線を使ってデータ送信する想定で実装してみますので、[前回](https://qiita.com/nara256/items/6fb46a9b5636e932d409) の続きから実装を開始します。

## 修正のポイント（ざっくりまとめ）
サンプルのソースをベースに、修正するポイントをざっくりまとめると、以下の通りです。

- Pluginから外部のネットワークにアクセスできるように、アプリのパーミッションを追加します。
- Plugin起動時に、クライアントモードでWiFi接続するようにします。
- サンプルは、一定時間の録画が終わると自動的に録画が終了してしまうため、録画終了のイベントを拾って録画を再開するようにします。
- ファイルが保存されたイベントを拾って、ストレージに保存するようにします。

## 修正後のソースコードについて
下記の修正点を適用したソースコード一式をgithubに置いておきました。
https://github.com/nara256/theta-plugin-camera-api-sample/tree/cloud_recording_test

## 修正点
### パーミッションの設定
今回はデータをクラウドストレージに保存するので、アプリにインターネットアクセスのパーミッションを追加します。

```xml:AndroidManifest.xml(修正後)
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <!-- ↓追加↓ -->
    <uses-permission android:name="android.permission.INTERNET" />
```

### WiFi接続
Plugin の起動時に、クライアントモードでWiFi接続するように、`MainActivity#onCreate()` を修正します。

```java:MainActivity.java(修正後)
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        //(中略)

        //notificationWlanOff();  //削除
        notificationWlanCl();     //追加
        notificationCameraClose();
    }
```

### 繰り返し録画に対応
1分ごとに録画を終了（・動画ファイルを生成）した後に、続けて次の録画を開始するように、修正します。

`CameraFragment` のソースを参照します。
以下は修正前のソースです。

```java:CameraFragment.java(修正前)
    private MediaRecorder.OnInfoListener onInfoListener = new MediaRecorder.OnInfoListener() {
        @Override
        public void onInfo(MediaRecorder mr, int what, int extra) {
        }
    };
```
録画が終了すると、上記 listener にイベントが飛んできます。 が、上記のようにサンプルではイベントを読み飛ばしている状態ですので、その部分を直します。

```java:CameraFragment.java(修正後)
    private MediaRecorder.OnInfoListener onInfoListener;

    public void setInfoListener(@NonNull MediaRecorder.OnInfoListener listener) {
        onInfoListener = listener;
    }
```

```java:MainActivity.java(修正後)
    // OnInfoListener の Callback 定義を追加する
    private MediaRecorder.OnInfoListener mInfoListenerCallback = (mr, what, extra) -> {
        //容量オーバー or タイムオーバー
        if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED ||
                what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
            takeVideo();    //stop recording
            takeVideo();    //restart recording
        }
    };

    // 既存の takeVideo メソッドを修正する
    private boolean takeVideo() {
        //(中略)
                ((CameraFragment) fragment).setBoxCallback(mBoxCallBack);
                //(↓この行を追加   上記で定義した Callback をセットする)
                ((CameraFragment) fragment).setInfoListener(mInfoListenerCallback);
        //(中略)
    }
```

上記の通り、録画が一定時間で終了した場合は `MEDIA_RECORDER_INFO_MAX_DURATION_REACHED` が、設定した上限ファイルサイズを超えた場合は `MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED` が、変数 `what` に入ってきますので、それを検知して録画の停止と再開を行っています。

### 録画ファイルを外部ストレージに保存する
今回の主目的の処理です。録画が終わって内部ストレージに動画ファイルが保存されたタイミングで、そのファイルを外部ストレージに保存します。
この部分は、どのような外部ストレージに保存するかによって処理が変わってきますが、今回はサクッと外部のhttpサーバにファイルをPOSTする方法にしてみます。
（なお、今回の方法はIIJ IoTサービスの使用を想定して実装しています。IIJ IoTサービスではファイルをhttp postするだけで閉域網を使って（インターネットにデータを晒さずに）クラウドのオブジェクトストレージに保存することができるので便利です）。

今回は http post するためのライブラリとして okhttp を使用するので、 `build.gradle` に記述を追加します。

```gradle:build.gradle(修正後)
dependencies {
    // (中略)
    // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
    implementation 'com.squareup.okhttp3:okhttp:4.4.0'
    // https://mvnrepository.com/artifact/org.conscrypt/conscrypt-android
    implementation 'org.conscrypt:conscrypt-android:2.2.1'
}
```

THETAは、録画が終わって内部ストレージに保存するタイミングで、動画ファイルにメタデータを追加する処理が走ります。この処理は `CameraFragment#takeVideo()` のソース内に記述されています（下記 `Box`の部分）。

```java:CameraFragment.java
    public boolean takeVideo() {
        // (中略)
            try {
                mMediaRecorder.stop();
                Log.d("debug", "mMediaRecorder.stop()");
                /**
                 * Metadata is written to the movie file
                 * by specifying mp4 file path and wav file path in form box of BoxClass
                 */
                Box box = new Box();
                box.formBox(mMp4filePath, "", mBoxCallback);
        // (中略)
    }
```

このメタデータの追加処理が終わると、登録されているコールバック `Box.Callback#onCompleted()` の処理が走りますので、この部分でクラウドストレージにファイルを保存処理を追加します。

```java:MainActivity.java(変更後)
    private Box.Callback mBoxCallBack = new Box.Callback() {

        @Override
        public void onCompleted(String[] fileUrls) {
            Log.d("Sample", "Success in writing metadata");
            // Delete Wav file if unnecessary
            notificationSensorStop();

            // (↓ここから追加）-----------------------------
            //mp4ファイルをクラウドストレージに保存する
            String mp4 = fileUrls[0];
            Log.i("Sample", "POST TO Storage : " + mp4);
            //saveToStorage()メソッドの中身は後述
            saveToStorage(mp4);    
            //なぜか内部に古いファイルが残りっぱなしなので削除しておく
            String orgFileName = mp4.replace(".MP4", ".org.MP4");
            new File(orgFileName).delete();
            // (↑ここまで追加）-----------------------------

            String storagePath = Environment.getExternalStorageDirectory().getPath();
            for (int i = 0; i < fileUrls.length; i++) {
                fileUrls[i] = fileUrls[i].replace(storagePath, "");
            }
            notificationDatabaseUpdate(fileUrls);
        }

```

ストレージに保存する処理です。
今回は、内部ストレージに保存されているファイルを、外部のサイトに http post するだけにします。

まずは、okhttpのclientを定義します。
[OkHttpはシングルトンにしたほうがいい](https://qiita.com/taki4227/items/a6ce71c6496998aa1ecc) らしいですが、今回は単にクラス変数として定義しています。THETA上で動かすだけならそれほど大きな影響はないだろう、という判断です。

```java:MainActivity.java(変更後)
public class MainActivity extends PluginActivity implements CameraFragment.CFCallback {
    //↓追加
    private OkHttpClient httpClient = null;
```

`onCreate()` 内で `httpClient` のインスタンスを生成します。
今回はLTE回線を使って比較的大きなファイルサイズのデータをPOSTする想定で、無駄にタイムアウト時間を伸ばしています。

```java:MainActivity.java(変更後)

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // (中略)

        if (httpClient == null) {
            httpClient = new OkHttpClient().newBuilder()
                    .connectTimeout(10, TimeUnit.MINUTES)
                    .writeTimeout(10, TimeUnit.MINUTES)
                    .readTimeout(10, TimeUnit.MINUTES)
                    .build();
        }

        // (中略)
    }
````

保存処理を行うためのメソッド `saveToStorage()` を実装します。
保存処理については、例えば Amazon S3 に保存したい場合は S3用のライブラリを使って処理を書けばいいと思いますし、その他のストレージに保存するような場合でも チョロっと修正するだけで対応できると思います。

```java:MainActivity.java(変更後)
    private void saveToStorage(String targetFilePath) {
        /* ここで、ファイルをストレージに保存する処理を書きます。
           下記サンプルでは、ファイルを特定のサイトに HTTP POST しています。 */
        File target = new File(targetFilePath);
        final String BOUNDARY = String.valueOf(System.currentTimeMillis());
        RequestBody body = new MultipartBody.Builder(BOUNDARY)
                .setType(MultipartBody.FORM)
                .addFormDataPart("data", target.getName(), RequestBody.create(target, MediaType.parse("application/octet-stream")))
                .build();
        okhttp3.Request req = new okhttp3.Request.Builder()
                .url(UPLOAD_URL)   //post先のURLは事前に定義しておく
                .post(body)
                .build();
        //非同期でrequestを送る
        httpClient.newCall(req).enqueue(new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                Log.e("Sample", "err", e);
                notificationErrorOccured();
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                Log.i("Sample", "response=" + response.code());
                new File(targetFilePath).delete();
            }
        });
    }
```

これでとりあえず実装完了です。

## 作った Plugin を動かしてみる

[前回](https://qiita.com/nara256/items/6fb46a9b5636e932d409) の実装の続きで実装したため、解像度は2K/1Mbps/9fpsの動画が1分ごとに生成される動きになります。
動作については、以下のような順番で動きます。

1. Pluginを起動後、手動で動画の録画を開始する
2. 1分経過後、一旦録画終了し、1分間の動画ファイルが生成される。
3. 自動的に次の1分の録画が開始される。
4. 次の1分の録画をしている裏で、生成完了した動画ファイルがストレージにPOSTされる
5. POSTが正常終了したら、内部ストレージから動画ファイルが自動削除される
6. 2に戻る

ほぼ無制限に動画が撮り続けられることになり、とっても便利になりました :-)

## 注意事項
今回の実装は、サンプル的にだいぶ端折って実装したものなので、内容的に不十分な状態です。例えばエラー処理は明らかに不足しています。その他、面倒な考慮は一切すっ飛ばしていますので、実際にはもう少し考えて実装すべきだと思います。

# まとめ
## 今回の実装で考えた、THETAの可能性
THETAはどちらかといえば「お遊び用」として使われる例が多いと思いますが、今回のように無限に撮影を続けられるような Plugin があれば、監視カメラ代わりとして使えるようにならないか、と考えています。

例えば、通常は部屋の4角に監視カメラを置くべくところを、中央1箇所にTHETA 1個置くだけで対応可能になります。だいぶコスト削減できると思います。

屋内だけでなく屋外の監視用としても使える気がします。
田んぼの真ん中、工場の敷地の中、イベント会場、などに監視用として設置すると、便利かつ低コストに使えるのではないかと考えました。

...と考えたときに、（多分読んでいないと思いますが）リコーの中の人に勝手なお願いを書いてみます。

###ハードウェアについて

- 屋外設置用の防水ケースがほしい
  - 純正オプションとして水中用の防水ケースがありますが、あれだと電源が接続できず、常時設置には向きません。常時設置できるように、電源のコードを通す穴を開けた防水ケースがあると嬉しいなー、と思いました。
  - もしくは、最初から防水仕様として作ったTHETAを発売してもらえると嬉しいかも。
-  直接SIMを差せるTHETAがほしい
  -  ネットワーク環境を意識せずに単体で稼働させることができると便利。
  -  中身は Android なので、比較的簡単に実現できるのではないかなー、と素人考えをしてみる。
  -  組み込み用のSIMのご相談はぜひ弊社まで...ｹﾞﾌﾝｹﾞﾌﾝ... Qiita は商用利用禁止だったw

### ソフトウェアについて

- 撮影時に上下逆転できるようにしたい
  -  監視カメラ用として使おうとすると、THETAを天井から吊り下げて設置する方法が現実的な気がします。となると上下逆で固定することになるので、撮影時のオプションなどで自動的に上下逆転で撮影できるようになると嬉しいなーと思いました。
  -  (2020.03.05追記) これはandroidアプリの実装で画面を回転させれば良いような気がしてきた。
- 電源を入れたら自動的にPlug-inを起動する仕組みが欲しいです

# 最後に
[前回](https://qiita.com/nara256/items/6fb46a9b5636e932d409) も同じことを書いたのですが、THETA SDK を細部まで確認しないままテキトーに実装していますので、なにかお気づきな点や、変なことをやっているようなことがあればご指摘いただけると幸いです。