題名みても、よくわからないような気がしますが、やりたいことは、普段つけているスマートウォッチ「Xiaomi Mi Band 3」に、自分で好きなメッセージを、自分の好きな時に通知して、スマートウォッチをバイブレーションさせます。
で、特にXiaomi Mi Band 3である必要はなく、一般的なスマートウォッチでよいです。
私は、LINEとXiaomi Mi Band 3を連携させているので、LINE通知が来ると、Xiaomi Mi Band 3がバイブレーションするようになっていますが、LINEとは関係なく、自分で好きなメッセージを、自分の好きな時にバイブレーションさせようというのが今回の趣旨です。
対象のスマホはAndroidです。
もろもろのソースコードは以下のGitHubに上げてあります。
poruruba/NotificationManager
対象のスマートウォッチ
適用可能なスマートウォッチは、アプリ連携できるアプリを選べるスマートウォッチです。
おそらく、たいていのスマートウォッチは、通話の着信だけでなく、アプリからのシステムトレイへの通知に連動して、バイブレーションおよび通知内容のスマートウォッチのディスプレイ表示の機能がついているのではないでしょうか。さらに、その連携するアプリとしてAndroidにインストール済みのアプリから選べることが多いと思います。
その対象アプリとして今回作成するAndroidアプリを選択するわけです。
全体構成
Androidへの通知には、FCM(Firebase Cloud Messaging)を利用します。Androidには通知を受けるサービスが稼働済みなので、バックグラウンドの時やアプリ未起動の時、ロック状態でも、通知を受けることができます。
Androidの事前準備
以下のページにあるとおりに進めます。
① Android StudioからAndroidアプリを作成する
Android Studioからとりあえず、アプリを作成します。
この時に指定したパッケージ名を覚えておきます。
また、アプリの署名に使った署名証明書のSHA-1フィンガプリントをメモっておきます。
こちらが参考になります。keytoolコマンドを使います。
② FirebaseプロジェクトにAndroidを追加する
(参考)https://firebase.google.com/docs/android/setup
Firebaseプロジェクトに、「+アプリを追加」のリンクをクリックし、Androidボタンを選択します。
次の画面で、先ほどメモったパッケージ名とフィンガープリント(SHA-1)を指定します。
google-services.jsonファイルをダウンロードし、Android Studioのプロジェクトのappフォルダに配置します。
プロジェクトレベルのbuild.gradleの先頭に以下を追加します。
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.3.13'
}
repositories {
google()
}
}
アプリレベルのbuild.gradleに以下を追加します。
plugins {
id 'com.android.application'
id 'com.google.gms.google-services' // ★これを追加
}
・・・
dependencies {
implementation platform('com.google.firebase:firebase-bom:31.1.0') // ★これを追加
implementation 'com.google.firebase:firebase-messaging' // ★これを追加
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
トピックのサブスクライブ
次に、Androidで通知を受けるために、トピック名を決めてサブスクライブします。
今回は、とりあえず「fcm_notification」という名前にしました。
MainActivity.javaのonCreateメソッドにおいて、以下を呼び出します。
FirebaseMessaging.getInstance().subscribeToTopic(topic)
.addOnCompleteListener(new OnCompleteListener<Void>() {
@Override
public void onComplete(@NonNull Task<Void> task) {
String msg = "Subscribed";
if (!task.isSuccessful()) {
msg = "Subscribe failed";
}
Log.d(TAG, msg + " : " + topic);
}
});
これで、指定したトピック名で、通知を待ち受けます。
ですが、通知は、アプリ起動していない状態(ロック状態やバックグラウンド状態も含む)では、自身で通知を受け取れないため、FCMのサービスが受け取ります。逆に言うと、アプリが起動していない状態では、FCMのサービスが受け取ってシステムトレイに通知をしてくれますが、アプリが起動している状態では、アプリが処理してシステムトレイに通知する必要があります。
通知処理の違いは以下に記載があります。
通知の中身とアプリの状態の組み合わせで決まります。
アプリの状態 | 通知のみ | データのみ | 両方 |
---|---|---|---|
フォアグラウンド | onMessageReceived | onMessageReceived | onMessageReceived |
バックグラウンド | システムトレイ | onMessageReceived | 通知:システムトレイ、データ:インテント |
いずれの方法でもよく、やることはonMessageReceivedを受けたら自らシステムトレイに通知を入れるようにします。今回は、通知とデータ両方の含めた通知を採用します。
onMessageReceivedは、クラスFirebaseMessagingServiceの派生クラスで実装します。以下抜粋です。
public class MyFirebaseMessagingService extends FirebaseMessagingService {
private static final String TAG = MainActivity.TAG;
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
if (remoteMessage.getData().size() > 0) {
Log.d(TAG, "Message data payload: " + remoteMessage.getData());
Map<String, String> map = remoteMessage.getData();
String title = map.get("title");
String body = map.get("body");
String id = map.get("id");
String datetime_str = map.get("datetime");
long datetime = Long.parseLong(datetime_str);
MainActivity.NotificationItem item = new MainActivity.NotificationItem(datetime, title, body, id);
MainActivity.handler.sendUIMessage(UIHandler.MSG_ID_OBJ_BASE + 0, item);
return;
}
受信メッセージにインテントのエクストラとして含まれるデータを取り出し、MainActivityに処理を依頼しています。依頼先では以下の処理をしています。
NotificationItem item = (NotificationItem) message.obj;
Intent notifyIntent = new Intent(this, NotificationActivity.class);
notifyIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
notifyIntent.putExtra("title", item.messageTitle);
notifyIntent.putExtra("body", item.messageText);
notifyIntent.putExtra("datetime", String.valueOf(item.datetime));
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
Notification.Builder builder = new Notification.Builder(this);
builder.setContentTitle(item.messageTitle);
builder.setContentText(item.messageText);
builder.setSmallIcon(android.R.drawable.ic_popup_reminder);
builder.setChannelId(topic);
builder.setContentIntent(pendingIntent);
builder.setAutoCancel(true);
Notification notification = builder.build();
notificationManager.notify(1, notification);
notificationManager.notify(1, notification)
のところが、システムトレイに通知を入れているところです。これだけで、システムトレイの通知に、通知のタイトルと本文が表示されます。
今回は、それだけではなく、 builder.setContentIntent(pendingIntent)
としてペンディングインテントを入れています。これは、通知をタップしたら、アプリを起動するための指定です。起動するアプリに、通知のタイトルと本文等も渡せるようにしています。
アプリからの通知の参考
https://developer.android.com/guide/topics/ui/notifiers/notifications?hl=ja
AndroidManifest.xmlのapplication内に以下を追記しておきます。
<service
android:name=".MyFirebaseMessagingService"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@android:drawable/ic_popup_reminder" />
これにより、アプリが起動していない状態では、FCMのサービスにより、インテント付きの通知をシステムトレイに通知してくれて、アプリが起動している状態でも自らインテント付きの通知をシステムトレイに通知されるようになります。
これでシステムトレイに通知されるようになったので、スマートウォッチの設定で、このアプリを登録すれば、システムトレイに通知が来たタイミングで、スマートウォッチがバイブレーションするかと思います。
通知をタッチした後の動作
インテント付きの通知をシステムトレイに通知しました。その通知をタッチすると、アプリが起動します。
アプリが起動したときの動作について説明します。インテント付きで起動すると、onNewIntentが呼び出されます。その中で、別のActivity(NotificationActivity)を起動して、通知の内容(タイトルと本文)を表示するようにしています。
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
try {
String title = intent.getStringExtra("title");
String body = intent.getStringExtra("body");
String datetime_str = intent.getStringExtra("datetime");
Intent activity_intent = new Intent(this, NotificationActivity.class);
activity_intent.putExtra("title", title);
activity_intent.putExtra("body", body);
activity_intent.putExtra("datetime", datetime_str);
startActivity(activity_intent);
}catch(Exception ex){
Log.d(TAG, ex.getMessage());
}
}
通常のアプリ起動時の動作
通常のアプリ起動時は、過去に通知した通知内容をリスト表示します。
過去に通知した通知内容は、Node.jsサーバで管理しており、そこから取得しています。
取得する範囲として、本日・昨日・今週・先週・今月・先月・今年・昨年と選べるようにしています。
ちょっと長いのですが、ソースコード抜粋です。
private void listUpdate(int position){
Calendar cal_end = Calendar.getInstance();
cal_end.set(Calendar.HOUR_OF_DAY, 0);
cal_end.set(Calendar.MINUTE, 0);
cal_end.set(Calendar.SECOND, 0);
cal_end.set(Calendar.MILLISECOND, 0);
Calendar cal_start = (Calendar)cal_end.clone();
switch(position){
case 0: { // 今日
cal_end.add(Calendar.DATE, 1);
break;
}
case 1: { // 昨日
cal_start.add(Calendar.DATE, -1);
break;
}
case 2: { // 今週
cal_end.add(Calendar.DATE, 1);
cal_start.add(Calendar.DATE, -7);
break;
}
case 3: { // 今月
cal_end.add(Calendar.DATE, 1);
cal_start.set(Calendar.DATE, 1);
break;
}
case 4: { // 先月
cal_end.set(Calendar.DATE, 1);
cal_start.add(Calendar.MONTH, -1);
cal_start.set(Calendar.DATE, 1);
break;
}
case 5: { // 今年
cal_end.add(Calendar.DATE, 1);
cal_start.set(Calendar.MONTH, 0);
cal_start.set(Calendar.DATE, 1);
break;
}
case 6: { // 昨年
cal_end.set(Calendar.MONTH, 0);
cal_end.set(Calendar.DATE, 1);
cal_start.add(Calendar.YEAR, -1);
cal_start.set(Calendar.MONTH, 0);
cal_start.set(Calendar.DATE, 1);
break;
}
}
long end = cal_end.getTimeInMillis();
long start = cal_start.getTimeInMillis();
new ProgressAsyncTaskManager.Callback(this, "通信中です。", null) {
@Override
public Object doInBackground(Object obj) throws Exception {
JSONObject json = new JSONObject();
json.put("topic", topic);
json.put("start", start);
json.put("end", end);
JSONObject response = HttpPostJson.doPost_withApikey(base_url + "/notification-get-list", json, API_KEY, DEFAULT_TIMEOUT);
return response;
}
@Override
public void doPostExecute(Object obj) {
if (obj instanceof Exception) {
Toast.makeText(getApplicationContext(), obj.toString(), Toast.LENGTH_SHORT).show();
return;
}
try {
JSONObject response = (JSONObject)obj;
adapter.clear();
JSONArray list = response.getJSONArray("rows");
for (int i = 0; i < list.length(); i++) {
JSONObject item = list.getJSONObject(i);
String[] params = new String[3];
params[0] = item.getString("messageTitle");
params[1] = sdf.format(item.getLong("datetime"));
params[2] = item.getString("messageText");
adapter.add(params);
}
ListView listView;
listView = (ListView) findViewById(R.id.list_items);
listView.deferNotifyDataSetChanged();
TextView textView;
textView = (TextView)findViewById(R.id.txt_list_message);
if( list.length() > 0 )
textView.setVisibility(View.GONE);
else
textView.setVisibility(View.VISIBLE);
} catch (Exception ex) {
Log.d(TAG, ex.getMessage());
}
}
};
ということで、インターネットアクセスするので、AndroidManifest.xmlに以下を追記します。
<uses-permission android:name="android.permission.INTERNET" />
Node.jsサーバの実装
Node.jsサーバは以下の役割を持っています。
・WebAPIのエンドポイントを公開し、呼び出しにより通知や、過去の通知内容を提供します。
・過去に通知した内容をDBファイルに保存します。
・通知するためのWebページや過去の通知内容を表示するWebページを提供します。
以下3つのエンドポイントを公開します。
paths:
/notification-push-message:
post:
security:
- apikeyAuth: []
parameters:
- in: body
name: body
schema:
type: object
properties:
topic:
type: string
title:
type: string
body:
type: string
responses:
200:
description: Success
schema:
type: object
/notification-get-list:
post:
security:
- apikeyAuth: []
parameters:
- in: body
name: body
schema:
type: object
properties:
topic:
type: string
start:
type: integer
end:
type: integer
responses:
200:
description: Success
schema:
type: object
/notification-delete-allmessage:
post:
security:
- apikeyAuth: []
parameters:
- in: body
name: body
schema:
type: object
responses:
200:
description: Success
schema:
type: object
/notification-push-message
メッセージを通知します。
/notification-get-list
指定した範囲の過去の通知を取得します。
/notification-delete-allmessage
指定したトピックの過去の全通知を削除します。
過去の通知内容の保存に、SQLite3を使っています。
'use strict';
const HELPER_BASE = process.env.HELPER_BASE || "/opt/";
const Response = require(HELPER_BASE + 'response');
const crypto = require('crypto');
const sqlite3 = require("sqlite3");
const admin = require("firebase-admin");
const NOTIFICATION_TABLE_NAME = "notification";
const API_KEY = "12345678";
const NOTIFICATION_FILE_PATH = process.env.THIS_BASE_PATH + '/data/notification/notification.db';
const db = new sqlite3.Database(NOTIFICATION_FILE_PATH);
db.each("SELECT COUNT(*) FROM sqlite_master WHERE TYPE = 'table' AND name = '" + NOTIFICATION_TABLE_NAME + "'", (err, row) =>{
if( err ){
console.error(err);
return;
}
if( row["COUNT(*)"] == 0 ){
db.run("CREATE TABLE '" + NOTIFICATION_TABLE_NAME + "' (id TEXT PRIMARY KEY, messageTopic TEXT, messageTitle TEXT, messageText TEXT, datetime INTEGER)", (err, row) =>{
if( err ){
console.error(err);
return;
}
});
}
});
admin.initializeApp();
exports.handler = async (event, context, callback) => {
var body = JSON.parse(event.body);
console.log(body);
if( event.requestContext.apikeyAuth.apikey != API_KEY )
throw "apikey invalid";
if( event.path == '/notification-push-message' ){
var topic = body.topic;
var title = body.title;
var body = body.body;
var now = new Date().getTime();
var id = crypto.randomUUID();
await new Promise((resolve, reject) =>{
db.run("INSERT INTO '" + NOTIFICATION_TABLE_NAME + "' (id, messageTopic, messageTitle, messageText, datetime) VALUES (?, ?, ?, ?, ?)", [id, topic, title, body, now], (err) =>{
if( err )
return reject(err);
resolve({});
});
});
var msg = {
notification: {
title: title,
body: body
},
data: {
title: title,
body: body,
id: id,
datetime: String(now)
},
topic: topic
};
var result = await admin.messaging().send(msg);
return new Response({ result: result });
}else
if( event.path == '/notification-get-list' ){
var topic = body.topic;
var start = body.start;
var end = body.end;
return new Promise((resolve, reject) =>{
db.all("SELECT * FROM '" + NOTIFICATION_TABLE_NAME + "' WHERE messageTopic = ? AND datetime BETWEEN ? AND ? ORDER BY datetime DESC", [topic, start, end], (err, rows) => {
if( err )
return reject(err);
resolve(new Response({ rows: rows }));
});
});
}else
if( event.path == '/notification-delete-allmessage' ){
var topic = body.topic;
return new Promise((resolve, reject) =>{
db.all("DELETE FROM '" + NOTIFICATION_TABLE_NAME + "' WHERE messageTopic = ?", [topic], (err) => {
if( err )
return reject(err);
resolve(new Response({}));
});
});
}else
{
throw "unknown endpoint";
}
};
一応、ヘッダX-API-KEYに、既定の値を指定しないとエラーにするようにはしています。
主に以下の2つの追加のnpmモジュールを利用しています。
〇sqlite3
https://github.com/TryGhost/node-sqlite3
SQLite3ファイルを扱うためのライブラリです。
〇firebase-admin
https://github.com/firebase/firebase-admin-node
FCMを扱うために使っています。
(参考)https://firebase.google.com/docs/cloud-messaging/server
firebaseを扱うために、クレデンシャルを生成する必要があります。
Firebaseプロジェクトのページを開き、左上のプロジェクトの概要の近くにある歯車ボタンを選択し、プロジェクトの設定をクリックします。そして、サービスアカウントタブを選択します。
そこに、新しい秘密鍵の生成ボタンがありますので、押下し、クレデンシャルファイルを取得します。
ダウンロードしたファイルをNode.jsサーバの適当な場所にコピーし、その場所を環境変数「GOOGLE_APPLICATION_CREDENTIALS」にセットします。そのために、.envファイルを作成して、以下を書き込みます。keys\notificationフォルダにコピーした場合の例です。
GOOGLE_APPLICATION_CREDENTIALS=.\keys\notification\XXXXXXXXXXX-YYYYY-firebase-adminsdk-ZZZZZ-ZZZZZZZZ.json
それにより、以下のように初期化処理がシンプルになります。
admin.initializeApp();
あとは、以下を実行すれば、Node.jsサーバが立ち上がります。
> mkdir data
> mkdir data\notification
> npm install
> node app.js
これで、ポート番号20080でサーバが立ち上がりました。
最後に、以下が、Webページ(抜粋)です。Bootstrap3とVue2を使っています。
<div id="top" class="container">
<div class="jumbotron">
<button class="btn btn-default btn-sm pull-right" v-on:click="setup_apikey">ApiKey設定</button>
<h2>通知管理ページ</h2>
</div>
<button class="btn btn-default btn-sm pull-right" v-on:click="delete_all_items_call">全削除</button>
<div class="form-inline">
<label>topic</label> <input type="text" class="form-control" v-model="notification_send.topic"><br>
</div>
<br>
<button class="btn btn-default btn-sm pull-right" v-on:click="input_clear">クリア</button><br>
<label>title</label> <input type="text" class="form-control" v-model="notification_send.title"><br>
<label>body</label> <input type="text" class="form-control" v-model="notification_send.body"><br>
<button class="btn btn-primary" v-on:click="notification_send_call">送信</button>
<br><br>
<button class="btn btn-default pull-right" v-on:click="get_all_items_call">更新</button>
<table class="table table-striped">
<thead>
<tr><th>id</th><th>messageTitle</th><th>messageText</th><th>datetime</th></tr>
</thead>
<tbody>
<tr v-for="(item, index) in item_list">
<td>{{item.id}}</td><td>{{item.messageTitle}}</td><td>{{item.messageText}}</td><td>{{toLocaleString(item.datetime)}}</td>
</tr>
</tbody>
</table>
<!-- for progress-dialog -->
<progress-dialog v-bind:title="progress_title"></progress-dialog>
</div>
以下が、処理するJavascriptです。
'use strict';
//const vConsole = new VConsole();
//const remoteConsole = new RemoteConsole("http://[remote server]/logio-post");
//window.datgui = new dat.GUI();
const base_url = "";
var vue_options = {
el: "#top",
mixins: [mixins_bootstrap],
store: vue_store,
data: {
apikey: "",
notification_send: {
topic: 'fcm_notification'
},
item_list: [],
},
computed: {
},
methods: {
notification_send_call: async function(){
var result = await do_post_with_apikey(base_url + "/notification-push-message", this.notification_send, this.apikey);
console.log(result);
alert('送信しました。');
this.get_all_items_call();
},
delete_all_items_call: async function(){
if( !confirm('本当に全部削除しますか?') )
return;
var result = await do_post_with_apikey(base_url + "/notification-delete-allmessage", this.notification_send, this.apikey);
console.log(result);
alert('全削除しました。');
this.get_all_items_call();
},
get_all_items_call: async function(){
var params = {
topic: this.notification_send.topic,
start: 0,
end: new Date().getTime()
};
var result = await do_post_with_apikey(base_url + "/notification-get-list", params, this.apikey);
console.log(result);
this.item_list = result.rows;
},
input_clear: function(){
this.notification_send.title = "";
this.notification_send.body = "";
},
setup_apikey: function(){
var apikey = prompt("ApiKeyを指定してください。", this.apikey);
if( apikey ){
this.apikey = apikey;
localStorage.setItem("notification_apikey", this.apikey);
}
}
},
created: function(){
},
mounted: function(){
proc_load();
this.apikey = localStorage.getItem("notification_apikey");
this.get_all_items_call();
}
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);
/* add additional components */
window.vue = new Vue( vue_options );
function do_post_with_apikey(url, body, apikey) {
const headers = new Headers({ "Content-Type": "application/json", "X-API-KEY": apikey });
return fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers: headers
})
.then((response) => {
if (!response.ok)
throw new Error('status is not 200');
return response.json();
});
}
Webページからアクセスしてみましょう。
最初は、ApiKeyが設定されていないので、右上の「ApiKey設定」ボタンから設定してください。
続編
続編もあります。スマホ複数台持ちの方は必見です。
スマートウォッチをリマインダ代わりにバイブレーションさせる:その2
以上