#include "ScreenXmlParser.h" #include #include #include #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 &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("История")) { hasBottomNav = true; } if (hasGlavnaya && hasBottomNav) { return true; } } return false; } double ScreenXmlParser::parseBalanceFromHomeScreen() { // На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15" // внутри него child node content-desc="0.00" (или "10.00") // Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc 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 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) { if (node.contentDesc == QString::fromUtf8("Выход")) { hasVyhod = true; } // На реальном экране content-desc = "Идентифицирован\nУспешно", // поэтому строгое равенство не проходит — используем startsWith. if (node.contentDesc.startsWith(QString::fromUtf8("Идентифицирован"))) { hasIdentified = true; } if (hasVyhod && hasIdentified) { return true; } } return false; } QString ScreenXmlParser::parseProfileFullName() { // content-desc: "Мехрубон Имомкулзода\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443" for (const Node &node : m_nodes) { if (node.contentDesc.contains(QString::fromUtf8("Версия:")) && node.contentDesc.contains(QString::fromUtf8("Сборка:"))) { QStringList lines = node.contentDesc.split('\n'); if (!lines.isEmpty()) { return lines[0].trimmed(); } } } return {}; } QString ScreenXmlParser::parseProfilePhone() { for (const Node &node : m_nodes) { if (node.contentDesc.contains(QString::fromUtf8("Версия:")) && node.contentDesc.contains(QString::fromUtf8("Сборка:"))) { QStringList lines = node.contentDesc.split('\n'); if (lines.size() >= 2) { return lines[1].trimmed(); } } } return {}; } bool ScreenXmlParser::isCardsScreen() { for (const Node &node : m_nodes) { if (node.contentDesc == QString::fromUtf8("Мои карты")) { return true; } } return false; } QList 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 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 {}; } 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::parseTransactionItems() { // Транзакции — ImageView с content-desc формата: // "9762***3258\n992882770011\n493.00 \nDC (по номеру телефона)\n 18:57:10" QList 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() { // Якорь: View "Мин. сумма: ..." расположен сразу под полем суммы. Node anchor; for (const Node &node : m_nodes) { if (node.contentDesc.contains(QString::fromUtf8("Мин. сумма"))) { anchor = node; break; } } if (anchor.x1() < 0) return {}; // Ближайший EditText, который заканчивается выше якоря. Node best; for (const Node &node : m_nodes) { if (node.className != "android.widget.EditText") continue; if (node.y2() > anchor.y1()) continue; if (best.x1() < 0 || node.y2() > best.y2()) best = node; } return best; } 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