M5Core2を入手したので、何かしようかなあと思ったのですが、画面もついて、バイブレーションもついているので、手持ちのAndroidでブルった通知をM5Core2に表示させてみようと思います。
といいながらも、バイブレーションはまたの機会にしたので、ESP32とディスプレイさえあれば動きます。
M5StickCとM5Core2のどちらでも動くようにしてます。
Android・ArduinoのソースコードをGitHubに上げておきました。
poruruba/NotificationReporter
https://github.com/poruruba/NotificationReporter
(2020/09/06)修正
Notificationのメッセージの中で、TickerTextもよくつかわれるようでそれも転送し表示するようにしました。
仕組み
Androidでは、NotificationListenerServiceを継承したサービスを実装することで、他のアプリ含めてすべての通知を受け取ることができます。
以下を参考にさせていただきました。
NOTIFICATIONLISTENERSERVICEを使ってステータスバーを監視する
https://techbooster.org/android/ui/16546/
そこで、インストール済みのアプリ一覧をActivityで表示し、チェックボックスでOnにしたアプリの通知のみをESP32に転送するようにします。
ちなみに、すべての通知を受け取るという非常にアレな機能なので、使うには注意が必要で、ユーザに権限をもらう必要があります。
ESP32への転送には、MQTTを使っています。AndroidからPublishし、ESP32ではsubscribeしています。
インストール済みアプリの一覧表示
こちらを参考にさせていただきました。
端末にインストールされているアプリを一覧で取得してみる
https://qiita.com/shota_low/items/b2ee7fb1fd353f69df7a
こんな感じです。
packageManager = getPackageManager();
List<ApplicationInfo> dataList = packageManager.getInstalledApplications(PackageManager.GET_META_DATA);
これらのうち、選択したアプリのみ通知するように、それを記憶するためにSQLite3データベースを使いました。
とはいっても、選択したアプリ=パッケージ名 を記録するためのだけの単純なテーブルです。
扱いやすいように、以下の3つのメソッドを定義しています。
・deletePackageName:テーブルからパッケージ名を削除
・insertPackageName:テーブルにパッケージ名を追加
・hasPackageName:テーブルにパッケージ名があるかの確認
public class NotificationDbHelper extends SQLiteOpenHelper {
public static final String DATABASE_NAME = "NotificationDb.db";
public static final String TABLE_NAME = "NotificationTbl";
public static final int DATABASE_VERSION = 1;
private static final String SQL_CREATE_ENTRIES = "CREATE TABLE " + TABLE_NAME + " (packageName TEXT PRIMARY KEY)";
private static final String SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS " + TABLE_NAME;
NotificationDbHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL( SQL_CREATE_ENTRIES );
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL( SQL_DELETE_ENTRIES );
onCreate(db);
}
public void deletePackageName(SQLiteDatabase db, String packageName){
boolean has = hasPackageName(db, packageName);
if( !has )
return;
ContentValues values = new ContentValues();
values.put("packageName", packageName);
db.delete(TABLE_NAME, "packageName = ?", new String[]{ packageName });
}
public void insertPackageName(SQLiteDatabase db, String packageName){
boolean has = hasPackageName(db, packageName);
if( has )
return;
ContentValues values = new ContentValues();
values.put("packageName", packageName);
db.insert(TABLE_NAME, null, values);
}
public boolean hasPackageName(SQLiteDatabase db, String packageName){
Cursor cursor = db.query(TABLE_NAME, new String[] { "packageName" },
"packageName = ?",
new String[]{ packageName },
null,
null,
null );
boolean result = false;
if( cursor.getCount() > 0 )
result = true;
cursor.close();
return result;
}
}
使うときには以下を事前に呼び出しておきます。
パッケージ名をデータベースに登録するMainActivity.javaと、登録したパッケージ名を参照するNotificationReporteService.javaのそれぞれで呼び出しています。
helper = new NotificationDbHelper(this);
db = helper.getReadableDatabase();
MainActivity.javaに戻って、最後に、インストール済みアプリをリスト表示する部分を示します。とはいっても、MainActivityの実装のほとんどがこれに費やしていますので、ソースをそのまま載せておきます。
public class MainActivity extends AppCompatActivity{
public static final String TAG = "NotifyMon";
public static PackageManager packageManager;
PackageListAdapter adapter;
NotificationDbHelper helper;
SQLiteDatabase db;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
helper = new NotificationDbHelper(this);
db = helper.getWritableDatabase();
packageManager = getPackageManager();
List<ApplicationInfo> dataList = packageManager.getInstalledApplications(PackageManager.GET_META_DATA);
ListView list;
list = (ListView)findViewById(R.id.list_app);
adapter = new PackageListAdapter(dataList);
list.setAdapter(adapter);
}
private class PackageListAdapter extends BaseAdapter {
List<ApplicationInfo> dataList;
public PackageListAdapter(List<ApplicationInfo> dataList){
this.dataList = new ArrayList<>();
for (ApplicationInfo info : dataList) {
if ((info.flags & ApplicationInfo.FLAG_SYSTEM) == ApplicationInfo.FLAG_SYSTEM)
continue;
this.dataList.add(info);
}
Collections.sort(this.dataList, new Comparator<ApplicationInfo>(){
Collator collector = Collator.getInstance(Locale.JAPANESE);
@Override
public int compare(ApplicationInfo p1, ApplicationInfo p2) {
return collector.compare(p1.loadLabel(packageManager).toString(), p2.loadLabel(packageManager).toString());
}
});
}
@Override
public int getCount() {
return dataList.size();
}
@Override
public Object getItem(int position) {
return dataList.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView( final int position, View convertView, ViewGroup parent) {
if(convertView == null){
LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);
convertView = inflater.inflate(R.layout.package_list, null);
}
ApplicationInfo aInfo = (ApplicationInfo)getItem(position);
if(aInfo != null){
String packageName = aInfo.packageName;
String label = aInfo.loadLabel(packageManager).toString();
Drawable icon = aInfo.loadIcon(packageManager);
Log.d(MainActivity.TAG, "icon_width:" + icon.getIntrinsicWidth() + " icon_height:" + icon.getIntrinsicHeight());
TextView text;
ImageView image;
CheckBox chk;
text = (TextView) convertView.findViewById(R.id.txt_package_name);
text.setText(packageName);
text = (TextView) convertView.findViewById(R.id.txt_label);
text.setText(label);
image = (ImageView) convertView.findViewById(R.id.img_icon);
image.setImageDrawable(icon);
chk = (CheckBox)convertView.findViewById(R.id.chk_allowed);
chk.setOnCheckedChangeListener(null);
chk.setChecked(helper.hasPackageName(db, packageName));
chk.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
ApplicationInfo app = (ApplicationInfo)adapter.getItem(position);
String packageName = app.packageName;
Log.d(MainActivity.TAG, "checked packageName:" + packageName + " b:" + b);
if( b )
helper.insertPackageName(db, packageName);
else
helper.deletePackageName(db, packageName);
}
});
}
return convertView;
}
}
}
AndroidでのMQTTの実装
いつものように、Pahoを使います。
Eclipse Paho Android Service
https://github.com/eclipse/paho.mqtt.android
appのbuild.gradleのdependenciesのところに以下を追記します。
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.1.0'
implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'
AndroidManifest.xmlに以下を追記します。
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
・・・
<service android:name="org.eclipse.paho.android.service.MqttService">
</service>
後は、こんな感じです。
try {
mqttClient = new MqttAsyncClient(serverUri, clientId, new MemoryPersistence());
mqttClient.setCallback(new MqttCallbackExtended() {
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
Log.d(MainActivity.TAG, "Reconnected to : " + serverURI);
} else {
Log.d(MainActivity.TAG, "Connected to: " + serverURI);
}
// 接続完了後の処理
}
@Override
public void connectionLost(Throwable cause) {
Log.e(MainActivity.TAG, "The Connection was lost.");
}
@Override
public void messageArrived(String topic, MqttMessage message) throws Exception {
Log.d(MainActivity.TAG, "Incoming message: " + new String(message.getPayload()));
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
Log.d(MainActivity.TAG, "deliveryComplete");
}
});
MqttConnectOptions options = new MqttConnectOptions();
options.setCleanSession(true);
options.setAutomaticReconnect(true);
mqttClient.connect(options, null, new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {
Log.d(MainActivity.TAG, "onSuccess");
}
@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
Log.e(MainActivity.TAG, "Failed to connect to: " + serverUri);
}
});
}catch(Exception ex){
Log.e(MainActivity.TAG, ex.getMessage());
}
あと、使い終わったら、MQTTを切断しておきます。
mqttClient.disconnect();
mqttClient.close();
NotificationListenerServiceの実装
通知を受け取るために、NotificationListenerServiceを継承して実装します。
必ず実装しないといけないのが、
・onCreate
・onNotificationPosted
・onNotificationRemoved
・onDestroy (必要に応じて後処理)
※ちなみに、AndroidのServiceを継承しているのですが、IBinderを使おうとメソッドをoverrideすると通知が受け取れなくなるので、IBinderは使えないと思った方がよいと思います。
@Override
public void onCreate() {
super.onCreate();
Log.d(MainActivity.TAG, "NotificationMonitorService onCreate");
・・・
}
@Override
public void onNotificationPosted(StatusBarNotification sbn) {
Log.d(MainActivity.TAG,"onNotificationPosted");
processNotification(sbn, true);
}
@Override
public void onNotificationRemoved(StatusBarNotification sbn) {
Log.d(MainActivity.TAG,"onNotificationRemoved");
processNotification(sbn, false);
}
private void processNotification( StatusBarNotification sbn, boolean posted ){
・・・
}
@Override
public void onDestroy() {
Log.d(MainActivity.TAG, "onDestroy");
・・・
}
}
あとは、メソッドprocessNotification の中で、MQTTでpublishするJSONデータを作成して、publishしています。あと、途中で、通知に表示されるアイコンもESP32に転送したく、32x32の画像に縮小し、さらに2値変換したりしています。詳細は、GitHubを参照してください。
こんな感じです。
・・・
private void processNotification( StatusBarNotification sbn, boolean posted ){
int id = sbn.getId();
String packageName = sbn.getPackageName();
String groupKey = sbn.getGroupKey();
String key = sbn.getKey();
String tag = sbn.getTag();
long time = sbn.getPostTime();
Log.d(MainActivity.TAG,"id:" + id + " packageName:" + packageName + " posted:" + posted + " time:" +time);
Log.d(MainActivity.TAG,"groupKey:" + groupKey + " key:" + key + " tag:" + tag);
try {
ApplicationInfo app = MainActivity.packageManager.getApplicationInfo(packageName, 0);
String label = app.loadLabel(MainActivity.packageManager).toString();
Notification notification = sbn.getNotification();
CharSequence tickerText = notification.tickerText;
Bundle extras = notification.extras;
String title = extras.getString(Notification.EXTRA_TITLE);
CharSequence text = extras.getCharSequence(Notification.EXTRA_TEXT);
CharSequence subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT);
CharSequence infoText = extras.getCharSequence(Notification.EXTRA_INFO_TEXT);
Log.d(MainActivity.TAG, "Title:" + title + " Text:" + text + " subText:" + subText + " infoText:" + infoText + " tickerText:" + tickerText);
Icon smallIcon = notification.getSmallIcon();
Drawable icon = smallIcon.loadDrawable(this);
Log.d(MainActivity.TAG, "width:" + icon.getIntrinsicWidth() + " height:" + icon.getIntrinsicHeight());
Bitmap bitmap = Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
icon.setBounds(0, 0, ICON_SIZE, ICON_SIZE);
icon.draw(canvas);
byte[] bmp_bin = new byte[ICON_SIZE * ((ICON_SIZE + 7) / 8)];
for( int y = 0 ; y < ICON_SIZE ; y++ ){
for( int x = 0 ; x < ICON_SIZE ; x++ ){
Color color = bitmap.getColor(x, y);
float alpha = color.alpha();
double gray = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue();
if( gray >= 0.5 )
bmp_bin[y * ((ICON_SIZE + 7) / 8) + x / 8] |= 0x0001 << ( 8 - (x % 8) - 1);
}
}
bitmap.recycle();
if( helper.hasPackageName(db, packageName ) ) {
JSONObject message_json = new JSONObject();
message_json.put("name", packageName);
message_json.put("posted", posted);
message_json.put("id", id);
message_json.put("icon", Bin2Hex(bmp_bin));
message_json.put("time", time);
if (title != null) message_json.put("title", title);
if (subText != null) message_json.put("subtext", subText);
if( label != null ) message_json.put("label", label);
if( tickerText != null ) message_json.put("ticker", tickerText);
mqttClient.publish(topic, message_json.toString().getBytes(), 0, false);
}
}catch(Exception ex){
Log.e(MainActivity.TAG, ex.getMessage());
}
}
・・・
AndroidManifest.xmlに以下を追加しておきます。
<service android:name=".NotificationReporterService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
で、これをコンパイルしてインストール実行するのですが、最初権限がなくてNotificationを受け取れません。
ダイアログを表示して、ユーザにOKしてもらうのが通常かもしれませんが、この機能は結構重要なので、手動で設定するようにします。
以下をAndroidの設定から選択します。
設定 → アプリと通知 → 詳細設定 → 特別なアプリアクセス → 通知へのアクセス
※Androidのバージョンによって異なるかもしれません。
そうすると、複数アプリがリスト表示されている中に、さきほどインストール実行したアプリが表示されています。
最初はOffになっていると思いますのでOnにします。
これで、NotificationListenerService を実装したサービスに通知が届くようになります。
ESP32側の実装
以下のライブラリを使っています。
knolleary/pubsubclient
https://github.com/knolleary/pubsubclient
ArduinoJson
https://arduinojson.org/v6/doc/
Tamakichi/Arduino-misakiUTF16
https://github.com/Tamakichi/Arduino-misakiUTF16
前の2つは、Arduino IDEのライブラリマネージからインストールできます。
最後のは、Arduino用 美咲フォントライブラリで、手動でインストールが必要です。
とはいっても、上記のページに書いてある通り、ZIPをダウンロードして、misakiUTF16 フォルダをlibraryフォルダに配置するだけです。
M5Core2とM5StickCを以下のdefineで切り替えていますので、対象の方をコメント解除してください。
#define M5CORE2
//#define M5STICKC
ちなみに、M5Core2の開発環境はこちらを参考にさせていただきました。
M5Stack Core2が発売されました
https://lang-ship.com/blog/work/m5stack-core2/
ArduinoでのMQTTの実装
まずは宣言です。
WiFiClient espClient;
PubSubClient client(espClient);
WiFi接続後に以下を呼び出します。
// バッファサイズの変更
client.setBufferSize(MQTT_BUFFER_SIZE);
// MQTTコールバック関数の設定
client.setCallback(mqtt_callback);
// MQTTブローカに接続
client.setServer(mqtt_server, mqtt_port);
これで、subscribeで受信したデータは、mqtt_callbackの関数にコールバックされてきます。
実際のconnectやsubscribeは、loopの中でしてます。MQTTブローカとの切断時の再接続もここでやっています。
void loop() {
#ifdef M5STICKC
M5.update();
#endif
client.loop();
// MQTT未接続の場合、再接続
while(!client.connected() ){
Serial.println("Mqtt Reconnecting");
if( client.connect(MQTT_CLIENT_NAME) ){
// MQTT Subscribe
client.subscribe(topic);
Serial.println("Mqtt Connected and Subscribing");
break;
}
delay(1000);
}
・・・
MQTT受信したときの処理は以下の感じです。
何してるかわかりにくいかもしれませんが、まずはJSONで送られてくるので、ArduinoJsonでパースします。
そこから抽出するのですが、受信したデータは、リスト管理しておくようにしています。タッチパネルの左ボタンや右ボタンで過去の受信したデータの表示を切り替えられます。
で、受信したデータが過去に受信したものと同じものがあるかどうかを確認し、あれば、いったん削除します。これは、最新の受信データをリストの先頭に持ってくるためです。同じかどうかの判断は、Android側で採番されたNotificationのidで見ています。
受信したJsonの中のpostedで、通知されたものか、通知が消えたのかがわかります。posted=trueの場合に、リストの先頭に情報を抜き出して登録しています。
リストの順番の入れ替えのために、データ(notify_message)とリスト(notify_list)の2つに分けで管理している関係で、少々面倒に見えるかもしれません。
// MQTT Subscribeで受信した際のコールバック関数
void mqtt_callback(char* topic, byte* payload, unsigned int length) {
Serial.println("received");
// JSONをパース
DeserializationError err = deserializeJson(json_notify, payload, length);
if( err ){
Serial.println("Deserialize error");
Serial.println(err.c_str());
return;
}
int id = json_notify["id"];
if( id < 0 ){
// 再接続発生時には、過去の通知をすべてリセット
for( int i = 0 ; i < NUM_OF_NOTIFY ; i++ ){
notify_list[i].index = -1;
}
notify_index = -1;
}else{
// すでに同じIDのNotifyがあれば一旦削除
for( int i = 0 ; i < NUM_OF_NOTIFY ; i++ ){
if( notify_list[i].index < 0 )
break;
if( notify_list[i].id == id ){
for( int j = i ; j < NUM_OF_NOTIFY - 1 ; j++ )
notify_list[j] = notify_list[j + 1];
notify_list[NUM_OF_NOTIFY - 1].index = -1;
// 現在表示位置も修正
if( i == notify_index )
notify_index = -1;
else if( notify_index > i )
notify_index--;
break;
}
}
// posted=trueの場合、リストの先頭に追加
bool posted = json_notify["posted"];
if( posted ){
Serial.println("posted=true");
// 末尾を削除して、先頭から1つずつ後ろにシフト
for( int i = NUM_OF_NOTIFY - 1 ; i > 0 ; i-- ){
notify_list[i] = notify_list[i - 1];
}
notify_list[0].index = -1;
// notfy_messageリストから空きを検索
for( int index = 0 ; index < NUM_OF_NOTIFY ; index++ ){
int j;
for( j = 1 ; j < NUM_OF_NOTIFY ; j++ ){
if( notify_list[j].index == index )
break;
}
if( j >= NUM_OF_NOTIFY ){
// notify_messageに受信データを格納
const char *title = json_notify["title"];
const char *name = json_notify["name"];
const char *label = json_notify["label"];
const char *ticker = json_notify["ticker"];
notify_message[index].name[sizeof(notify_message[index].name) - 1] = '\0';
notify_message[index].title[sizeof(notify_message[index].title) - 1] = '\0';
notify_message[index].label[sizeof(notify_message[index].label) - 1] = '\0';
notify_message[index].ticker[sizeof(notify_message[index].ticker) - 1] = '\0';
strncpy( notify_message[index].name, name, sizeof(notify_message[index].name) - 1 );
strncpy( notify_message[index].title, title, sizeof(notify_message[index].title) - 1 );
strncpy( notify_message[index].label, label, sizeof(notify_message[index].label) - 1 );
strncpy( notify_message[index].ticker, ticker, sizeof(notify_message[index].ticker) - 1 );
// iconがある場合は、バイト配列に変換して格納
const char *icon = json_notify["icon"];
if( icon != NULL && strlen(icon) == ICON_WIDTH * ((ICON_WIDTH + 7) / 8) * 2 )
parse_hex(icon, notify_message[index].icon);
else
memset(notify_message[index].icon, 0x00, sizeof(notify_message[index].icon));
// リストの先頭に確定
notify_list[0].index = index;
notify_list[0].id = id;
// 現在表示中も先頭に変更
notify_index = 0;
break;
}
}
}
}
// 表示を更新
updateNotify();
}
漢字表示は、以下の方のページを参考にさせていただきました。
watchX:美咲フォントで漢字表示
https://shikarunochi.matrix.jp/?p=2421
#おわりに
バイブレーション追加はまた今度。。。
以上