Android 4.0-4.2での通知の追跡

バージョン4.3以降、 NotificationListenerServiceを使用してシステム内のすべての通知を追跡する機能がAndroid OSに追加されました。 残念ながら、以前のバージョンのOSとの後方互換性はありません。 古いバージョンのオペレーティングシステムを搭載したデバイスでこのような機能が必要な場合はどうすればよいですか?



この記事では、Android OSバージョン4.0-4.2で通知を追跡するための松葉杖とハッキングのセットを見つけることができます。 すべてのデバイスで結果が100%実行できるわけではないため、特定の場合に通知を削除するように松葉杖を追加する必要があります。



この問題に関するインターネット上の情報を検索すると、 AccessibilityServiceを使用してTYPE_NOTIFICATION_STATE_CHANGEDイベントを追跡する必要があるという結論に至ります。 テストでは、このイベントは通知がステータスバーに追加されたときにのみ発生し、通知が削除されたときには発生しないことが示されました。 受け取った通知に関する追加データの読み取りと削除の追跡は、この問題を解決する上で最大の松葉杖です。



追加情報で着信通知を追跡する



そのため、通知が到着し、イベントTYPE_NOTIFICATION_STATE_CHANGEDが受信されました。 AccessibilityEvent.getPackageName()メソッドを使用して、通知を送信したアプリケーションのパッケージ名を見つけることができます。 通知自体はAccessibilityRecord.getParcelableData()メソッドを使用して抽出できます;出力では、 Notificationタイプのオブジェクトを取得します。 しかし、残念ながら、抽出された通知に含まれる利用可能なデータのセットは非常に少ないです。 通知の削除をさらに追跡するには、少なくともテキストの見出しを取得する必要があります。 これを行うには、反射や他の松葉杖を使用する必要があります。



コード
public CharSequence getNotificationTitle(Notification notification, String packageName) { CharSequence title = null; title = getExpandedTitle(notification); if (title == null) { Bundle extras = NotificationCompat.getExtras(notification); if (extras != null) { Timber.d("getNotificationTitle: has extras: %1$s", extras.toString()); title = extras.getCharSequence("android.title"); Timber.d("getNotificationTitle: notification has no title, trying to get from bundle. found: %1$s", title); } } if (title == null) { // if title was not found, use package name as title title = packageName; } Timber.d("getNotificationTitle: discovered title %1$s", title); return title; } private CharSequence getExpandedTitle(Notification n) { CharSequence title = null; RemoteViews view = n.contentView; // first get information from the original content view title = extractTitleFromView(view); // then try get information from the expanded view if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { view = getBigContentView(n); title = extractTitleFromView(view); } Timber.d("getExpandedTitle: discovered title %1$s", title); return title; } private CharSequence extractTitleFromView(RemoteViews view) { CharSequence title = null; HashMap<Integer, CharSequence> notificationStrings = getNotificationStringFromRemoteViews(view); if (notificationStrings.size() > 0) { // get title string if available if (notificationStrings.containsKey(mNotificationTitleId)) { title = notificationStrings.get(mNotificationTitleId); } else if (notificationStrings.containsKey(mBigNotificationTitleId)) { title = notificationStrings.get(mBigNotificationTitleId); } else if (notificationStrings.containsKey(mInboxNotificationTitleId)) { title = notificationStrings.get(mInboxNotificationTitleId); } } return title; } // use reflection to extract string from remoteviews object private HashMap<Integer, CharSequence> getNotificationStringFromRemoteViews(RemoteViews view) { HashMap<Integer, CharSequence> notificationText = new HashMap<>(); try { ArrayList<Parcelable> actions = null; Field fs = RemoteViews.class.getDeclaredField("mActions"); if (fs != null) { fs.setAccessible(true); //noinspection unchecked actions = (ArrayList<Parcelable>) fs.get(view); } if (actions != null) { // Find the setText() and setTime() reflection actions for (Parcelable p : actions) { Parcel parcel = Parcel.obtain(); p.writeToParcel(parcel, 0); parcel.setDataPosition(0); // The tag tells which type of action it is (2 is ReflectionAction, from the source) int tag = parcel.readInt(); if (tag != 2) continue; // View ID int viewId = parcel.readInt(); String methodName = parcel.readString(); //noinspection ConstantConditions if (methodName == null) continue; // Save strings else if (methodName.equals("setText")) { // Parameter type (10 = Character Sequence) int i = parcel.readInt(); // Store the actual string try { CharSequence t = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(parcel); notificationText.put(viewId, t); } catch (Exception exp) { Timber.d("getNotificationStringFromRemoteViews: Can't get the text for setText with viewid:" + viewId + " parameter type:" + i + " reason:" + exp.getMessage()); } } parcel.recycle(); } } } catch (Exception exp) { Timber.e(exp, null); } return notificationText; }
      
      







上記のコードでは、 通知タイプのオブジェクトに関連するすべての文字列値とビューIDが取得されます。 このために、反射とParcelableオブジェクトからの読み取りが使用されます。 ただし、どのビューIDに通知タイトルがあるかはわかりません。 これを判断するには、次のコードを使用します。



コード
 /* * Data constants used to parse notification view ids */ public static final String NOTIFICATION_TITLE_DATA = "1"; public static final String BIG_NOTIFICATION_TITLE_DATA = "8"; public static final String INBOX_NOTIFICATION_TITLE_DATA = "9"; /** * The id of the notification title view. Initialized in the {@link #detectNotificationIds()} method */ public int mNotificationTitleId = 0; /** * The id of the big notification title view. Initialized in the {@link #detectNotificationIds()} method */ public int mBigNotificationTitleId = 0; /** * The id of the inbox notification title view. Initialized in the {@link #detectNotificationIds()} method */ public int mInboxNotificationTitleId = 0; /** * Detect required view ids which are used to parse notification information */ private void detectNotificationIds() { Timber.d("detectNotificationIds"); NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(mContext) .setContentTitle(NOTIFICATION_TITLE_DATA); Notification n = mBuilder.build(); LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); ViewGroup localView; // detect id's from normal view localView = (ViewGroup) inflater.inflate(n.contentView.getLayoutId(), null); n.contentView.reapply(mContext, localView); recursiveDetectNotificationsIds(localView); // detect id's from expanded views if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { NotificationCompat.BigTextStyle bigtextstyle = new NotificationCompat.BigTextStyle(); mBuilder.setContentTitle(BIG_NOTIFICATION_TITLE_DATA); mBuilder.setStyle(bigtextstyle); n = mBuilder.build(); detectExpandedNotificationsIds(n); NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); mBuilder.setContentTitle(INBOX_NOTIFICATION_TITLE_DATA); mBuilder.setStyle(inboxStyle); n = mBuilder.build(); detectExpandedNotificationsIds(n); } } @TargetApi(Build.VERSION_CODES.JELLY_BEAN) private void detectExpandedNotificationsIds(Notification n) { LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); ViewGroup localView = (ViewGroup) inflater.inflate(n.bigContentView.getLayoutId(), null); n.bigContentView.reapply(mContext, localView); recursiveDetectNotificationsIds(localView); } private void recursiveDetectNotificationsIds(ViewGroup v) { for (int i = 0; i < v.getChildCount(); i++) { View child = v.getChildAt(i); if (child instanceof ViewGroup) recursiveDetectNotificationsIds((ViewGroup) child); else if (child instanceof TextView) { String text = ((TextView) child).getText().toString(); int id = child.getId(); switch (text) { case NOTIFICATION_TITLE_DATA: mNotificationTitleId = id; break; case BIG_NOTIFICATION_TITLE_DATA: mBigNotificationTitleId = id; break; case INBOX_NOTIFICATION_TITLE_DATA: mInboxNotificationTitleId = id; break; } } } }
      
      







上記のコードのロジックは、ヘッダーの一意のテキスト値を使用してテスト通知を作成することです。 LayoutInflaterを使用してこの通知のビューが作成され、以前に指定されたテキストを持つ子TextViewが再帰検索によって検索されます。 見つかったオブジェクトのID。すべての着信通知のヘッダーの一意の識別子になります。



ヘッダーが抽出された後、さらに確認するために、アクティブな通知のリストにいくつかのパッケージ、タイトルを保存します。



コード
 /** * List to store currently active notifications data */ ConcurrentLinkedQueue<NotificationData> mAvailableNotifications = new ConcurrentLinkedQueue<>(); @Override public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) { switch (accessibilityEvent.getEventType()) { case AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED: Timber.d("onAccessibilityEvent: notification state changed"); if (accessibilityEvent.getParcelableData() != null && accessibilityEvent.getParcelableData() instanceof Notification) { Notification n = (Notification) accessibilityEvent.getParcelableData(); String packageName = accessibilityEvent.getPackageName().toString(); Timber.d("onAccessibilityEvent: notification posted package: %1$s; notification: %2$s", packageName, n); mAvailableNotifications.add(new NotificationData(mNotificationParser.getNotificationTitle(n, packageName), packageName)); // fire event onNotificationPosted(); } break; ... } } /** * Simple notification information holder */ class NotificationData { CharSequence title; CharSequence packageName; public NotificationData(CharSequence title, CharSequence packageName) { this.title = title; this.packageName = packageName; } }
      
      







最初の部分は対処されたようです。 このアプローチは、Androidのさまざまなバージョンで多少安定しています。 通知の削除を追跡しようとする2番目の部分に移りましょう。



削除通知の追跡



通知がいつ削除されたかを調べる標準的な方法は不可能なので、質問に答える必要があります。どのような場合に削除できますか? 次のオプションが思い浮かびます:



  1. ユーザースワイプ通知
  2. ユーザーが通知をクリックしてアプリケーションを開くと、消えました。
  3. ユーザーが[すべての通知をクリア]ボタンをクリックしました。
  4. アプリケーション自体が通知を削除しました。


私はすぐに最後のポイントで何もできなかったことを認めざるを得ませんが、そのような行動はあまり頻繁ではないので、あまり人気がないという希望があります。



各シナリオを個別に検討してみましょう。



ユーザースワイプ通知



ユーザーが通知をホイップしたときに発生するイベントを追跡したところ、ステータス行に属するwindowIdを持つパッケージ名「android.system.ui」に対してタイプTYPE_WINDOW_CONTENT_CHANGEDのイベントが生成されていることがわかりました。 残念ながら、アプリケーションを切り替えるウィンドウにもパッケージ名「android.system.ui」がありますが、 windowIdは異なります 。 WindowIdは定数ではなく、デバイスの再起動後またはAndroidの異なるバージョンで変更される場合があります。



イベントがステータス行から正確に来たことを計算する方法は? 私はこの問題についてかなり困惑しなければなりませんでした。 結局、このために特定の松葉杖を実装する必要がありました。 ユーザーが通知を削除したときに、ステータス行を展開することを提案しました。 特定のアクセシビリティの説明を含むすべての通知をクリアするためのボタンが必要です。 幸いなことに、定数はAndroidの異なるバージョンで同じ名前を持っています。 ここで、このボタンの存在についてビュー階層を分析する必要があり、ステータスバーに属するwindowIdを検出できます。 おそらく、行商人の1人がこれを行うためのより信頼性の高い方法を知っているので、知識を共有していただければ幸いです。



イベントがステータス行に属しているかどうかを確認します。



コード
 /** * Find "clear all notifications" button accessibility text used by the systemui application */ private void findClearAllButton() { Timber.d("findClearAllButton: called"); Resources res; try { res = mPackageManager.getResourcesForApplication(SYSTEMUI_PACKAGE_NAME); int i = res.getIdentifier("accessibility_clear_all", "string", "com.android.systemui"); if (i != 0) { mClearButtonName = res.getString(i); } } catch (Exception exp) { Timber.e(exp, null); } } /** * Check whether accessibility event belongs to the status bar window by checking event package * name and window id * * @param accessibilityEvent * @return */ public boolean isStatusBarWindowEvent(AccessibilityEvent accessibilityEvent) { boolean result = false; if (!SYSTEMUI_PACKAGE_NAME.equals(accessibilityEvent.getPackageName())) { Timber.v("isStatusBarWindowEvent: not system ui package"); } else if (mStatusBarWindowId != -1) { // if status bar window id is already initialized result = accessibilityEvent.getWindowId() == mStatusBarWindowId; Timber.v("isStatusBarWindowEvent: comparing window ids %1$d %2$d, result %3$b", mStatusBarWindowId, accessibilityEvent.getWindowId(), result); } else { Timber.v("isStatusBarWindowEvent: status bar window id not initialized, starting detection"); AccessibilityNodeInfo node = accessibilityEvent.getSource(); node = getRootNode(node); if (hasClearButton(node)) { Timber.v("isStatusBarWindowEvent: the root node has clear text button in the view hierarchy. Remember window id for future use"); mStatusBarWindowId = accessibilityEvent.getWindowId(); result = isStatusBarWindowEvent(accessibilityEvent); } if (!result) { Timber.v("isStatusBarWindowEvent: can't initizlie status bar window id"); } } return result; } /** * Get the root node for the specified node if it is not null * * @param node * @return the root node for the specified node in the view hierarchy */ public AccessibilityNodeInfo getRootNode(AccessibilityNodeInfo node) { if (node != null) { // workaround for Android 4.0.3 to avoid NPE. Should to remember first call of the node.getParent() such // as second call may return null AccessibilityNodeInfo parent; while ((parent = node.getParent()) != null) { node = parent; } } return node; } /** * Check whether the node has clear notifications button in the view hierarchy * * @param node * @return */ private boolean hasClearButton(AccessibilityNodeInfo node) { boolean result = false; if (node == null) { return result; } Timber.d("hasClearButton: %1$s %2$d %3$s", node.getClassName(), node.getWindowId(), node.getContentDescription()); if (TextUtils.equals(mClearButtonName, node.getContentDescription())) { result = true; } else { for (int i = 0; i < node.getChildCount(); i++) { if (hasClearButton(node.getChild(i))) { result = true; break; } } } return result; }
      
      







次に、通知が削除されているか、まだ存在するかを判断する必要があります。 100%の信頼性を持たない方法を使用します。ステータス行からすべての行を抽出し、以前に保存された通知ヘッダーと一致するものを探します。 タイトルが欠落している場合、通知は削除されたと思われます。 目的のwindowIdでイベントが到着しますが、 AccessibilityNodeInfoが空であることが発生します(ユーザーが利用可能な最後の通知をホイップすると発生します)。 この場合、すべての通知が削除されたと思われます。



コード
 /** * Update the available notification information from the node information of the accessibility event * <br> * The algorithm is not exact. All the strings are recursively retrieved in the view hierarchy and then * titles are compared with the available notifications * * @param accessibilityEvent */ private void updateNotifications(AccessibilityEvent accessibilityEvent) { AccessibilityNodeInfo node = accessibilityEvent.getSource(); node = mStatusBarWindowUtils.getRootNode(node); boolean removed = false; Set<String> titles = node == null ? Collections.emptySet() : recursiveGetStrings(node); for (Iterator<NotificationData> iter = mAvailableNotifications.iterator(); iter.hasNext(); ) { NotificationData data = iter.next(); if (!titles.contains(data.title.toString())) { // if the title is absent in the view hierarchy remove notification from available notifications iter.remove(); removed = true; } } if (removed) { Timber.d("updateNotifications: removed"); // fire event if at least one notification was removed onNotificationRemoved(); } } /** * Get all the text information from the node view hierarchy * * @param node * @return */ private Set<String> recursiveGetStrings(AccessibilityNodeInfo node) { Set<String> strings = new HashSet<>(); if (node != null) { if (node.getText() != null) { strings.add(node.getText().toString()); Timber.d("recursiveGetStrings: %1$s", node.getText().toString()); } for (int i = 0; i < node.getChildCount(); i++) { strings.addAll(recursiveGetStrings(node.getChild(i))); } } return strings; }
      
      







イベント処理コード
 case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED: // auto clear notifications when cleared from notifications bar (old api, Android < 4.3) if (mStatusBarWindowUtils.isStatusBarWindowEvent(accessibilityEvent)) { Timber.d("onAccessibilityEvent: status bar content changed"); updateNotifications(accessibilityEvent); } break;
      
      







ユーザーが通知をクリックしてアプリケーションを開くと、消えました



最初のケースのように、パッケージ名「android.system.ui」TYPE_WINDOW_CONTENT_CHANGEDイベントがこの動作によって生成される場合は理想的であり、このケースを個別に考慮する必要はありません。 しかし、テストでは、必要なイベントが生成されることは示されましたが、常にではありません。Androidのバージョン、ステータスバーを閉じる速度に依存します。 私のアプリケーションでは、ユーザーに通知の欠落を通知するのを停止する必要がありました。 安全にプレイし、ユーザーが通知を逃したアプリケーションを開いたため、以前に保存された通知は彼にとって重要ではなく、自分自身を思い出さないかもしれないと考えることができると判断されました。



アプリケーションが開くと、イベントTYPE_WINDOW_STATE_CHANGEDが生成されます。そこからpackageNameを見つけて、追跡されたすべての通知を削除できます。



コード
 /** * Remove all notifications from the available notifications with the specified package name * * @param packageName */ private void removeNotificationsFor(String packageName) { boolean removed = false; Timber.d("removeNotificationsFor: %1$s", packageName); for (Iterator<NotificationData> iter = mAvailableNotifications.iterator(); iter.hasNext(); ) { NotificationData data = iter.next(); if (TextUtils.equals(packageName, data.packageName)) { iter.remove(); removed = true; } } if (removed) { Timber.d("removeNotificationsFor: removed for %1$s", packageName); onNotificationRemoved(); } }
      
      







イベント処理コード
 case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: // auto clear notifications for launched application (TYPE_WINDOW_CONTENT_CHANGED not always generated // when app is clicked or cleared) Timber.d("onAccessibilityEvent: window state changed"); if (accessibilityEvent.getPackageName() != null) { String packageName = accessibilityEvent.getPackageName().toString(); Timber.d("onAccessibilityEvent: window state has been changed for package %1$s", packageName); removeNotificationsFor(packageName); } break;
      
      







ユーザーがすべての通知をクリアをクリックした



ここでは、前の場合と同様に、 TYPE_WINDOW_CONTENT_CHANGEDイベントが常に生成されるとは限りません。 ユーザーがボタンをクリックしたため、以前に受信した通知は重要ではなくなり、通知を停止することを想定しなければなりませんでした。



ステータス行でTYPE_VIEW_CLICKEDイベントを追跡する必要があり、「すべてクリア」ボタンに属している場合は、すべての通知の追跡を停止します。



コード
 /** * Check whether the accessibility event is generated by the clear all notifications button * * @param accessibilityEvent * @return */ public boolean isClearNotificationsButtonEvent(AccessibilityEvent accessibilityEvent) { return TextUtils.equals(accessibilityEvent.getClassName(), android.widget.ImageView.class.getName()) && TextUtils.equals(accessibilityEvent.getContentDescription(), mClearButtonName); }
      
      







イベント処理コード
 case AccessibilityEvent.TYPE_VIEW_CLICKED: // auto clear notifications when clear all notifications button clicked (TYPE_WINDOW_CONTENT_CHANGED not always generated // when this event occurs so need to handle this manually // // also handle notification clicked event Timber.d("onAccessibilityEvent: view clicked"); if (mStatusBarWindowUtils.isStatusBarWindowEvent(accessibilityEvent)) { Timber.d("onAccessibilityEvent: status bar content clicked"); if (mStatusBarWindowUtils.isClearNotificationsButtonEvent(accessibilityEvent)) { // if clicked image view element with the clear button name content description Timber.d("onAccessibilityEvent: clear notifications button clicked"); mAvailableNotifications.clear(); // fire event onNotificationRemoved(); } else { // update notifications if another view is clicked updateNotifications(accessibilityEvent); } } break;
      
      







バージョン4.0までのAndroidには何がありますか?



残念ながら、通知の削除を追跡する有効な方法を見つけることができませんでした。 AccessibilityServiceでViewHierarchyを操作する機能は、APIバージョン14以降でのみ追加されました。ViewHierarchyステータスバーに直接アクセスする方法を誰かが知っている場合は、この問題を解決できます。



PS



この記事で説明されているトピックに誰かが興味を持っていることを願っています。 通知の削除を追跡した結果を改善する方法についてのご意見をお待ちしております。



ここからほとんどの情報を取得しましたhttps://github.com/minhdangoz/notifications-widget (いくつかの場所で終了しなければなりませんでした)



Readyプロジェクトhttps://github.com/httpdispatch/MissedNotificationsReminder-通知を逃したことを思い出させるアプリケーション。 v14ビルドバリアントを選択することを忘れないでください。 v18はNotificationListenerServiceを介して機能します



All Articles