Android Thingsを常時稼働するマイクロサーバっぽく使う（FCMの受信）

この記事は「[Android Things Advent Calendar 2017](https://qiita.com/advent-calendar/2017/androidthings)」７日目の記事となります。そしてAndroid Advent Calendarで書いた「[TimberとRealtime Databaseを併用してエラーログを収集する](https://qiita.com/yamacraft/items/6f4d59b146c47f523a12)」の続きとなります。
といっても、特に前回の記事を読む必要はないと思います。

## Android ThingsとGooge services

Android ThingsはGoogle製ということもあってか、Google Servicesが用意されています。

![platform-architecture.png](https://qiita-image-store.s3.amazonaws.com/0/4560/42099e7d-6fbb-6487-de97-2251a1ebbeac.png)
（公式サイトより引用）

とはいえ、すべてのGoogle Servicesを利用することはできません。
サポートされているAPIの一覧は、公式サイトから確認できます。

https://developer.android.com/things/sdk/index.html#google-services

そして一覧をご覧ください。「Firebase Cloud Messaging （FCM）」がサポートされています。つまりAndroid ThingsにPush通知を送ることが可能なのです。

## Android Thingsをマイクロサーバっぽく使う

ということで、ここから前回の記事の続きです。

Firebase Realtime Databaseへの書き込みをトリガとしてCloud Functionsを実行できます。Firebaseの無料プランを利用していると、Cloud Functionsから直接Google以外のAPIを利用できません。ですがGoogle内のサービスは利用が可能です。そしてFCMも、そのひとつに入ります。

つまりFCMで、必要な情報をAndroid Thingsに送ってしまうのです。そこからはAndroid Things内で動くAndroidアプリの話となるので、SlackのAPIなどが制限なく利用できます。

![system.png](https://qiita-image-store.s3.amazonaws.com/0/4560/6e8b7e1c-fabf-30fe-db16-0dbf30c9a0f0.png)

さらにAndroid Thingsで動かすアプリは通常のAndroidアプリと変わらないため、ServiceクラスやJobServiceクラスがメインで動く作りにしておけば、ちょっとしたサーバーっぽく利用することができるのです！（個人的に非推奨）

Android ThingsはまだDeveloper Previewではありますが、常に最新のAndroid OSをベースに作られています。それにRaspberry Pi3一式が1万円以内でそろえることができます。
Pi3にはWi-Fiだけでなく有線LANコネクタも使うことができます。このシステムを実装する上で、適当なAndroidスマートフォンを用意するよりも、コスパは良いはずです。個人的にお勧めしない使い方ですが。

## Android Thingsで受信処理アプリを作成する

基本的に通常のAndroidアプリでFCMを受信するときと同様の実装になります。その気になれば、スマートフォンアプリとして作ったものを、そのままAndroid Thingsにインストールしてしまってもよいでしょう。

あとコードがJavaになっていますが、気にしないでください。気にしないで…。
今回はSlack APIへ受け取った内容を送るようにしています。

```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest package="io.github.yamacraft.app.receive"
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools">
   <application
      android:name=".CustomApplication"
      android:allowBackup="false"
      android:icon="@mipmap/ic_launcher"
      android:label="@string/app_name"
      android:roundIcon="@mipmap/ic_launcher_round"
      android:supportsRtl="true"
      android:theme="@style/AppTheme">

      <!-- 省略 -->
      <service
          android:name=".Service.MyFirebaseMessagingService"
          android:exported="false"
          tools:ignore="InnerclassSeparator">
          <intent-filter>
            <action android:name
               ="com.google.firebase.MESSAGING_EVENT"/>
          </intent-filter>
      </service>
      <service
          android:name=".Service.MyFirebaseInstanceIdService"
          android:exported="false"
          tools:ignore="InnerclassSeparator">
          <intent-filter>
            <action android:name
               ="com.google.firebase.INSTANCE_ID_EVENT"/>
          </intent-filter>
      </service>
      <service
          android:name=".Service.MyJobService"
          android:exported="false"
          tools:ignore="InnerclassSeparator">
          <intent-filter>
            <action android:name
               ="com.firebase.jobdispatcher.ACTION_EXECUTE"/>
          </intent-filter>
      </service>
   </application>
</manifest>
```

```java:MyFirebaseInstanceIdService.java
public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService {
    @Override
    public void onTokenRefresh() {
        // Push Device Token
        String token = FirebaseInstanceId.getInstance().getToken();
        Timber.d("token: %s", token);
    }
}
```

```java:MyFirebaseMessagingService.java
public class MyFirebaseMessagingService
   extends FirebaseMessagingService {
    private static final String JOB_TAG = "Job";

    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {

        // データメッセージ処理（現在Cloud Functionsで送っているのはこの形式）
        if (remoteMessage.getData().size() > 0) {
            Map<String, String> payload = remoteMessage.getData();
            String title
               = payload.containsKey("title") ?
                  payload.get("title") : "";
            String message
               = payload.containsKey("message") ?
                  payload.get("message") : "";
            String crashId
               = payload.containsKey("timestamp") ?
                  payload.get("timestamp") : "";
            String model
               = payload.containsKey("model") ?
                  payload.get("model") : "";
            String body
               = payload.containsKey("body") ?
                  payload.get("body") : "";
            scheduleJob(title, message, crashId, model, body);
            return;
        }
    }

    /**
     * 実行Job登録
     *
     * @param title   通知タイトル
     * @param message 通知本文
     * @param crashId クラッシュID（タイムスタンプ）
     * @param model   端末名
     * @param body    クラッシュ情報
     */
    private void scheduleJob(String title, String message, String crashId,
      String model, String body) {
        Bundle extras = new Bundle();
        extras.putString(EXTRAS_KEY_TITLE, title);
        extras.putString(EXTRAS_KEY_MESSAGE, message);
        extras.putString(EXTRAS_KEY_CRASH_ID, crashId);
        extras.putString(EXTRAS_KEY_MODEL, model);
        extras.putString(EXTRAS_KEY_BODY, body);

        FirebaseJobDispatcher dispatcher
            = new FirebaseJobDispatcher(new GooglePlayDriver(this));
        Job job = dispatcher.newJobBuilder()
                .setService(MyJobService.class)
                .setTag(JOB_TAG)
                .setExtras(extras)
                .build();
        dispatcher.schedule(job);
    }
}
```

```java:MyJobService.java
public class MyJobService extends JobService {
    public static final String EXTRAS_KEY_TITLE
      = MyJobService.class.getName() + "title";
    public static final String EXTRAS_KEY_MESSAGE
      = MyJobService.class.getName() + "message";
    public static final String EXTRAS_KEY_CRASH_ID
      = MyJobService.class.getName() + "crash_id";
    public static final String EXTRAS_KEY_MODEL
      = MyJobService.class.getName() + "model";
    public static final String EXTRAS_KEY_BODY
      = MyJobService.class.getName() + "body";

    @Override
    public boolean onStartJob(JobParameters job) {

        Bundle extras = job.getExtras();
        String title = getExtrasString(
           extras, EXTRAS_KEY_TITLE, "No Title");
        String message = getExtrasString(
           extras, EXTRAS_KEY_MESSAGE, "No Message");
        String crashId = getExtrasString(
           extras, EXTRAS_KEY_CRASH_ID, "UnKnown");
        String model = getExtrasString(
           extras, EXTRAS_KEY_MODEL, "UnKnown");
        String body = getExtrasString(
           extras, EXTRAS_KEY_BODY, "No Body");

        // slackに通知を行う
        PostSlackClient client = new PostSlackClient(null);
        client.post(
             getResources().getString(
                R.string.post_slack_channel_alert),
             getResources().getString(
                R.string.post_slack_message_alert),
             convertPostSlackMessage(crashId, model, body),
             getResources().getString(
                R.string.post_slack_username_alert),
             getResources().getString(
                R.string.post_slack_icon_emoji_alert));
        return false;
    }

    @Override
    public boolean onStopJob(JobParameters job) {
        return false;
    }

    /**
     * Extrasから文字列取得
     *
     * @param extras       Extras
     * @param key          キー名
     * @param defaultValue デフォルト文字列
     * @return 文字列
     */
    private String getExtrasString(Bundle extras, String key,
      String defaultValue) {
        if (extras == null) {
            return defaultValue;
        }
        if (!extras.containsKey(key)) {
            return defaultValue;
        }
        return extras.getString(key, defaultValue);
    }

    /**
     * Slackに投げるメッセージ整形
     *
     * @param crashId CrashId(timestamp)
     * @param model   Model
     * @param body    Body
     * @return 整形したメッセージ
     */
    private String convertPostSlackMessage(
      String crashId, String model, String body) {
        return "crashId(timestamp): " + crashId + "\n"
                + "model: " + model + "\n"
                + "body: \n" + body;
    }
}
```

`FirebaseInstanceId.getInstance().getToken()` で端末のPush Device Tokenが取得できます。

前述していますが、Android Thingsは最新のAndroid OS（現在は8.1）をベースに作られているため、バックグラウンドの動作が昔のような作りだと容赦なく止められる可能性があります。JobSchedulerで実装しておくとよいでしょう。
また、そういった意味で完全な同期で動作を実施することもできません。注意しましょう。

## 【補足】Cloud FunctionsでFCMを利用するサンプルコード

いちおう、Databaseの特定のノード以下に書き込みがあった場合、特定のtokenに向けてPushを通知するためのコードも掲載します。

```javascript:functions/index.js
'use strict';

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

exports.sendCrashNotificationRelease =
   functions.database.ref('/crash_list/{timestamp}/')
      .onWrite(event => {
      const timestamp = event.params.timestamp;

      if (!event.data.val()) {
        // データが削除された場合は、ここに入る
        return console.log('Delete Crash id: ', timestamp);
      }
      const model = event.data.val().model;
      const message = event.data.val().message;

      const payload = {
        data: {
          title: `新しいCrashを検知しました`,
          message: `${message}`,
          timestamp: `${timestamp}`,
          model: `${model}`,
          body: `${message}`
        }
      };

      // 本来はDBから取得するとよいですね
      const tokens = ["xxxxxxxxxxxxxxxxxx"];

      // Push通知の実行
      return admin.messaging().sendToDevice(tokens, payload)
         .then(response => {
        response.results.forEach((result, index) => {
          const error = result.error;
            if (error) {
              console.error('Failure sending notification to', error);
            }
        });
        console.log('done.');
      });
    });
});
```