1
0
forked from BRT/arc
arc/android/dushanbe/GetLastTransactionsScript.cpp
trnsmkot 1346ca3576 Introduce TaskDumpRecorder for centralized task dump management:
- Add TaskDumpRecorder to save task-related XML dumps and upload failure info to Telegram.
- Integrate TaskDumpRecorder into `PayByPhoneScript` and `PayByCardScript` for Dushanbe and Ozon banks.
- Extend ADB utilities to record XML dumps with TaskDumpRecorder and refine keyboard dismissal logic.
- Enhance freeze detection with consecutive dump checks in CommonScript and payment scripts.
- Update mock server profiles and scenario configurations with new material IDs.
2026-04-26 19:22:40 +07:00

809 lines
39 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 "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "AppLogger.h"
#include <QCoreApplication>
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
qWarning() << "[Dushanbe::OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
api->End(); delete api;
return {};
}
api->SetImage(pix);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&pix);
api->End();
delete api;
return result;
}
// Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее
// и распознаёт только цифры. Работает с любым разрешением экрана.
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
// 1. Полный OCR с координатами слов — ищем "операции"
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1;
int opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) {
pixDestroy(&pix);
return {};
}
// 2. Кропаем правую половину этой строки и OCR только цифры
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
struct OcrTransactionDetail {
QString date;
QString time;
QString operationId;
QString provider;
QString senderAccount;
QString receiverAccount;
double amount = 0.0;
double fee = 0.0;
QString status;
};
static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) {
OcrTransactionDetail d;
const QStringList lines = ocrText.split('\n');
for (const QString &rawLine : lines) {
const QString line = rawLine.trimmed();
// "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке
if (line.contains(QString::fromUtf8("Дата операции"))) {
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1);
}
// "Время операции: 18:19:54"
else if (line.contains(QString::fromUtf8("Время операции"))) {
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1);
}
// "Номер операции: 1598279763"
else if (line.contains(QString::fromUtf8("омер операции"))) {
QRegularExpression re(R"((\d{5,}))");
if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1);
}
// "Поставщик: DC (по номеру карты)"
else if (line.contains(QString::fromUtf8("Поставщик"))) {
QRegularExpression re(R"(Поставщик[:\s]*(.+))");
if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed();
}
// "Счет отправителя: 9762***3258"
else if (line.contains(QString::fromUtf8("Счет отправителя")) ||
line.contains(QString::fromUtf8("чет отправителя"))) {
QRegularExpression re(R"((\d[\d*]+))");
if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1);
}
// "Счет получателя: 9762000186263942" или "992111100664"
else if (line.contains(QString::fromUtf8("Счет получателя")) ||
line.contains(QString::fromUtf8("чет получателя"))) {
QRegularExpression re(R"((\d{6,}))");
if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1);
}
// "Сумма операции: 1.00"
else if (line.contains(QString::fromUtf8("Сумма операции")) ||
line.contains(QString::fromUtf8("умма операции"))) {
QRegularExpression re(R"((\d+[\.,]\d{2}))");
if (auto m = re.match(line); m.hasMatch()) {
QString val = m.captured(1);
val.replace(',', '.');
d.amount = val.toDouble();
}
}
// "Комиссия: 0.00"
else if (line.contains(QString::fromUtf8("Комиссия")) ||
line.contains(QString::fromUtf8("омиссия"))) {
QRegularExpression re(R"((\d+[\.,]\d{2}))");
if (auto m = re.match(line); m.hasMatch()) {
QString val = m.captured(1);
val.replace(',', '.');
d.fee = val.toDouble();
}
}
// "Статус: Успешный"
else if (line.contains(QString::fromUtf8("Статус"))) {
if (line.contains(QString::fromUtf8("Успешн"))) {
d.status = QString::fromUtf8("Успешный");
}
}
}
// Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном
if (d.date.isEmpty()) {
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1);
}
if (d.time.isEmpty()) {
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
auto it = re.globalMatch(ocrText);
QStringList times;
while (it.hasNext()) times.append(it.next().captured(1));
if (times.size() >= 2) d.time = times[1];
else if (times.size() == 1) d.time = times[0];
}
if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный");
return d;
}
namespace Dushanbe {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
double searchAmount,
QDateTime searchTime,
QString materialId,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) {
}
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
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;
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) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::GetLastTransactions] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
AppLogger::log("dushanbe/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Тапаем "История" в нижнем меню
Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
if (historyBtn.x() == 0 && historyBtn.y() == 0) {
m_error = "Cannot find History button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
QThread::msleep(3000);
// Ждём экран истории
bool historyLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Проверяем диалог "Ошибка" — нажимаем "ОК" и обновляем свайпом вниз
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) {
Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК"));
if (!okBtn.isEmpty()) {
qDebug() << "[Dushanbe::GetLastTransactions] Error dialog detected, tapping OK";
AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y());
QThread::msleep(1000);
}
continue;
}
if (xmlScreenParser.isHistoryScreen()) {
historyLoaded = true;
break;
}
QThread::msleep(1500);
}
if (!historyLoaded) {
m_error = "History screen not loaded: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Переключаемся на вкладку "Выписка" (по умолчанию открыта "Операции")
Node vypiskaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Выписка"));
if (vypiskaBtn.isEmpty()) {
m_error = "Cannot find 'Выписка' tab: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
qDebug() << "[Dushanbe::GetLastTransactions] Switching to 'Выписка' tab";
AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y());
QThread::msleep(3000);
// Pull-to-refresh: свайп сверху вниз для обновления списка
{
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight / 3;
const int toY = device.screenHeight * 2 / 3;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(3000);
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
}
// Сохраняем дамп Выписки для анализа формата
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Закрываем возможный диалог "Ошибка" на Выписке
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) {
Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК"));
if (!okBtn.isEmpty()) {
qDebug() << "[Dushanbe::GetLastTransactions] Error dialog on Выписка, tapping OK";
AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
}
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
if (!accounts.isEmpty()) {
accountId = accounts.first().id;
} else {
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
}
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate);
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe");
const int maxScrolls = 10;
auto interrupted = []() {
return QThread::currentThread()->isInterruptionRequested();
};
struct VypiskaItem {
double amount = 0.0;
QString time; // "21:31:21"
QString date; // "05.04.2026"
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
Node node;
};
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
if (interrupted()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Верх нижнего меню: минимальный y1() среди табов и FAB "Сканировать".
// Тап по строке с y >= navTopY - navMargin промахивается и попадает в FAB/таб.
int navTopY = device.screenHeight;
for (const QString &lbl : {
QString::fromUtf8("Главная"), QString::fromUtf8("История"),
QString::fromUtf8("Переводы"), QString::fromUtf8("Сканировать")}) {
Node nav = xmlScreenParser.findNodeByContentDesc(lbl);
if (!nav.isEmpty() && nav.y1() > 0 && nav.y1() < navTopY) navTopY = nav.y1();
}
const int navMargin = 20;
qDebug() << "[Dushanbe::GetLastTransactions] navTopY=" << navTopY;
// 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию,
// которая впечатана в content-desc родительского ImageView "Обновлено: ..."
// вместо отдельного android.view.View (самая свежая запись в Выписке).
const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date();
QStringList groupDates;
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()) {
if (n.className != "android.widget.ImageView") continue;
if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue;
updatedImgNode = n;
const QStringList descLines = n.contentDesc.split('\n');
for (const QString &line : descLines) {
const QString t = line.trimmed();
if (reDate.match(t).hasMatch()) {
groupDates.append(t);
} else if (t.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) {
groupDates.append(todayLocal.toString("dd.MM.yyyy"));
} else if (t.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) {
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;
}
// 2. Собираем View-транзакции (формат Выписки)
// Два формата content-desc:
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue;
const QStringList lines = n.contentDesc.split('\n');
if (lines.size() < 5) continue;
const QString last = lines.last().trimmed();
if (!reTime.match(last).hasMatch()) continue;
// Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат
const QString firstLine = lines.first().trimmed();
const bool isP2P = reDateOrToday.match(firstLine).hasMatch();
if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
VypiskaItem it;
bool ok = false;
// В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
const int amtIdx = isP2P ? 3 : 2;
if (amtIdx >= lines.size()) continue;
it.amount = lines[amtIdx].trimmed().toDouble(&ok);
if (!ok) continue;
it.time = last;
it.node = n;
// В P2P формате дата встроена в первую строку — надёжная
if (isP2P) {
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.toString("dd.MM.yyyy");
else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
else
it.date = firstLine;
it.dateReliable = true;
}
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 и группируем по разрывам (> 30px → новая группа-день)
std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) {
return a.node.y1() < b.node.y1();
});
int groupIdx = 0;
int prevY2 = -1;
for (VypiskaItem &it : items) {
if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx;
// Не перезаписываем дату если она уже установлена (P2P формат)
if (it.date.isEmpty() && groupIdx < groupDates.size())
it.date = groupDates[groupIdx];
prevY2 = it.node.y2();
}
qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
<< "items:" << items.size() << "groups:" << groupDates
<< "hasEmbedded:" << hasEmbedded;
// Логируем все items для отладки
for (int i = 0; i < items.size(); ++i) {
qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount
<< "date=" << items[i].date << "time=" << items[i].time
<< "y=" << items[i].node.y();
}
const double searchAbs = qAbs(m_searchAmount);
for (const auto &tx : items) {
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
<< (matchedX100 ? "(scaled x100)" : "(direct)")
<< "date:" << tx.date << "time:" << tx.time;
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 3) {
qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:"
<< tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy")
<< "diff=" << dayDiff << "days";
continue;
}
}
}
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
<< "diff=" << diffSecs << "sec";
continue;
}
}
}
Node txNode = tx.node;
if (txNode.x() == 0 && txNode.y() == 0) {
qWarning() << "[Dushanbe::GetLastTransactions] Cannot find node for transaction";
continue;
}
// Строка под нижним меню — тап попадёт в FAB "Сканировать" или таб.
// Пропускаем кандидата, ищем совпадение выше; если таких нет — скролл.
if (txNode.y() >= navTopY - navMargin) {
qDebug() << "[Dushanbe::GetLastTransactions] Candidate y=" << txNode.y()
<< "behind nav (navTopY=" << navTopY << "), skipping, will scroll";
continue;
}
// Тап по левому краю ряда — уходим от центра экрана, где FAB "Сканировать".
const int tapX = txNode.x1() + 80;
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
QThread::msleep(3000);
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
OcrTransactionDetail detail;
bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.contentDesc.contains(QString::fromUtf8("Успешный платеж"))) continue;
if (!n.contentDesc.contains(QString::fromUtf8("Дата:"))) continue;
const QStringList lines = n.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString key = lines[i].trimmed();
if (i + 1 >= lines.size()) continue;
const QString val = lines[i + 1].trimmed();
if (key == QString::fromUtf8("Получатель:")) {
detail.receiverAccount = val;
} else if (key == QString::fromUtf8("Сумма:")) {
detail.amount = val.toDouble();
} else if (key == QString::fromUtf8("Дата:")) {
detail.date = val;
} else if (key == QString::fromUtf8("Время:")) {
detail.time = val;
}
}
if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) {
detail.status = QString::fromUtf8("Успешный");
}
detailLoaded = true;
break;
}
if (detailLoaded) break;
QThread::msleep(1500);
}
if (!detailLoaded) {
qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded "
"(tap missed or detail format unknown); staying on Выписка";
// Не вызываем goBack — после промаха тапа мы всё ещё на Выписке.
// goBack тут закрывает Выписку и ломает дальнейший скролл.
// Если же тап сработал и открылся НЕ-распознанный экран — следующая
// итерация parse обнаружит это, и мы обработаем штатно.
QThread::msleep(500);
continue;
}
qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:"
<< "date=" << detail.date << "time=" << detail.time
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
// Проверяем время ±1 час
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (!txTime.isValid()) {
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
}
if (txTime.isValid()) {
txTime.setTimeZone(tjTz);
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
}
// Нашли! Сохраняем
qDebug() << "[Dushanbe::GetLastTransactions] FOUND:"
<< "amount=" << detail.amount
<< "date=" << detail.date << detail.time
<< "opId=" << detail.operationId
<< "receiver=" << detail.receiverAccount
<< "status=" << detail.status;
// Формируем task_result напрямую из распарсенных данных и отдаём
// EventHandler через сигнал — без записи/чтения БД.
{
const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (!txTime.isValid()) {
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
}
if (txTime.isValid()) txTime.setTimeZone(tjTz);
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);
}
// Скриншот экрана деталей транзакции — отправляем в мониторинг
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Назад к истории
Node nazadBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад"));
if (nazadBtn.x() > 0 && nazadBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, nazadBtn.x(), nazadBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
emit finishedWithResult(m_error); // m_error пустой — успех
return;
}
// Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
const int x = device.screenWidth / 2;
// Определяем область контента из реальных Y позиций items
int contentBottom = device.screenHeight * 2 / 3;
int contentTop = device.screenHeight / 3;
if (!items.isEmpty()) {
contentBottom = items.last().node.y();
contentTop = items.first().node.y();
}
const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3);
const int toY = qMax(contentTop, device.screenHeight / 4);
qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(2000);
}
}
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
+ " time=" + m_searchTime.toString(Qt::ISODate);
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
// Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
emit finishedWithResult(m_error);
}
} // namespace Dushanbe