この記事は「Android Things Advent Calendar 2017」7日目の記事となります。そしてAndroid Advent Calendarで書いた「TimberとRealtime Databaseを併用してエラーログを収集する」の続きとなります。
といっても、特に前回の記事を読む必要はないと思います。
Android ThingsとGooge services
Android ThingsはGoogle製ということもあってか、Google Servicesが用意されています。
とはいえ、すべてのGoogle Servicesを利用することはできません。
サポートされているAPIの一覧は、公式サイトから確認できます。
そして一覧をご覧ください。「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などが制限なく利用できます。
さらに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 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>
public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService {
@Override
public void onTokenRefresh() {
// Push Device Token
String token = FirebaseInstanceId.getInstance().getToken();
Timber.d("token: %s", token);
}
}
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);
}
}
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を通知するためのコードも掲載します。
'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.');
});
});
});