1
0
forked from BRT/arc

Refine transaction parsing and handling logic:

- Add fast path for avoiding unnecessary app restarts if already on the home or PIN screen.
- Improve PIN code and home screen detection with fallback mechanisms.
- Implement URL and error sanitization for network messages to hide sensitive data from logs.
- Add embedded transaction parsing from content description with adjusted tap positioning logic.
- Refactor transaction fetching to use direct signal-based parsed data instead of database queries.
This commit is contained in:
slava 2026-04-11 16:37:14 +07:00
parent 4407f999c7
commit 33c8420910
9 changed files with 320 additions and 169 deletions

View File

@ -42,6 +42,11 @@ Node CommonScript::swipeToButton(
} }
bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) { bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) {
if (pinCode.isEmpty()) {
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
return false;
}
qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9") // DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
for (const QChar &digit : pinCode) { for (const QChar &digit : pinCode) {
Node button = xmlScreenParser.findNodeByContentDesc(QString(digit)); Node button = xmlScreenParser.findNodeByContentDesc(QString(digit));
@ -64,9 +69,32 @@ bool CommonScript::runAppAndGoToHomeScreen(
) { ) {
AdbUtils::unlockScreen(deviceId, width, height); AdbUtils::unlockScreen(deviceId, width, height);
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
// а смотрим текущий экран. Если это Home — готово. Если PIN — вводим
// и ждём Home. Только если не смогли — убиваем и рестартим.
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) { if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "[Dushanbe] Process already running, pid:" << pid; qDebug() << "[Dushanbe] Process already running, pid:" << pid;
AdbUtils::getScreenDumpAsXml(deviceId, 1000); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe] Fast path: already on Home screen";
return true;
}
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe] Fast path: PIN screen, entering PIN";
if (inputPinCode(deviceId, pinCode)) {
QThread::msleep(3000);
for (int i = 0; i < 10; ++i) {
xmlScreenParser.parseAndSaveXml(
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe] Fast path: Home reached after PIN";
return true;
}
QThread::msleep(1000);
}
}
}
qDebug() << "[Dushanbe] Fast path failed, restarting app";
AdbUtils::tryToKillApplication(deviceId, packageName); AdbUtils::tryToKillApplication(deviceId, packageName);
} }
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) { if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
@ -82,23 +110,39 @@ bool CommonScript::runAppAndGoToHomeScreen(
while (true) { while (true) {
counter++; counter++;
if (counter > 10) { if (counter > 20) {
qWarning() << "[Dushanbe] runAppAndGoToHomeScreen: too many attempts, giving up";
AdbUtils::takeScreenshot(deviceId, "too_many_attempts"); AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
return false; return false;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) { if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe] Home reached on attempt" << counter;
return true; return true;
} }
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) { if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
inputPinCode(deviceId, pinCode); qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
if (!inputPinCode(deviceId, pinCode)) {
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter;
}
findAndInputPincode = true; findAndInputPincode = true;
QThread::msleep(3000); QThread::msleep(3000);
continue; continue;
} }
// Диагностика: что реально на экране?
QStringList sample;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.contentDesc.isEmpty() && n.text.isEmpty()) continue;
QString s = !n.contentDesc.isEmpty() ? n.contentDesc : n.text;
s.replace('\n', ' ');
if (s.size() > 60) s = s.left(60) + "...";
sample.append(s);
if (sample.size() >= 8) break;
}
qDebug() << "[Dushanbe] attempt" << counter << "not home, not PIN. Sample:" << sample;
QThread::msleep(1000); QThread::msleep(1000);
} }
} }

View File

@ -1,5 +1,6 @@
#include "GetLastTransactionsScript.h" #include "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QRegularExpression> #include <QRegularExpression>
#include <QThread> #include <QThread>
#include <QTimeZone> #include <QTimeZone>
@ -11,7 +12,6 @@
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h" #include "AppLogger.h"
#include <QCoreApplication> #include <QCoreApplication>
@ -244,6 +244,9 @@ GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() { void GetLastTransactionsScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
qDebug() << "[Dushanbe::GetLastTransactions] device.id=" << device.id << "adbSerial=" << device.adbSerial
<< "appCode=" << m_appCode << "app.id=" << app.id
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
if (device.id.isEmpty() || app.id == -1) { if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode; m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
@ -367,15 +370,22 @@ void GetLastTransactionsScript::doStart() {
for (int scroll = 0; scroll <= maxScrolls; ++scroll) { for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// 1. Собираем список дат-групп из родительского ImageView "Обновлено: ...\n05.04.2026\n..." // 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию,
// Поддерживаем "DD.MM.YYYY", "Сегодня", "Вчера" // которая впечатана в content-desc родительского ImageView "Обновлено: ..."
// вместо отдельного android.view.View (самая свежая запись в Выписке).
const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date(); const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date();
QStringList groupDates; QStringList groupDates;
static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)"); static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)");
static const QRegularExpression reEmbTime(R"(^\s*(\d{2}:\d{2}:\d{2})\s*$)");
Node updatedImgNode;
VypiskaItem embedded;
bool hasEmbedded = false;
for (const Node &n : xmlScreenParser.findAllNodes()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.ImageView") continue; if (n.className != "android.widget.ImageView") continue;
if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue; if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue;
for (const QString &line : n.contentDesc.split('\n')) { updatedImgNode = n;
const QStringList descLines = n.contentDesc.split('\n');
for (const QString &line : descLines) {
const QString t = line.trimmed(); const QString t = line.trimmed();
if (reDate.match(t).hasMatch()) { if (reDate.match(t).hasMatch()) {
groupDates.append(t); groupDates.append(t);
@ -385,6 +395,19 @@ void GetLastTransactionsScript::doStart() {
groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy")); groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy"));
} }
} }
// Паттерн встроенной: ...\ncard\ncard\namount\nописание\n HH:MM:SS\n...
for (int i = 4; i < descLines.size(); ++i) {
const auto tm = reEmbTime.match(descLines[i]);
if (!tm.hasMatch()) continue;
bool ok = false;
const double amt = descLines[i - 2].trimmed().toDouble(&ok);
if (!ok) continue;
embedded.amount = amt;
embedded.time = tm.captured(1);
if (!groupDates.isEmpty()) embedded.date = groupDates.first();
hasEmbedded = true;
break;
}
break; break;
} }
@ -408,6 +431,55 @@ void GetLastTransactionsScript::doStart() {
items.append(it); items.append(it);
} }
// 2a. Если нашли встроенную транзакцию — создаём синтетический Node с
// координатами тапа над первой "реальной" View-записью и добавляем
// как первый элемент списка.
if (hasEmbedded && !updatedImgNode.isEmpty()) {
int firstViewY1 = -1;
int firstViewRowH = 0;
for (const VypiskaItem &v : items) {
if (firstViewY1 < 0 || v.node.y1() < firstViewY1) {
firstViewY1 = v.node.y1();
firstViewRowH = v.node.height();
}
}
const int topY = updatedImgNode.y1();
// Если первая реальная View-запись уже находится у верха ImageView
// (значит скролл поднял контент и встроенная запись ушла за экран),
// пропускаем embedded — её визуально нет.
const int minEmbeddedSpace = 120;
const bool embeddedVisible = firstViewY1 < 0 || (firstViewY1 - topY) >= minEmbeddedSpace;
if (embeddedVisible) {
int tapY;
if (firstViewY1 > 0 && firstViewRowH > 0) {
// Между встроенной записью и первой "реальной" View лежит
// заголовок даты (~0.4 * rowH). Ставим тап по центру
// строки: firstViewY1 - headerH - rowH/2 ≈ firstViewY1 - rowH * 0.9.
// Дополнительно ограничиваем диапазоном [topY + rowH/2 .. firstViewY1 - 40].
tapY = firstViewY1 - (firstViewRowH * 9 / 10);
const int minY = topY + firstViewRowH / 2;
const int maxY = firstViewY1 - 40;
if (tapY < minY) tapY = minY;
if (tapY > maxY) tapY = maxY;
} else {
tapY = topY + 200;
}
Node synth;
synth.setBounds(updatedImgNode.x1(), tapY - 10, updatedImgNode.x2(), tapY + 10);
synth.className = "android.view.View";
synth.contentDesc = updatedImgNode.contentDesc;
synth.clickable = true;
embedded.node = synth;
items.prepend(embedded);
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx added:"
<< "amount=" << embedded.amount << "time=" << embedded.time
<< "date=" << embedded.date << "tapY=" << tapY;
} else {
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx skipped "
"(scrolled off): firstViewY1=" << firstViewY1 << "topY=" << topY;
}
}
// 3. Сортируем по Y и группируем по разрывам (> 50px → новая группа-день) // 3. Сортируем по Y и группируем по разрывам (> 50px → новая группа-день)
std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) { std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) {
return a.node.y1() < b.node.y1(); return a.node.y1() < b.node.y1();
@ -423,10 +495,16 @@ void GetLastTransactionsScript::doStart() {
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size() qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size()
<< "groups:" << groupDates; << "groups:" << groupDates;
const double searchAbs = qAbs(m_searchAmount);
for (const auto &tx : items) { for (const auto &tx : items) {
if (qAbs(tx.amount - m_searchAmount) > 0.01) continue; const double absListAmt = qAbs(tx.amount);
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
if (!amountDirect && !amountX100) continue;
const bool matchedX100 = amountX100 && !amountDirect;
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
<< (matchedX100 ? "(scaled x100)" : "(direct)")
<< "date:" << tx.date << "time:" << tx.time; << "date:" << tx.date << "time:" << tx.time;
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи) // Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
@ -488,9 +566,13 @@ void GetLastTransactionsScript::doStart() {
} }
if (!detailLoaded) { if (!detailLoaded) {
qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded"; qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded "
AdbUtils::goBack(m_deviceId); "(tap missed or detail format unknown); staying on Выписка";
QThread::msleep(1000); // Не вызываем goBack — после промаха тапа мы всё ещё на Выписке.
// goBack тут закрывает Выписку и ломает дальнейший скролл.
// Если же тап сработал и открылся НЕ-распознанный экран — следующая
// итерация parse обнаружит это, и мы обработаем штатно.
QThread::msleep(500);
continue; continue;
} }
qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:" qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:"
@ -524,30 +606,41 @@ void GetLastTransactionsScript::doStart() {
<< "receiver=" << detail.receiverAccount << "receiver=" << detail.receiverAccount
<< "status=" << detail.status; << "status=" << detail.status;
if (accountId != -1) { // Формируем task_result напрямую из распарсенных данных и отдаём
TransactionInfo saveTx; // EventHandler через сигнал — без записи/чтения БД.
saveTx.accountId = accountId; {
saveTx.amount = detail.amount; const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
saveTx.fee = detail.fee; const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
saveTx.bankName = m_appCode;
saveTx.bankTime = detail.date + " " + detail.time;
saveTx.bankTrExternalId = "dushanbe_" + detail.date + "_" + detail.time;
saveTx.description = detail.provider;
saveTx.phone = "";
saveTx.name = detail.receiverAccount;
saveTx.status = detail.status.contains(QString::fromUtf8("Успешн"))
? TransactionStatus::Complete : TransactionStatus::Unknown;
saveTx.type = TransactionType::Phone;
saveTx.timestamp = QDateTime::currentDateTimeUtc();
QDateTime txTime = QDateTime::fromString( QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (txTime.isValid()) { if (!txTime.isValid()) {
txTime.setTimeZone(tjTz); txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
saveTx.completeTime = txTime;
} }
if (txTime.isValid()) txTime.setTimeZone(tjTz);
TransactionDAO::insertTransactionIfNotExists(saveTx); QJsonObject txJson;
txJson["bank_transaction_id"] = detail.operationId.isEmpty()
? QString("dushanbe_") + detail.date + "_" + detail.time
: detail.operationId;
// Списание хранится с отрицательным знаком, пополнение — положительным.
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
txJson["amount"] = static_cast<int>(signedAmount * 100);
txJson["currency"] = 972; // TJS
txJson["transaction_type"] = "bank";
txJson["bank_participator_id"] = detail.receiverAccount;
txJson["status"] = detail.status.contains(QString::fromUtf8("Успешн"))
? QStringLiteral("completed") : QStringLiteral("progress");
QJsonObject extraObj;
extraObj["fee"] = static_cast<int>(finalFee * 100);
txJson["extra"] = extraObj;
txJson["created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
emit transactionParsed(txJson);
} }
// Назад к истории // Назад к истории

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include "CommonScript.h" #include "CommonScript.h"
#include <QDateTime> #include <QDateTime>
#include <QJsonObject>
#include "db/DeviceInfo.h" #include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h" #include "db/BankProfileInfo.h"
@ -20,6 +21,12 @@ public:
~GetLastTransactionsScript() override; ~GetLastTransactionsScript() override;
signals:
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
// EventHandler использует эти данные напрямую для task_result вместо
// повторного чтения из БД.
void transactionParsed(const QJsonObject &tx);
protected: protected:
void doStart() override; void doStart() override;

View File

@ -24,8 +24,14 @@ LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default; LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
void LoginAndCheckAccountsScript::doStart() { void LoginAndCheckAccountsScript::doStart() {
// Канонический ключ материала (device.id в БД) — для лукапов BankProfile;
// m_deviceId же перезаписываем ADB-серийником для команд.
const QString canonicalDeviceId = m_deviceId;
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
qDebug() << "[Dushanbe::Login] device.id=" << device.id << "adbSerial=" << device.adbSerial
<< "appCode=" << m_appCode << "app.id=" << app.id
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
if (device.id.isEmpty() || app.id == -1) { if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode; m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
@ -53,7 +59,9 @@ void LoginAndCheckAccountsScript::doStart() {
return; return;
} }
GetProfileInfoScript profileScript(m_deviceId, m_appCode); // В GetProfileInfoScript отдаём КАНОНИЧЕСКИЙ ключ материала, а не ADB-серийник,
// чтобы BankProfileDAO::getApplication нашла правильную запись с bank_profile_id.
GetProfileInfoScript profileScript(canonicalDeviceId, m_appCode);
profileScript.start(); profileScript.start();
emit finishedWithResult(m_error); emit finishedWithResult(m_error);

View File

@ -129,12 +129,29 @@ Node ScreenXmlParser::findFirstEditText() {
} }
bool ScreenXmlParser::isPinCodeScreen() { bool ScreenXmlParser::isPinCodeScreen() {
// 1. Прямое совпадение по тексту заголовка
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа"))) { if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа")) ||
node.contentDesc.contains(QString::fromUtf8("Введите ПИН")) ||
node.contentDesc.contains(QString::fromUtf8("код доступа")) ||
node.text.contains(QString::fromUtf8("код доступа"))) {
return true; return true;
} }
} }
return false; // 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() { Node ScreenXmlParser::findPinCodeEditText() {
@ -148,15 +165,19 @@ Node ScreenXmlParser::findPinCodeEditText() {
bool ScreenXmlParser::isHomeScreen() { bool ScreenXmlParser::isHomeScreen() {
bool hasGlavnaya = false; bool hasGlavnaya = false;
bool hasTJS = false; bool hasBottomNav = false;
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Главная")) { if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
hasGlavnaya = true; hasGlavnaya = true;
} }
if (node.contentDesc.contains("TJS")) { // Нижняя навигация — любая из соседних вкладок указывает, что мы на
hasTJS = true; // основной странице приложения, а не на splash/PIN/диалоге.
if (node.contentDesc == QString::fromUtf8("Переводы") ||
node.contentDesc == QString::fromUtf8("Сервисы") ||
node.contentDesc == QString::fromUtf8("История")) {
hasBottomNav = true;
} }
if (hasGlavnaya && hasTJS) { if (hasGlavnaya && hasBottomNav) {
return true; return true;
} }
} }

View File

@ -62,10 +62,37 @@ bool CommonScript::runAppAndGoToHomeScreen(
// Разблокируем экран перед запуском // Разблокируем экран перед запуском
AdbUtils::unlockScreen(deviceId, width, height); AdbUtils::unlockScreen(deviceId, width, height);
// Запустить устройство // Быстрый path: если приложение уже запущено — не перезапускаем сразу,
// а смотрим текущий экран. Home → готово. PIN → вводим и ждём Home.
// Только если не смогли — убиваем и рестартим.
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) { if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "Process already running, pid: " << pid; qDebug() << "Process already running, pid: " << pid;
AdbUtils::getScreenDumpAsXml(deviceId, 1000); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Ozon] Fast path: already on Home screen";
return true;
}
{
const QList<Node> fastPincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) {
qDebug() << "[Ozon] Fast path: PIN screen, entering PIN";
if (Node wake = xmlScreenParser.findTextNode("Введите ПИН"); !wake.isEmpty()) {
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
}
inputPinCode(deviceId, fastPincode);
QThread::msleep(3000);
for (int i = 0; i < 10; ++i) {
xmlScreenParser.parseAndSaveXml(
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Ozon] Fast path: Home reached after PIN";
return true;
}
QThread::msleep(1000);
}
}
}
qDebug() << "[Ozon] Fast path failed, restarting app";
AdbUtils::tryToKillApplication(deviceId, packageName); AdbUtils::tryToKillApplication(deviceId, packageName);
} }
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) { if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {

View File

@ -281,121 +281,38 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
QJsonValue taskData; QJsonValue taskData;
if (event.type == EventType::FetchProfile) { if (event.type == EventType::FetchProfile) {
if (event.bankName == "ozon") { // FETCH_PROFILE всегда один объект. Amount читаем актуальный:
EventDAO::updateEventAmount(event.id, account.amount); // - для Ozon скрипт обновляет MaterialDAO → берём из account;
QJsonObject obj; // - для остальных (Dushanbe и т.п.) скрипт обновляет BankProfileDAO
obj["bank_account_id"] = account.materialId; // через updateAmount → берём из bank_profiles.
obj["amount"] = static_cast<int>(account.amount * 100); double freshAmount = account.amount;
obj["currency"] = currencyCode; if (event.bankName != "ozon") {
taskData = obj; const BankProfileInfo freshApp = BankProfileDAO::getApplication(
} else { account.deviceId, event.bankName);
// Отправляем суммы всех материалов устройства для этого банка if (freshApp.id != -1) freshAmount = freshApp.amount;
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(event.deviceId, event.bankName);
if (!allAccounts.isEmpty()) {
QJsonArray accountsArr;
for (const auto &acc : allAccounts) {
const int accCurrency = currencyCodes.value(acc.currency.toUpper(), 643);
QJsonObject accObj;
accObj["bank_account_id"] = acc.materialId;
accObj["amount"] = static_cast<int>(acc.amount * 100);
accObj["currency"] = accCurrency;
accountsArr.append(accObj);
}
taskData = accountsArr;
} else {
EventDAO::updateEventAmount(event.id, account.amount);
QJsonObject obj;
obj["bank_account_id"] = account.materialId;
obj["amount"] = static_cast<int>(account.amount * 100);
obj["currency"] = currencyCode;
taskData = obj;
}
} }
EventDAO::updateEventAmount(event.id, freshAmount);
QJsonObject obj;
obj["bank_account_id"] = account.materialId;
obj["amount"] = static_cast<int>(freshAmount * 100);
obj["currency"] = currencyCode;
taskData = obj;
} else if (event.type == EventType::FetchTransactions) { } else if (event.type == EventType::FetchTransactions) {
QList<TransactionInfo> txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 24); // FETCH_TRANSACTIONS больше НЕ читает из БД — берём то, что
// Оставляем только совпадения по сумме // скрипт распарсил вживую и передал через сигнал transactionParsed.
txList.erase(std::remove_if(txList.begin(), txList.end(), const QJsonObject parsedTx = m_parsedTxs.take(key);
[&](const TransactionInfo &tx) { if (parsedTx.isEmpty()) {
return qAbs(tx.amount - event.amount) > 0.01; qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
}), txList.end());
// Если больше одной — берём ближайшую по времени к запросу сервера
if (txList.size() > 1) {
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
const QDateTime target = QDateTime::fromString(
taskJson["body"].toObject()["created_at"].toString(), Qt::ISODate);
if (target.isValid()) {
auto best = std::min_element(txList.begin(), txList.end(),
[&](const TransactionInfo &a, const TransactionInfo &b) {
const QDateTime ta = a.completeTime.isValid() ? a.completeTime : a.timestamp;
const QDateTime tb = b.completeTime.isValid() ? b.completeTime : b.timestamp;
return qAbs(ta.toUTC().secsTo(target)) < qAbs(tb.toUTC().secsTo(target));
});
const TransactionInfo chosen = *best;
txList.clear();
txList.append(chosen);
}
}
// Фильтруем: отправляем только транзакции с bank_transaction_id
bool hasWithoutId = false;
QJsonArray arr;
for (const auto &tx : txList) {
if (tx.bankTrExternalId.isEmpty()) {
hasWithoutId = true;
continue;
}
QJsonObject txObj;
txObj["bank_transaction_id"] = tx.bankTrExternalId;
txObj["amount"] = static_cast<int>(tx.amount * 100);
txObj["currency"] = currencyCode;
txObj["transaction_type"] = (tx.type == TransactionType::Phone) ? "top-up" : "bank";
txObj["bank_participator_id"]= tx.phone;
txObj["status"] = tx.status == TransactionStatus::Complete ? "completed" : (tx.status == TransactionStatus::Wait ? "progress" : "completed");
QJsonObject extraObj;
extraObj["fee"] = static_cast<int>(tx.fee * 100);
txObj["extra"] = extraObj;
// Банковские приложения показывают время в таймзоне устройства
const DeviceInfo bankDevice = DeviceDAO::getDeviceById(event.deviceId);
const QString bankAdbSerial = bankDevice.adbSerial.isEmpty()
? event.deviceId : bankDevice.adbSerial;
const QString bankTzName = AdbUtils::getDeviceTimezone(bankAdbSerial);
QTimeZone bankTz(bankTzName.toUtf8());
if (!bankTz.isValid()) {
bankTz = QTimeZone(event.bankName == "dushanbe_city_bank"
? "Asia/Dushanbe" : "Europe/Moscow");
}
// Приоритет: bankTime (время операции) > timestamp (время сохранения)
// Сервер ожидает UTC ("Z")
bool createdAtSet = false;
if (!tx.bankTime.isEmpty()) {
QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm");
if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm");
if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm:ss");
if (dt.isValid()) {
dt.setTimeZone(bankTz);
txObj["created_at"] = dt.toUTC().toString(Qt::ISODate);
createdAtSet = true;
}
}
if (!createdAtSet && tx.timestamp.isValid()) {
txObj["created_at"] = tx.timestamp.toUTC().toString(Qt::ISODate);
createdAtSet = true;
}
if (!createdAtSet) {
txObj["created_at"] = QJsonValue(QJsonValue::Null);
}
arr.append(txObj);
}
if (arr.isEmpty() && hasWithoutId) {
// Транзакция найдена но без bank_transaction_id — ошибка
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: tx found but no bank_transaction_id";
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Transaction found but bank_transaction_id is empty"); "Transaction not parsed from screen");
EventDAO::updateEvent(event.id, EventStatus::Error, EventDAO::updateEvent(event.id, EventStatus::Error,
"Transaction found but bank_transaction_id is empty (OCR failed)"); "Transaction not parsed from screen");
EventDAO::markSentToServer(event.id); EventDAO::markSentToServer(event.id);
m_threads.remove(key); m_threads.remove(key);
return; return;
} }
QJsonArray arr;
arr.append(parsedTx);
taskData = arr; taskData = arr;
} else if (event.type == EventType::CreateTransaction) { } else if (event.type == EventType::CreateTransaction) {
// Определяем тип транзакции из JSON задачи // Определяем тип транзакции из JSON задачи
@ -506,9 +423,10 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
}; };
const QString &appCode = account.appCode; const QString &appCode = account.appCode;
// Скриптам нужен ADB serial для команд, не android_id // Передаём в скрипт канонический device_id из материала — скрипт сам
const DeviceInfo device = DeviceDAO::getDeviceById(account.deviceId); // разрезолвит его в adb serial через DeviceDAO и использует ТОТ ЖЕ ключ
const QString adbSerial = device.adbSerial.isEmpty() ? account.deviceId : device.adbSerial; // для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи).
const QString adbSerial = account.deviceId;
auto connectScreenshot = [this, key](Ozon::CommonScript *w) { auto connectScreenshot = [this, key](Ozon::CommonScript *w) {
connect(w, &Ozon::CommonScript::screenshotReady, this, connect(w, &Ozon::CommonScript::screenshotReady, this,
@ -539,8 +457,13 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
} }
else if (appCode == "black") else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") else if (appCode == "dushanbe_city_bank") {
setup(new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime)); auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime);
setup(w);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection);
}
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
} }
@ -658,6 +581,8 @@ void EventHandler::clearAll() {
timer->deleteLater(); timer->deleteLater();
} }
m_timers.clear(); m_timers.clear();
m_screenshots.clear();
m_parsedTxs.clear();
} }
void EventHandler::stop() { void EventHandler::stop() {

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject>
#include <QMap> #include <QMap>
#include <QObject> #include <QObject>
#include <QTimer> #include <QTimer>
@ -33,6 +34,7 @@ private:
QMap<QString, QThread *> m_threads; QMap<QString, QThread *> m_threads;
QMap<QString, QTimer *> m_timers; QMap<QString, QTimer *> m_timers;
QMap<QString, QByteArray> m_screenshots; // deviceId → скриншот от скрипта QMap<QString, QByteArray> m_screenshots; // deviceId → скриншот от скрипта
QMap<QString, QJsonObject> m_parsedTxs; // key → распарсенная транзакция для FETCH_TRANSACTIONS
QTimer *m_pollTimer = nullptr; QTimer *m_pollTimer = nullptr;
NetworkService *m_network = nullptr; NetworkService *m_network = nullptr;

View File

@ -21,6 +21,26 @@
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "db/EventInfo.h" #include "db/EventInfo.h"
// Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только
// путь эндпоинта и HTTP-код. Нужно, чтобы адрес сервера не попадал в
// логи/UI/general_logs/application.log.
static QString sanitizePath(const QString &fullUrl) {
const int schemeEnd = fullUrl.indexOf("://");
if (schemeEnd < 0) return fullUrl;
const int pathStart = fullUrl.indexOf('/', schemeEnd + 3);
return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/");
}
static QString sanitizeNetError(QNetworkReply *reply, const QString &path) {
const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString endpoint = sanitizePath(path);
if (httpCode > 0) {
return QStringLiteral("HTTP %1 %2").arg(httpCode).arg(endpoint);
}
// Сетевой уровень (не HTTP) — выводим enum-код вместо строки с URL.
return QStringLiteral("net_err=%1 %2").arg(static_cast<int>(reply->error())).arg(endpoint);
}
NetworkService::NetworkService(QObject *parent) : QObject(parent) { NetworkService::NetworkService(QObject *parent) : QObject(parent) {
const QSettings settings("config.ini", QSettings::IniFormat); const QSettings settings("config.ini", QSettings::IniFormat);
m_urlAccountUpdate = settings.value("net/accountUpdate").toString(); m_urlAccountUpdate = settings.value("net/accountUpdate").toString();
@ -90,7 +110,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path)
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "Таймаут запроса:" << path; qCritical() << "Таймаут запроса:" << sanitizePath(path);
reply->abort(); reply->abort();
} else if (reply->error() == QNetworkReply::NoError) { } else if (reply->error() == QNetworkReply::NoError) {
const QByteArray raw = reply->readAll(); const QByteArray raw = reply->readAll();
@ -105,7 +125,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path)
} }
} }
} else { } else {
qCritical() << "Ошибка:" << reply->errorString() << path; qCritical() << "Ошибка:" << sanitizeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
return result; return result;
@ -136,7 +156,7 @@ QJsonArray NetworkService::get(const QString &path, const QMap<QString, QString>
QJsonArray result; QJsonArray result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (GET):" << path; qCritical() << "Таймаут запроса (GET):" << sanitizePath(path);
reply->abort(); reply->abort();
} else if (reply->error() == QNetworkReply::NoError) { } else if (reply->error() == QNetworkReply::NoError) {
const QByteArray raw = reply->readAll(); const QByteArray raw = reply->readAll();
@ -235,19 +255,21 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
QJsonValue result; QJsonValue result;
const QString shortPath = sanitizePath(path);
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (postJson):" << path; qCritical() << "Таймаут запроса (postJson):" << shortPath;
reply->abort(); reply->abort();
GeneralLogDAO::insert("CRITICAL", "NetworkService", GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Timeout (POST): " + path, {}, reqBodyStr, {}); "Timeout (POST): " + shortPath, {}, reqBodyStr, {});
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
const QByteArray responseBody = reply->readAll(); const QByteArray responseBody = reply->readAll();
qCritical().noquote() << "Network error (POST):" << reply->errorString() << path const QString errSummary = sanitizeNetError(reply, path);
qCritical().noquote() << "Network error (POST):" << errSummary
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId)
<< "\n response body:" << responseBody; << "\n response body:" << responseBody;
GeneralLogDAO::insert("CRITICAL", "NetworkService", GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Network error (POST): " + reply->errorString() + " " + path, {}, "Network error (POST): " + errSummary, {},
reqBodyStr, QString::fromUtf8(responseBody)); reqBodyStr, QString::fromUtf8(responseBody));
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else { } else {
@ -261,7 +283,7 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
} else { } else {
qCritical() << "API error:" << root; qCritical() << "API error:" << root;
GeneralLogDAO::insert("WARNING", "NetworkService", GeneralLogDAO::insert("WARNING", "NetworkService",
"API error (POST): " + path, {}, "API error (POST): " + shortPath, {},
reqBodyStr, QString::fromUtf8(raw)); reqBodyStr, QString::fromUtf8(raw));
result = QJsonValue::Null; result = QJsonValue::Null;
} }
@ -453,17 +475,19 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (PATCH):" << path; const QString shortPath = sanitizePath(path);
qCritical() << "Таймаут запроса (PATCH):" << shortPath;
reply->abort(); reply->abort();
GeneralLogDAO::insert("CRITICAL", "NetworkService", GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Timeout (PATCH): " + path, {}, reqBodyStr, {}); "Timeout (PATCH): " + shortPath, {}, reqBodyStr, {});
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
const QByteArray responseBody = reply->readAll(); const QByteArray responseBody = reply->readAll();
qCritical().noquote() << "Network error (PATCH):" << reply->errorString() << path const QString errSummary = sanitizeNetError(reply, path);
qCritical().noquote() << "Network error (PATCH):" << errSummary
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId)
<< "\n response body:" << responseBody; << "\n response body:" << responseBody;
GeneralLogDAO::insert("CRITICAL", "NetworkService", GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Network error (PATCH): " + reply->errorString() + " " + path, {}, "Network error (PATCH): " + errSummary, {},
reqBodyStr, QString::fromUtf8(responseBody)); reqBodyStr, QString::fromUtf8(responseBody));
} else { } else {
const QByteArray raw = reply->readAll(); const QByteArray raw = reply->readAll();
@ -476,7 +500,7 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod
} else { } else {
qCritical() << "API error (PATCH):" << root; qCritical() << "API error (PATCH):" << root;
GeneralLogDAO::insert("WARNING", "NetworkService", GeneralLogDAO::insert("WARNING", "NetworkService",
"API error (PATCH): " + path, {}, "API error (PATCH): " + sanitizePath(path), {},
reqBodyStr, QString::fromUtf8(raw)); reqBodyStr, QString::fromUtf8(raw));
} }
} }
@ -504,11 +528,11 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) {
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (DELETE):" << path; qCritical() << "Таймаут запроса (DELETE):" << sanitizePath(path);
reply->abort(); reply->abort();
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "Network error (DELETE):" << reply->errorString() << path qCritical().noquote() << "Network error (DELETE):" << sanitizeNetError(reply, path)
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId);
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else { } else {
@ -550,11 +574,11 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) {
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (getJson):" << path; qCritical() << "Таймаут запроса (getJson):" << sanitizePath(path);
reply->abort(); reply->abort();
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "Network error (GET):" << reply->errorString() << path qCritical().noquote() << "Network error (GET):" << sanitizeNetError(reply, path)
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId);
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else { } else {