966 lines
49 KiB
C++
966 lines
49 KiB
C++
#include "GetLastTransactionsScript.h"
|
||
|
||
#include <QJsonDocument>
|
||
#include <QRegularExpression>
|
||
#include <QSet>
|
||
#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>
|
||
|
||
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
|
||
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
|
||
// потому что требует, чтобы вся строка была валидным числом.
|
||
static double parseDushanbeAmount(const QString &raw) {
|
||
QString s = raw;
|
||
s.remove(QRegularExpression(R"([^\d,.\-])"));
|
||
s.replace(',', '.');
|
||
const int firstDot = s.indexOf('.');
|
||
if (firstDot >= 0) {
|
||
QString head = s.left(firstDot);
|
||
QString tail = s.mid(firstDot + 1);
|
||
tail.remove('.');
|
||
s = head + '.' + tail;
|
||
}
|
||
return s.toDouble();
|
||
}
|
||
|
||
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,
|
||
QList<QPair<double, QDateTime>> targets,
|
||
QString materialId,
|
||
QObject *parent
|
||
) : CommonScript(parent),
|
||
m_deviceId(std::move(deviceId)),
|
||
m_appCode(std::move(appCode)),
|
||
m_targets(std::move(targets)),
|
||
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 double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first;
|
||
const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}};
|
||
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));
|
||
}
|
||
}
|
||
|
||
// Находим 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";
|
||
}
|
||
|
||
// Ищем каждую цель из списка. Найденные отдаём сразу через transactionParsed;
|
||
// ненайденные копим и репортим одной агрегированной ошибкой в конце.
|
||
QStringList notFound;
|
||
for (int i = 0; i < m_targets.size(); ++i) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; break; }
|
||
const double amount = m_targets[i].first;
|
||
const QDateTime time = m_targets[i].second;
|
||
// Перед каждой целью (кроме первой) возвращаемся к началу Выписки.
|
||
if (i > 0) scrollHistoryToTop(device);
|
||
if (!searchOne(device, accountId, amount, time)) {
|
||
if (m_error == "Interrupted") break;
|
||
notFound << QString("amount=%1 time=%2")
|
||
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
|
||
}
|
||
}
|
||
if (m_error != "Interrupted") {
|
||
m_error = notFound.isEmpty()
|
||
? QString()
|
||
: QString("Transactions not found (%1/%2): %3")
|
||
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
|
||
}
|
||
|
||
// Возвращаемся на главную
|
||
{
|
||
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);
|
||
}
|
||
|
||
void GetLastTransactionsScript::scrollHistoryToTop(const DeviceInfo &device) {
|
||
// Свайпы сверху-вниз поднимают список Выписки к началу.
|
||
for (int i = 0; i < 4; ++i) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) return;
|
||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||
device.screenHeight / 3,
|
||
device.screenWidth / 2,
|
||
device.screenHeight * 2 / 3, 300);
|
||
QThread::msleep(500);
|
||
}
|
||
QThread::msleep(800);
|
||
}
|
||
|
||
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId,
|
||
double searchAmount, const QDateTime &searchTime) {
|
||
// Скриншот списка транзакций — отправим при неудаче
|
||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
|
||
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << searchAmount
|
||
<< "time=" << 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";
|
||
return false;
|
||
}
|
||
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 (всё через '\n'):
|
||
// a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
|
||
// b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS
|
||
// c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
|
||
// Позиция amount уехала между версиями приложения, поэтому ищем по
|
||
// паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей.
|
||
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("Вчера")));
|
||
// Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая
|
||
// ("109" — P2P-зачисления показываются без копеек). Целую часть
|
||
// ограничиваем 7 цифрами, чтобы не спутать с 12–16-значным номером
|
||
// карты, если он окажется на отдельной строке без пробелов/дефисов.
|
||
static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)");
|
||
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;
|
||
// Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet,
|
||
// и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также
|
||
// текстовые поля не подпадают под паттерн.
|
||
for (const QString &line : lines) {
|
||
const QString t = line.trimmed();
|
||
if (!reAmountLine.match(t).hasMatch()) continue;
|
||
QString v = t; v.replace(',', '.');
|
||
it.amount = v.toDouble(&ok);
|
||
if (ok) break;
|
||
}
|
||
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(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 && searchTime.isValid() && !tx.date.isEmpty()) {
|
||
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
||
const QDate searchLocalDate = 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 (±10 минут, с учётом wraparound полуночи)
|
||
if (searchTime.isValid() && !tx.time.isEmpty()) {
|
||
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
||
const QTime searchLocalTime = 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"; return false; }
|
||
|
||
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
|
||
OcrTransactionDetail detail;
|
||
bool detailLoaded = false;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
if (interrupted()) { m_error = "Interrupted"; return false; }
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
|
||
// одна нода со всем content-desc через \n. Лейблы без двоеточий, сумма —
|
||
// отдельной строкой со знаком сразу после даты+времени.
|
||
// "Успешный платёж\n21.05.2026 16:58:32\n-10\nКарта\n...\nПолучатель\n...\nЗакрыть"
|
||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||
const QString cd = n.contentDesc;
|
||
if (!cd.contains(QString::fromUtf8("Карта"))) continue;
|
||
if (!cd.contains(QString::fromUtf8("Получатель"))) continue;
|
||
if (!cd.contains(QString::fromUtf8("Операция"))) continue;
|
||
const QStringList lines = cd.split('\n', Qt::SkipEmptyParts);
|
||
if (lines.size() < 6) continue;
|
||
|
||
// lines[0] — заголовок: "Успешный платёж" / "Поступление" / ...
|
||
if (lines[0].contains(QString::fromUtf8("Успешн"))
|
||
|| lines[0].contains(QString::fromUtf8("Поступление"))
|
||
|| lines[0].contains(QString::fromUtf8("выполнен"))) {
|
||
detail.status = QString::fromUtf8("Успешный");
|
||
}
|
||
// lines[1] — "dd.MM.yyyy HH:mm:ss"
|
||
{
|
||
static const QRegularExpression reDt(
|
||
R"((\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2}))");
|
||
const auto m = reDt.match(lines[1]);
|
||
if (m.hasMatch()) {
|
||
detail.date = m.captured(1);
|
||
detail.time = m.captured(2);
|
||
}
|
||
}
|
||
// lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50".
|
||
detail.amount = parseDushanbeAmount(lines[2]);
|
||
|
||
// Дальше — пары label/value; значение может быть multi-line (Получатель).
|
||
// На сервер шлём первую строку получателя — таков сложившийся контракт.
|
||
static const QSet<QString> labels{
|
||
QString::fromUtf8("Карта"),
|
||
QString::fromUtf8("Получатель"),
|
||
QString::fromUtf8("Операция"),
|
||
QString::fromUtf8("Дата"),
|
||
QString::fromUtf8("Время"),
|
||
QString::fromUtf8("Закрыть"),
|
||
QString::fromUtf8("Назад"),
|
||
};
|
||
for (int i = 3; i < lines.size(); ++i) {
|
||
const QString lbl = lines[i].trimmed();
|
||
if (!labels.contains(lbl)) continue;
|
||
QStringList vals;
|
||
for (int j = i + 1; j < lines.size(); ++j) {
|
||
const QString cand = lines[j].trimmed();
|
||
if (labels.contains(cand)) break;
|
||
vals << cand;
|
||
}
|
||
if (vals.isEmpty()) continue;
|
||
if (lbl == QString::fromUtf8("Получатель")) {
|
||
detail.receiverAccount = vals.first();
|
||
} else if (lbl == QString::fromUtf8("Дата") && detail.date.isEmpty()) {
|
||
detail.date = vals.first();
|
||
} else if (lbl == QString::fromUtf8("Время") && detail.time.isEmpty()) {
|
||
detail.time = vals.first();
|
||
}
|
||
}
|
||
detailLoaded = true;
|
||
break;
|
||
}
|
||
// 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" —
|
||
// одна нода с ключами и значениями через \n.
|
||
if (!detailLoaded) 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) {
|
||
QString key = lines[i].trimmed();
|
||
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
|
||
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 = parseDushanbeAmount(val);
|
||
} else if (key == QString::fromUtf8("Дата")) {
|
||
detail.date = val;
|
||
} else if (key == QString::fromUtf8("Время")) {
|
||
detail.time = val;
|
||
}
|
||
}
|
||
detailLoaded = true;
|
||
break;
|
||
}
|
||
// 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах
|
||
// на одной Y-полосе. Промежуточная версия между blob и bottom-sheet.
|
||
if (!detailLoaded) {
|
||
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||
if (!d.isEmpty() && !t.isEmpty()) {
|
||
detail.date = d;
|
||
detail.time = t;
|
||
detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель"));
|
||
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма")));
|
||
if (detail.amount == 0.0) {
|
||
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению")));
|
||
}
|
||
detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия")));
|
||
detail.operationId = pairs.value(QString::fromUtf8("Номер операции"));
|
||
detail.status = pairs.value(QString::fromUtf8("Статус"));
|
||
detailLoaded = true;
|
||
}
|
||
}
|
||
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;
|
||
|
||
// Проверяем время ±10 минут
|
||
if (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(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);
|
||
|
||
return true; // успех — транзакция найдена и отдана через transactionParsed
|
||
}
|
||
|
||
// Скроллим вниз — свайп внутри области транзакций
|
||
if (scroll < maxScrolls) {
|
||
if (interrupted()) { m_error = "Interrupted"; return false; }
|
||
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 не трогаем — агрегированную ошибку по всем
|
||
// ненайденным целям соберёт оркестратор в doStart (он же вернёт на главную).
|
||
qWarning() << "[Dushanbe::GetLastTransactions] Transaction not found: amount="
|
||
<< QString::number(searchAmount, 'f', 2)
|
||
<< "time=" << searchTime.toString(Qt::ISODate);
|
||
|
||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||
m_resultScreenshot = listScreenshot.isEmpty()
|
||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||
|
||
return false;
|
||
}
|
||
|
||
} // namespace Dushanbe
|