2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Androidの通知をM5Core2に転送する

Last updated at Posted at 2020-09-05

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

こんな感じです。

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

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

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

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

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

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

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

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

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>

後は、こんな感じです。

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を切断しておきます。

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

NotificationListenerServiceの実装

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

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

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

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

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に以下を追加しておきます。

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

NotificationMonitor.ino
#define M5CORE2
//#define M5STICKC

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

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

ArduinoでのMQTTの実装

まずは宣言です。

NotificationMonitor.ino
WiFiClient espClient;
PubSubClient client(espClient);

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

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ブローカとの切断時の再接続もここでやっています。

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

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

#おわりに

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

以上

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?