Files
tech-service-portal/tools/guide/shots_android.py
T

342 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Скриншоты 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")