Содержание статьи
Понажимай эти кнопки за меня
Еще в 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" />
В нем мы описываем полномочия сервиса, типы событий, которые он может обрабатывать, и активити, которое запустится для настройки работы сервиса.
Полное описание этих параметров, как всегда, есть в документации.
Xakep #305. Многошаговые SQL-инъекции
Жизненным циклом сервиса управляет система. Сами остановить сервис мы не можем. ОС самостоятельно выгрузит ненужные сервисы — к примеру, зачем крутить сервис для приложения, которое не запущено?
Мы можем привязать сервис к строго нужному приложению. Как только мы дали разрешение на работу, у сервиса вызовется метод 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-устройстве. Его возможностей достаточно, чтобы реализовать почти любую задачу, главное — дать разрешения и найти кликабельный элемент на экране.