1
0
forked from BRT/arc
arc/android/dushanbe/ScreenXmlParser.cpp
slava 377844e94b Add account number parsing and database integration:
- Extend `ScreenXmlParser` with methods for detecting and extracting account numbers from multiple screens.
- Update database schema and DAO to support `account_number` field.
- Modify card information scripts to collect, save, and utilize account numbers.
- Enhance `GetCardInfoScript` logic to fetch account numbers via screen parsing and interaction.
2026-05-08 20:29:00 +07:00

655 lines
24 KiB
C++
Raw Permalink 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("История")) {
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::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