Androidの通知をM5Core2に転送する

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

こんな感じです。

```java:MainActivity.java
        packageManager = getPackageManager();
        List<ApplicationInfo> dataList = packageManager.getInstalledApplications(PackageManager.GET_META_DATA);
```

これらのうち、選択したアプリのみ通知するように、それを記憶するためにSQLite3データベースを使いました。

とはいっても、選択したアプリ＝パッケージ名 を記録するためのだけの単純なテーブルです。

扱いやすいように、以下の3つのメソッドを定義しています。
・deletePackageName：テーブルからパッケージ名を削除
・insertPackageName：テーブルにパッケージ名を追加
・hasPackageName：テーブルにパッケージ名があるかの確認

```java:NotificationDbHelper.java
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のそれぞれで呼び出しています。

```java
        helper = new NotificationDbHelper(this);
        db = helper.getReadableDatabase();
```

MainActivity.javaに戻って、最後に、インストール済みアプリをリスト表示する部分を示します。とはいっても、MainActivityの実装のほとんどがこれに費やしていますので、ソースをそのまま載せておきます。

```java:MainActivity.java
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のところに以下を追記します。

```build.gradle
    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に以下を追記します。

```xml: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>
```

後は、こんな感じです。

```java:NotificationReporteService.java
        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を切断しておきます。

```java:NotificationReporteService.java
            mqttClient.disconnect();
            mqttClient.close();
```


# NotificationListenerServiceの実装

通知を受け取るために、NotificationListenerServiceを継承して実装します。

必ず実装しないといけないのが、
・onCreate
・onNotificationPosted
・onNotificationRemoved
・onDestroy (必要に応じて後処理)

※ちなみに、AndroidのServiceを継承しているのですが、IBinderを使おうとメソッドをoverrideすると通知が受け取れなくなるので、IBinderは使えないと思った方がよいと思います。

```java:NotificationReporteService.java
    @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を参照してください。
こんな感じです。

```java:NotificationReporterService.java
・・・
    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に以下を追加しておきます。

```xml: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で切り替えていますので、対象の方をコメント解除してください。

```c:NotificationMonitor.ino
#define M5CORE2
//#define M5STICKC
```

ちなみに、M5Core2の開発環境はこちらを参考にさせていただきました。

M5Stack Core2が発売されました
　https://lang-ship.com/blog/work/m5stack-core2/

# ArduinoでのMQTTの実装

まずは宣言です。

```c:NotificationMonitor.ino
WiFiClient espClient;
PubSubClient client(espClient);
```

WiFi接続後に以下を呼び出します。

```c:NotificationMonitor.ino
  // バッファサイズの変更
  client.setBufferSize(MQTT_BUFFER_SIZE);
  // MQTTコールバック関数の設定
  client.setCallback(mqtt_callback);
  // MQTTブローカに接続
  client.setServer(mqtt_server, mqtt_port);
```

これで、subscribeで受信したデータは、mqtt_callbackの関数にコールバックされてきます。
実際のconnectやsubscribeは、loopの中でしてます。MQTTブローカとの切断時の再接続もここでやっています。

```c:NotificationMonitor.ino
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つに分けで管理している関係で、少々面倒に見えるかもしれません。

```c:NotificationMonitor.ino
// 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

#おわりに

バイブレーション追加はまた今度。。。

以上