📜 ⬆️ ⬇️

Tracking Notifications on Android 4.0-4.2

Starting with version 4.3, Android OS has added the ability to track all notifications in the system using NotificationListenerService . Unfortunately, backward compatibility with previous versions of the OS is missing. What if such functionality is needed on devices with an older version of the operating system?

In the article you can find a set of crutches and hacks to track notifications on Android OS version 4.0-4.2. Not on all devices, the result is 100% efficient, so you have to use additional crutches to assume the removal of notifications in certain cases.

Searching for information on the Internet on this issue leads to the conclusion that it is necessary to use the AccessibilityService and monitor the TYPE_NOTIFICATION_STATE_CHANGED event. Testing has shown that this event occurs only at the moment when the notification is added to the status bar, but does not occur when the notification is deleted. Reading additional data about the received notification and tracking removal is the greatest crutches in this task.

Tracking incoming notifications with additional information retrieval


So, the notification came, event TYPE_NOTIFICATION_STATE_CHANGED is received . We can find out the package name of the application that sent the notification using the AccessibilityEvent.getPackageName () method. The notification itself can be retrieved using the AccessibilityRecord.getParcelableData () method; we will get an object of Notification type at the output. But, unfortunately, the set of available data in the retrieved notification is very poor. To further track the removal of the notification, we will need to get at least a text header. For this you have to use reflection and other crutches.
')
Code
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; } 


In the above code, all string values ​​and View Ids related to the Notification type object are retrieved. Reflection and reading from Parcelable objects are used for this. But we do not know which View Id has a notification header. To determine this, use the following code:

Code
 /* * 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; } } } } 


The logic of the above code is that a test notification is created with a unique text value for the header. A View is created for this notification using a LayoutInflater and a recursive search to find a child TextView with previously specified text. The id of the found object will be the unique identifier for the header of all incoming notifications.

After the title has been extracted, save the package, title pair in our list of active notifications for further checks.

Code
 /** * 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; } } 


With the first part, it seems like they did it. This approach works more or less stable on different versions of Android. Let us turn to the second part, in which we will try to track the removal of notifications.

Tracking deletion notifications


Since the standard way to find out when the notification was deleted is not possible, it is necessary to answer the question: in what cases can it be deleted? The following options come to mind:

  1. User waved notification
  2. The user opened the application by clicking on the notification and it disappeared.
  3. User clicked clear all notifications.
  4. The application itself has deleted the notification.

Immediately I have to admit that I haven’t been able to do anything with the last point, but there is hope that this behavior is not too frequent, and therefore not too popular.

Consider each scenario separately.

User waved notification


Having traced what events occur when the user looks like a notification, he discovered that an event of type TYPE_WINDOW_CONTENT_CHANGED is generated for the package name "android.system.ui" with a windowId belonging to the status line. Unfortunately, the window for switching between applications also has a package name "android.system.ui" but another windowId . WindowId is not a constant, and may change after restarting the device or on different versions of Android.

How to calculate that the event came exactly from the status line? I had to pretty much puzzle over this issue. In the end, I had to implement a certain crutch for this. Assumed that the status bar should be expanded at the time of removal of the notification by the user. It should contain a button to clear all notifications with a specific accessibility description. Fortunately, the constant has the same name on different versions of Android. Now we need to analyze the view hierarchy for the presence of this button, and then we will be able to detect the windowId belonging to the status line. Probably, someone from the knowers knows a more reliable way to do this, I will be grateful if you share your knowledge.

Determine whether an event belongs to the status line:

Code
 /** * 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; } 


Now it is necessary to determine whether the notification has been deleted or is still present. We use a method that does not have 100% reliability: we extract all rows from the status line and look for matches with previously saved notification headers. If the title is missing, consider the notification to be deleted. It happens that an event comes with the necessary windowId but with an empty AccessibilityNodeInfo (it happens when the user looks like the last available notification). In this case, we assume that all notifications have been deleted.

Code
 /** * 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; } 


Event handling code
 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; 


The user opened the application by clicking on the notification and it disappeared


It would be ideal if this behavior generated, as in the first case, the event TYPE_WINDOW_CONTENT_CHANGED for the package name "android.system.ui" , would not have to consider this case separately. But tests have shown that the desired event is generated, but not always: it depends on the Android version, the speed at which the status line is closed, and it is not clear from what. In my application, I needed to stop notifying the user of a missed notification. It was decided to insure and assume that once a user has opened an application that has missed notifications, it can be considered that the previously saved notifications are not important for him and may not remind of themselves.

When the application opens, a TYPE_WINDOW_STATE_CHANGED event is generated , where you can find out the packageName and delete all tracked notifications for it.

Code
 /** * 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(); } } 


Event handling code
 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; 


User clicked clear all notifications


Here, as in the previous case, the TYPE_WINDOW_CONTENT_CHANGED event is not always generated. I had to assume that once the user pressed the button, the previously received notifications are no longer important and stop notifying about them.

It is necessary to track the TYPE_VIEW_CLICKED event in the status bar and if it belongs to the “Clear All” button, stop tracking all notifications.

Code
 /** * 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); } 


Event handling code
 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; 


What's up with Android up to version 4.0?


Unfortunately, I have not yet managed to find a working way to track the removal of notifications. The ability to work with the ViewHierarchy in the AccessibilityService was added only starting from API version 14. If someone knows how to access the ViewHierarchy status bar directly, this task may be solved

PS


I hope someone is interested in the topic discussed in the article. I will be glad to hear your ideas on how to improve the result of tracking the removal of notifications.

Most of the information was drawn from here https://github.com/minhdangoz/notifications-widget (I had to finish in some places)

Ready project https://github.com/httpdispatch/MissedNotificationsReminder is an application that reminds of missed notifications. Do not forget to choose the v14 build variant, since v18 works through NotificationListenerService

Source: https://habr.com/ru/post/271541/


All Articles