И программистам, и хакерам, и безопасникам иногда нужны способы автоматизировать пользовательские действия — то есть писать скрипты, которые будут тыкать в кнопки чужих софтин так, чтобы те принимали это чистую монету. Кто сказал «негласно автоматизировать работу с мобильным банком»? 🙂 Стыдитесь, товарищ! Мы за мирную автоматизацию. В прошлой статье по Accessibility Service мы сфокусировались на получении данных экрана, а сегодня мы научим Accessibility Service делать работу на устройстве за нас!
 

Понажимай эти кнопки за меня

Еще в XIX веке Гегель сказал: «Машиноподобный труд нужно отдать машинам». И тут с одним из творцов немецкой классической философии трудно поспорить: вряд ли Георг Вильгельм Фридрих отказался бы от автоматизации таких действий, как отключение звука и нажатие кнопки «Пропустить рекламу» при просмотре ролика в YouTube или получение ежедневных бонусов за посещение приложения. Тем более что для всего этого у нас уже есть готовый инструментарий!

Как мы говорили в предыдущей статье, Accessibility Service может получать события, происходящие на экране, но он же может и вызывать их. Например, находить нужные элементы в приложении и кликать по ним.

Ставить чужое приложение с подобной функциональностью опасно — все мы знаем репутацию Google Play и примерно представляем себе, что такое приложение может сделать с твоим банковским клиентом на телефоне. Поэтому выхода два: либо декомпилировать чужое ПО и смотреть, куда именно оно нажимает, либо пилить свое, строго под поставленные задачи.

Для исследовательских целей я создал приложение, которое будет само в себя кликать из собственного сервиса :).

 

Подготовка к работе сервиса

Чтобы наш сервис начал работу, ему нужно предоставить права в специальном разделе настроек. Как перенаправить туда пользователя, ты уже знаешь из первой статьи. Дополнительно мы сами можем перепроверить, есть ли эти права у приложения.

 protected boolean checkAccess() {
    String string = getString(R.string.accessibilityservice_id);
    for (AccessibilityServiceInfo id : ((AccessibilityManager) getSystemService(Context.ACCESSIBILITY_SERVICE)).getEnabledAccessibilityServiceList(AccessibilityEvent.TYPES_ALL_MASK)) {
        if (string.equals(id.getId())) {
            return true;
        }
    }
    return false;
}

Здесь accessibilityservice_id — это строка вида «имя пакета/.сервис», у нас это ru.androidtools.selfclicker/.ClickService.

Вот описание сервиса из манифеста:

<service
    android:name="ru.androidtools.selfcliker.ClickService"
    android:label="@string/accessibility_service_label"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/serviceconfig" />
</service>

Параметр label отвечает за название приложения в настройках сервиса спецвозможностей. В разделе meta-data задается указание на описание нужных функций для работы сервиса. Вот файл serviceconfig:

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:settingsActivity="ru.androidtools.selfcliker.MainActivity" />

В нем мы описываем полномочия сервиса, типы событий, которые он может обрабатывать, и активити, которое запустится для настройки работы сервиса.

Полное описание этих параметров, как всегда, есть в документации.

checkAccess() вернул false!
checkAccess() вернул false!

Жизненным циклом сервиса управляет система. Сами остановить сервис мы не можем. ОС самостоятельно выгрузит ненужные сервисы — к примеру, зачем крутить сервис для приложения, которое не запущено?

Мы можем привязать сервис к строго нужному приложению. Как только мы дали разрешение на работу, у сервиса вызовется метод onServiceConnected. У него мы должны вызвать метод setServiceInfo() с параметром AccessibilityServiceInfo. За фильтрацию приложений, с которыми работает сервис, отвечает строковый массив packageNames.

@Override
protected void onServiceConnected() {
    super.onServiceConnected();
    Log.v(TAG, "onServiceConnected");
    AccessibilityServiceInfo info = new AccessibilityServiceInfo();
    info.flags = AccessibilityServiceInfo.DEFAULT |
            AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS |
            AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS;

    info.eventTypes = AccessibilityEvent.TYPES_ALL_MASK;
    info.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC;
    info.packageNames = new String[]{"ru.androidtools.selfcliсker"};
    setServiceInfo(info);
}
Да, он точно будет
Да, он точно будет
 

Работаем с событиями AccessibilityEvent

После раздачи всех разрешений нам надо запустить нужное приложение. Если мы знаем его имя пакета, сделать это несложно:

 private void startApp() {
    Intent launchIntent = getPackageManager().getLaunchIntentForPackage("ru.androidtools.selfclicker");
    // Запуск из нужного места без предыстории приложения
    launchIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
    startActivity(launchIntent);
}

Для запуска приложения с чистого листа используем флаг Intent.FLAG_ACTIVITY_CLEAR_TOP. В противном случае приложение может вернуться на экран со старым состоянием, очень далеким от стартового экрана.

Теперь нужно обрабатывать события в методе onAccessibilityEvent. У события есть тип, он поможет определить, что произошло (например, сменилось окно, кликнули по элементу, элемент получил фокус). Чтобы получить источник события AccessibilityNodeInfo, надо у объекта события вызвать метод getSource().

Источник имеет много полезных свойств, помогающих в работе: текст, ID, имя класса. У него могут быть родительский и дочерние элементы.

Он может быть кликабельным isClickable(), и, чтобы щелкнуть по нему, как нормальный пользователь, нужно вызвать метод performAction(AccessibilityNodeInfo.ACTION_CLICK).

Если мы хотим более глобальных действий, например нажать клавишу «Назад» на устройстве, то следует вызвать метод performGlobalAction() с нужным параметром.

Чтобы найти на экране требующуюся AccessibilityNodeInfo, мы можем вызвать один из методов: поиск по ID (findAccessibilityNodeInfosByViewId) и поиск по тексту (findAccessibilityNodeInfosByText). Будь готов к тому, что он вернет нам массив элементов или вообще ни одного.

 

Потренируемся на кошках, точнее — на окошках

Вот разметка нашего подопытного экрана:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin">

    <Button
        android:id="@+id/buttonTest"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:onClick="testButtonClick"
        android:text="id/buttonTest" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Без ID"
        android:onClick="noIdClick" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorAccent"
        android:onClick="linearLayoutClick"
        android:orientation="vertical">

        <TextView
            android:id="@+id/textView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="Кликабельный LinearLayout"
            android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large" />

        <Button
            android:id="@+id/button3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="Нерабочая кнопка" />
    </LinearLayout>
</LinearLayout>

У некоторых элементов есть ID и текст, у других только текст, некоторые некликабельны.

Иногда обработчики кликов устанавливают на области, превышающие своими размерами элемент с текстом или картинкой.

Поизучаем эту задачу с помощью метода debugClick.

 private void debugClick(AccessibilityEvent event) {
    if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_CLICKED) {
        AccessibilityNodeInfo nodeInfo = event.getSource();
        if (nodeInfo == null) {
            return;
        }
        nodeInfo.refresh();
        Log.d(TAG, "ClassName:" + nodeInfo.getClassName() +
                " Text:" + nodeInfo.getText() +
                " ViewIdResourceName:" + nodeInfo.getViewIdResourceName() +
                " isClickable:" + nodeInfo.isClickable());
    }
}

Вот что вышло в лог:

03-03 16:23:15.220 24461-24461/ru.androidtools.selfclicker D/ClickService: ClassName:android.widget.Button Text:ID/BUTTONTEST ViewIdResourceName:ru.androidtools.selfclicker:id/buttonTest isClickable:true

03-03 16:23:26.356 24461-24461/ru.androidtools.selfclicker D/ClickService: ClassName:android.widget.Button Text:БЕЗ ID ViewIdResourceName:null isClickable:true

03-03 16:23:36.697 24461-24461/ru.androidtools.selfclicker D/ClickService: ClassName:android.widget.LinearLayout Text:null ViewIdResourceName:null isClickable:true

03-03 16:23:44.320 24461-24461/ru.androidtools.selfclicker D/ClickService: ClassName:android.widget.Button Text:НЕРАБОЧАЯ КНОПКА ViewIdResourceName:ru.androidtools.selfclicker:id/button3 isClickable:true

Чтобы воспроизвести последовательность кликов, нужно сначала изучить элементы, которые будут нажиматься. Но иногда также важна и последовательность их нажатий.

Для нажатий на первые две кнопки можно использовать findAccessibilityNodeInfosByText и findAccessibilityNodeInfosByViewId. Если текст у элементов повторяется, дополнительно можно проверять на ClassName или родителя.

Чтобы кликнуть в наш LinearLayout, нужно получить его AccessibilityNodeInfo, ID у него нет, но есть дочерние элементы TextView и Button, у которых есть текст.

Для начала нам нужно получить один из них, а потом кликнуть в его родителя.

private boolean linearClick(AccessibilityNodeInfo nodeInfo) {
    List<AccessibilityNodeInfo> list = nodeInfo.findAccessibilityNodeInfosByText("Нерабочая кнопка");
    if (list.size() > 0) {
        for (AccessibilityNodeInfo node : list) {
            AccessibilityNodeInfo parent = node.getParent();
            parent.performAction(AccessibilityNodeInfo.ACTION_CLICK);
        }
        return true;
    } else
        return false;
}

Бывают и обратные ситуации, когда есть родитель, а кликаем мы в дочерние. Для этого используй nodeInfo.getChildCount() и обращайся к элементу в цикле по ID nodeInfo.getChild(id) (если не ошибаюсь, нумерация ID идет с нуля).

Начинать работу сервиса лучше с события смены окна:

event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED

Если весь алгоритм действий уже готов, то можно запускать сервис автоматически через AlarmManager, например раз в сутки.

private void setRepeatTask() {
    Intent alarmIntent = new Intent(this, ClickService.class);
    PendingIntent pendingIntent = PendingIntent.getService(
        this, 0, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT);
    AlarmManager manager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);

    // Запускаем в 10:00
    Calendar calendar = Calendar.getInstance();
    calendar.setTimeInMillis(System.currentTimeMillis());
    calendar.set(Calendar.HOUR_OF_DAY, 10);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);

    manager.setInexactRepeating(AlarmManager.RTC_WAKEUP,
            calendar.getTimeInMillis(), AlarmManager.INTERVAL_DAY, // Повторять каждые 24 часа
            pendingIntent);
} 

Отменить запуск можно вот так:

public void cancelRepeat() {
    Intent intent = new Intent(this, ClickService.class);
    final PendingIntent pIntent = PendingIntent.getService(this, 0,
            intent, PendingIntent.FLAG_UPDATE_CURRENT);
    AlarmManager alarm = (AlarmManager) this.getSystemService(Context.ALARM_SERVICE);
    alarm.cancel(pIntent);
}
 

Заключение

Класс AccessibilityService позволит избавиться от рутинных операций на твоем Android-устройстве. Его возможностей достаточно, чтобы реализовать почти любую задачу, главное — дать разрешения и найти кликабельный элемент на экране.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    3 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии