Pull to refresh

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

Reading time 14 min
Views 11K
Начиная с версии 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
Tags:
Hubs:
+8
Comments 1
Comments Comments 1

Articles