1
0
forked from BRT/arc
arc/android/dushanbe/ScreenXmlParser.cpp

710 lines
27 KiB
C++
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.

#include "ScreenXmlParser.h"
#include <QRegularExpression>
#include <QXmlStreamReader>
#include <QDomDocument>
#include "android/xml/Node.h"
namespace Dushanbe {
static const QRegularExpression boundsRegex(R"(\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\])");
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
}
ScreenXmlParser::~ScreenXmlParser() = default;
void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
QDomDocument doc;
doc.setContent(xml);
m_xml = doc.documentElement();
m_nodes.clear();
QXmlStreamReader reader(xml);
while (!reader.atEnd()) {
reader.readNext();
if (reader.isStartElement() && reader.name() == u"node") {
Node node;
if (reader.attributes().hasAttribute("bounds")) {
QString bounds = reader.attributes().value("bounds").toString();
if (QRegularExpressionMatch match = boundsRegex.match(bounds); match.hasMatch()) {
node.setBounds(
match.captured(1).toInt(),
match.captured(2).toInt(),
match.captured(3).toInt(),
match.captured(4).toInt()
);
}
}
if (reader.attributes().hasAttribute("text")) {
node.text = reader.attributes().value("text").toString();
}
if (reader.attributes().hasAttribute("content-desc")) {
node.contentDesc = reader.attributes().value("content-desc").toString();
}
if (reader.attributes().hasAttribute("class")) {
node.className = reader.attributes().value("class").toString();
}
if (reader.attributes().hasAttribute("clickable")) {
node.clickable = reader.attributes().value("clickable").toString() == "true";
}
if (reader.attributes().hasAttribute("focusable")) {
node.focusable = reader.attributes().value("focusable").toString() == "true";
}
if (reader.attributes().hasAttribute("resource-id")) {
node.resourceId = reader.attributes().value("resource-id").toString();
}
if (reader.attributes().hasAttribute("hint")) {
node.hint = reader.attributes().value("hint").toString();
}
if (reader.attributes().hasAttribute("password")) {
node.password = reader.attributes().value("password").toString() == "true";
}
m_nodes.append(node);
}
}
}
const QList<Node> &ScreenXmlParser::findAllNodes() const {
return m_nodes;
}
Node ScreenXmlParser::findTextNode(const QString &text) {
for (const Node &node : m_nodes) {
if (node.text.trimmed() == text) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
for (const Node &node : m_nodes) {
if (node.contentDesc == contentDesc) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc, bool contains) {
for (const Node &node : m_nodes) {
if (contains) {
if (node.contentDesc.contains(contentDesc)) {
return node;
}
} else {
if (node.contentDesc == contentDesc) {
return node;
}
}
}
return {};
}
Node ScreenXmlParser::findButtonNode(const QString &text, bool contains) {
for (const Node &node : m_nodes) {
if (!node.clickable) continue;
if (contains) {
if (node.text.contains(text) || node.contentDesc.contains(text)) {
return node;
}
} else {
if (node.text.trimmed() == text || node.contentDesc == text) {
return node;
}
}
}
return {};
}
Node ScreenXmlParser::findFirstEditText() {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText") {
return node;
}
}
return {};
}
bool ScreenXmlParser::isPinCodeScreen() {
// 1. Прямое совпадение по тексту заголовка
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа")) ||
node.contentDesc.contains(QString::fromUtf8("Введите ПИН")) ||
node.contentDesc.contains(QString::fromUtf8("код доступа")) ||
node.text.contains(QString::fromUtf8("код доступа"))) {
return true;
}
}
// 2. Fallback: если на экране есть полная цифровая клавиатура 0-9
// (content-desc каждой кнопки = одна цифра) — это PIN-экран.
int digitButtons = 0;
bool seen[10] = {false};
for (const Node &node : m_nodes) {
if (node.contentDesc.size() != 1) continue;
const QChar c = node.contentDesc.at(0);
if (!c.isDigit()) continue;
const int d = c.digitValue();
if (d < 0 || d > 9 || seen[d]) continue;
seen[d] = true;
++digitButtons;
}
return digitButtons >= 10;
}
Node ScreenXmlParser::findPinCodeEditText() {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.password) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isHomeScreen() {
bool hasGlavnaya = false;
bool hasBottomNav = false;
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
hasGlavnaya = true;
}
// Нижняя навигация — любая из соседних вкладок указывает, что мы на
// основной странице приложения, а не на splash/PIN/диалоге.
if (node.contentDesc == QString::fromUtf8("Переводы") ||
node.contentDesc == QString::fromUtf8("Сервисы") ||
node.contentDesc == QString::fromUtf8("MiniApps") ||
node.contentDesc == QString::fromUtf8("История")) {
hasBottomNav = true;
}
if (hasGlavnaya && hasBottomNav) {
return true;
}
}
return false;
}
std::optional<double> ScreenXmlParser::parseBalanceFromHomeScreenOpt() {
// На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15"
// внутри него child node content-desc="0.00" (или "10.00")
// Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc.
// Во время refresh-загрузки parent временно превращается в content-desc="TJS" (без даты)
// и числового child нет — в этом случае возвращаем nullopt, чтобы вызывающий
// код мог отличить состояние загрузки от реального нулевого баланса.
for (int i = 0; i < m_nodes.size(); ++i) {
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
// Проверяем что это блок с датой (не просто "TJS" текст)
if (!m_nodes[i].contentDesc.contains('\n')) continue;
// Child node с балансом идёт сразу после parent или рядом
for (int j = i + 1; j < qMin(i + 5, m_nodes.size()); ++j) {
QString raw = m_nodes[j].contentDesc.trimmed();
if (raw.isEmpty()) continue;
raw.replace(',', '.');
raw.remove(' ');
bool ok = false;
double val = raw.toDouble(&ok);
if (ok) return val;
}
}
return std::nullopt;
}
double ScreenXmlParser::parseBalanceFromHomeScreen() {
return parseBalanceFromHomeScreenOpt().value_or(0.0);
}
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
// TODO: реализовать парсинг имени
return {};
}
Node ScreenXmlParser::findRefreshButtonOnHomeScreen() {
// В блоке content-desc="TJS\n<дата>" три clickable дочерних элемента:
// View с балансом, Button (глазик) и ImageView (рефреш). Тапать нужно
// именно ImageView — он правее глазика и единственный ImageView внутри
// bounds parent'а.
Node parent;
for (const Node &node : m_nodes) {
if (node.contentDesc.startsWith("TJS") && node.contentDesc.contains('\n')) {
parent = node;
break;
}
}
if (parent.x1() < 0) return {};
Node best;
for (const Node &node : m_nodes) {
if (!node.clickable) continue;
if (node.className != "android.widget.ImageView") continue;
if (node.x1() < parent.x1() || node.x2() > parent.x2()) continue;
if (node.y1() < parent.y1() || node.y2() > parent.y2()) continue;
if (best.x1() < 0 || node.x1() > best.x1()) {
best = node;
}
}
return best;
}
bool ScreenXmlParser::isSideMenu() {
bool hasVyhod = false;
bool hasIdentified = false;
for (const Node &node : m_nodes) {
// До 3.2.33 кнопка была "Выход", с 3.2.33 — "Выйти".
if (node.contentDesc == QString::fromUtf8("Выход") ||
node.contentDesc == QString::fromUtf8("Выйти")) {
hasVyhod = true;
}
// До 3.2.33: "Идентифицирован\nУспешно" — отдельная нода (startsWith).
// С 3.2.33: "Идентифицирован" вшито в блок профиля
// ("IN\nIsroil Nazarov\n+992 ...\nИдентифицирован\nУспешно") — нужен contains.
if (node.contentDesc.contains(QString::fromUtf8("Идентифицирован"))) {
hasIdentified = true;
}
if (hasVyhod && hasIdentified) {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseProfileFullName() {
// До 3.2.33 одна нода содержит:
// "Имя Фамилия\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443"
// С 3.2.33 "Версия"/"Сборка" вынесены в отдельные ноды, а профиль:
// "IN\nIsroil Nazarov\n+992 (17) 682-7668\nИдентифицирован\nУспешно"
// Находим ноду по наличию "Идентифицирован" ИЛИ "Версия:+Сборка:", затем
// выбираем строку, идущую непосредственно перед строкой с телефоном "+...".
static const QRegularExpression phoneRe(R"(^\+\d)");
for (const Node &node : m_nodes) {
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
if (!isNewProfileBlock && !isOldProfileBlock) continue;
const QStringList lines = node.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
if (phoneRe.match(lines[i].trimmed()).hasMatch() && i > 0) {
return lines[i - 1].trimmed();
}
}
}
return {};
}
QString ScreenXmlParser::parseProfilePhone() {
static const QRegularExpression phoneRe(R"(^\+\d)");
for (const Node &node : m_nodes) {
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
if (!isNewProfileBlock && !isOldProfileBlock) continue;
for (const QString &line : node.contentDesc.split('\n')) {
const QString trimmed = line.trimmed();
if (phoneRe.match(trimmed).hasMatch()) {
return trimmed;
}
}
}
return {};
}
QMap<QString, QString> ScreenXmlParser::parseLabelValuePairs() {
// Применяется к экранам "Платеж выполнен" и подобным, где с 3.2.33
// лейблы и значения сидят в отдельных View-нодах: лейбл слева
// (content-desc оканчивается ':'), значение — справа на той же Y-полосе.
QMap<QString, QString> result;
for (int i = 0; i < m_nodes.size(); ++i) {
const QString rawLabel = m_nodes[i].contentDesc.trimmed();
if (rawLabel.isEmpty() || !rawLabel.endsWith(':')) continue;
const QString label = rawLabel.left(rawLabel.size() - 1).trimmed();
if (label.isEmpty() || result.contains(label)) continue;
const Node &lbl = m_nodes[i];
int bestIdx = -1;
for (int j = 0; j < m_nodes.size(); ++j) {
if (j == i) continue;
const QString candidate = m_nodes[j].contentDesc.trimmed();
if (candidate.isEmpty()) continue;
if (candidate.endsWith(':')) continue;
if (m_nodes[j].x1() < lbl.x2()) continue;
if (qAbs(m_nodes[j].y1() - lbl.y1()) > 8) continue;
if (bestIdx < 0 || m_nodes[j].x1() < m_nodes[bestIdx].x1()) {
bestIdx = j;
}
}
if (bestIdx >= 0) {
result[label] = m_nodes[bestIdx].contentDesc.trimmed();
}
}
return result;
}
bool ScreenXmlParser::isCardsScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Мои карты")) {
return true;
}
}
return false;
}
QList<ScreenXmlParser::CardInfo> ScreenXmlParser::parseCards() {
// Карты — ImageView с content-desc формата:
// "Обновлено: 03.04.26 - 22:43:57\n0.00 TJS\n**** 3258\n до \n11/35\nIMOMKULZODA MEKHRUBON"
// или "-\n**** \n до \n12/29\n \nКредитная карта"
QList<CardInfo> result;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.ImageView") continue;
if (!node.clickable) continue;
if (!node.contentDesc.contains("****")) continue;
if (node.contentDesc.contains(QString::fromUtf8("Кредитная карта"))) continue;
QStringList lines;
for (const QString &line : node.contentDesc.split('\n')) {
QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
lines.append(trimmed);
}
}
CardInfo card;
for (const QString &line : lines) {
// Баланс: "0.00 TJS"
if (line.contains("TJS")) {
QStringList parts = line.split(' ');
if (parts.size() >= 2) {
card.amount = parts[0].toDouble();
card.currency = "TJS";
}
continue;
}
// Последние цифры: "**** 3258"
if (line.startsWith("****")) {
QString nums = line.mid(4).trimmed();
if (!nums.isEmpty() && nums[0].isDigit()) {
card.lastNumbers = nums;
}
continue;
}
// Срок: "11/35" или "12/29"
static const QRegularExpression expiryRe(R"(^\d{2}/\d{2}$)");
if (expiryRe.match(line).hasMatch()) {
card.expiry = line;
continue;
}
// Пропускаем "до", "Обновлено:...", "-"
if (line == QString::fromUtf8("до") || line.startsWith(QString::fromUtf8("Обновлено")) || line == "-") {
continue;
}
// Имя держателя (CAPS) или описание
if (line == line.toUpper() && line.length() > 3) {
card.holderName = line;
} else {
card.description = line;
}
}
result.append(card);
}
return result;
}
bool ScreenXmlParser::isCardDetailScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Карта")) {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseFullCardNumber() {
// Полный номер карты: "9762 0002 0711 3258" — clickable View
static const QRegularExpression cardNumberRe(R"(^\d{4}\s\d{4}\s\d{4}\s\d{4}$)");
for (const Node &node : m_nodes) {
if (cardNumberRe.match(node.contentDesc).hasMatch()) {
return node.contentDesc;
}
}
return {};
}
double ScreenXmlParser::parseCardDetailBalance() {
// "0,00 TJS" или "- TJS" — содержит TJS, но не "сомони"
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("TJS") && !node.contentDesc.contains(QString::fromUtf8("сомони"))) {
QString raw = node.contentDesc.trimmed();
raw.remove("TJS");
raw = raw.trimmed();
if (raw == "-" || raw.isEmpty()) return 0.0;
raw.replace(',', '.');
raw.remove(' ');
bool ok = false;
double val = raw.toDouble(&ok);
if (ok) return val;
}
}
return 0.0;
}
QString ScreenXmlParser::parseCardDetailHolder() {
// Имя держателя — текст в CAPS, не содержит цифр, длина > 3
static const QRegularExpression cardNumberRe(R"(\d{4}\s\d{4})");
for (const Node &node : m_nodes) {
const QString &desc = node.contentDesc;
if (desc.isEmpty() || desc.length() < 4) continue;
if (desc == desc.toUpper() && !desc.contains(QRegularExpression(R"(\d)")) &&
desc != QString::fromUtf8("TJS")) {
return desc.trimmed();
}
}
return {};
}
Node ScreenXmlParser::findCardSettingsButton() {
for (const Node &node : m_nodes) {
if (node.clickable && node.contentDesc == QString::fromUtf8("Настройки карты")) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isCardSettingsBottomSheet() {
bool hasRequisites = false;
bool hasBlock = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Реквизиты карты")) hasRequisites = true;
if (node.contentDesc == QString::fromUtf8("Заблокировать карту")) hasBlock = true;
if (hasRequisites && hasBlock) return true;
}
return false;
}
Node ScreenXmlParser::findCardRequisitesMenuItem() {
// На bottom sheet это ImageView; на экране "Реквизиты и информация"
// одноимённый таб — Button. Берём именно ImageView, чтобы не перепутать.
for (const Node &node : m_nodes) {
if (node.clickable
&& node.className == "android.widget.ImageView"
&& node.contentDesc == QString::fromUtf8("Реквизиты карты")) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isCardRequisitesScreen() {
bool hasCardTab = false;
bool hasAccountTab = false;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button") continue;
if (node.contentDesc == QString::fromUtf8("Реквизиты карты")) hasCardTab = true;
if (node.contentDesc == QString::fromUtf8("Реквизиты счета")) hasAccountTab = true;
if (hasCardTab && hasAccountTab) return true;
}
return false;
}
Node ScreenXmlParser::findAccountRequisitesTab() {
for (const Node &node : m_nodes) {
if (node.clickable
&& node.className == "android.widget.Button"
&& node.contentDesc == QString::fromUtf8("Реквизиты счета")) {
return node;
}
}
return {};
}
QString ScreenXmlParser::parseAccountNumber() {
// Берём ноду "Номер счета" (label) и ищем ближайший узел снизу
// с чисто цифровым content-desc — это и есть номер счёта.
Node label;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Номер счета")) {
label = node;
break;
}
}
if (label.y2() < 0) return {};
static const QRegularExpression digitsOnly(R"(^\d{10,}$)");
Node best;
for (const Node &node : m_nodes) {
if (node.y1() < label.y2()) continue;
if (!digitsOnly.match(node.contentDesc).hasMatch()) continue;
if (best.y1() < 0 || node.y1() < best.y1()) {
best = node;
}
}
return best.contentDesc;
}
bool ScreenXmlParser::isHistoryScreen() {
bool hasOperacii = false;
bool hasVypiska = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Операции")) {
hasOperacii = true;
}
if (node.contentDesc == QString::fromUtf8("Выписка")) {
hasVypiska = true;
}
if (hasOperacii && hasVypiska) {
return true;
}
}
return false;
}
QList<ScreenXmlParser::TransactionItem> ScreenXmlParser::parseTransactionItems() {
// Транзакции — ImageView с content-desc формата:
// "9762***3258\n992882770011\n493.00 \nDC (по номеру телефона)\n 18:57:10"
QList<TransactionItem> result;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.ImageView") continue;
if (!node.contentDesc.contains("***")) continue;
QStringList lines;
for (const QString &line : node.contentDesc.split('\n')) {
QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
lines.append(trimmed);
}
}
if (lines.size() < 4) continue;
TransactionItem tx;
tx.cardMask = lines[0]; // "9762***3258"
tx.phone = lines[1]; // "992882770011"
// Сумма: "493.00" (может быть с пробелом в конце)
bool ok = false;
tx.amount = lines[2].toDouble(&ok);
if (!ok) tx.amount = 0.0;
tx.description = lines[3]; // "DC (по номеру телефона)" или "Tcell"
// Время — последний элемент
if (lines.size() >= 5) {
tx.time = lines[4]; // "18:57:10"
}
result.append(tx);
}
return result;
}
bool ScreenXmlParser::isTransactionDetailScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Дата операции:")) &&
node.contentDesc.contains(QString::fromUtf8("Номер операции:"))) {
return true;
}
}
return false;
}
ScreenXmlParser::TransactionDetail ScreenXmlParser::parseTransactionDetail() {
// Все данные в одном content-desc, формат "ключ: \nзначение"
TransactionDetail detail;
for (const Node &node : m_nodes) {
if (!node.contentDesc.contains(QString::fromUtf8("Дата операции:"))) continue;
// Разбиваем по \n, парсим пары "ключ:" + следующая строка = значение
QStringList lines = node.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Дата операции:")) && i + 1 < lines.size()) {
detail.date = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время операции:")) && i + 1 < lines.size()) {
detail.time = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Номер операции:")) && i + 1 < lines.size()) {
detail.operationId = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Поставщик:")) && i + 1 < lines.size()) {
detail.provider = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Счет отправителя:")) && i + 1 < lines.size()) {
detail.senderAccount = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Счет получателя:")) && i + 1 < lines.size()) {
detail.receiverAccount = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Сумма операции:")) && i + 1 < lines.size()) {
detail.amount = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
detail.fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Статус:")) && i + 1 < lines.size()) {
detail.status = lines[i + 1].trimmed();
}
}
break;
}
return detail;
}
Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.hint.contains(hint)) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findSumEditText() {
// На экране перевода по карте порядок EditText'ов сверху вниз:
// [0] номер карты, [1] сумма, [2] комментарий.
QList<Node> editTexts;
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText") editTexts.append(node);
}
std::sort(editTexts.begin(), editTexts.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
return editTexts.size() >= 2 ? editTexts[1] : Node{};
}
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
for (const Node &node : m_nodes) {
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
&& node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
return node;
}
}
return {};
}
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Доступно обновление")) {
Node tapNode;
const int cx = screenWidth / 2;
const int cy = screenHeight / 20;
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
return tapNode;
}
}
return {};
}
} // namespace Dushanbe