Отслеживание уведомлений на Android 4.0-4.2

от автора

Начиная с версии 4.3 в Android OS была добавлена возможность отслеживать все уведомления в системе используя NotificationListenerService. К сожалению, обратная совместимость с предыдущими версиями OS отсутствует. Что делать, если подобный функционал необходим на устройствах с более старой версией операционной системы?

В статье можно найти набор костылей и хаков для отслеживания уведомлений на Android OS версии 4.0-4.2. Не на всех устройствах результат 100% работоспособен, поэтому приходится использовать дополнительные костыли, чтобы предположить удаление уведомлений в определенных случаях.

Поиск информации в интернете по этому вопросу приводит к выводу, что необходимо использовать AccessibilityService и отслеживать событие TYPE_NOTIFICATION_STATE_CHANGED. Тестирование показало, что данное событие происходит лишь в тот момент, когда уведомление добавляется в статусную строку, но не происходит, когда уведомление удаляется. Чтение дополнительных данных о пришедшем уведомлении и отслеживание удаления и является наибольшими костылями в решении этой задачи.

Отслеживание входящих уведомлений с извлечение дополнительной информации

Итак, уведомление пришло, получено событие TYPE_NOTIFICATION_STATE_CHANGED. Мы можем узнать package name приложения, которое послало уведомление используя метод AccessibilityEvent.getPackageName(). Само уведомление можно извлечь используя метод AccessibilityRecord.getParcelableData(), на выходе получим объект типа Notification. Но, к сожалению, набор доступных данных в извлеченном уведомлении весьма скуден. Для дальнейшего отслеживания удаления уведомления нам потребуется достать хотя бы текстовый заголовок. Для этого придется использовать reflection и другие костыли.

Код

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; } 

В вышеприведенном коде извлекаются все строковые значения и View Ids, относящиеся к объекту типа Notification. Для этого используются Reflection и чтение из Parcelable объектов. Но мы не знаем, какое View 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;             }         }     } } 

Логика вышеприведенного кода в том, что создается тестовое уведомление с уникальным текстовым значением для заголовка. Создается View для данного уведомления при помощи LayoutInflater и рекурсивным поиском ищется дочерний TextView с ранее заданным текстом. Id найденного объекта и будет уникальным идентификатором заголовка всех входящих уведомлений.

После того, как заголовок был извлечен, сохраняем пару package, title в нашем списке активных уведомлений для дальнейших проверок.

Код

/**  * 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. Перейдем ко второй части, в которой мы будем пытаться отследить удаление уведомлений.

Отслеживание удаления уведомлений

По скольку стандартным способом узнать, когда уведомление было удалено не представляется возможным, необходимо ответить на вопрос: в каких случаях оно может быть удалено? На ум приходят следующие варианты:

  1. Пользователь смахнул уведомление
  2. Пользователь открыл приложение по клику на уведомлении и оно исчезло.
  3. Пользователь нажал кнопку очистить все уведомления.
  4. Приложение само удалило уведомление.

Сразу вынужден признать, что с последним пунктом ничего сделать пока не смог, но есть надежда, что такое поведение не слишком частое, и поэтому не слишком востребованное.

Рассмотрим каждый сценарий в отдельности.

Пользователь смахнул уведомление

Отследив, какие события происходят, когда пользователь смахивает уведомления, обнаружил, что генерируется событие типа TYPE_WINDOW_CONTENT_CHANGED для package name «android.system.ui» с windowId принадлежащему статусной строке. К сожалению, окно переключения между приложениями тоже имеет package name «android.system.ui» но другой windowId. WindowId это не константа, и может меняться после перезапуска устройства или на разных версиях Android.

Как же вычислить, что событие пришло именно из статусной строки? Мне пришлось изрядно поломать голову над этим вопросом. В конце концов, пришлось реализовать определенный костыль для этого. Предположил, что статусная строка должна быть развернута в момент удаления уведомления пользователем. На ней должна присутствовать кнопка очистить все уведомления с определенным accessibility description. К счастью, константа имеет одно и то же название на разных версиях Android. Теперь необходимо проанализировать иерархию вида на предмет присутствия данной кнопки, и тогда мы сможем обнаружить windowId принадлежащий статусной строке. Возможно, кто-нибудь из хабражителей знает более достоверный способ сделать это, буду благодарен, если поделитесь знаниями.

Определяем, принадлежит ли событие статусной строке:

Код

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

Пользователь открыл приложение по клику на уведомлении и оно исчезло

Было бы идеально, если бы данное поведение генерировало, как и в первом случае, событие TYPE_WINDOW_CONTENT_CHANGED для package name «android.system.ui», не пришлось бы рассматривать этот случай отдельно. Но тесты показали, что нужное событие генерируется, но не всегда: это зависит от версии 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; 

Что с Android до версии 4.0?

К сожалению, мне пока не удалось найти рабочий способ отследить удаление уведомлений. Возможность работать с ViewHierarchy в AccessibilityService была добавлена только начиная с API версии 14. Если кто-нибудь знает способ, как получить доступ к ViewHierarchy статусной строки напрямую, возможно, эту задачу удастся решить

P.S.

Надеюсь, кому-нибудь интересна рассмотренная в статье тема. Буду рад услышать ваши идеи по поводу того, как улучшить результат отслеживания удаления уведомлений.

Большинство информации черпал отсюда https://github.com/minhdangoz/notifications-widget (пришлось допилить в некоторых местах)

Готовый проект https://github.com/httpdispatch/MissedNotificationsReminder — приложение напоминающее о пропущенных уведомлениях. Не забудьте выбрать v14 build variant, т.к. v18 работает через NotificationListenerService

ссылка на оригинал статьи http://habrahabr.ru/post/271541/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *