Начиная с версии 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. Перейдем ко второй части, в которой мы будем пытаться отследить удаление уведомлений.
Отслеживание удаления уведомлений
По скольку стандартным способом узнать, когда уведомление было удалено не представляется возможным, необходимо ответить на вопрос: в каких случаях оно может быть удалено? На ум приходят следующие варианты:
- Пользователь смахнул уведомление
- Пользователь открыл приложение по клику на уведомлении и оно исчезло.
- Пользователь нажал кнопку очистить все уведомления.
- Приложение само удалило уведомление.
Сразу вынужден признать, что с последним пунктом ничего сделать пока не смог, но есть надежда, что такое поведение не слишком частое, и поэтому не слишком востребованное.
Рассмотрим каждый сценарий в отдельности.
Пользователь смахнул уведомление
Отследив, какие события происходят, когда пользователь смахивает уведомления, обнаружил, что генерируется событие типа 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/
Добавить комментарий