"""Скриншоты Android-приложения на эмуляторе: управление через adb, элементы для разметки — через uiautomator. Запуск: python tools/guide/shots_android.py Нужны: запущенный эмулятор с установленным приложением и сервер с тестовыми данными на localhost:8000. """ import os import re import subprocess import sys import time import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image sys.path.insert(0, str(Path(__file__).parent)) from annotate import annotate_side # noqa: E402 from spec import M # noqa: E402 ADB = r"C:\Users\darko\AppData\Local\Android\Sdk\platform-tools\adb.exe" PKG = "ru.checklists.app" OUT = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img" ANDROID = [] # (имя, метки) — заполняется при съёмке, нужен PDF-сборщику MARKS = {} def adb(*a, binary=False): r = subprocess.run([ADB, *a], capture_output=True) return r.stdout if binary else r.stdout.decode("utf-8", "replace") def sleep(s): time.sleep(s) def dump(): for attempt in range(4): # uiautomator иногда отдаёт пустой файл, пока экран перерисовывается adb("shell", "uiautomator", "dump", "/sdcard/ui.xml") xml = adb("exec-out", "cat", "/sdcard/ui.xml") try: root = ET.fromstring(xml) break except ET.ParseError: if attempt == 3: raise sleep(1) out = [] for n in root.iter("node"): m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", n.get("bounds", "")) if not m: continue x1, y1, x2, y2 = map(int, m.groups()) out.append({"text": n.get("text", ""), "desc": n.get("content-desc", ""), "cls": n.get("class", ""), "b": (x1, y1, x2, y2), "click": n.get("clickable") == "true"}) return out def find(text=None, desc=None, cls=None, nth=0, exact=False, retry=6): for _ in range(retry): res = [] for n in dump(): if cls and n["cls"] != cls: continue if text is not None and not ((n["text"] == text) if exact else (text in n["text"])): continue if desc is not None and desc not in n["desc"]: continue if n["b"][2] - n["b"][0] > 0 and n["b"][3] - n["b"][1] > 0: res.append(n) if len(res) > nth: return res[nth] sleep(1) raise RuntimeError(f"не найден элемент text={text!r} desc={desc!r} cls={cls!r} nth={nth}") def pick(nodes, text, nth=0): res = [n for n in nodes if text in n["text"] and n["b"][2] > n["b"][0] and n["b"][3] > n["b"][1]] return res[nth] if len(res) > nth else None def btn(text, nth=0, exact=False): """Кликабельный контейнер (кнопка, чип, карточка) по тексту внутри него.""" t = find(text, nth=nth, exact=exact) best = None for n in dump(): if n["click"] and n["b"][0] <= t["b"][0] and n["b"][1] <= t["b"][1] and n["b"][2] >= t["b"][2] and n["b"][3] >= t["b"][3]: area = (n["b"][2] - n["b"][0]) * (n["b"][3] - n["b"][1]) if best is None or area < best[0]: best = (area, n) return best[1] if best else t def scroll_to(text, tries=6): for _ in range(tries): try: return btn(text) if find(text, retry=1) else None except RuntimeError: swipe_up(700) raise RuntimeError(f"не найден при прокрутке: {text!r}") def tap(n): x1, y1, x2, y2 = n["b"] adb("shell", "input", "tap", str((x1 + x2) // 2), str((y1 + y2) // 2)) def type_text(s): adb("shell", "input", "text", s.replace(" ", "%s")) def drawer_item(text): """Пункт бокового меню: если он ниже края экрана, меню прокручивается.""" for _ in range(4): n = btn(text, exact=True) if n["b"][3] < 2250: return n adb("shell", "input", "swipe", "300", "2000", "300", "900", "450") sleep(0.8) return btn(text, exact=True) def swipe_up(dist=1100): adb("shell", "input", "swipe", "540", "1900", "540", str(1900 - dist), "450") sleep(0.8) def swipe_down(dist=1100): adb("shell", "input", "swipe", "540", "700", "540", str(700 + dist), "450") sleep(0.8) def back(): adb("shell", "input", "keyevent", "4") sleep(0.8) def shot(name, marks): """marks: [(M, node)] — M даёт подпись и направление, node — найденный элемент.""" sleep(0.8) OUT.mkdir(parents=True, exist_ok=True) raw = OUT / f"{name}.raw.png" raw.write_bytes(adb("exec-out", "screencap", "-p", binary=True)) rects = [] for i, (m, n) in enumerate(marks, 1): x1, y1, x2, y2 = n["b"] gx, gy = m.grow rects.append((i, (x1, y1, x2 - x1 + gx, y2 - y1 + gy), m.dir)) img = annotate_side(Image.open(raw), rects, margin=170, scale=2.2) img.save(OUT / f"{name}.png", optimize=True) raw.unlink() MARKS[name] = [m.label for m, _ in marks] print("ok", name, img.size, flush=True) def login(user, pw="demo1234"): fields = [n for n in dump() if n["cls"] == "android.widget.EditText"] tap(fields[1]); sleep(0.5) type_text(user) adb("shell", "input", "keyevent", "61"); type_text(pw) back() # скрыть клавиатуру tap(btn("Войти", exact=True)) sleep(7) PORT = os.environ.get("GUIDE_PORT", "8000") # порт сервера на ПК; в приложении адрес всегда localhost:8000 def restart(clear=True): adb("reverse", "tcp:8000", f"tcp:{PORT}") if clear: adb("shell", "pm", "clear", PKG) adb("shell", "pm", "grant", PKG, "android.permission.POST_NOTIFICATIONS") adb("shell", "am", "force-stop", PKG) adb("shell", "am", "start", "-n", f"{PKG}/.MainActivity") sleep(4) def run(): # 1. вход restart() fields = [n for n in dump() if n["cls"] == "android.widget.EditText"] tap(fields[0]); sleep(0.4); type_text("http://localhost:8000"); back() fields = [n for n in dump() if n["cls"] == "android.widget.EditText"] shot("a_login", [ (M("Адрес сервера — его сообщит администратор (например, https://checklists.example.ru)"), fields[0]), (M("Ваш логин и пароль"), {"b": (fields[1]["b"][0], fields[1]["b"][1], fields[2]["b"][2], fields[2]["b"][3])}), (M("«Войти»"), btn("Войти", exact=True)), ]) login("sidorov") # 2. вкладка «Задачи» (открывается после входа), затем список нарядов shot("a_work", [ (M("Шапка: быстрые действия (задача, авария, неисправность, заявка) и сводка"), btn("Задача себе")), (M("Сводка: мои наряды и задачи, открытые аварии и неисправности"), find("мои наряды")), (M("Блок «Наряды»: фильтры и ближайшие наряды; «Все →» — полный список"), find("Мои ·")), (M("Меню: все разделы приложения"), find(desc="Меню")), ]) tap(find(desc="Меню")); sleep(1.2) tap(btn("Наряды", exact=True)); sleep(3) shot("a_list", [ (M("Время последней синхронизации"), find("Обновлено")), (M("Меню: все разделы приложения"), find(desc="Меню")), (M("Поиск и обновление"), {"b": (find(desc="Поиск")["b"][0], find(desc="Поиск")["b"][1], find(desc="Обновить")["b"][2], find(desc="Обновить")["b"][3])}), (M("Фильтры: активные, мои, завершённые"), btn("Активные")), (M("Карточка наряда: статус, срок, прогресс. «▶ можно выполнять» — наряд ваш"), btn("Проверка насосной")), ]) # 3. наряд в работе tap(scroll_to("Насос Н-2")); sleep(3) shot("a_order_top", [ (M("Назад к списку"), find(desc="Назад")), (M("Статус наряда и кто назначен"), find("В работе", exact=True)), (M("Число: введите значение, ниже — допустимая норма"), find(cls="android.widget.EditText", nth=0)), ]) swipe_up(1300) shot("a_order_bottom", [ (M("Выбор варианта — нажатием на нужный"), btn("нет", exact=True)), (M("Фото: откроется камера телефона"), btn("Фото")), (M("«Сдать наряд» — когда всё заполнено"), btn("Сдать наряд", exact=True)), ]) # 4. без связи: убираем проброс порта и выбираем вариант ответа adb("reverse", "--remove", "tcp:8000") tap(btn("есть, незначительные", exact=True)); sleep(2) back(); sleep(2.5) # к списку tap(find(desc="Обновить")); sleep(5) # попытка синхронизации без связи swipe_up(900); sleep(1) D = dump() shot("a_offline", [x for x in [ (M("Сверху видно, сколько нарядов ещё не отправлено: данные сохранены на телефоне и уйдут сами"), pick(D, "Не отправлено")), (M("Приложение сообщает, что связи нет"), pick(D, "Нет связи")), (M("Наряд помечен «не отправлено»"), pick(D, "не отправлено", 1) or pick(D, "не отправлено")), ] if x[1]]) adb("reverse", "tcp:8000", f"tcp:{PORT}") # 5. боковое меню и разделы портала tap(find(desc="Меню")); sleep(1.5) shot("a_menu", [ (M("Кто вошёл: ФИО и должность"), find("Сидоров")), (M("Работа: вкладка «Задачи», наряды, задачи и поручения"), {"b": (btn("Задачи — всё")["b"][0], btn("Задачи — всё")["b"][1], btn("Задачи и поручения")["b"][2], btn("Задачи и поручения")["b"][3])}), (M("Оборудование: состояние машин и фиксация инцидентов"), btn("Оборудование и инциденты")), (M("Инструмент: что выдано вам и заявки на покупку"), {"b": (btn("Мой инструмент")["b"][0], btn("Мой инструмент")["b"][1], btn("Заявки на инструмент")["b"][2], btn("Заявки на инструмент")["b"][3])}), (M("Карта завода и сотрудники открываются в полной версии сайта (в браузере)"), btn("Карта завода")), ]) tap(btn("Задачи и поручения")); sleep(3) shot("a_tasks", [ (M("Кнопка меню — переход к другим разделам"), find(desc="Меню")), (M("Показать все задания или только активные"), btn("Все", exact=True)), (M("Карточка задания: статус, день, оборудование"), btn("Замер вибрации")), (M("Поставить задание себе"), btn("+")), ]) tap(btn("Замер вибрации")); sleep(3) comment = find(cls="android.widget.EditText", nth=0) shot("a_task", [ (M("Статус задания"), find("Назначен", exact=True)), (M("Комментарий: обязателен для «приостановлен», «выполнен», «отменён»"), comment), (M("Кнопки смены статуса"), {"b": (btn("В работу")["b"][0], btn("В работу")["b"][1], btn("Отменить")["b"][2], btn("Отменить")["b"][3])}), (M("Фото к заданию — с камеры телефона"), btn("Сфотографировать")), ]) back(); sleep(1) tap(find(desc="Меню")); sleep(1.2) tap(btn("Оборудование и инциденты")); sleep(3) tap(btn("Насос Н-2")); sleep(3) shot("a_equipment", [ (M("Состояние машины: «Авария» — есть открытый инцидент"), find("Авария", exact=True)), (M("Зафиксировать аварию (машина остановлена)"), btn("Зафиксировать инцидент")), (M("Сообщить о неисправности (машина работает)"), btn("Сообщить о неисправности")), (M("Инцидент: что случилось, узлы, была ли замена; «Закрыть» — после устранения"), btn("Течь по торцевому")), ]) tap(btn("История машины")); sleep(3) shot("a_history", [ (M("Фильтры по типам событий"), btn("Инциденты")), (M("События: что произошло, когда, кто"), find(cls="android.widget.TextView", nth=8)), ]) back(); sleep(1.5) tap(btn("Зафиксировать инцидент")); sleep(1.5) shot("a_incident", [ (M("Что произошло и подробности"), find(cls="android.widget.EditText", nth=0)), (M("Затронутые узлы (заведены в карточке машины); отметка «заменён» сбрасывает ресурс узла"), find("Подшипники")), (M("«Зафиксировать» — машина получит статус «Авария»"), btn("Зафиксировать", exact=True)), ]) tap(btn("Отмена", exact=True)); sleep(1) back() # 6. помощник (если включён на сервере) tap(find(desc="Меню")); sleep(1.2) try: tap(drawer_item("Помощник")); sleep(2) tap(btn("Как зафиксировать аварию?")); sleep(4) shot("a_assistant", [ (M("Вопрос пользователя"), find("Как зафиксировать аварию?")), (M("Ответ со ссылками: нажмите — откроется нужный экран"), find("Чтобы зафиксировать")), (M("Поле вопроса"), find(cls="android.widget.EditText", nth=0)), ]) back(); sleep(1) tap(find(desc="Меню")); sleep(1.2) except RuntimeError as e: print("помощник не снят:", e) back(); sleep(1) tap(find(desc="Меню")); sleep(1.2) # 7. настройки tap(drawer_item("Настройки")); sleep(2) shot("a_settings", [ (M("Ваша должность и статус смены"), find("Дежурный механик")), (M("Принудительная синхронизация"), btn("Синхронизировать")), (M("Выход из аккаунта"), btn("Выйти", exact=True)), ]) # 8. инженер: проверка tap(btn("Выйти", exact=True)); sleep(1.5) dlg = [n for n in dump() if n["text"] in ("Выйти", "Всё равно выйти")] tap(dlg[-1]); sleep(2.5) login("morozova") tap(find(desc="Меню")); sleep(1.2) tap(btn("Наряды", exact=True)); sleep(3) tap(btn("На проверку")); sleep(1.5) shot("a_review_list", [ (M("Фильтр «На проверку» — наряды, которые ждут вашего решения"), btn("На проверку")), (M("«✔ ждёт вашей проверки» — откройте наряд"), btn("ждёт вашей проверки")), ]) tap(btn("ждёт вашей проверки")); sleep(3) for _ in range(4): try: find("Принять", exact=True, retry=1) break except RuntimeError: swipe_up(900) accept = btn("Принять", exact=True) comment = [n for n in dump() if n["cls"] == "android.widget.EditText" and n["b"][3] < accept["b"][1]][-1] shot("a_review", [ (M("Комментарий проверяющего (обязателен при возврате)"), comment), (M("«Принять» — наряд завершён"), accept), (M("«Вернуть» — на доработку"), btn("Вернуть", exact=True)), ]) if __name__ == "__main__": run() import json (OUT / "android_marks.json").write_text(json.dumps(MARKS, ensure_ascii=False, indent=1), encoding="utf-8")